mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-12 01:57:29 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97df340960 |
@@ -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 = ">"
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user