docs: weixin ClawBot bridge protocol and examples
This commit is contained in:
commit
4ea3957da9
13 files changed
+2158
No files matched your search
@@ -0,0 +1,223 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in new issue
Block a user