224 lines
6.0 KiB
Go
224 lines
6.0 KiB
Go
// main.go — 微信 ClawBot iLink 最小示例(Go,仅标准库)
|
||
// 流程: 扫码登录 → 长轮询收消息 → 回声回复
|
||
// 运行: go run main.go
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"time"
|
||
)
|
||
|
||
const baseURL = "https://ilinkai.weixin.qq.com"
|
||
|
||
var stateFile = filepath.Join(func() string { d, _ := os.Executable(); return filepath.Dir(d) }(), "bridge-state.json")
|
||
|
||
// ---------- 状态持久化: bot_token / 游标 / context_token ----------
|
||
type State struct {
|
||
BotToken string `json:"bot_token"`
|
||
BotID string `json:"bot_id,omitempty"`
|
||
Cursor string `json:"cursor"`
|
||
Contexts map[string]string `json:"contexts"`
|
||
}
|
||
|
||
var state = State{Contexts: map[string]string{}}
|
||
|
||
func loadState() {
|
||
if b, err := os.ReadFile(stateFile); err == nil {
|
||
json.Unmarshal(b, &state)
|
||
}
|
||
if state.Contexts == nil {
|
||
state.Contexts = map[string]string{}
|
||
}
|
||
}
|
||
|
||
func saveState() {
|
||
b, _ := json.MarshalIndent(state, "", " ")
|
||
os.WriteFile(stateFile, b, 0o600)
|
||
}
|
||
|
||
// ---------- 通用请求头: 鉴权 + 防重放 X-WECHAT-UIN ----------
|
||
func headers(req *http.Request) {
|
||
var u [4]byte
|
||
rand.Read(u[:])
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||
req.Header.Set("X-WECHAT-UIN", base64.StdEncoding.EncodeToString(
|
||
[]byte(fmt.Sprint(binary.BigEndian.Uint32(u[:])))))
|
||
if state.BotToken != "" {
|
||
req.Header.Set("Authorization", "Bearer "+state.BotToken)
|
||
}
|
||
}
|
||
|
||
func apiPost(path string, body any, timeout time.Duration) (map[string]any, error) {
|
||
buf, _ := json.Marshal(body)
|
||
req, err := http.NewRequest("POST", baseURL+path, bytes.NewReader(buf))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
headers(req)
|
||
client := &http.Client{Timeout: timeout}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
var out map[string]any
|
||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||
}
|
||
|
||
func apiGet(path string, extraHeaders map[string]string) (map[string]any, error) {
|
||
req, err := http.NewRequest("GET", baseURL+path, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for k, v := range extraHeaders {
|
||
req.Header.Set(k, v)
|
||
}
|
||
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
var out map[string]any
|
||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||
}
|
||
|
||
// ---------- 接口1+2: 扫码登录(get_bot_qrcode / get_qrcode_status) ----------
|
||
func login() error {
|
||
qr, err := apiGet("/ilink/bot/get_bot_qrcode?bot_type=3", nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fmt.Println("请用微信扫码:", qr["qrcode_img_content"])
|
||
deadline := time.Now().Add(8 * time.Minute)
|
||
for time.Now().Before(deadline) {
|
||
s, err := apiGet("/ilink/bot/get_qrcode_status?qrcode="+url(qr["qrcode"]),
|
||
map[string]string{"iLink-App-ClientVersion": "1"})
|
||
if err == nil && s["status"] == "confirmed" { // wait → scaned → confirmed
|
||
state.BotToken, _ = s["bot_token"].(string)
|
||
state.BotID, _ = s["ilink_bot_id"].(string)
|
||
saveState()
|
||
fmt.Println("登录成功, bot:", state.BotID)
|
||
return nil
|
||
}
|
||
time.Sleep(time.Second)
|
||
}
|
||
return fmt.Errorf("扫码超时")
|
||
}
|
||
|
||
func url(v any) string { // 极简 query 转义(qrcode 内容一般安全,正式代码用 url.QueryEscape)
|
||
s, _ := v.(string)
|
||
return s
|
||
}
|
||
|
||
// ---------- 你的 AI 后端: 替换成自己项目的调用 ----------
|
||
func AskYourAI(peerID, text string) string {
|
||
return "你说的是:" + text
|
||
}
|
||
|
||
// ---------- 接口4: POST /ilink/bot/sendmessage ----------
|
||
func sendText(toUserID, text, contextToken string) {
|
||
if text == "" {
|
||
text = " "
|
||
}
|
||
for i := 0; i < len(text); i += 1500 { // 长文本分片(按字节切仅适合演示,正式按 rune 切)
|
||
end := i + 1500
|
||
if end > len(text) {
|
||
end = len(text)
|
||
}
|
||
apiPost("/ilink/bot/sendmessage", map[string]any{
|
||
"msg": map[string]any{
|
||
"to_user_id": toUserID,
|
||
"client_id": fmt.Sprintf("%d-%d", time.Now().UnixNano(), i),
|
||
"message_type": 2, // bot 消息
|
||
"message_state": 2, // FINISH
|
||
"context_token": contextToken,
|
||
"item_list": []any{map[string]any{
|
||
"type": 1, "text_item": map[string]string{"text": text[i:end]},
|
||
}},
|
||
},
|
||
}, 15*time.Second)
|
||
}
|
||
}
|
||
|
||
// ---------- 接口3: POST /ilink/bot/getupdates 长轮询 ----------
|
||
func pollLoop() {
|
||
failures := 0
|
||
for {
|
||
resp, err := apiPost("/ilink/bot/getupdates", map[string]any{
|
||
"get_updates_buf": state.Cursor,
|
||
"base_info": map[string]string{"channel_version": "1.0.2"},
|
||
}, 45*time.Second)
|
||
|
||
if err != nil {
|
||
if os.IsTimeout(err) { // 长轮询正常超时
|
||
continue
|
||
}
|
||
failures++
|
||
wait := 2 * time.Second
|
||
if failures > 3 {
|
||
wait = 30 * time.Second // 退避: 2s ×3 → 30s
|
||
}
|
||
fmt.Printf("轮询异常(%d次),%v 后重试: %v\n", failures, wait, err)
|
||
time.Sleep(wait)
|
||
continue
|
||
}
|
||
|
||
if code, _ := resp["errcode"].(float64); code == -14 { // 会话过期
|
||
fmt.Println("会话过期(errcode=-14),请删除 bridge-state.json 重新扫码")
|
||
os.Exit(2)
|
||
}
|
||
|
||
if buf, ok := resp["get_updates_buf"].(string); ok && buf != "" {
|
||
state.Cursor = buf // 游标立即落盘
|
||
saveState()
|
||
}
|
||
|
||
msgs, _ := resp["msgs"].([]any)
|
||
for _, m := range msgs {
|
||
msg, _ := m.(map[string]any)
|
||
if t, _ := msg["message_type"].(float64); t != 1 { // 只处理用户消息
|
||
continue
|
||
}
|
||
text := ""
|
||
for _, it := range msg["item_list"].([]any) {
|
||
item, _ := it.(map[string]any)
|
||
if t, _ := item["type"].(float64); t == 1 {
|
||
if ti, ok := item["text_item"].(map[string]any); ok {
|
||
s, _ := ti["text"].(string)
|
||
text += s + "\n"
|
||
}
|
||
}
|
||
}
|
||
if text == "" {
|
||
continue
|
||
}
|
||
peer, _ := msg["from_user_id"].(string)
|
||
token, _ := msg["context_token"].(string)
|
||
state.Contexts[peer] = token
|
||
saveState()
|
||
sendText(peer, AskYourAI(peer, text), token)
|
||
}
|
||
failures = 0
|
||
}
|
||
}
|
||
|
||
func main() {
|
||
loadState()
|
||
if state.BotToken == "" {
|
||
if err := login(); err != nil {
|
||
fmt.Println(err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
pollLoop()
|
||
}
|