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