Skip to content

Commit 39831fb

Browse files
voidkeylyingbug
authored andcommitted
fix(wecom): strip @mention from group chat messages to fix slash commands
WeCom AI Bot does not strip @mention from text.content in group chats, causing all slash commands (/stop, /clear, /help) to fail — the content arrives as "@botName /stop" instead of "/stop". - Add stripAtMentionBasic (stateless) and stripAtMention (with bot name caching) to handle multi-word bot display names - Support bot_name in channel credentials for immediate precision - Apply stripping in both WebSocket and webhook adapters, text and mixed - Add mention_test.go with 21 test cases
1 parent b665527 commit 39831fb

File tree

4 files changed

+254
-9
lines changed

4 files changed

+254
-9
lines changed

internal/container/container.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,7 @@ func registerIMAdapterFactories(imService *imPkg.Service) {
10091009
client := wecom.NewLongConnClient(
10101010
getString(creds, "bot_id"),
10111011
getString(creds, "bot_secret"),
1012+
getString(creds, "bot_name"),
10121013
msgHandler,
10131014
)
10141015

internal/im/wecom/longconn.go

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,24 @@ type LongConnClient struct {
136136
// the previously displayed text, so we must send the full accumulated text.
137137
streamBufsMu sync.Mutex
138138
streamBufs map[string]*strings.Builder
139+
140+
// botDisplayName caches the bot's display name for @mention stripping.
141+
// Set from credentials "bot_name", or learned from double-space messages.
142+
botDisplayName atomic.Value // string
139143
}
140144

141145
// NewLongConnClient creates a WeCom long connection client.
142-
func NewLongConnClient(botID, secret string, handler MessageHandler) *LongConnClient {
143-
return &LongConnClient{
146+
// botName is the bot's display name for @mention stripping; empty to auto-detect.
147+
func NewLongConnClient(botID, secret, botName string, handler MessageHandler) *LongConnClient {
148+
c := &LongConnClient{
144149
botID: botID,
145150
secret: secret,
146151
handler: handler,
147152
}
153+
if botName != "" {
154+
c.botDisplayName.Store(botName)
155+
}
156+
return c
148157
}
149158

150159
// Start connects and runs the long connection loop. It reconnects automatically on failure.
@@ -475,7 +484,8 @@ func (c *LongConnClient) handleCallback(ctx context.Context, frame wsFrame) {
475484

476485
chatType := im.ChatTypeDirect
477486
chatID := ""
478-
if msg.ChatType == "group" {
487+
isGroup := msg.ChatType == "group"
488+
if isGroup {
479489
chatType = im.ChatTypeGroup
480490
chatID = msg.ChatID
481491
}
@@ -490,14 +500,20 @@ func (c *LongConnClient) handleCallback(ctx context.Context, frame wsFrame) {
490500

491501
switch msg.MsgType {
492502
case "text":
503+
// WeCom does not strip @mention in group chat; strip it so slash
504+
// commands (/stop, /clear) are recognized.
505+
textContent := msg.Text.Content
506+
if isGroup {
507+
textContent = c.stripAtMention(textContent)
508+
}
493509
incoming = &im.IncomingMessage{
494510
Platform: im.PlatformWeCom,
495511
MessageType: im.MessageTypeText,
496512
UserID: msg.From.UserID,
497513
UserName: msg.From.UserID,
498514
ChatID: chatID,
499515
ChatType: chatType,
500-
Content: strings.TrimSpace(msg.Text.Content),
516+
Content: strings.TrimSpace(textContent),
501517
MessageID: msg.MsgID,
502518
Extra: map[string]string{"req_id": reqID},
503519
}
@@ -558,7 +574,7 @@ func (c *LongConnClient) handleCallback(ctx context.Context, frame wsFrame) {
558574

559575
case "mixed":
560576
// Extract text parts for QA content, and detect if any images are present
561-
incoming = convertMixedMessage(&msg, chatID, chatType, reqID)
577+
incoming = c.convertMixedMessage(&msg, chatID, chatType, reqID)
562578
if incoming == nil {
563579
logger.Infof(ctx, "[WeCom] Ignoring empty mixed message")
564580
return
@@ -588,15 +604,20 @@ func (c *LongConnClient) handleCallback(ctx context.Context, frame wsFrame) {
588604

589605
// convertMixedMessage converts a WeCom mixed (text+image) message.
590606
// Extracts all text content for QA; if there's only images, treat as image message.
591-
func convertMixedMessage(msg *botMessage, chatID string, chatType im.ChatType, reqID string) *im.IncomingMessage {
607+
func (c *LongConnClient) convertMixedMessage(msg *botMessage, chatID string, chatType im.ChatType, reqID string) *im.IncomingMessage {
608+
isGroup := chatType == im.ChatTypeGroup
592609
var textParts []string
593610
var firstImageURL string
594611
var firstImageAESKey string
595612

596613
for _, item := range msg.Mixed.MsgItem {
597614
switch item.MsgType {
598615
case "text":
599-
if t := strings.TrimSpace(item.Text.Content); t != "" {
616+
t := strings.TrimSpace(item.Text.Content)
617+
if isGroup {
618+
t = c.stripAtMention(t)
619+
}
620+
if t != "" {
600621
textParts = append(textParts, t)
601622
}
602623
case "image":
@@ -651,6 +672,68 @@ func (c *LongConnClient) closeConn() {
651672
}
652673
}
653674

675+
// stripAtMentionBasic removes a leading "@Name" prefix from group chat content.
676+
// Strategies: double-space split → heuristic (space + "/" or CJK) → first @word.
677+
// Used directly by the webhook adapter (stateless) and as the base for
678+
// LongConnClient.stripAtMention (stateful).
679+
func stripAtMentionBasic(content string) string {
680+
content = strings.TrimSpace(content)
681+
if !strings.HasPrefix(content, "@") {
682+
return content
683+
}
684+
// Some WeCom clients insert two spaces between @mention and user text.
685+
if idx := strings.Index(content, " "); idx > 0 {
686+
return strings.TrimSpace(content[idx+2:])
687+
}
688+
// Heuristic: bot names are ASCII words; user content starts with "/"
689+
// or non-ASCII (CJK). Scan for the transition.
690+
for i := 1; i < len(content); i++ {
691+
if content[i] == ' ' && i+1 < len(content) {
692+
if next := content[i+1]; next == '/' || next >= 0x80 {
693+
return strings.TrimSpace(content[i+1:])
694+
}
695+
}
696+
}
697+
// Fallback: strip first @word.
698+
if idx := strings.Index(content, " "); idx > 0 {
699+
return strings.TrimSpace(content[idx+1:])
700+
}
701+
return content
702+
}
703+
704+
// stripAtMention removes the leading "@BotName" prefix from group chat messages.
705+
// Bot names may contain spaces (e.g., "WeKnora Bot"), so this adds two strategies
706+
// on top of stripAtMentionBasic: (1) double-space split with bot-name learning,
707+
// (2) cached/configured bot name prefix match.
708+
// Concurrent calls are safe; atomic.Value races are benign (same bot name).
709+
func (c *LongConnClient) stripAtMention(content string) string {
710+
content = strings.TrimSpace(content)
711+
if !strings.HasPrefix(content, "@") {
712+
return content
713+
}
714+
715+
// Strategy 1: double-space separator. Learn bot name on first occurrence
716+
// (skip if already cached to avoid false positives from user double-spaces).
717+
if idx := strings.Index(content, " "); idx > 0 {
718+
botName := content[1:idx] // between "@" and " "
719+
if cached, _ := c.botDisplayName.Load().(string); cached == "" && botName != "" {
720+
c.botDisplayName.Store(botName)
721+
}
722+
return strings.TrimSpace(content[idx+2:])
723+
}
724+
725+
// Strategy 2: cached/configured bot name with word-boundary check.
726+
if name, _ := c.botDisplayName.Load().(string); name != "" {
727+
prefix := "@" + name
728+
if strings.HasPrefix(content, prefix) && (len(content) == len(prefix) || content[len(prefix)] == ' ') {
729+
return strings.TrimSpace(content[len(prefix):])
730+
}
731+
}
732+
733+
// Strategy 3: delegate to stateless helper (heuristic scan + first-@word fallback).
734+
return stripAtMentionBasic(content)
735+
}
736+
654737
func (c *LongConnClient) writeJSON(v interface{}) error {
655738
c.mu.Lock()
656739
defer c.mu.Unlock()

internal/im/wecom/mention_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package wecom
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestStripAtMentionBasic(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
content string
11+
want string
12+
}{
13+
{"no mention", "hello world", "hello world"},
14+
{"empty string", "", ""},
15+
{"mention only no content", "@Bot", "@Bot"},
16+
{"single-word bot double-space", "@Bot /stop", "/stop"},
17+
{"single-word bot single-space", "@Bot /stop", "/stop"},
18+
{"single-word bot double-space chinese", "@Bot 你好世界", "你好世界"},
19+
{"multi-word bot double-space", "@WeKnora Bot /stop", "/stop"},
20+
{"multi-word bot double-space chinese", "@WeKnora Bot 什么是上下文工程", "什么是上下文工程"},
21+
{"multi-word bot single-space command", "@WeKnora Bot /stop", "/stop"},
22+
{"multi-word bot single-space chinese", "@WeKnora Bot 什么是老登", "什么是老登"},
23+
{"leading whitespace", " @Bot hello ", "hello"},
24+
{"double-space in user content", "@Bot hello world", "hello world"},
25+
}
26+
for _, tt := range tests {
27+
t.Run(tt.name, func(t *testing.T) {
28+
got := stripAtMentionBasic(tt.content)
29+
if got != tt.want {
30+
t.Errorf("stripAtMentionBasic(%q) = %q, want %q", tt.content, got, tt.want)
31+
}
32+
})
33+
}
34+
}
35+
36+
func TestLongConnClient_StripAtMention(t *testing.T) {
37+
t.Run("learns bot name from double-space then handles single-space", func(t *testing.T) {
38+
c := &LongConnClient{}
39+
40+
// First message: double-space → learn "WeKnora Bot"
41+
got := c.stripAtMention("@WeKnora Bot 什么是上下文工程")
42+
if got != "什么是上下文工程" {
43+
t.Errorf("first message: got %q, want %q", got, "什么是上下文工程")
44+
}
45+
46+
// Verify bot name was cached
47+
if name, _ := c.botDisplayName.Load().(string); name != "WeKnora Bot" {
48+
t.Errorf("cached bot name = %q, want %q", name, "WeKnora Bot")
49+
}
50+
51+
// Second message: single-space → should use cached name
52+
got = c.stripAtMention("@WeKnora Bot /stop")
53+
if got != "/stop" {
54+
t.Errorf("second message: got %q, want %q", got, "/stop")
55+
}
56+
57+
// Single-space with chinese content
58+
got = c.stripAtMention("@WeKnora Bot 什么是B+树")
59+
if got != "什么是B+树" {
60+
t.Errorf("chinese single-space: got %q, want %q", got, "什么是B+树")
61+
}
62+
})
63+
64+
t.Run("preconfigured bot name", func(t *testing.T) {
65+
c := &LongConnClient{}
66+
c.botDisplayName.Store("WeKnora Bot")
67+
68+
// Single-space should work immediately without learning
69+
got := c.stripAtMention("@WeKnora Bot /stop")
70+
if got != "/stop" {
71+
t.Errorf("got %q, want %q", got, "/stop")
72+
}
73+
})
74+
75+
t.Run("cached name must not partial-match a longer bot name", func(t *testing.T) {
76+
c := &LongConnClient{}
77+
c.botDisplayName.Store("Bot")
78+
79+
// "@BotX /stop" should NOT match cached "Bot" — "BotX" is a different name.
80+
// Falls through to Strategy 3 (strip first @word).
81+
got := c.stripAtMention("@BotX /stop")
82+
if got != "/stop" {
83+
t.Errorf("got %q, want %q", got, "/stop")
84+
}
85+
})
86+
87+
t.Run("NewLongConnClient with bot name", func(t *testing.T) {
88+
c := NewLongConnClient("id", "secret", "My Bot", nil)
89+
90+
got := c.stripAtMention("@My Bot /help")
91+
if got != "/help" {
92+
t.Errorf("got %q, want %q", got, "/help")
93+
}
94+
})
95+
96+
t.Run("does not overwrite cached name from user double-spaces", func(t *testing.T) {
97+
c := &LongConnClient{}
98+
c.botDisplayName.Store("Bot")
99+
100+
// User content has double space — should NOT overwrite "Bot" with "Bot hello"
101+
got := c.stripAtMention("@Bot hello world")
102+
if got != "hello world" {
103+
t.Errorf("got %q, want %q", got, "hello world")
104+
}
105+
106+
// Cached name should still be "Bot"
107+
if name, _ := c.botDisplayName.Load().(string); name != "Bot" {
108+
t.Errorf("cached bot name = %q, want %q", name, "Bot")
109+
}
110+
})
111+
112+
t.Run("no mention passthrough", func(t *testing.T) {
113+
c := &LongConnClient{}
114+
got := c.stripAtMention("hello world")
115+
if got != "hello world" {
116+
t.Errorf("got %q, want %q", got, "hello world")
117+
}
118+
})
119+
120+
t.Run("empty string", func(t *testing.T) {
121+
c := &LongConnClient{}
122+
got := c.stripAtMention("")
123+
if got != "" {
124+
t.Errorf("got %q, want %q", got, "")
125+
}
126+
})
127+
128+
t.Run("cold start single-space command", func(t *testing.T) {
129+
c := &LongConnClient{}
130+
// No cached name, single space → heuristic detects " /" boundary
131+
got := c.stripAtMention("@WeKnora Bot /stop")
132+
if got != "/stop" {
133+
t.Errorf("got %q, want %q", got, "/stop")
134+
}
135+
})
136+
137+
t.Run("cold start single-space chinese", func(t *testing.T) {
138+
c := &LongConnClient{}
139+
// No cached name, single space → heuristic detects non-ASCII boundary
140+
got := c.stripAtMention("@WeKnora Bot 什么是老登")
141+
if got != "什么是老登" {
142+
t.Errorf("got %q, want %q", got, "什么是老登")
143+
}
144+
})
145+
146+
t.Run("cold start all-ascii content falls back to first word", func(t *testing.T) {
147+
c := &LongConnClient{}
148+
// No cached name, all ASCII → heuristic can't find boundary, strips first @word
149+
got := c.stripAtMention("@WeKnora Bot hello")
150+
if got != "Bot hello" {
151+
t.Errorf("got %q, want %q", got, "Bot hello")
152+
}
153+
})
154+
}

internal/im/wecom/webhook_adapter.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,21 +167,28 @@ func (a *WebhookAdapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, err
167167
// Determine chat type
168168
chatType := im.ChatTypeDirect
169169
chatID := ""
170-
if msg.ChatID != "" {
170+
isGroup := msg.ChatID != ""
171+
if isGroup {
171172
chatType = im.ChatTypeGroup
172173
chatID = msg.ChatID
173174
}
174175

175176
switch msg.MsgType {
176177
case "text":
178+
// Strip @mention in group chat (same issue as long connection mode).
179+
// Webhook adapter has no persistent state, so use the standalone helper.
180+
textContent := msg.Content
181+
if isGroup {
182+
textContent = stripAtMentionBasic(textContent)
183+
}
177184
return &im.IncomingMessage{
178185
Platform: im.PlatformWeCom,
179186
MessageType: im.MessageTypeText,
180187
UserID: msg.FromUserName,
181188
UserName: msg.FromUserName,
182189
ChatID: chatID,
183190
ChatType: chatType,
184-
Content: strings.TrimSpace(msg.Content),
191+
Content: strings.TrimSpace(textContent),
185192
MessageID: msg.MsgID,
186193
}, nil
187194

0 commit comments

Comments
 (0)