返回 DeepSeek-Reasonix
weixin.go
根目录 / internal / bot / weixin / weixin.go
1 // Package weixin 实现微信 iLink Bot 适配器。
2 // 参考 Hermes Agent 的 weixin adapter:
3 // - getupdates 长轮询
4 // - sendmessage / sendtyping
5 // - context_token 持久化
6 // - 二维码登录
7 // - DM allowlist(默认只对 allowlist 内用户开放 DM;群聊默认关闭)
8 package weixin
9
10 import (
11 "bytes"
12 "context"
13 "crypto/rand"
14 "crypto/sha256"
15 "encoding/base64"
16 "encoding/hex"
17 "encoding/json"
18 "fmt"
19 "io"
20 "log/slog"
21 "net/http"
22 "os"
23 "path/filepath"
24 "strings"
25 "sync"
26 "time"
27
28 "reasonix/internal/bot"
29 "reasonix/internal/config"
30 fileencoding "reasonix/internal/fileutil/encoding"
31 )
32
33 const (
34 defaultWeixinAPI = "https://ilinkai.weixin.qq.com"
35 getUpdatesPath = "/ilink/bot/getupdates"
36 sendMessagePath = "/ilink/bot/sendmessage"
37 sendTypingPath = "/ilink/bot/sendtyping"
38 uploadMediaPath = "/ilink/bot/getuploadurl"
39 getBotQRPath = "/ilink/bot/get_bot_qrcode"
40 getQRStatusPath = "/ilink/bot/get_qrcode_status"
41
42 ilinkAppID = "bot"
43 ilinkClientVersion = (2 << 16) | (2 << 8)
44 ilinkChannelVersion = "2.2.0"
45 weixinItemText = 1
46 weixinMsgTypeBot = 2
47 weixinMsgStateDone = 2
48
49 weixinHTTPTimeout = 30 * time.Second
50 )
51
52 var weixinHTTPClient = &http.Client{Timeout: weixinHTTPTimeout}
53
54 // ilinkUpdate 微信 iLink getupdates 返回的更新消息。
55 type ilinkUpdate struct {
56 UpdateID int64 `json:"update_id"`
57 UpdateType string `json:"update_type"`
58 Message struct {
59 MessageID ilinkString `json:"message_id"`
60 ChatID string `json:"chat_id"`
61 ChatType string `json:"chat_type"`
62 From struct {
63 UserID string `json:"user_id"`
64 UserName string `json:"user_name"`
65 } `json:"from"`
66 Text string `json:"text"`
67 Timestamp int64 `json:"timestamp"`
68 } `json:"message"`
69 }
70
71 type ilinkMessage struct {
72 MessageID ilinkString `json:"message_id"`
73 FromUserID string `json:"from_user_id"`
74 ToUserID string `json:"to_user_id"`
75 RoomID string `json:"room_id"`
76 ChatRoomID string `json:"chat_room_id"`
77 ContextToken string `json:"context_token"`
78 MsgType int `json:"msg_type"`
79 ItemList []struct {
80 Type int `json:"type"`
81 TextItem struct {
82 Text string `json:"text"`
83 } `json:"text_item"`
84 } `json:"item_list"`
85 }
86
87 type ilinkResponse struct {
88 Ret int `json:"ret"`
89 Errcode int `json:"errcode"`
90 Errmsg string `json:"errmsg"`
91 Updates []ilinkUpdate `json:"updates"`
92 Msgs []ilinkMessage `json:"msgs"`
93 HasMore bool `json:"has_more"`
94 ContextToken string `json:"context_token"`
95 GetUpdatesBuf string `json:"get_updates_buf"`
96 LongpollingTimeoutMs int `json:"longpolling_timeout_ms"`
97 }
98
99 type ilinkString string
100
101 func (s *ilinkString) UnmarshalJSON(data []byte) error {
102 if string(data) == "null" {
103 *s = ""
104 return nil
105 }
106 var str string
107 if err := json.Unmarshal(data, &str); err == nil {
108 *s = ilinkString(str)
109 return nil
110 }
111 var num json.Number
112 if err := json.Unmarshal(data, &num); err == nil {
113 *s = ilinkString(num.String())
114 return nil
115 }
116 return fmt.Errorf("ilink string: expected string or number, got %s", string(data))
117 }
118
119 // adapter 微信适配器实现。
120 type adapter struct {
121 cfg config.WeixinBotConfig
122 logger *slog.Logger
123 msgCh chan bot.InboundMessage
124 cancel context.CancelFunc
125
126 mu sync.Mutex
127 contextTokens map[string]string
128 syncBuf string
129 lastUpdateID int64
130 pollReadyOnce sync.Once
131 lastPollLog time.Time
132 }
133
134 // New 创建微信 Bot 适配器。
135 func New(cfg config.WeixinBotConfig, logger *slog.Logger) bot.Adapter {
136 return &adapter{
137 cfg: cfg,
138 logger: logger.With("platform", "weixin"),
139 contextTokens: make(map[string]string),
140 }
141 }
142
143 func (a *adapter) Platform() bot.Platform { return bot.PlatformWeixin }
144 func (a *adapter) Name() string { return "weixin" }
145
146 func (a *adapter) Start(ctx context.Context) error {
147 a.msgCh = make(chan bot.InboundMessage, 64)
148 ctx, a.cancel = context.WithCancel(ctx)
149 a.loadContextTokens()
150 if a.token() == "" {
151 return a.tokenMissingError()
152 }
153
154 a.logger.Info("weixin polling started", "account", logHash(a.accountID()), "api_base", a.apiBase())
155 go a.pollLoop(ctx)
156 return nil
157 }
158
159 func (a *adapter) Stop() error {
160 if a.cancel != nil {
161 a.cancel()
162 }
163 return nil
164 }
165
166 func (a *adapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
167 return a.sendMessage(ctx, msg)
168 }
169
170 func (a *adapter) SendTyping(ctx context.Context, chatID string) error {
171 return a.sendTyping(ctx, chatID)
172 }
173
174 func (a *adapter) Messages() <-chan bot.InboundMessage {
175 return a.msgCh
176 }
177
178 // SendText sends one plain text message to a saved Weixin iLink conversation.
179 // It is used by desktop settings as an actual connection test.
180 func SendText(ctx context.Context, cfg config.WeixinBotConfig, chatID, text string) (bot.SendResult, error) {
181 a := &adapter{cfg: cfg, logger: slog.Default().With("platform", "weixin"), contextTokens: make(map[string]string)}
182 return a.sendMessage(ctx, bot.OutboundMessage{ChatID: chatID, Text: text})
183 }
184
185 // token 从环境变量获取微信 token。
186 func (a *adapter) token() string {
187 if token := os.Getenv(a.cfg.TokenEnv); token != "" {
188 return token
189 }
190 account, _ := loadSavedAccount(a.accountID())
191 if account.Token != "" {
192 return account.Token
193 }
194 if a.cfg.AccountID == "" {
195 account, _ = loadAnySavedAccount()
196 return account.Token
197 }
198 return ""
199 }
200
201 func (a *adapter) tokenMissingError() error {
202 if strings.TrimSpace(a.cfg.TokenEnv) == "" {
203 return fmt.Errorf("weixin token is not configured and no saved weixin account is available")
204 }
205 return fmt.Errorf("%s not set and no saved weixin account is available", a.cfg.TokenEnv)
206 }
207
208 // apiBase 返回 API base URL。
209 func (a *adapter) apiBase() string {
210 if a.cfg.APIBase != "" {
211 return a.cfg.APIBase
212 }
213 account, _ := loadSavedAccount(a.accountID())
214 if account.BaseURL != "" {
215 return strings.TrimRight(account.BaseURL, "/")
216 }
217 return defaultWeixinAPI
218 }
219
220 func (a *adapter) accountID() string {
221 if a.cfg.AccountID != "" {
222 return a.cfg.AccountID
223 }
224 return "default"
225 }
226
227 func (a *adapter) contextToken(chatID string) string {
228 a.mu.Lock()
229 defer a.mu.Unlock()
230 return a.contextTokens[chatID]
231 }
232
233 func (a *adapter) setContextToken(chatID, token string) {
234 a.mu.Lock()
235 if token == "" {
236 delete(a.contextTokens, chatID)
237 } else {
238 a.contextTokens[chatID] = token
239 }
240 a.mu.Unlock()
241 a.saveContextTokens()
242 }
243
244 func (a *adapter) tokenStorePath() string {
245 root := config.MemoryUserDir()
246 if root == "" {
247 return ""
248 }
249 return filepath.Join(weixinAccountDir(root), a.accountID()+".context-tokens.json")
250 }
251
252 func (a *adapter) loadContextTokens() {
253 path := a.tokenStorePath()
254 if path == "" {
255 return
256 }
257 data, err := fileencoding.ReadFileUTF8(path)
258 if err != nil {
259 return
260 }
261 var tokens map[string]string
262 if err := json.Unmarshal(data, &tokens); err != nil {
263 a.logger.Warn("failed to load weixin context tokens", "err", err)
264 return
265 }
266 a.mu.Lock()
267 a.contextTokens = tokens
268 a.mu.Unlock()
269 }
270
271 func (a *adapter) saveContextTokens() {
272 path := a.tokenStorePath()
273 if path == "" {
274 return
275 }
276 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
277 a.logger.Warn("failed to create weixin token dir", "err", err)
278 return
279 }
280 a.mu.Lock()
281 data, err := json.MarshalIndent(a.contextTokens, "", " ")
282 a.mu.Unlock()
283 if err != nil {
284 return
285 }
286 if err := os.WriteFile(path, data, 0o600); err != nil {
287 a.logger.Warn("failed to save weixin context tokens", "err", err)
288 }
289 }
290
291 func ilinkGET(ctx context.Context, baseURL, endpoint string) (map[string]any, error) {
292 req, err := http.NewRequestWithContext(ctx, "GET", strings.TrimRight(baseURL, "/")+"/"+strings.TrimLeft(endpoint, "/"), nil)
293 if err != nil {
294 return nil, err
295 }
296 req.Header.Set("iLink-App-Id", ilinkAppID)
297 req.Header.Set("iLink-App-ClientVersion", fmt.Sprintf("%d", ilinkClientVersion))
298 resp, err := weixinHTTPClient.Do(req)
299 if err != nil {
300 return nil, err
301 }
302 defer resp.Body.Close()
303 data, _ := io.ReadAll(resp.Body)
304 if resp.StatusCode >= 400 {
305 if len(data) > 200 {
306 data = data[:200]
307 }
308 return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
309 }
310 var out map[string]any
311 if err := json.Unmarshal(data, &out); err != nil {
312 return nil, err
313 }
314 return out, nil
315 }
316
317 // pollLoop 长轮询获取更新。
318 func (a *adapter) pollLoop(ctx context.Context) {
319 // 启动时短暂等待让登录完成
320 if !bot.SleepCtx(ctx, 2*time.Second) {
321 return
322 }
323
324 for {
325 if ctx.Err() != nil {
326 return
327 }
328
329 updates, err := a.getUpdates(ctx)
330 if err != nil {
331 a.logger.Error("getupdates failed", "err", err)
332 if !bot.SleepCtx(ctx, 5*time.Second) {
333 return
334 }
335 continue
336 }
337
338 for _, upd := range updates {
339 a.handleUpdate(upd)
340 }
341
342 // 没有更新时短暂等待
343 if len(updates) == 0 {
344 if !bot.SleepCtx(ctx, 500*time.Millisecond) {
345 return
346 }
347 }
348 }
349 }
350
351 // getUpdates 调用微信 iLink getupdates API。
352 func (a *adapter) getUpdates(ctx context.Context) ([]ilinkUpdate, error) {
353 tok := a.token()
354 if tok == "" {
355 return nil, a.tokenMissingError()
356 }
357
358 url := a.apiBase() + getUpdatesPath
359
360 a.mu.Lock()
361 payload := map[string]interface{}{
362 "get_updates_buf": a.syncBuf,
363 "base_info": map[string]string{
364 "channel_version": ilinkChannelVersion,
365 },
366 }
367 a.mu.Unlock()
368
369 body, _ := json.Marshal(payload)
370 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
371 if err != nil {
372 return nil, err
373 }
374 setIlinkHeaders(req, tok, body)
375
376 resp, err := weixinHTTPClient.Do(req)
377 if err != nil {
378 return nil, err
379 }
380 defer resp.Body.Close()
381
382 var result ilinkResponse
383 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
384 return nil, err
385 }
386 if result.Ret != 0 || result.Errcode != 0 {
387 return nil, fmt.Errorf("getupdates error ret=%d errcode=%d: %s", result.Ret, result.Errcode, result.Errmsg)
388 }
389 a.pollReadyOnce.Do(func() {
390 a.logger.Info("weixin getupdates ready", "account", logHash(a.accountID()), "api_base", a.apiBase())
391 })
392 a.logPollHealth(result)
393
394 a.mu.Lock()
395 if result.GetUpdatesBuf != "" {
396 a.syncBuf = result.GetUpdatesBuf
397 }
398 if len(result.Updates) > 0 {
399 last := result.Updates[len(result.Updates)-1]
400 a.lastUpdateID = last.UpdateID
401 }
402 a.mu.Unlock()
403
404 if len(result.Msgs) > 0 {
405 for _, msg := range result.Msgs {
406 a.handleIlinkMessage(msg)
407 }
408 }
409 return result.Updates, nil
410 }
411
412 func (a *adapter) logPollHealth(result ilinkResponse) {
413 shouldLog := len(result.Updates) > 0 || len(result.Msgs) > 0
414 a.mu.Lock()
415 if !shouldLog && time.Since(a.lastPollLog) >= 5*time.Minute {
416 shouldLog = true
417 }
418 if shouldLog {
419 a.lastPollLog = time.Now()
420 }
421 a.mu.Unlock()
422 if !shouldLog {
423 return
424 }
425 a.logger.Info("weixin getupdates heartbeat",
426 "updates", len(result.Updates),
427 "msgs", len(result.Msgs),
428 "has_more", result.HasMore,
429 "timeout_ms", result.LongpollingTimeoutMs)
430 }
431
432 // handleUpdate 处理单条微信更新消息。
433 func (a *adapter) handleUpdate(upd ilinkUpdate) {
434 if upd.UpdateType != "message" {
435 a.logger.Info("weixin update ignored", "reason", "non_message", "update_type", upd.UpdateType)
436 return
437 }
438
439 m := upd.Message
440 chatType := bot.ChatDM
441 if m.ChatType == "group" {
442 chatType = bot.ChatGroup
443 }
444
445 ib := bot.InboundMessage{
446 Platform: bot.PlatformWeixin,
447 ChatType: chatType,
448 ChatID: m.ChatID,
449 UserID: m.From.UserID,
450 UserName: m.From.UserName,
451 Text: m.Text,
452 MessageID: string(m.MessageID),
453 }
454
455 select {
456 case a.msgCh <- ib:
457 a.logger.Info("weixin inbound queued", "source", "update", "chat_type", chatType, "chat", logHash(ib.ChatID), "user", logHash(ib.UserID), "message", logHash(ib.MessageID), "text_chars", len([]rune(ib.Text)))
458 default:
459 a.logger.Warn("weixin message channel full")
460 }
461 }
462
463 func (a *adapter) handleIlinkMessage(m ilinkMessage) {
464 if m.FromUserID == "" || m.FromUserID == a.accountID() {
465 a.logger.Info("weixin message ignored", "reason", "self_or_missing_sender", "from", logHash(m.FromUserID), "message", logHash(string(m.MessageID)))
466 return
467 }
468 text := extractIlinkText(m.ItemList)
469 if text == "" {
470 a.logger.Info("weixin message ignored", "reason", "empty_text", "from", logHash(m.FromUserID), "message", logHash(string(m.MessageID)))
471 return
472 }
473 chatType, chatID := guessIlinkChat(m, a.accountID())
474 if chatID == "" {
475 a.logger.Info("weixin message ignored", "reason", "missing_chat", "from", logHash(m.FromUserID), "message", logHash(string(m.MessageID)))
476 return
477 }
478 if m.ContextToken != "" {
479 a.setContextToken(chatID, m.ContextToken)
480 }
481 ib := bot.InboundMessage{
482 Platform: bot.PlatformWeixin,
483 ChatType: chatType,
484 ChatID: chatID,
485 UserID: m.FromUserID,
486 UserName: m.FromUserID,
487 Text: text,
488 MessageID: string(m.MessageID),
489 }
490 select {
491 case a.msgCh <- ib:
492 a.logger.Info("weixin inbound queued", "source", "message", "chat_type", chatType, "chat", logHash(ib.ChatID), "user", logHash(ib.UserID), "message", logHash(ib.MessageID), "text_chars", len([]rune(ib.Text)))
493 default:
494 a.logger.Warn("weixin message channel full")
495 }
496 }
497
498 func logHash(id string) string {
499 if id == "" {
500 return ""
501 }
502 sum := sha256.Sum256([]byte(id))
503 return hex.EncodeToString(sum[:])[:12]
504 }
505
506 func extractIlinkText(items []struct {
507 Type int `json:"type"`
508 TextItem struct {
509 Text string `json:"text"`
510 } `json:"text_item"`
511 }) string {
512 var out []string
513 for _, item := range items {
514 if item.Type == weixinItemText && item.TextItem.Text != "" {
515 out = append(out, item.TextItem.Text)
516 }
517 }
518 return strings.TrimSpace(strings.Join(out, "\n"))
519 }
520
521 func guessIlinkChat(m ilinkMessage, accountID string) (bot.ChatType, string) {
522 roomID := firstNonEmptyString(m.RoomID, m.ChatRoomID)
523 if roomID != "" {
524 return bot.ChatGroup, roomID
525 }
526 if m.ToUserID != "" && accountID != "" && m.ToUserID != accountID && m.MsgType == 1 {
527 return bot.ChatGroup, m.ToUserID
528 }
529 return bot.ChatDM, m.FromUserID
530 }
531
532 func setIlinkHeaders(req *http.Request, token string, body []byte) {
533 req.Header.Set("Content-Type", "application/json")
534 req.Header.Set("AuthorizationType", "ilink_bot_token")
535 req.Header.Set("Authorization", "Bearer "+token)
536 req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
537 req.Header.Set("X-WECHAT-UIN", randomWechatUIN())
538 req.Header.Set("iLink-App-Id", ilinkAppID)
539 req.Header.Set("iLink-App-ClientVersion", fmt.Sprintf("%d", ilinkClientVersion))
540 }
541
542 func randomWechatUIN() string {
543 var b [4]byte
544 if _, err := rand.Read(b[:]); err != nil {
545 return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
546 }
547 return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", uint32(b[0])<<24|uint32(b[1])<<16|uint32(b[2])<<8|uint32(b[3]))))
548 }
549
550 func firstNonEmptyString(vals ...string) string {
551 for _, v := range vals {
552 if v != "" {
553 return v
554 }
555 }
556 return ""
557 }
558
559 // sendMessage 使用微信 iLink sendmessage API 发送消息。
560 func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
561 tok := a.token()
562 if tok == "" {
563 return bot.SendResult{}, a.tokenMissingError()
564 }
565
566 url := a.apiBase() + sendMessagePath
567
568 payload := map[string]interface{}{
569 "base_info": map[string]string{"channel_version": ilinkChannelVersion},
570 "msg": map[string]interface{}{
571 "from_user_id": "",
572 "to_user_id": msg.ChatID,
573 "client_id": fmt.Sprintf("reasonix-%d", time.Now().UnixNano()),
574 "message_type": weixinMsgTypeBot,
575 "message_state": weixinMsgStateDone,
576 "item_list": []map[string]interface{}{
577 {"type": weixinItemText, "text_item": map[string]string{"text": msg.Text}},
578 },
579 },
580 }
581 if contextToken := a.contextToken(msg.ChatID); contextToken != "" {
582 if m, ok := payload["msg"].(map[string]interface{}); ok {
583 m["context_token"] = contextToken
584 }
585 }
586
587 body, _ := json.Marshal(payload)
588 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
589 if err != nil {
590 return bot.SendResult{}, err
591 }
592 setIlinkHeaders(req, tok, body)
593
594 resp, err := weixinHTTPClient.Do(req)
595 if err != nil {
596 return bot.SendResult{}, err
597 }
598 defer resp.Body.Close()
599
600 var result struct {
601 Ret int `json:"ret"`
602 Errcode int `json:"errcode"`
603 Errmsg string `json:"errmsg"`
604 MessageID ilinkString `json:"message_id"`
605 }
606 respBody, _ := io.ReadAll(resp.Body)
607 if err := json.Unmarshal(respBody, &result); err != nil {
608 return bot.SendResult{}, err
609 }
610 if result.Ret != 0 || result.Errcode != 0 {
611 if a.contextToken(msg.ChatID) != "" {
612 a.setContextToken(msg.ChatID, "")
613 return a.sendMessage(ctx, msg)
614 }
615 return bot.SendResult{}, fmt.Errorf("sendmessage error ret=%d errcode=%d: %s", result.Ret, result.Errcode, result.Errmsg)
616 }
617
618 return bot.SendResult{MessageID: string(result.MessageID)}, nil
619 }
620
621 // sendTyping 发送"正在输入"状态。
622 func (a *adapter) sendTyping(ctx context.Context, chatID string) error {
623 tok := a.token()
624 if tok == "" {
625 return a.tokenMissingError()
626 }
627
628 url := a.apiBase() + sendTypingPath
629
630 payload := map[string]interface{}{
631 "base_info": map[string]string{"channel_version": ilinkChannelVersion},
632 "ilink_user_id": chatID,
633 "status": 1,
634 }
635 if contextToken := a.contextToken(chatID); contextToken != "" {
636 payload["context_token"] = contextToken
637 }
638 body, _ := json.Marshal(payload)
639
640 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
641 if err != nil {
642 return err
643 }
644 setIlinkHeaders(req, tok, body)
645
646 resp, err := weixinHTTPClient.Do(req)
647 if err != nil {
648 return err
649 }
650 defer resp.Body.Close()
651
652 var result struct {
653 Ret int `json:"ret"`
654 Errcode int `json:"errcode"`
655 Errmsg string `json:"errmsg"`
656 }
657 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
658 return err
659 }
660 if result.Ret != 0 || result.Errcode != 0 {
661 return fmt.Errorf("sendtyping error ret=%d errcode=%d: %s", result.Ret, result.Errcode, result.Errmsg)
662 }
663
664 return nil
665 }
666
666 lines GO