diff --git a/cmd/main.go b/cmd/main.go index 2966979..8c8c966 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,7 +15,7 @@ import ( "github.com/YspCoder/clawgo/pkg/logger" ) -var version = "1.2.0" +var version = "1.2.2" var buildTime = "unknown" const logo = ">" diff --git a/pkg/channels/weixin.go b/pkg/channels/weixin.go index 137a2b9..44dce2d 100644 --- a/pkg/channels/weixin.go +++ b/pkg/channels/weixin.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -95,6 +96,7 @@ type WeixinPendingLogin struct { LoginID string `json:"login_id,omitempty"` QRCode string `json:"qr_code,omitempty"` QRCodeImgContent string `json:"qr_code_img_content,omitempty"` + BaseURL string `json:"base_url,omitempty"` Status string `json:"status,omitempty"` LastError string `json:"last_error,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` @@ -132,6 +134,12 @@ type weixinMessageItem struct { TextItem struct { Text string `json:"text"` } `json:"text_item"` + VoiceItem struct { + Text string `json:"text"` + } `json:"voice_item"` + FileItem struct { + FileName string `json:"file_name"` + } `json:"file_item"` } type weixinAPIResponse struct { @@ -158,10 +166,12 @@ type weixinQRCodeResponse struct { } type weixinQRCodeStatusResponse struct { - Status string `json:"status"` - BotToken string `json:"bot_token"` - IlinkBotID string `json:"ilink_bot_id"` - IlinkUserID string `json:"ilink_user_id"` + Status string `json:"status"` + BotToken string `json:"bot_token"` + IlinkBotID string `json:"ilink_bot_id"` + IlinkUserID string `json:"ilink_user_id"` + BaseURL string `json:"baseurl"` + RedirectHost string `json:"redirect_host"` } func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) { @@ -301,6 +311,7 @@ func (c *WeixinChannel) StartLogin(ctx context.Context) (*WeixinPendingLogin, er LoginID: loginID, QRCode: strings.TrimSpace(payload.QRcode), QRCodeImgContent: strings.TrimSpace(firstNonEmpty(payload.QRcodeImgContent, payload.QRcode)), + BaseURL: c.config.BaseURL, Status: "wait", UpdatedAt: time.Now().UTC().Format(time.RFC3339), } @@ -675,6 +686,9 @@ func (c *WeixinChannel) pollAccount(ctx context.Context, botID string) { } resp, err := c.getUpdates(ctx, account, pollTimeout) if err != nil { + if errors.Is(err, context.Canceled) { + return + } c.updateAccountError(botID, err) consecutiveFails++ if c.isSessionExpiredError(err) { @@ -768,13 +782,30 @@ func (c *WeixinChannel) handleInboundMessage(botID string, msg weixinInboundMess itemTypesBuilder.WriteByte(',') } itemTypesBuilder.WriteString(strconv.Itoa(item.Type)) - if item.Type == 1 { + switch item.Type { + case 1: if text := strings.TrimSpace(item.TextItem.Text); text != "" { if contentBuilder.Len() > 0 { contentBuilder.WriteByte('\n') } contentBuilder.WriteString(text) } + case 2: + appendWeixinContentPart(&contentBuilder, "[image]") + case 3: + if text := strings.TrimSpace(item.VoiceItem.Text); text != "" { + appendWeixinContentPart(&contentBuilder, text) + } else { + appendWeixinContentPart(&contentBuilder, "[audio]") + } + case 4: + if name := strings.TrimSpace(item.FileItem.FileName); name != "" { + appendWeixinContentPart(&contentBuilder, fmt.Sprintf("[file: %s]", name)) + } else { + appendWeixinContentPart(&contentBuilder, "[file]") + } + case 5: + appendWeixinContentPart(&contentBuilder, "[video]") } } content := contentBuilder.String() @@ -1001,7 +1032,8 @@ func (c *WeixinChannel) refreshLoginStatus(ctx context.Context, loginID string) return nil } - reqURL := c.config.BaseURL + "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(pending.QRCode) + baseURL := normalizeWeixinBaseURL(firstNonEmpty(pending.BaseURL, c.config.BaseURL)) + reqURL := 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 @@ -1030,12 +1062,29 @@ func (c *WeixinChannel) refreshLoginStatus(ctx context.Context, loginID string) pl.LastError = "" pl.UpdatedAt = time.Now().UTC().Format(time.RFC3339) }) + case "scaned_but_redirect": + redirectBaseURL := redirectHostToWeixinBaseURL(status.RedirectHost) + if redirectBaseURL == "" { + c.updatePendingLogin(loginID, func(pl *WeixinPendingLogin) { + pl.Status = "scaned_but_redirect" + pl.LastError = "missing redirect_host" + pl.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + }) + return nil + } + c.updatePendingLogin(loginID, func(pl *WeixinPendingLogin) { + pl.Status = "scaned_but_redirect" + pl.BaseURL = redirectBaseURL + 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": + nextBaseURL := normalizeWeixinBaseURL(status.BaseURL) account := config.WeixinAccountConfig{ BotID: strings.TrimSpace(status.IlinkBotID), BotToken: strings.TrimSpace(status.BotToken), @@ -1047,6 +1096,14 @@ func (c *WeixinChannel) refreshLoginStatus(ctx context.Context, loginID string) if err := c.addOrUpdateAccount(account); err != nil { return err } + if nextBaseURL != "" { + c.mu.Lock() + if c.config.BaseURL != nextBaseURL { + c.config.BaseURL = nextBaseURL + c.schedulePersistLocked() + } + c.mu.Unlock() + } c.deletePendingLogin(loginID) default: c.updatePendingLogin(loginID, func(pl *WeixinPendingLogin) { @@ -1309,6 +1366,17 @@ func mergeWeixinAccount(existing, next config.WeixinAccountConfig) config.Weixin return out } +func appendWeixinContentPart(builder *strings.Builder, part string) { + part = strings.TrimSpace(part) + if part == "" { + return + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(part) +} + func formatTime(ts time.Time) string { if ts.IsZero() { return "" @@ -1355,6 +1423,25 @@ func splitWeixinChatID(chatID string) (string, string) { return botID, rawChatID } +func normalizeWeixinBaseURL(baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return "" + } + return strings.TrimRight(baseURL, "/") +} + +func redirectHostToWeixinBaseURL(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") { + return normalizeWeixinBaseURL(host) + } + return "https://" + strings.TrimRight(host, "/") +} + func firstNonEmpty(values ...string) string { for _, v := range values { if trimmed := strings.TrimSpace(v); trimmed != "" { diff --git a/pkg/channels/weixin_test.go b/pkg/channels/weixin_test.go index 67a2f63..89ef644 100644 --- a/pkg/channels/weixin_test.go +++ b/pkg/channels/weixin_test.go @@ -105,7 +105,7 @@ func TestWeixinHandleInboundMessageBuildsMetadataAndContent(t *testing.T) { if !ok { t.Fatalf("expected inbound message") } - if msg.Content != "hello\nworld" { + if msg.Content != "[image]\nhello\nworld\n[audio]" { t.Fatalf("unexpected content: %q", msg.Content) } if got := msg.Metadata["item_types"]; got != "2,1,1,3" { @@ -116,6 +116,45 @@ func TestWeixinHandleInboundMessageBuildsMetadataAndContent(t *testing.T) { } } +func TestWeixinHandleInboundMessageIncludesNonTextContent(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: 2}, + {Type: 3, VoiceItem: struct { + Text string `json:"text"` + }{Text: "voice text"}}, + {Type: 4, FileItem: struct { + FileName string `json:"file_name"` + }{FileName: "report.pdf"}}, + {Type: 5}, + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + msg, ok := mb.ConsumeInbound(ctx) + if !ok { + t.Fatalf("expected inbound message") + } + want := "[image]\nvoice text\n[file: report.pdf]\n[video]" + if msg.Content != want { + t.Fatalf("unexpected content:\nwant %q\n got %q", want, msg.Content) + } +} + func TestWeixinResolveAccountForCompositeChatID(t *testing.T) { mb := bus.NewMessageBus() ch, err := NewWeixinChannel(config.WeixinConfig{ @@ -619,6 +658,95 @@ func TestWeixinRefreshLoginStatusesHonorsMinGap(t *testing.T) { } } +func TestWeixinRefreshLoginStatusFollowsRedirectHost(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://initial.example", + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + ch.pendingLogins["login-1"] = &WeixinPendingLogin{ + LoginID: "login-1", + QRCode: "code-1", + BaseURL: "https://initial.example", + Status: "wait", + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + ch.loginOrder = []string{"login-1"} + + var hosts []string + ch.httpClient = &http.Client{Transport: weixinRoundTripFunc(func(req *http.Request) (*http.Response, error) { + hosts = append(hosts, req.URL.Host) + body := `{"status":"wait"}` + if req.URL.Host == "initial.example" { + body = `{"status":"scaned_but_redirect","redirect_host":"redirect.example"}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })} + + if err := ch.refreshLoginStatus(context.Background(), "login-1"); err != nil { + t.Fatalf("first refresh: %v", err) + } + pending := ch.PendingLoginByID("login-1") + if pending == nil || pending.BaseURL != "https://redirect.example" { + t.Fatalf("expected redirected pending base url, got %#v", pending) + } + if err := ch.refreshLoginStatus(context.Background(), "login-1"); err != nil { + t.Fatalf("second refresh: %v", err) + } + if len(hosts) != 2 || hosts[0] != "initial.example" || hosts[1] != "redirect.example" { + t.Fatalf("unexpected status hosts: %#v", hosts) + } +} + +func TestWeixinRefreshLoginStatusStoresConfirmedBaseURL(t *testing.T) { + mb := bus.NewMessageBus() + ch, err := NewWeixinChannel(config.WeixinConfig{ + BaseURL: "https://initial.example", + }, mb) + if err != nil { + t.Fatalf("new weixin channel: %v", err) + } + ch.pendingLogins["login-1"] = &WeixinPendingLogin{ + LoginID: "login-1", + QRCode: "code-1", + BaseURL: "https://redirect.example", + Status: "wait", + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } + ch.loginOrder = []string{"login-1"} + ch.httpClient = &http.Client{Transport: weixinRoundTripFunc(func(req *http.Request) (*http.Response, error) { + body := `{"status":"confirmed","bot_token":"token-a","ilink_bot_id":"bot-a","ilink_user_id":"u-1","baseurl":"https://confirmed.example/"}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })} + + if err := ch.refreshLoginStatus(context.Background(), "login-1"); err != nil { + t.Fatalf("refresh: %v", err) + } + if got := ch.config.BaseURL; got != "https://confirmed.example" { + t.Fatalf("expected confirmed base url to be stored, got %q", got) + } + account, ok := ch.accountConfig("bot-a") + if !ok { + t.Fatalf("expected confirmed account") + } + if account.BotToken != "token-a" || account.IlinkUserID != "u-1" { + t.Fatalf("unexpected account: %#v", account) + } + if pending := ch.PendingLoginByID("login-1"); pending != nil { + t.Fatalf("expected pending login to be removed, got %#v", pending) + } +} + func TestPollDelayForAttempt(t *testing.T) { if got := pollDelayForAttempt(1); got != weixinRetryDelay { t.Fatalf("attempt 1 delay = %s", got) @@ -678,3 +806,54 @@ func TestWeixinDoJSONWithTimeoutSetsRequestDeadline(t *testing.T) { t.Fatalf("doJSONWithTimeout: %v", err) } } + +func TestWeixinPollAccountIgnoresContextCancellation(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.BaseChannel.running.Store(true) + + reqSeen := make(chan struct{}) + release := make(chan struct{}) + ch.httpClient = &http.Client{Transport: weixinRoundTripFunc(func(req *http.Request) (*http.Response, error) { + close(reqSeen) + <-release + return nil, req.Context().Err() + })} + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + ch.pollAccount(ctx, "bot-a") + }() + + select { + case <-reqSeen: + case <-time.After(time.Second): + t.Fatalf("expected getupdates request") + } + cancel() + close(release) + + select { + case <-done: + case <-time.After(time.Second): + t.Fatalf("pollAccount did not exit after context cancellation") + } + + snapshots := ch.ListAccounts() + if len(snapshots) != 1 { + t.Fatalf("expected one account snapshot, got %d", len(snapshots)) + } + if snapshots[0].LastError != "" { + t.Fatalf("expected cancellation to leave last error empty, got %q", snapshots[0].LastError) + } +}