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,