diff --git a/config/config.example.json b/config/config.example.json index 887e8dfe98..3b3ccf3b78 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -94,7 +94,7 @@ "csgclaw": { "enabled": false, "base_url": "http://127.0.0.1:18080", - "bot_id": "u-manager", + "participant_id": "manager", "access_token": "YOUR_CSGCLAW_ACCESS_TOKEN", "allow_from": [], "group_trigger": { diff --git a/docker/config/config.json b/docker/config/config.json index c398c781d4..640a32ce03 100644 --- a/docker/config/config.json +++ b/docker/config/config.json @@ -35,7 +35,7 @@ "access_token": "your_access_token", "allow_from": [], "base_url": "http://127.0.0.1:18080", - "bot_id": "u-bot", + "participant_id": "manager", "enabled": true, "group_trigger": { "mention_only": true diff --git a/go.mod b/go.mod index 54c2751024..f8d8cacd84 100644 --- a/go.mod +++ b/go.mod @@ -35,7 +35,7 @@ require ( go.mau.fi/util v0.9.7 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.43.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -82,7 +82,7 @@ require ( github.com/vektah/gqlparser/v2 v2.5.27 // indirect go.mau.fi/libsignal v0.2.1 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/text v0.37.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -111,8 +111,8 @@ require ( github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.49.0 - golang.org/x/net v0.52.0 + golang.org/x/crypto v0.51.0 + golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.45.0 ) diff --git a/go.sum b/go.sum index ae12473f32..ea909bcbd1 100644 --- a/go.sum +++ b/go.sum @@ -286,16 +286,16 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -309,8 +309,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -343,15 +343,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -359,8 +359,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -370,8 +370,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/channels/csgclaw/csgclaw.go b/pkg/channels/csgclaw/csgclaw.go index 43732f8684..d19d58981e 100644 --- a/pkg/channels/csgclaw/csgclaw.go +++ b/pkg/channels/csgclaw/csgclaw.go @@ -58,6 +58,7 @@ type eventPayload struct { type eventContext struct { Channel string `json:"channel"` + Account string `json:"account"` ChatID string `json:"chat_id"` ChatType string `json:"chat_type"` TopicID string `json:"topic_id"` @@ -86,8 +87,8 @@ func NewChannel(cfg config.CSGClawConfig, messageBus *bus.MessageBus) (*Channel, if strings.TrimSpace(cfg.BaseURL) == "" { return nil, fmt.Errorf("csgclaw base_url is required") } - if strings.TrimSpace(cfg.BotID) == "" { - return nil, fmt.Errorf("csgclaw bot_id is required") + if strings.TrimSpace(cfg.ParticipantID) == "" { + return nil, fmt.Errorf("csgclaw participant_id is required") } if strings.TrimSpace(cfg.AccessToken) == "" { return nil, fmt.Errorf("csgclaw access_token is required") @@ -120,8 +121,8 @@ func (c *Channel) Start(ctx context.Context) error { go c.runEventLoop() logger.InfoCF("csgclaw", "CSGClaw channel started", map[string]any{ - "base_url": c.config.BaseURL, - "bot_id": c.config.BotID, + "base_url": c.config.BaseURL, + "participant_id": c.config.ParticipantID, }) return nil } @@ -234,7 +235,10 @@ func (c *Channel) openEventStream(ctx context.Context) (*http.Response, error) { } if !strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { defer resp.Body.Close() - return nil, fmt.Errorf("csgclaw events endpoint returned non-SSE content type %q", resp.Header.Get("Content-Type")) + return nil, fmt.Errorf( + "csgclaw events endpoint returned non-SSE content type %q", + resp.Header.Get("Content-Type"), + ) } return resp, nil } @@ -270,7 +274,9 @@ func (c *Channel) runEventLoop() { "events_url": c.eventsURL(), }) - err = c.consumeEvents(resp) + err = c.consumeEvents(resp.Body) + _ = resp.Body.Close() + c.clearEventStream(resp.Body) if c.ctx.Err() != nil { return } @@ -286,13 +292,8 @@ func (c *Channel) runEventLoop() { } } -func (c *Channel) consumeEvents(resp *http.Response) error { - defer func() { - _ = resp.Body.Close() - c.clearEventStream(resp.Body) - }() - - reader := bufio.NewReader(resp.Body) +func (c *Channel) consumeEvents(body io.Reader) error { + reader := bufio.NewReader(body) var ( eventType string dataLines []string @@ -301,8 +302,8 @@ func (c *Channel) consumeEvents(resp *http.Response) error { for { line, err := reader.ReadString('\n') if err != nil { - if c.ctx.Err() != nil { - return nil + if ctxErr := c.ctx.Err(); ctxErr != nil { + return ctxErr } return err } @@ -364,7 +365,7 @@ func (c *Channel) handleInboundEvent(evt eventPayload) { content := strings.TrimSpace(evt.Text) if strings.EqualFold(evt.ChatType, "group") { peerKind = "group" - isMentioned := hasInboundBotAtMention(content, c.config.BotID) + isMentioned := c.isInboundBotMentioned(evt, content) if !isMentioned && hasInboundAtMention(content) { return } @@ -414,6 +415,37 @@ func (c *Channel) handleInboundEvent(evt eventPayload) { ) } +func (c *Channel) isInboundBotMentioned(evt eventPayload, content string) bool { + for _, id := range c.inboundMentionIDs(evt) { + if hasInboundBotAtMention(content, id) { + return true + } + } + return len(evt.Mentions) > 0 +} + +func (c *Channel) inboundMentionIDs(evt eventPayload) []string { + seen := make(map[string]struct{}) + var ids []string + add := func(id string) { + id = strings.TrimSpace(id) + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + ids = append(ids, id) + } + add(c.config.ParticipantID) + add(evt.Context.Account) + for _, mention := range evt.Mentions { + add(mention) + } + return ids +} + func resolvedRoomID(evt eventPayload) string { if roomID := strings.TrimSpace(evt.RoomID); roomID != "" { return roomID @@ -578,21 +610,26 @@ func minDuration(a, b time.Duration) time.Duration { } func (c *Channel) eventsURL() string { - return c.botAPIURL("/events") + return c.participantAPIURL("/events") } func (c *Channel) sendURL() string { - return c.botAPIURL("/messages/send") + return c.participantAPIURL("/messages") } -func (c *Channel) botAPIURL(suffix string) string { +func (c *Channel) participantAPIURL(suffix string) string { baseURL, err := url.Parse(c.config.BaseURL) if err != nil { base := strings.TrimRight(c.config.BaseURL, "/") - return fmt.Sprintf("%s/api/bots/%s%s", base, url.PathEscape(c.config.BotID), suffix) + return fmt.Sprintf( + "%s/api/v1/channels/csgclaw/participants/%s%s", + base, + url.PathEscape(c.config.ParticipantID), + suffix, + ) } - pathParts := []string{"api", "bots", c.config.BotID} + pathParts := []string{"api", "v1", "channels", "csgclaw", "participants", c.config.ParticipantID} for _, part := range strings.Split(strings.Trim(suffix, "/"), "/") { if part == "" { continue diff --git a/pkg/channels/csgclaw/csgclaw_test.go b/pkg/channels/csgclaw/csgclaw_test.go index 5bb31ccb2e..90f43a9600 100644 --- a/pkg/channels/csgclaw/csgclaw_test.go +++ b/pkg/channels/csgclaw/csgclaw_test.go @@ -29,7 +29,7 @@ func TestChannelReconnectsSSEWithBackoff(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case "/api/bots/test-bot/events": + case "/api/v1/channels/csgclaw/participants/test-bot/events": attempt := eventAttempts.Add(1) if attempt == 2 || attempt == 3 { http.Error(w, "service restarting", http.StatusServiceUnavailable) @@ -42,11 +42,25 @@ func TestChannelReconnectsSSEWithBackoff(t *testing.T) { t.Fatal("response writer does not implement http.Flusher") } - payload := fmt.Sprintf(`{"message_id":"msg-%d","room_id":"room-1","chat_type":"direct","sender":{"id":"user-1","username":"alice","display_name":"Alice"},"text":"@test-bot hello-%d","timestamp":"2026-03-26T00:00:00Z"}`, attempt, attempt) + payload, err := json.Marshal(eventPayload{ + MessageID: fmt.Sprintf("msg-%d", attempt), + RoomID: "room-1", + ChatType: "direct", + Sender: sender{ + ID: "user-1", + Username: "alice", + DisplayName: "Alice", + }, + Text: fmt.Sprintf("@test-bot hello-%d", attempt), + Timestamp: "2026-03-26T00:00:00Z", + }) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } _, _ = fmt.Fprintf(w, "event: message\n") - _, _ = fmt.Fprintf(w, "data: %s\n\n", payload) + _, _ = fmt.Fprintf(w, "data: %s\n\n", string(payload)) flusher.Flush() - case "/api/bots/test-bot/messages/send": + case "/api/v1/channels/csgclaw/participants/test-bot/messages": w.WriteHeader(http.StatusOK) default: http.NotFound(w, r) @@ -58,9 +72,9 @@ func TestChannelReconnectsSSEWithBackoff(t *testing.T) { defer mb.Close() ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: server.URL, - BotID: "test-bot", - AccessToken: "secret", + BaseURL: server.URL, + ParticipantID: "test-bot", + AccessToken: "secret", }, mb) if err != nil { t.Fatalf("NewChannel() error = %v", err) @@ -101,12 +115,17 @@ func TestHasInboundBotAtMention(t *testing.T) { content string ok bool }{ - {name: "matching at tag", botID: "u-manager", content: `manager hello`, ok: true}, - {name: "other at tag ignored", botID: "u-manager", content: `alice hello`, ok: false}, - {name: "later matching at tag works", botID: "u-manager", content: `alice manager hello`, ok: true}, - {name: "plain at text ignored", botID: "u-manager", content: "@manager hello", ok: false}, - {name: "missing quote ignored", botID: "u-manager", content: `manager hello`, ok: true}, + {name: "other at tag ignored", botID: "manager", content: `alice hello`, ok: false}, + { + name: "later matching at tag works", + botID: "manager", + content: `alice manager hello`, + ok: true, + }, + {name: "plain at text ignored", botID: "manager", content: "@manager hello", ok: false}, + {name: "missing quote ignored", botID: "manager", content: `manager hi`, want: `@manager hi`}, - {name: "multiple mentions", content: `alice hi manager`, want: `@alice hi @manager`}, - {name: "empty mention name keeps original tag", content: ` hi`, want: ` hi`}, - {name: "broken tag keeps tail", content: `manager hi`, want: `manager hi`}, + {name: "single mention", content: `manager hi`, want: `@manager hi`}, + { + name: "multiple mentions", + content: `alice hi manager`, + want: `@alice hi @manager`, + }, + { + name: "empty mention name keeps original tag", + content: ` hi`, + want: ` hi`, + }, + { + name: "broken tag keeps tail", + content: `manager hi`, + want: `manager hi`, + }, } for _, tt := range tests { @@ -142,21 +173,8 @@ func TestNormalizeInboundAtMentions(t *testing.T) { } func TestHandleInboundEventDirectAlwaysProcesses(t *testing.T) { - mb := bus.NewMessageBus() - defer mb.Close() - - ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: "http://127.0.0.1:18080", - BotID: "u-manager", - AccessToken: "secret", - }, mb) - if err != nil { - t.Fatalf("NewChannel() error = %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch.ctx = ctx + mb := newTestMessageBus(t) + ch := newTestChannel(t, mb, "manager") ch.handleInboundEvent(eventPayload{ MessageID: "msg-1", @@ -168,35 +186,12 @@ func TestHandleInboundEventDirectAlwaysProcesses(t *testing.T) { Text: "hello", }) - select { - case msg := <-mb.InboundChan(): - if msg.ChatID != "room-1" { - t.Fatalf("inbound chat ID = %q, want %q", msg.ChatID, "room-1") - } - if msg.Content != "hello" { - t.Fatalf("inbound content = %q, want %q", msg.Content, "hello") - } - case <-time.After(50 * time.Millisecond): - t.Fatal("timed out waiting for inbound message") - } + assertInboundMessage(t, mb, "room-1", "hello") } func TestHandleInboundEventGroupIgnoresNonBotMention(t *testing.T) { - mb := bus.NewMessageBus() - defer mb.Close() - - ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: "http://127.0.0.1:18080", - BotID: "u-manager", - AccessToken: "secret", - }, mb) - if err != nil { - t.Fatalf("NewChannel() error = %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch.ctx = ctx + mb := newTestMessageBus(t) + ch := newTestChannel(t, mb, "manager") ch.handleInboundEvent(eventPayload{ MessageID: "msg-1", @@ -208,29 +203,12 @@ func TestHandleInboundEventGroupIgnoresNonBotMention(t *testing.T) { Text: `alice hello`, }) - select { - case msg := <-mb.InboundChan(): - t.Fatalf("unexpected inbound message published: %+v", msg) - case <-time.After(50 * time.Millisecond): - } + assertNoInboundMessage(t, mb) } func TestHandleInboundEventGroupProcessesBotMention(t *testing.T) { - mb := bus.NewMessageBus() - defer mb.Close() - - ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: "http://127.0.0.1:18080", - BotID: "u-manager", - AccessToken: "secret", - }, mb) - if err != nil { - t.Fatalf("NewChannel() error = %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch.ctx = ctx + mb := newTestMessageBus(t) + ch := newTestChannel(t, mb, "manager") ch.handleInboundEvent(eventPayload{ MessageID: "msg-1", @@ -239,40 +217,43 @@ func TestHandleInboundEventGroupProcessesBotMention(t *testing.T) { Sender: sender{ ID: "user-1", }, - Text: `manager hi`, + Text: `manager hi`, }) - select { - case msg := <-mb.InboundChan(): - if msg.ChatID != "room-1" { - t.Fatalf("inbound chat ID = %q, want %q", msg.ChatID, "room-1") - } - if msg.Content != `@manager hi` { - t.Fatalf("inbound content = %q, want %q", msg.Content, `@manager hi`) - } - case <-time.After(50 * time.Millisecond): - t.Fatal("timed out waiting for inbound message") - } + assertInboundMessage(t, mb, "room-1", `@manager hi`) } -func TestHandleInboundEventThreadUsesTopicChatID(t *testing.T) { - mb := bus.NewMessageBus() - defer mb.Close() +func TestHandleInboundEventGroupProcessesCanonicalParticipantMentionWhenConfigUsesAgentID(t *testing.T) { + mb := newTestMessageBus(t) + ch := newTestChannel(t, mb, "u-agent-hhtz4b") - ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: "http://127.0.0.1:18080", - BotID: "u-manager", - AccessToken: "secret", - }, mb) - if err != nil { - t.Fatalf("NewChannel() error = %v", err) - } + ch.handleInboundEvent(eventPayload{ + MessageID: "msg-1", + RoomID: "room-1", + ChatType: "group", + Sender: sender{ + ID: "user-1", + }, + Text: `qa hi`, + Context: eventContext{Account: "agent-hhtz4b"}, + }) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch.ctx = ctx + assertInboundMessage(t, mb, "room-1", `@qa hi`) +} - ch.dispatchEvent("message", `{"message_id":"msg-reply","room_id":"room-1","chat_type":"group","thread_root_id":"msg-root","sender":{"id":"user-1"},"text":"manager hi","context":{"topic_id":"msg-root"}}`) +func TestHandleInboundEventThreadUsesTopicChatID(t *testing.T) { + mb := newTestMessageBus(t) + ch := newTestChannel(t, mb, "manager") + + dispatchTestMessage(t, ch, eventPayload{ + MessageID: "msg-reply", + RoomID: "room-1", + ChatType: "group", + ThreadRootID: "msg-root", + Sender: sender{ID: "user-1"}, + Text: `manager hi`, + Context: eventContext{TopicID: "msg-root"}, + }) select { case msg := <-mb.InboundChan(): @@ -305,7 +286,7 @@ func TestHandleInboundEventThreadUsesTopicChatID(t *testing.T) { func TestSendThreadMessageIncludesTopicContext(t *testing.T) { var got map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/bots/u-manager/messages/send" { + if r.URL.Path != "/api/v1/channels/csgclaw/participants/manager/messages" { http.NotFound(w, r) return } @@ -324,9 +305,9 @@ func TestSendThreadMessageIncludesTopicContext(t *testing.T) { defer mb.Close() ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: server.URL, - BotID: "u-manager", - AccessToken: "secret", + BaseURL: server.URL, + ParticipantID: "manager", + AccessToken: "secret", }, mb) if err != nil { t.Fatalf("NewChannel() error = %v", err) @@ -362,45 +343,87 @@ func TestSendThreadMessageIncludesTopicContext(t *testing.T) { } func TestHandleInboundEventConsumesRoomIDPayload(t *testing.T) { + mb := newTestMessageBus(t) + ch := newTestChannel(t, mb, "manager") + + ch.handleInboundEvent(eventPayload{ + MessageID: "msg-1", + RoomID: "room-1", + ChatType: "direct", + Sender: sender{ + ID: "user-1", + }, + Text: "hi", + }) + + assertInboundMessage(t, mb, "room-1", "hi") +} + +func newTestMessageBus(t *testing.T) *bus.MessageBus { + t.Helper() + mb := bus.NewMessageBus() - defer mb.Close() + t.Cleanup(mb.Close) + + return mb +} + +func newTestChannel(t *testing.T, mb *bus.MessageBus, participantID string) *Channel { + t.Helper() ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: "http://127.0.0.1:18080", - BotID: "u-manager", - AccessToken: "secret", + BaseURL: "http://127.0.0.1:18080", + ParticipantID: participantID, + AccessToken: "secret", }, mb) if err != nil { t.Fatalf("NewChannel() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + t.Cleanup(cancel) ch.ctx = ctx - ch.handleInboundEvent(eventPayload{ - MessageID: "msg-1", - RoomID: "room-1", - ChatType: "direct", - Sender: sender{ - ID: "user-1", - }, - Text: "hi", - }) + return ch +} + +func dispatchTestMessage(t *testing.T, ch *Channel, evt eventPayload) { + t.Helper() + + raw, err := json.Marshal(evt) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + ch.dispatchEvent("message", string(raw)) +} + +func assertInboundMessage(t *testing.T, mb *bus.MessageBus, wantChatID, wantContent string) { + t.Helper() select { case msg := <-mb.InboundChan(): - if msg.ChatID != "room-1" { - t.Fatalf("inbound chat ID = %q, want %q", msg.ChatID, "room-1") + if msg.ChatID != wantChatID { + t.Fatalf("inbound chat ID = %q, want %q", msg.ChatID, wantChatID) } - if msg.Content != "hi" { - t.Fatalf("inbound content = %q, want %q", msg.Content, "hi") + if msg.Content != wantContent { + t.Fatalf("inbound content = %q, want %q", msg.Content, wantContent) } case <-time.After(50 * time.Millisecond): t.Fatal("timed out waiting for inbound message") } } +func assertNoInboundMessage(t *testing.T, mb *bus.MessageBus) { + t.Helper() + + select { + case msg := <-mb.InboundChan(): + t.Fatalf("unexpected inbound message published: %+v", msg) + case <-time.After(50 * time.Millisecond): + } +} + func waitInboundMessage(t *testing.T, ch <-chan bus.InboundMessage, timeout time.Duration) bus.InboundMessage { t.Helper() diff --git a/pkg/channels/csgclaw/url_test.go b/pkg/channels/csgclaw/url_test.go index f37d72aeac..562a6d5232 100644 --- a/pkg/channels/csgclaw/url_test.go +++ b/pkg/channels/csgclaw/url_test.go @@ -7,22 +7,22 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -func TestBotAPIURLPreservesBaseQueryAndPath(t *testing.T) { +func TestParticipantAPIURLPreservesBaseQueryAndPath(t *testing.T) { mb := bus.NewMessageBus() defer mb.Close() ch, err := NewChannel(config.CSGClawConfig{ - BaseURL: "http://127.0.0.1:8080/v1?name=foo", - BotID: "test-bot", - AccessToken: "secret", + BaseURL: "http://127.0.0.1:8080/v1?name=foo", + ParticipantID: "test-bot", + AccessToken: "secret", }, mb) if err != nil { t.Fatalf("NewChannel() error = %v", err) } - got := ch.botAPIURL("/events") - want := "http://127.0.0.1:8080/v1/api/bots/test-bot/events?name=foo" + got := ch.participantAPIURL("/events") + want := "http://127.0.0.1:8080/v1/api/v1/channels/csgclaw/participants/test-bot/events?name=foo" if got != want { - t.Fatalf("botAPIURL() = %q, want %q", got, want) + t.Fatalf("participantAPIURL() = %q, want %q", got, want) } } diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index b10d497beb..ed70ddf09f 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -19,7 +19,11 @@ type FeishuChannel struct { var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures") // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported -func NewFeishuChannel(cfg config.FeishuConfig, csgclawCfg config.CSGClawConfig, bus *bus.MessageBus) (*FeishuChannel, error) { +func NewFeishuChannel( + cfg config.FeishuConfig, + csgclawCfg config.CSGClawConfig, + bus *bus.MessageBus, +) (*FeishuChannel, error) { return nil, errors.New( "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config", ) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index d033262f40..11660a6a7a 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -75,7 +75,11 @@ type feishuSSEMessage struct { Mentions []string `json:"mentions"` } -func NewFeishuChannel(cfg config.FeishuConfig, csgclawCfg config.CSGClawConfig, bus *bus.MessageBus) (*FeishuChannel, error) { +func NewFeishuChannel( + cfg config.FeishuConfig, + csgclawCfg config.CSGClawConfig, + bus *bus.MessageBus, +) (*FeishuChannel, error) { base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), @@ -143,11 +147,14 @@ func (c *FeishuChannel) Start(ctx context.Context) error { if c.hasCSGClawSSEConfig() { go c.runCSGClawSSELoop(runCtx) logger.InfoCF("feishu", "Feishu CSGClaw SSE listener started", map[string]any{ - "base_url": c.csgclawConfig.BaseURL, - "bot_id": c.csgclawConfig.BotID, + "base_url": c.csgclawConfig.BaseURL, + "participant_id": c.csgclawConfig.ParticipantID, }) } else if c.hasPartialCSGClawSSEConfig() { - logger.WarnC("feishu", "Feishu CSGClaw SSE listener disabled because base_url, bot_id, or access_token is missing") + logger.WarnC( + "feishu", + "Feishu CSGClaw SSE listener disabled because base_url, participant_id, or access_token is missing", + ) } return nil @@ -526,13 +533,13 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. func (c *FeishuChannel) hasCSGClawSSEConfig() bool { return strings.TrimSpace(c.csgclawConfig.BaseURL) != "" && - strings.TrimSpace(c.csgclawConfig.BotID) != "" && + strings.TrimSpace(c.csgclawConfig.ParticipantID) != "" && strings.TrimSpace(c.csgclawConfig.AccessToken) != "" } func (c *FeishuChannel) hasPartialCSGClawSSEConfig() bool { return strings.TrimSpace(c.csgclawConfig.BaseURL) != "" || - strings.TrimSpace(c.csgclawConfig.BotID) != "" || + strings.TrimSpace(c.csgclawConfig.ParticipantID) != "" || strings.TrimSpace(c.csgclawConfig.AccessToken) != "" } @@ -746,8 +753,8 @@ func (c *FeishuChannel) feishuSSEPayloadToLarkEvent(payload feishuSSEPayload) (* func (c *FeishuChannel) csgclawFeishuEventsURL() string { return strings.TrimRight(c.csgclawConfig.BaseURL, "/") + - "/api/v1/channels/feishu/bots/" + - url.PathEscape(c.csgclawConfig.BotID) + + "/api/v1/channels/feishu/participants/" + + url.PathEscape(c.csgclawConfig.ParticipantID) + "/events" } diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 219045a5a0..cb8aab5fde 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -285,8 +285,8 @@ func TestExtractFeishuSenderID(t *testing.T) { func TestFeishuSSEPayloadToLarkEvent(t *testing.T) { ch := &FeishuChannel{ csgclawConfig: config.CSGClawConfig{ - BaseURL: "https://csg.example.com/", - BotID: "u-manager", + BaseURL: "https://csg.example.com/", + ParticipantID: "u-manager", }, } @@ -329,7 +329,7 @@ func TestFeishuSSEPayloadToLarkEvent(t *testing.T) { if got := stringValue(event.Event.Message.Mentions[0].Id.OpenId); got != "ou_2074" { t.Fatalf("mention open id = %q, want ou_2074", got) } - if got := ch.csgclawFeishuEventsURL(); got != "https://csg.example.com/api/v1/channels/feishu/bots/u-manager/events" { + if got := ch.csgclawFeishuEventsURL(); got != "https://csg.example.com/api/v1/channels/feishu/participants/u-manager/events" { t.Fatalf("events url = %q", got) } if got, _ := ch.botOpenID.Load().(string); got != "ou_2074" { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index c2c20d8dcf..b254b4122c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -359,7 +359,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { if channels.CSGClaw.Enabled && channels.CSGClaw.BaseURL != "" && - channels.CSGClaw.BotID != "" && + channels.CSGClaw.ParticipantID != "" && channels.CSGClaw.AccessToken != "" { m.initChannel("csgclaw", "CSGClaw") } diff --git a/pkg/config/config.go b/pkg/config/config.go index 933bb13149..c3b834b177 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -431,13 +431,13 @@ type TelegramConfig struct { } type CSGClawConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_CSGCLAW_ENABLED"` - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_CSGCLAW_BASE_URL"` - BotID string `json:"bot_id" env:"PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_CSGCLAW_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_CSGCLAW_ENABLED"` + BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_CSGCLAW_BASE_URL"` + ParticipantID string `json:"participant_id" env:"PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_CSGCLAW_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_CSGCLAW_REASONING_CHANNEL_ID"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_CSGCLAW_REASONING_CHANNEL_ID"` } // Token returns the Telegram bot token diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9109e48fd5..0435f5ed1a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -69,10 +69,10 @@ func DefaultConfig() *Config { UseMarkdownV2: false, }, CSGClaw: CSGClawConfig{ - Enabled: false, - BaseURL: "http://127.0.0.1:18080", - BotID: "", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + BaseURL: "http://127.0.0.1:18080", + ParticipantID: "", + AllowFrom: FlexibleStringSlice{}, }, Feishu: FeishuConfig{ Enabled: false,