From 8e2bf3c4920399ac0e91c3547fe8a856720c11bf Mon Sep 17 00:00:00 2001 From: lpf Date: Mon, 23 Mar 2026 18:04:13 +0800 Subject: [PATCH] feat: add multi-account weixin channel --- cmd/cmd_gateway.go | 14 + config.example.json | 7 + pkg/api/server.go | 254 +++++++ pkg/channels/compiled_channels.go | 5 +- pkg/channels/manager.go | 28 + pkg/channels/weixin.go | 1051 +++++++++++++++++++++++++++++ pkg/channels/weixin_stub.go | 16 + pkg/channels/weixin_test.go | 142 ++++ pkg/config/config.go | 29 + pkg/config/validate.go | 3 + 10 files changed, 1548 insertions(+), 1 deletion(-) create mode 100644 pkg/channels/weixin.go create mode 100644 pkg/channels/weixin_stub.go create mode 100644 pkg/channels/weixin_test.go diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 0e65191..03059a0 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -188,6 +188,12 @@ func gatewayCmd() { if whatsAppBridge != nil { registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath) } + if rawWeixin, ok := channelManager.GetChannel("weixin"); ok { + if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok { + weixinChannel.SetConfigPath(getConfigPath()) + registryServer.SetWeixinChannel(weixinChannel) + } + } registryServer.SetCronHandler(func(action string, args map[string]interface{}) (interface{}, error) { getStr := func(k string) string { v, _ := args[k].(string) @@ -424,6 +430,14 @@ func gatewayCmd() { registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath) + if rawWeixin, ok := channelManager.GetChannel("weixin"); ok { + if weixinChannel, ok := rawWeixin.(*channels.WeixinChannel); ok { + weixinChannel.SetConfigPath(getConfigPath()) + registryServer.SetWeixinChannel(weixinChannel) + } + } else { + registryServer.SetWeixinChannel(nil) + } sentinelService.Stop() sentinelService = sentinel.NewService( getConfigPath(), diff --git a/config.example.json b/config.example.json index e2755c3..063326c 100644 --- a/config.example.json +++ b/config.example.json @@ -145,6 +145,13 @@ "inbound_message_id_dedupe_ttl_seconds": 600, "inbound_content_dedupe_window_seconds": 12, "outbound_dedupe_window_seconds": 12, + "weixin": { + "enabled": false, + "base_url": "https://ilinkai.weixin.qq.com", + "default_bot_id": "", + "accounts": [], + "allow_from": [] + }, "telegram": { "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", diff --git a/pkg/api/server.go b/pkg/api/server.go index 6a38974..54446fc 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -53,6 +53,7 @@ type Server struct { onToolsCatalog func() interface{} whatsAppBridge *channels.WhatsAppBridgeService whatsAppBase string + weixinChannel *channels.WeixinChannel oauthFlowMu sync.Mutex oauthFlows map[string]*providers.OAuthPendingFlow extraRoutesMu sync.RWMutex @@ -109,6 +110,10 @@ func (s *Server) SetWhatsAppBridge(service *channels.WhatsAppBridgeService, base s.whatsAppBase = strings.TrimSpace(basePath) } +func (s *Server) SetWeixinChannel(ch *channels.WeixinChannel) { + s.weixinChannel = ch +} + func (s *Server) handleWhatsAppBridgeWS(w http.ResponseWriter, r *http.Request) { if s.whatsAppBridge == nil { http.Error(w, "whatsapp bridge unavailable", http.StatusServiceUnavailable) @@ -190,6 +195,12 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/api/whatsapp/status", s.handleWebUIWhatsAppStatus) mux.HandleFunc("/api/whatsapp/logout", s.handleWebUIWhatsAppLogout) mux.HandleFunc("/api/whatsapp/qr.svg", s.handleWebUIWhatsAppQR) + mux.HandleFunc("/api/weixin/status", s.handleWebUIWeixinStatus) + mux.HandleFunc("/api/weixin/login/start", s.handleWebUIWeixinLoginStart) + mux.HandleFunc("/api/weixin/login/cancel", s.handleWebUIWeixinLoginCancel) + mux.HandleFunc("/api/weixin/qr.svg", s.handleWebUIWeixinQR) + mux.HandleFunc("/api/weixin/accounts/remove", s.handleWebUIWeixinAccountRemove) + mux.HandleFunc("/api/weixin/accounts/default", s.handleWebUIWeixinAccountDefault) mux.HandleFunc("/api/upload", s.handleWebUIUpload) mux.HandleFunc("/api/cron", s.handleWebUICron) mux.HandleFunc("/api/skills", s.handleWebUISkills) @@ -1318,6 +1329,227 @@ func (s *Server) webUIWhatsAppStatusPayload(ctx context.Context) (map[string]int }, http.StatusOK } +func (s *Server) handleWebUIWeixinStatus(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + payload, code := s.webUIWeixinStatusPayload(r.Context()) + writeJSONStatus(w, code, payload) +} + +func (s *Server) handleWebUIWeixinLoginStart(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.weixinChannel == nil { + http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable) + return + } + if _, err := s.weixinChannel.StartLogin(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + payload, code := s.webUIWeixinStatusPayload(r.Context()) + writeJSONStatus(w, code, payload) +} + +func (s *Server) handleWebUIWeixinLoginCancel(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.weixinChannel == nil { + http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable) + return + } + var body struct { + LoginID string `json:"login_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + if !s.weixinChannel.CancelPendingLogin(body.LoginID) { + http.Error(w, "login_id not found", http.StatusNotFound) + return + } + payload, code := s.webUIWeixinStatusPayload(r.Context()) + writeJSONStatus(w, code, payload) +} + +func (s *Server) handleWebUIWeixinQR(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + payload, code := s.webUIWeixinStatusPayload(r.Context()) + if code != http.StatusOK { + http.Error(w, "qr unavailable", http.StatusNotFound) + return + } + qrCode := "" + loginID := strings.TrimSpace(r.URL.Query().Get("login_id")) + if loginID != "" && s.weixinChannel != nil { + if pending := s.weixinChannel.PendingLoginByID(loginID); pending != nil { + qrCode = fallbackString(pending.QRCodeImgContent, pending.QRCode) + } + } + if qrCode == "" { + pendingItems, _ := payload["pending_logins"].([]interface{}) + if len(pendingItems) > 0 { + if pending, ok := pendingItems[0].(map[string]interface{}); ok { + qrCode = fallbackString(stringFromMap(pending, "qr_code_img_content"), stringFromMap(pending, "qr_code")) + } + } + } + if strings.TrimSpace(qrCode) == "" { + http.Error(w, "qr unavailable", http.StatusNotFound) + return + } + qrImage, err := qr.Encode(strings.TrimSpace(qrCode), qr.M) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = io.WriteString(w, renderQRCodeSVG(qrImage, 8, 24)) +} + +func (s *Server) handleWebUIWeixinAccountRemove(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.weixinChannel == nil { + http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable) + return + } + var body struct { + BotID string `json:"bot_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + if err := s.weixinChannel.RemoveAccount(body.BotID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + payload, code := s.webUIWeixinStatusPayload(r.Context()) + writeJSONStatus(w, code, payload) +} + +func (s *Server) handleWebUIWeixinAccountDefault(w http.ResponseWriter, r *http.Request) { + if !s.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s.weixinChannel == nil { + http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable) + return + } + var body struct { + BotID string `json:"bot_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + if err := s.weixinChannel.SetDefaultAccount(body.BotID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + payload, code := s.webUIWeixinStatusPayload(r.Context()) + writeJSONStatus(w, code, payload) +} + +func (s *Server) webUIWeixinStatusPayload(ctx context.Context) (map[string]interface{}, int) { + cfg, err := s.loadConfig() + if err != nil { + return map[string]interface{}{ + "ok": false, + "error": err.Error(), + }, http.StatusInternalServerError + } + weixinCfg := cfg.Channels.Weixin + if s.weixinChannel == nil { + return map[string]interface{}{ + "ok": false, + "enabled": weixinCfg.Enabled, + "base_url": weixinCfg.BaseURL, + "error": "weixin channel unavailable", + }, http.StatusOK + } + pendingLogins, err := s.weixinChannel.RefreshLoginStatuses(ctx) + if err != nil { + return map[string]interface{}{ + "ok": false, + "enabled": weixinCfg.Enabled, + "base_url": weixinCfg.BaseURL, + "error": err.Error(), + }, http.StatusOK + } + accounts := s.weixinChannel.ListAccounts() + pendingPayload := make([]map[string]interface{}, 0, len(pendingLogins)) + for _, pending := range pendingLogins { + pendingPayload = append(pendingPayload, map[string]interface{}{ + "login_id": pendingString(pending, "login_id"), + "qr_code": pendingString(pending, "qr_code"), + "qr_code_img_content": pendingString(pending, "qr_code_img_content"), + "status": pendingString(pending, "status"), + "last_error": pendingString(pending, "last_error"), + "updated_at": pendingString(pending, "updated_at"), + "qr_available": pending != nil && strings.TrimSpace(fallbackString(pending.QRCodeImgContent, pending.QRCode)) != "", + }) + } + var firstPending *channels.WeixinPendingLogin + if len(pendingLogins) > 0 { + firstPending = pendingLogins[0] + } + return map[string]interface{}{ + "ok": true, + "enabled": weixinCfg.Enabled, + "base_url": fallbackString(weixinCfg.BaseURL, "https://ilinkai.weixin.qq.com"), + "pending_logins": pendingPayload, + "pending_login": map[string]interface{}{ + "login_id": pendingString(firstPending, "login_id"), + "qr_code": pendingString(firstPending, "qr_code"), + "qr_code_img_content": pendingString(firstPending, "qr_code_img_content"), + "status": pendingString(firstPending, "status"), + "last_error": pendingString(firstPending, "last_error"), + "updated_at": pendingString(firstPending, "updated_at"), + "qr_available": firstPending != nil && strings.TrimSpace(fallbackString(firstPending.QRCodeImgContent, firstPending.QRCode)) != "", + }, + "accounts": accounts, + }, http.StatusOK +} + func (s *Server) loadConfig() (*cfgpkg.Config, error) { configPath := strings.TrimSpace(s.configPath) if configPath == "" { @@ -1768,6 +2000,28 @@ func fallbackString(value, fallback string) string { return strings.TrimSpace(fallback) } +func pendingString(item *channels.WeixinPendingLogin, key string) string { + if item == nil { + return "" + } + switch strings.TrimSpace(key) { + case "login_id": + return strings.TrimSpace(item.LoginID) + case "qr_code": + return strings.TrimSpace(item.QRCode) + case "qr_code_img_content": + return strings.TrimSpace(item.QRCodeImgContent) + case "status": + return strings.TrimSpace(item.Status) + case "last_error": + return strings.TrimSpace(item.LastError) + case "updated_at": + return strings.TrimSpace(item.UpdatedAt) + default: + return "" + } +} + func (s *Server) handleWebUICron(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/pkg/channels/compiled_channels.go b/pkg/channels/compiled_channels.go index 47b26e4..02506bd 100644 --- a/pkg/channels/compiled_channels.go +++ b/pkg/channels/compiled_channels.go @@ -3,7 +3,10 @@ package channels import "sort" func CompiledChannelKeys() []string { - out := make([]string, 0, 7) + out := make([]string, 0, 8) + if weixinCompiled { + out = append(out, "weixin") + } if telegramCompiled { out = append(out, "telegram") } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 4733f3d..488e3b7 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -92,6 +92,28 @@ func (m *Manager) initChannels() error { } } + if m.config.Channels.Weixin.Enabled { + if len(m.config.Channels.Weixin.Accounts) == 0 && strings.TrimSpace(m.config.Channels.Weixin.BotToken) == "" { + logger.WarnCF("channels", 0, map[string]interface{}{ + "channel": "weixin", + "error": "missing accounts", + }) + } else { + weixin, err := NewWeixinChannel(m.config.Channels.Weixin, m.bus) + if err != nil { + logger.ErrorCF("channels", 0, map[string]interface{}{ + logger.FieldChannel: "weixin", + logger.FieldError: err.Error(), + }) + } else { + m.channels["weixin"] = weixin + logger.InfoCF("channels", 0, map[string]interface{}{ + logger.FieldChannel: "weixin", + }) + } + } + } + if m.config.Channels.WhatsApp.Enabled { if m.config.Channels.WhatsApp.BridgeURL == "" { logger.WarnC("channels", logger.C0009) @@ -415,6 +437,12 @@ func (m *Manager) GetEnabledChannels() []string { return names } +func (m *Manager) GetChannel(name string) (Channel, bool) { + cur, _ := m.snapshot.Load().(map[string]Channel) + ch, ok := cur[strings.TrimSpace(name)] + return ch, ok +} + func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() channel, exists := m.channels[channelName] diff --git a/pkg/channels/weixin.go b/pkg/channels/weixin.go new file mode 100644 index 0000000..8e7085d --- /dev/null +++ b/pkg/channels/weixin.go @@ -0,0 +1,1051 @@ +//go:build !omit_weixin + +package channels + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/YspCoder/clawgo/pkg/bus" + "github.com/YspCoder/clawgo/pkg/config" + "github.com/YspCoder/clawgo/pkg/logger" +) + +const ( + weixinCompiled = true + weixinDefaultBaseURL = "https://ilinkai.weixin.qq.com" + weixinDefaultTimeout = 45 * time.Second + weixinRetryDelay = 2 * time.Second + weixinChannelVersion = "1.0.2" + weixinPersistDelay = 1200 * time.Millisecond +) + +type WeixinChannel struct { + *BaseChannel + config config.WeixinConfig + configPath string + httpClient *http.Client + runCancel cancelGuard + runCtx context.Context + + mu sync.RWMutex + accounts map[string]*weixinAccountState + accountOrder []string + chatBindings map[string]string + chatContexts map[string]string + pollers map[string]context.CancelFunc + pendingLogins map[string]*WeixinPendingLogin + loginOrder []string + persistTimer *time.Timer +} + +type WeixinAccountSnapshot struct { + BotID string `json:"bot_id"` + IlinkUserID string `json:"ilink_user_id,omitempty"` + ContextToken string `json:"context_token,omitempty"` + GetUpdatesBuf string `json:"get_updates_buf,omitempty"` + Connected bool `json:"connected"` + LastEvent string `json:"last_event,omitempty"` + LastError string `json:"last_error,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Default bool `json:"default"` + LastInboundAt string `json:"last_inbound_at,omitempty"` + LastInboundChat string `json:"last_inbound_chat,omitempty"` + LastInboundText string `json:"last_inbound_text,omitempty"` +} + +type WeixinPendingLogin struct { + LoginID string `json:"login_id,omitempty"` + QRCode string `json:"qr_code,omitempty"` + QRCodeImgContent string `json:"qr_code_img_content,omitempty"` + Status string `json:"status,omitempty"` + LastError string `json:"last_error,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type weixinAccountState struct { + cfg config.WeixinAccountConfig + connected bool + lastEvent string + lastError string + updatedAt time.Time + lastInboundAt time.Time + lastInboundChat string + lastInboundText string +} + +type weixinGetUpdatesResponse struct { + Ret int `json:"ret"` + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + ErrMsg string `json:"err_msg"` + GetUpdatesBuf string `json:"get_updates_buf"` + LongpollingTimeoutMs int `json:"longpolling_timeout_ms"` + Msgs []weixinInboundMessage `json:"msgs"` +} + +type weixinInboundMessage struct { + FromUserID string `json:"from_user_id"` + ContextToken string `json:"context_token"` + ItemList []weixinMessageItem `json:"item_list"` +} + +type weixinMessageItem struct { + Type int `json:"type"` + TextItem struct { + Text string `json:"text"` + } `json:"text_item"` +} + +type weixinAPIResponse struct { + Ret int `json:"ret"` + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + ErrMsg string `json:"err_msg"` +} + +type weixinQRCodeResponse struct { + QRcode string `json:"qrcode"` + QRcodeImgContent string `json:"qrcode_img_content"` +} + +type weixinQRCodeStatusResponse struct { + Status string `json:"status"` + BotToken string `json:"bot_token"` + IlinkBotID string `json:"ilink_bot_id"` + IlinkUserID string `json:"ilink_user_id"` +} + +func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) { + base := NewBaseChannel("weixin", cfg, messageBus, cfg.AllowFrom) + baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if baseURL == "" { + baseURL = weixinDefaultBaseURL + } + cfg.BaseURL = baseURL + + ch := &WeixinChannel{ + BaseChannel: base, + config: cfg, + httpClient: &http.Client{Timeout: weixinDefaultTimeout}, + accounts: map[string]*weixinAccountState{}, + chatBindings: map[string]string{}, + chatContexts: map[string]string{}, + pollers: map[string]context.CancelFunc{}, + pendingLogins: map[string]*WeixinPendingLogin{}, + } + for _, account := range normalizeWeixinAccounts(cfg) { + ch.accounts[account.BotID] = &weixinAccountState{cfg: account} + ch.accountOrder = append(ch.accountOrder, account.BotID) + } + sort.Strings(ch.accountOrder) + return ch, nil +} + +func (c *WeixinChannel) SupportsAction(action string) bool { + switch strings.ToLower(strings.TrimSpace(action)) { + case "", "send", "typing": + return true + default: + return false + } +} + +func (c *WeixinChannel) SetConfigPath(path string) { + c.mu.Lock() + defer c.mu.Unlock() + c.configPath = strings.TrimSpace(path) +} + +func (c *WeixinChannel) Start(ctx context.Context) error { + if c.IsRunning() { + return nil + } + runCtx, cancel := context.WithCancel(ctx) + c.runCancel.set(cancel) + c.runCtx = runCtx + c.setRunning(true) + + logger.InfoCF("weixin", 0, map[string]interface{}{ + "accounts": len(c.accountOrder), + "base_url": c.config.BaseURL, + }) + + c.mu.Lock() + accountIDs := append([]string(nil), c.accountOrder...) + c.mu.Unlock() + for _, botID := range accountIDs { + c.startAccountPollerLocked(botID) + } + return nil +} + +func (c *WeixinChannel) Stop(ctx context.Context) error { + if !c.IsRunning() { + return nil + } + c.runCancel.cancelAndClear() + c.setRunning(false) + + c.mu.Lock() + defer c.mu.Unlock() + for botID, cancel := range c.pollers { + cancel() + delete(c.pollers, botID) + } + if c.persistTimer != nil { + c.persistTimer.Stop() + c.persistTimer = nil + } + return nil +} + +func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("weixin channel not running") + } + + action := strings.ToLower(strings.TrimSpace(msg.Action)) + switch action { + case "", "send": + return c.sendMessage(ctx, msg) + case "typing": + return c.sendTyping(ctx, strings.TrimSpace(msg.ChatID), 1) + default: + return fmt.Errorf("unsupported weixin action %q", msg.Action) + } +} + +func (c *WeixinChannel) StartLogin(ctx context.Context) (*WeixinPendingLogin, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.BaseURL+"/ilink/bot/get_bot_qrcode?bot_type=3", nil) + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var payload weixinQRCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + if strings.TrimSpace(payload.QRcode) == "" { + return nil, fmt.Errorf("empty qrcode returned") + } + loginID := weixinClientID() + pending := &WeixinPendingLogin{ + LoginID: loginID, + QRCode: strings.TrimSpace(payload.QRcode), + QRCodeImgContent: strings.TrimSpace(firstNonEmpty(payload.QRcodeImgContent, payload.QRcode)), + Status: "wait", + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + + c.mu.Lock() + c.pendingLogins[loginID] = pending + c.loginOrder = append(c.loginOrder, loginID) + c.mu.Unlock() + return clonePendingLogin(pending), nil +} + +func (c *WeixinChannel) RefreshLoginStatuses(ctx context.Context) ([]*WeixinPendingLogin, error) { + c.mu.RLock() + loginIDs := append([]string(nil), c.loginOrder...) + c.mu.RUnlock() + for _, loginID := range loginIDs { + if err := c.refreshLoginStatus(ctx, loginID); err != nil { + return nil, err + } + } + return c.PendingLogins(), nil +} + +func (c *WeixinChannel) PendingLogins() []*WeixinPendingLogin { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]*WeixinPendingLogin, 0, len(c.loginOrder)) + for _, loginID := range c.loginOrder { + if pending := c.pendingLogins[loginID]; pending != nil { + out = append(out, clonePendingLogin(pending)) + } + } + return out +} + +func (c *WeixinChannel) PendingLoginByID(loginID string) *WeixinPendingLogin { + loginID = strings.TrimSpace(loginID) + if loginID == "" { + return nil + } + return c.pendingLogin(loginID) +} + +func (c *WeixinChannel) CancelPendingLogin(loginID string) bool { + loginID = strings.TrimSpace(loginID) + if loginID == "" { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + if c.pendingLogins[loginID] == nil { + return false + } + delete(c.pendingLogins, loginID) + filtered := c.loginOrder[:0] + for _, item := range c.loginOrder { + if item != loginID { + filtered = append(filtered, item) + } + } + c.loginOrder = append([]string(nil), filtered...) + return true +} + +func (c *WeixinChannel) ListAccounts() []WeixinAccountSnapshot { + c.mu.RLock() + defer c.mu.RUnlock() + + defaultBotID := c.defaultBotIDLocked() + out := make([]WeixinAccountSnapshot, 0, len(c.accountOrder)) + for _, botID := range c.accountOrder { + state := c.accounts[botID] + if state == nil { + continue + } + out = append(out, WeixinAccountSnapshot{ + BotID: state.cfg.BotID, + IlinkUserID: state.cfg.IlinkUserID, + ContextToken: state.cfg.ContextToken, + GetUpdatesBuf: state.cfg.GetUpdatesBuf, + Connected: state.connected, + LastEvent: state.lastEvent, + LastError: state.lastError, + UpdatedAt: formatTime(state.updatedAt), + Default: botID == defaultBotID, + LastInboundAt: formatTime(state.lastInboundAt), + LastInboundChat: state.lastInboundChat, + LastInboundText: state.lastInboundText, + }) + } + return out +} + +func (c *WeixinChannel) SetDefaultAccount(botID string) error { + botID = strings.TrimSpace(botID) + if botID == "" { + return fmt.Errorf("bot_id is required") + } + c.mu.Lock() + if c.accounts[botID] == nil { + c.mu.Unlock() + return fmt.Errorf("bot_id not found: %s", botID) + } + c.config.DefaultBotID = botID + c.schedulePersistLocked() + c.mu.Unlock() + return nil +} + +func (c *WeixinChannel) RemoveAccount(botID string) error { + botID = strings.TrimSpace(botID) + if botID == "" { + return fmt.Errorf("bot_id is required") + } + + c.mu.Lock() + if cancel := c.pollers[botID]; cancel != nil { + cancel() + delete(c.pollers, botID) + } + delete(c.accounts, botID) + filtered := c.accountOrder[:0] + for _, item := range c.accountOrder { + if item != botID { + filtered = append(filtered, item) + } + } + c.accountOrder = append([]string(nil), filtered...) + for chatID, owner := range c.chatBindings { + if owner == botID { + delete(c.chatBindings, chatID) + delete(c.chatContexts, chatID) + } + } + if strings.TrimSpace(c.config.DefaultBotID) == botID { + c.config.DefaultBotID = "" + } + c.schedulePersistLocked() + c.mu.Unlock() + return nil +} + +func (c *WeixinChannel) sendMessage(ctx context.Context, msg bus.OutboundMessage) error { + chatID := strings.TrimSpace(msg.ChatID) + if chatID == "" { + return fmt.Errorf("weixin chat_id is required") + } + + account, rawChatID, contextToken, err := c.resolveAccountForChat(chatID) + if err != nil { + return err + } + + reqBody := map[string]interface{}{ + "msg": map[string]interface{}{ + "from_user_id": "", + "to_user_id": rawChatID, + "client_id": weixinClientID(), + "message_type": 2, + "message_state": 2, + "context_token": contextToken, + "item_list": []map[string]interface{}{ + { + "type": 1, + "text_item": map[string]string{ + "text": msg.Content, + }, + }, + }, + }, + "base_info": map[string]string{ + "channel_version": weixinChannelVersion, + }, + } + + var resp weixinAPIResponse + if err := c.doJSON(ctx, "/ilink/bot/sendmessage", reqBody, &resp, account.cfg.BotToken); err != nil { + c.updateAccountError(account.cfg.BotID, err) + return err + } + if resp.Ret != 0 || resp.Errcode != 0 { + err := fmt.Errorf("sendmessage failed: ret=%d errcode=%d msg=%s", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + c.updateAccountError(account.cfg.BotID, err) + return err + } + c.updateAccountEvent(account.cfg.BotID, "outbound_sent", true, nil) + return nil +} + +func (c *WeixinChannel) sendTyping(ctx context.Context, chatID string, status int) error { + account, _, contextToken, err := c.resolveAccountForChat(chatID) + if err != nil { + return err + } + if strings.TrimSpace(account.cfg.IlinkUserID) == "" { + return fmt.Errorf("weixin ilink_user_id is required for typing") + } + + ticket, err := c.getTypingTicket(ctx, account.cfg, contextToken) + if err != nil { + return err + } + + reqBody := map[string]interface{}{ + "ilink_user_id": account.cfg.IlinkUserID, + "typing_ticket": ticket, + "status": status, + "base_info": map[string]string{ + "channel_version": "1.0.0", + }, + } + + var resp weixinAPIResponse + if err := c.doJSON(ctx, "/ilink/bot/sendtyping", reqBody, &resp, account.cfg.BotToken); err != nil { + return err + } + if resp.Ret != 0 { + return fmt.Errorf("sendtyping failed: ret=%d", resp.Ret) + } + return nil +} + +func (c *WeixinChannel) getTypingTicket(ctx context.Context, account config.WeixinAccountConfig, contextToken string) (string, error) { + reqBody := map[string]interface{}{ + "ilink_user_id": account.IlinkUserID, + "context_token": contextToken, + "base_info": map[string]string{ + "channel_version": "1.0.0", + }, + } + var resp struct { + Ret int `json:"ret"` + TypingTicket string `json:"typing_ticket"` + } + if err := c.doJSON(ctx, "/ilink/bot/getconfig", reqBody, &resp, account.BotToken); err != nil { + return "", err + } + if resp.Ret != 0 || strings.TrimSpace(resp.TypingTicket) == "" { + return "", fmt.Errorf("getconfig failed: ret=%d", resp.Ret) + } + return strings.TrimSpace(resp.TypingTicket), nil +} + +func (c *WeixinChannel) pollAccount(ctx context.Context, botID string) { + for { + if ctx.Err() != nil { + return + } + account, ok := c.accountConfig(botID) + if !ok { + return + } + resp, err := c.getUpdates(ctx, account) + if err != nil { + c.updateAccountError(botID, err) + if !sleepWithContext(ctx, weixinRetryDelay) { + return + } + continue + } + + if resp.LongpollingTimeoutMs > 0 { + c.httpClient.Timeout = time.Duration(resp.LongpollingTimeoutMs+10000) * time.Millisecond + } + + if next := strings.TrimSpace(resp.GetUpdatesBuf); next != "" { + c.mu.Lock() + if state := c.accounts[botID]; state != nil { + state.cfg.GetUpdatesBuf = next + c.schedulePersistLocked() + } + c.mu.Unlock() + } + + for _, msg := range resp.Msgs { + c.handleInboundMessage(botID, msg) + } + c.updateAccountEvent(botID, "poll_ok", true, nil) + } +} + +func (c *WeixinChannel) getUpdates(ctx context.Context, account config.WeixinAccountConfig) (*weixinGetUpdatesResponse, error) { + reqBody := map[string]interface{}{ + "get_updates_buf": strings.TrimSpace(account.GetUpdatesBuf), + "base_info": map[string]string{ + "channel_version": weixinChannelVersion, + }, + } + + var resp weixinGetUpdatesResponse + if err := c.doJSON(ctx, "/ilink/bot/getupdates", reqBody, &resp, account.BotToken); err != nil { + return nil, err + } + if resp.Ret != 0 || resp.Errcode != 0 { + return nil, fmt.Errorf("getupdates failed: ret=%d errcode=%d msg=%s", resp.Ret, resp.Errcode, firstNonEmpty(resp.Errmsg, resp.ErrMsg)) + } + return &resp, nil +} + +func (c *WeixinChannel) handleInboundMessage(botID string, msg weixinInboundMessage) { + rawChatID := strings.TrimSpace(msg.FromUserID) + if rawChatID == "" { + return + } + chatID := buildWeixinChatID(botID, rawChatID) + contextToken := strings.TrimSpace(msg.ContextToken) + + c.mu.Lock() + if contextToken != "" { + c.chatContexts[chatID] = contextToken + } + c.chatBindings[chatID] = botID + if state := c.accounts[botID]; state != nil { + if contextToken != "" { + state.cfg.ContextToken = contextToken + c.schedulePersistLocked() + } + state.lastInboundAt = time.Now().UTC() + state.lastInboundChat = rawChatID + } + c.mu.Unlock() + + var textParts []string + var itemTypes []string + for _, item := range msg.ItemList { + itemTypes = append(itemTypes, fmt.Sprintf("%d", item.Type)) + if item.Type == 1 { + if text := strings.TrimSpace(item.TextItem.Text); text != "" { + textParts = append(textParts, text) + } + } + } + content := strings.Join(textParts, "\n") + if content == "" { + return + } + c.mu.Lock() + if state := c.accounts[botID]; state != nil { + state.lastInboundText = truncateString(content, 120) + } + c.mu.Unlock() + + metadata := map[string]string{ + "bot_id": botID, + "context_token": contextToken, + "item_types": strings.Join(itemTypes, ","), + "raw_chat_id": rawChatID, + } + c.HandleMessage(rawChatID, chatID, content, nil, metadata) + c.updateAccountEvent(botID, "inbound_message", true, nil) +} + +func (c *WeixinChannel) accountConfig(botID string) (config.WeixinAccountConfig, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + state := c.accounts[botID] + if state == nil { + return config.WeixinAccountConfig{}, false + } + return state.cfg, true +} + +func (c *WeixinChannel) resolveAccountForChat(chatID string) (*weixinAccountState, string, string, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + chatID = strings.TrimSpace(chatID) + if explicitBotID, rawChatID := splitWeixinChatID(chatID); explicitBotID != "" && rawChatID != "" { + if state := c.accounts[explicitBotID]; state != nil { + if token := strings.TrimSpace(c.chatContexts[chatID]); token != "" { + return cloneAccountState(state), rawChatID, token, nil + } + if token := strings.TrimSpace(state.cfg.ContextToken); token != "" { + return cloneAccountState(state), rawChatID, token, nil + } + return nil, "", "", fmt.Errorf("weixin context_token missing for chat %s", chatID) + } + } + + if botID := strings.TrimSpace(c.chatBindings[chatID]); botID != "" { + if state := c.accounts[botID]; state != nil { + rawChatID := chatID + if _, parsedRawChatID := splitWeixinChatID(chatID); parsedRawChatID != "" { + rawChatID = parsedRawChatID + } + if token := strings.TrimSpace(c.chatContexts[chatID]); token != "" { + return cloneAccountState(state), rawChatID, token, nil + } + if token := strings.TrimSpace(state.cfg.ContextToken); token != "" { + return cloneAccountState(state), rawChatID, token, nil + } + return nil, "", "", fmt.Errorf("weixin context_token missing for chat %s", chatID) + } + } + + defaultBotID := c.defaultBotIDLocked() + if defaultBotID != "" { + if state := c.accounts[defaultBotID]; state != nil { + if token := strings.TrimSpace(state.cfg.ContextToken); token != "" { + return cloneAccountState(state), chatID, token, nil + } + return nil, "", "", fmt.Errorf("weixin context_token missing for default bot %s", defaultBotID) + } + } + return nil, "", "", fmt.Errorf("weixin no account available for chat %s", chatID) +} + +func (c *WeixinChannel) addOrUpdateAccount(account config.WeixinAccountConfig) error { + account.BotID = strings.TrimSpace(account.BotID) + account.BotToken = strings.TrimSpace(account.BotToken) + account.IlinkUserID = strings.TrimSpace(account.IlinkUserID) + account.ContextToken = strings.TrimSpace(account.ContextToken) + account.GetUpdatesBuf = strings.TrimSpace(account.GetUpdatesBuf) + if account.BotID == "" || account.BotToken == "" { + return fmt.Errorf("bot_id and bot_token are required") + } + + c.mu.Lock() + if state := c.accounts[account.BotID]; state != nil { + state.cfg = mergeWeixinAccount(state.cfg, account) + } else { + c.accounts[account.BotID] = &weixinAccountState{cfg: account} + c.accountOrder = append(c.accountOrder, account.BotID) + sort.Strings(c.accountOrder) + } + if strings.TrimSpace(c.config.DefaultBotID) == "" { + c.config.DefaultBotID = account.BotID + } + c.schedulePersistLocked() + c.mu.Unlock() + + c.mu.Lock() + defer c.mu.Unlock() + if c.IsRunning() { + c.startAccountPollerLocked(account.BotID) + } + return nil +} + +func (c *WeixinChannel) schedulePersistLocked() { + if strings.TrimSpace(c.configPath) == "" { + return + } + if c.persistTimer == nil { + c.persistTimer = time.AfterFunc(weixinPersistDelay, func() { + _ = c.persistAccounts() + }) + return + } + c.persistTimer.Reset(weixinPersistDelay) +} + +func (c *WeixinChannel) persistAccounts() error { + c.mu.RLock() + configPath := strings.TrimSpace(c.configPath) + cfgCopy := c.config + accounts := c.accountConfigsLocked() + c.mu.RUnlock() + if configPath == "" { + return nil + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + return err + } + cfg.Channels.Weixin.Enabled = cfgCopy.Enabled + cfg.Channels.Weixin.BaseURL = cfgCopy.BaseURL + cfg.Channels.Weixin.AllowFrom = append([]string(nil), cfgCopy.AllowFrom...) + cfg.Channels.Weixin.DefaultBotID = strings.TrimSpace(cfgCopy.DefaultBotID) + cfg.Channels.Weixin.Accounts = accounts + cfg.Channels.Weixin.BotID = "" + cfg.Channels.Weixin.BotToken = "" + cfg.Channels.Weixin.IlinkUserID = "" + cfg.Channels.Weixin.ContextToken = "" + cfg.Channels.Weixin.GetUpdatesBuf = "" + err = config.SaveConfig(configPath, cfg) + c.mu.Lock() + if c.persistTimer != nil { + c.persistTimer.Stop() + c.persistTimer = nil + } + c.mu.Unlock() + return err +} + +func (c *WeixinChannel) accountConfigsLocked() []config.WeixinAccountConfig { + out := make([]config.WeixinAccountConfig, 0, len(c.accountOrder)) + for _, botID := range c.accountOrder { + state := c.accounts[botID] + if state == nil { + continue + } + out = append(out, state.cfg) + } + return out +} + +func (c *WeixinChannel) startAccountPollerLocked(botID string) { + if !c.IsRunning() || c.runCtx == nil { + return + } + if cancel := c.pollers[botID]; cancel != nil { + cancel() + delete(c.pollers, botID) + } + accountCtx, cancel := context.WithCancel(c.runCtx) + c.pollers[botID] = cancel + go c.pollAccount(accountCtx, botID) +} + +func (c *WeixinChannel) updatePendingLogin(loginID string, mut func(*WeixinPendingLogin)) { + c.mu.Lock() + defer c.mu.Unlock() + pending := c.pendingLogins[loginID] + if pending == nil { + return + } + mut(pending) +} + +func (c *WeixinChannel) deletePendingLogin(loginID string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.pendingLogins, loginID) + filtered := c.loginOrder[:0] + for _, item := range c.loginOrder { + if item != loginID { + filtered = append(filtered, item) + } + } + c.loginOrder = append([]string(nil), filtered...) +} + +func (c *WeixinChannel) pendingLogin(loginID string) *WeixinPendingLogin { + c.mu.RLock() + defer c.mu.RUnlock() + return clonePendingLogin(c.pendingLogins[loginID]) +} + +func (c *WeixinChannel) refreshLoginStatus(ctx context.Context, loginID string) error { + pending := c.pendingLogin(loginID) + if pending == nil || strings.TrimSpace(pending.QRCode) == "" { + return nil + } + + reqURL := c.config.BaseURL + "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(pending.QRCode) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return err + } + c.applyHeaders(req, false, "") + req.Header.Set("iLink-App-ClientVersion", "1") + + resp, err := c.httpClient.Do(req) + if err != nil { + c.updatePendingLogin(loginID, func(pl *WeixinPendingLogin) { + pl.LastError = err.Error() + pl.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + }) + return nil + } + defer resp.Body.Close() + + var status weixinQRCodeStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return err + } + + switch strings.ToLower(strings.TrimSpace(status.Status)) { + case "", "wait", "scaned": + c.updatePendingLogin(loginID, func(pl *WeixinPendingLogin) { + pl.Status = firstNonEmpty(status.Status, "wait") + pl.LastError = "" + pl.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + }) + case "expired": + c.updatePendingLogin(loginID, func(pl *WeixinPendingLogin) { + pl.Status = "expired" + pl.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + }) + case "confirmed": + account := config.WeixinAccountConfig{ + BotID: strings.TrimSpace(status.IlinkBotID), + BotToken: strings.TrimSpace(status.BotToken), + IlinkUserID: strings.TrimSpace(status.IlinkUserID), + } + if strings.TrimSpace(account.BotID) == "" || strings.TrimSpace(account.BotToken) == "" { + return fmt.Errorf("confirmed login missing bot credentials") + } + if err := c.addOrUpdateAccount(account); err != nil { + return err + } + c.deletePendingLogin(loginID) + default: + c.updatePendingLogin(loginID, func(pl *WeixinPendingLogin) { + pl.Status = firstNonEmpty(status.Status, "unknown") + pl.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + }) + } + return nil +} + +func (c *WeixinChannel) defaultBotIDLocked() string { + if strings.TrimSpace(c.config.DefaultBotID) != "" { + return strings.TrimSpace(c.config.DefaultBotID) + } + if len(c.accountOrder) == 1 { + return c.accountOrder[0] + } + return "" +} + +func (c *WeixinChannel) updateAccountEvent(botID, event string, connected bool, err error) { + c.mu.Lock() + defer c.mu.Unlock() + state := c.accounts[botID] + if state == nil { + return + } + state.connected = connected + state.lastEvent = strings.TrimSpace(event) + if err != nil { + state.lastError = err.Error() + } else if connected { + state.lastError = "" + } + state.updatedAt = time.Now().UTC() +} + +func (c *WeixinChannel) updateAccountError(botID string, err error) { + c.updateAccountEvent(botID, "error", false, err) +} + +func (c *WeixinChannel) doJSON(ctx context.Context, path string, payload interface{}, out interface{}, token string) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.BaseURL+path, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + c.applyHeaders(req, true, token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + if out == nil { + return nil + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return nil +} + +func (c *WeixinChannel) applyHeaders(req *http.Request, jsonBody bool, token string) { + if jsonBody { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("AuthorizationType", "ilink_bot_token") + req.Header.Set("X-WECHAT-UIN", randomWeixinUIN()) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + } +} + +func normalizeWeixinAccounts(cfg config.WeixinConfig) []config.WeixinAccountConfig { + out := make([]config.WeixinAccountConfig, 0, len(cfg.Accounts)+1) + seen := map[string]struct{}{} + add := func(account config.WeixinAccountConfig) { + account.BotID = strings.TrimSpace(account.BotID) + account.BotToken = strings.TrimSpace(account.BotToken) + account.IlinkUserID = strings.TrimSpace(account.IlinkUserID) + account.ContextToken = strings.TrimSpace(account.ContextToken) + account.GetUpdatesBuf = strings.TrimSpace(account.GetUpdatesBuf) + if account.BotID == "" || account.BotToken == "" { + return + } + if _, ok := seen[account.BotID]; ok { + return + } + seen[account.BotID] = struct{}{} + out = append(out, account) + } + for _, account := range cfg.Accounts { + add(account) + } + add(config.WeixinAccountConfig{ + BotID: cfg.BotID, + BotToken: cfg.BotToken, + IlinkUserID: cfg.IlinkUserID, + ContextToken: cfg.ContextToken, + GetUpdatesBuf: cfg.GetUpdatesBuf, + }) + return out +} + +func clonePendingLogin(in *WeixinPendingLogin) *WeixinPendingLogin { + if in == nil { + return nil + } + cp := *in + return &cp +} + +func cloneAccountState(in *weixinAccountState) *weixinAccountState { + if in == nil { + return nil + } + cp := *in + return &cp +} + +func mergeWeixinAccount(existing, next config.WeixinAccountConfig) config.WeixinAccountConfig { + out := existing + if strings.TrimSpace(next.BotToken) != "" { + out.BotToken = strings.TrimSpace(next.BotToken) + } + if strings.TrimSpace(next.IlinkUserID) != "" { + out.IlinkUserID = strings.TrimSpace(next.IlinkUserID) + } + if strings.TrimSpace(next.ContextToken) != "" { + out.ContextToken = strings.TrimSpace(next.ContextToken) + } + if strings.TrimSpace(next.GetUpdatesBuf) != "" { + out.GetUpdatesBuf = strings.TrimSpace(next.GetUpdatesBuf) + } + return out +} + +func formatTime(ts time.Time) string { + if ts.IsZero() { + return "" + } + return ts.UTC().Format(time.RFC3339) +} + +func randomWeixinUIN() string { + buf := make([]byte, 4) + _, _ = rand.Read(buf) + val := uint32(buf[0])<<24 | uint32(buf[1])<<16 | uint32(buf[2])<<8 | uint32(buf[3]) + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", val))) +} + +func weixinClientID() string { + buf := make([]byte, 4) + _, _ = rand.Read(buf) + return fmt.Sprintf("clawgo-weixin:%d-%x", time.Now().UnixMilli(), buf) +} + +func buildWeixinChatID(botID, rawChatID string) string { + botID = strings.TrimSpace(botID) + rawChatID = strings.TrimSpace(rawChatID) + if botID == "" || rawChatID == "" { + return rawChatID + } + return botID + "|" + rawChatID +} + +func splitWeixinChatID(chatID string) (string, string) { + chatID = strings.TrimSpace(chatID) + if chatID == "" { + return "", "" + } + botID, rawChatID, ok := strings.Cut(chatID, "|") + if !ok { + return "", chatID + } + botID = strings.TrimSpace(botID) + rawChatID = strings.TrimSpace(rawChatID) + if botID == "" || rawChatID == "" { + return "", chatID + } + return botID, rawChatID +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if trimmed := strings.TrimSpace(v); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/pkg/channels/weixin_stub.go b/pkg/channels/weixin_stub.go new file mode 100644 index 0000000..51a9baa --- /dev/null +++ b/pkg/channels/weixin_stub.go @@ -0,0 +1,16 @@ +//go:build omit_weixin + +package channels + +import ( + "github.com/YspCoder/clawgo/pkg/bus" + "github.com/YspCoder/clawgo/pkg/config" +) + +type WeixinChannel struct{ disabledChannel } + +const weixinCompiled = false + +func NewWeixinChannel(cfg config.WeixinConfig, bus *bus.MessageBus) (*WeixinChannel, error) { + return nil, errChannelDisabled("weixin") +} diff --git a/pkg/channels/weixin_test.go b/pkg/channels/weixin_test.go new file mode 100644 index 0000000..426da35 --- /dev/null +++ b/pkg/channels/weixin_test.go @@ -0,0 +1,142 @@ +//go:build !omit_weixin + +package channels + +import ( + "context" + "testing" + "time" + + "github.com/YspCoder/clawgo/pkg/bus" + "github.com/YspCoder/clawgo/pkg/config" +) + +func TestBuildAndSplitWeixinChatID(t *testing.T) { + chatID := buildWeixinChatID("bot-a", "wx-user-1") + if chatID != "bot-a|wx-user-1" { + t.Fatalf("unexpected composite chat id: %s", chatID) + } + botID, rawChatID := splitWeixinChatID(chatID) + if botID != "bot-a" || rawChatID != "wx-user-1" { + t.Fatalf("unexpected split result: %s %s", botID, rawChatID) + } +} + +func TestWeixinHandleInboundMessageUsesCompositeSessionChatID(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://ilinkai.weixin.qq.com", + Accounts: []config.WeixinAccountConfig{ + {BotID: "bot-a", BotToken: "token-a"}, + }, + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + + ch.handleInboundMessage("bot-a", weixinInboundMessage{ + FromUserID: "wx-user-1", + ContextToken: "ctx-1", + ItemList: []weixinMessageItem{ + {Type: 1, TextItem: struct { + Text string `json:"text"` + }{Text: "hello"}}, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + msg, ok := mb.ConsumeInbound(ctx) + if !ok { + t.Fatalf("expected inbound message") + } + if msg.ChatID != "bot-a|wx-user-1" { + t.Fatalf("expected composite chat id, got %s", msg.ChatID) + } + if msg.SessionKey != "weixin:bot-a|wx-user-1" { + t.Fatalf("expected composite session key, got %s", msg.SessionKey) + } + if msg.SenderID != "wx-user-1" { + t.Fatalf("expected raw sender id, got %s", msg.SenderID) + } +} + +func TestWeixinResolveAccountForCompositeChatID(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://ilinkai.weixin.qq.com", + DefaultBotID: "bot-b", + Accounts: []config.WeixinAccountConfig{ + {BotID: "bot-a", BotToken: "token-a", ContextToken: "ctx-a"}, + {BotID: "bot-b", BotToken: "token-b", ContextToken: "ctx-b"}, + }, + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + + account, rawChatID, contextToken, err := ch.resolveAccountForChat("bot-a|wx-user-7") + if err != nil { + t.Fatalf("resolve account: %v", err) + } + if account.cfg.BotID != "bot-a" { + t.Fatalf("expected bot-a, got %s", account.cfg.BotID) + } + if rawChatID != "wx-user-7" { + t.Fatalf("expected raw chat id wx-user-7, got %s", rawChatID) + } + if contextToken != "ctx-a" { + t.Fatalf("expected context token ctx-a, got %s", contextToken) + } +} + +func TestWeixinSetDefaultAccount(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://ilinkai.weixin.qq.com", + Accounts: []config.WeixinAccountConfig{ + {BotID: "bot-a", BotToken: "token-a"}, + {BotID: "bot-b", BotToken: "token-b"}, + }, + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + + if err := ch.SetDefaultAccount("bot-b"); err != nil { + t.Fatalf("set default account: %v", err) + } + accounts := ch.ListAccounts() + if len(accounts) != 2 { + t.Fatalf("expected 2 accounts, got %d", len(accounts)) + } + defaultCount := 0 + for _, account := range accounts { + if account.Default { + defaultCount++ + if account.BotID != "bot-b" { + t.Fatalf("expected bot-b to be default, got %s", account.BotID) + } + } + } + if defaultCount != 1 { + t.Fatalf("expected exactly one default account, got %d", defaultCount) + } +} + +func TestWeixinCancelPendingLogin(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{BaseURL: "https://ilinkai.weixin.qq.com"}, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + ch.pendingLogins["login-1"] = &WeixinPendingLogin{LoginID: "login-1", QRCode: "code-1", Status: "wait"} + ch.loginOrder = []string{"login-1"} + + if ok := ch.CancelPendingLogin("login-1"); !ok { + t.Fatalf("expected cancel to succeed") + } + if got := ch.PendingLogins(); len(got) != 0 { + t.Fatalf("expected no pending logins after cancel, got %d", len(got)) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index eb933c3..1b08eca 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -170,6 +170,7 @@ type ChannelsConfig struct { InboundMessageIDDedupeTTLSeconds int `json:"inbound_message_id_dedupe_ttl_seconds" env:"CLAWGO_CHANNELS_INBOUND_MESSAGE_ID_DEDUPE_TTL_SECONDS"` InboundContentDedupeWindowSeconds int `json:"inbound_content_dedupe_window_seconds" env:"CLAWGO_CHANNELS_INBOUND_CONTENT_DEDUPE_WINDOW_SECONDS"` OutboundDedupeWindowSeconds int `json:"outbound_dedupe_window_seconds" env:"CLAWGO_CHANNELS_OUTBOUND_DEDUPE_WINDOW_SECONDS"` + Weixin WeixinConfig `json:"weixin"` WhatsApp WhatsAppConfig `json:"whatsapp"` Telegram TelegramConfig `json:"telegram"` Feishu FeishuConfig `json:"feishu"` @@ -187,6 +188,27 @@ type WhatsAppConfig struct { RequireMentionInGroups bool `json:"require_mention_in_groups" env:"CLAWGO_CHANNELS_WHATSAPP_REQUIRE_MENTION_IN_GROUPS"` } +type WeixinConfig struct { + Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_WEIXIN_ENABLED"` + BaseURL string `json:"base_url" env:"CLAWGO_CHANNELS_WEIXIN_BASE_URL"` + DefaultBotID string `json:"default_bot_id,omitempty"` + Accounts []WeixinAccountConfig `json:"accounts,omitempty"` + AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_WEIXIN_ALLOW_FROM"` + BotID string `json:"bot_id,omitempty" env:"CLAWGO_CHANNELS_WEIXIN_BOT_ID"` + BotToken string `json:"bot_token,omitempty" env:"CLAWGO_CHANNELS_WEIXIN_BOT_TOKEN"` + IlinkUserID string `json:"ilink_user_id,omitempty" env:"CLAWGO_CHANNELS_WEIXIN_ILINK_USER_ID"` + ContextToken string `json:"context_token,omitempty" env:"CLAWGO_CHANNELS_WEIXIN_CONTEXT_TOKEN"` + GetUpdatesBuf string `json:"get_updates_buf,omitempty" env:"CLAWGO_CHANNELS_WEIXIN_GET_UPDATES_BUF"` +} + +type WeixinAccountConfig struct { + BotID string `json:"bot_id"` + BotToken string `json:"bot_token"` + IlinkUserID string `json:"ilink_user_id,omitempty"` + ContextToken string `json:"context_token,omitempty"` + GetUpdatesBuf string `json:"get_updates_buf,omitempty"` +} + type TelegramConfig struct { Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_TELEGRAM_ENABLED"` Token string `json:"token" env:"CLAWGO_CHANNELS_TELEGRAM_TOKEN"` @@ -465,6 +487,13 @@ func DefaultConfig() *Config { InboundMessageIDDedupeTTLSeconds: 600, InboundContentDedupeWindowSeconds: 12, OutboundDedupeWindowSeconds: 12, + Weixin: WeixinConfig{ + Enabled: false, + BaseURL: "https://ilinkai.weixin.qq.com", + DefaultBotID: "", + Accounts: []WeixinAccountConfig{}, + AllowFrom: []string{}, + }, WhatsApp: WhatsAppConfig{ Enabled: false, BridgeURL: "", diff --git a/pkg/config/validate.go b/pkg/config/validate.go index 5032595..df114b8 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -187,6 +187,9 @@ func Validate(cfg *Config) []error { if cfg.Channels.OutboundDedupeWindowSeconds <= 0 { errs = append(errs, fmt.Errorf("channels.outbound_dedupe_window_seconds must be > 0")) } + if cfg.Channels.Weixin.Enabled && len(cfg.Channels.Weixin.Accounts) == 0 && strings.TrimSpace(cfg.Channels.Weixin.BotToken) == "" { + errs = append(errs, fmt.Errorf("channels.weixin.accounts or channels.weixin.bot_token is required when channels.weixin.enabled=true")) + } if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" { errs = append(errs, fmt.Errorf("channels.telegram.token is required when channels.telegram.enabled=true")) }