Repository: chenhg5/cc-connect (2026-03-24T03:32:22Z)

Issue:
[Bug] 使用企微在群聊只与机器人互动，提示需要权限时，@机器人回复，机器人无法响应，原因当需要@机器人时，在收到消息时没有strip掉@mention
### cc-connect Version

master

### Operating System

macOS

### Agent Type

Claude Code

### Platform

WeChat Work (企业微信)

### Installation Method

None

### Bug Description

platform/wecom/websocket.go 文件这里需要增加处理@mention的逻辑。   注意这里期望不仅仅是处理消息头，人回复的习惯可能也是在后面@. 比如说： 允许 @xxbot
switch body.MsgType {
   2     case "text":
   3         text := stripAtMention(body.Text.Content)
   4         slog.Debug("wecom-ws: text received", "user", body.From.UserID, "len", len(text))
   5         go p.handler(p, &core.Message{
   6             SessionKey: sessionKey, Platform: "wecom",
   7             MessageID: body.MsgID,
   8             UserID: body.From.UserID, UserName: body.From.UserID,
   9             Content: text, ReplyCtx: rctx,
  10         })
  11 

### Steps to Reproduce

NA

### Expected Behavior

NA

### Actual Behavior

NA

### Configuration (config.toml)

```toml

```

### Logs / Error Output

```shell

```

### Additional Context

_No response_


Discussion:

Patch:
diff --git a/platform/wecom/mention_strip.go b/platform/wecom/mention_strip.go
new file mode 100644
index 000000000..5df002f2b
--- /dev/null
+++ b/platform/wecom/mention_strip.go
@@ -0,0 +1,61 @@
+package wecom
+
+import "strings"
+
+// stripWeComAtMentions removes @<botId> / ＠<botId> segments so group replies like
+// "允许 @机器人" still match engine permission keywords (#98). Only affects wecom.
+func stripWeComAtMentions(s string, botIDs ...string) string {
+	s = strings.TrimSpace(s)
+	for _, id := range botIDs {
+		id = strings.TrimSpace(id)
+		if id == "" {
+			continue
+		}
+		s = stripOneWeComAtMention(s, id)
+		s = strings.TrimSpace(s)
+	}
+	for strings.Contains(s, "  ") {
+		s = strings.ReplaceAll(s, "  ", " ")
+	}
+	return strings.TrimSpace(s)
+}
+
+func stripOneWeComAtMention(s, botID string) string {
+	if s == "" || botID == "" {
+		return s
+	}
+	// Fullwidth commercial at (common on mobile keyboards)
+	s = removeAllEqualFold(s, "＠"+botID)
+	// ASCII @
+	needleLower := "@" + strings.ToLower(botID)
+	for {
+		lower := strings.ToLower(s)
+		idx := strings.Index(lower, needleLower)
+		if idx < 0 {
+			return s
+		}
+		end := idx + len(needleLower)
+		if end > len(s) {
+			return s
+		}
+		s = s[:idx] + s[end:]
+	}
+}
+
+// removeAllEqualFold removes every case-insensitive occurrence of literal sub from s.
+// sub must be UTF-8; indices align because case folding does not change byte length
+// for ASCII letters in sub.
+func removeAllEqualFold(s, sub string) string {
+	if sub == "" {
+		return s
+	}
+	subLower := strings.ToLower(sub)
+	for {
+		lower := strings.ToLower(s)
+		idx := strings.Index(lower, subLower)
+		if idx < 0 {
+			return s
+		}
+		s = s[:idx] + s[idx+len(sub):]
+	}
+}
diff --git a/platform/wecom/websocket.go b/platform/wecom/websocket.go
index 8218dd535..38bbcdd5d 100644
--- a/platform/wecom/websocket.go
+++ b/platform/wecom/websocket.go
@@ -15,27 +15,27 @@ import (
 )
 
 const (
-	wsEndpoint     = "wss://openws.work.weixin.qq.com"
-	wsPingInterval = 30 * time.Second
-	wsMaxBackoff   = 30 * time.Second
+	wsEndpoint      = "wss://openws.work.weixin.qq.com"
+	wsPingInterval  = 30 * time.Second
+	wsMaxBackoff    = 30 * time.Second
 	wsMaxMissedPong = 2
 )
 
 // WSPlatform implements core.Platform using the WeChat Work WebSocket long-connection
 // mode (智能机器人长连接). No public URL, no message encryption, no IP allowlist required.
 type WSPlatform struct {
-	botID     string
-	secret    string
-	allowFrom string
-	conn      *websocket.Conn
-	handler   core.MessageHandler
-	ctx       context.Context
-	cancel    context.CancelFunc
-	mu        sync.Mutex // protects conn writes
-	dedup     core.MessageDedup
-	reqSeq    atomic.Int64 // monotonic counter for generating unique req_id
-	missedPong atomic.Int32 // consecutive heartbeat acks not received
-	pendingAcks sync.Map   // req_id -> chan error, for sequential send with ack waiting
+	botID       string
+	secret      string
+	allowFrom   string
+	conn        *websocket.Conn
+	handler     core.MessageHandler
+	ctx         context.Context
+	cancel      context.CancelFunc
+	mu          sync.Mutex // protects conn writes
+	dedup       core.MessageDedup
+	reqSeq      atomic.Int64 // monotonic counter for generating unique req_id
+	missedPong  atomic.Int32 // consecutive heartbeat acks not received
+	pendingAcks sync.Map     // req_id -> chan error, for sequential send with ack waiting
 }
 
 const wsAckTimeout = 5 * time.Second
@@ -54,11 +54,11 @@ type wsReplyContext struct {
 // Format: { cmd, headers: { req_id }, body: {...} }
 // Response frames may omit cmd and include errcode/errmsg instead.
 type wsFrame struct {
-	Cmd     string            `json:"cmd,omitempty"`
-	Headers wsFrameHeaders    `json:"headers"`
-	Body    json.RawMessage   `json:"body,omitempty"`
-	ErrCode *int              `json:"errcode,omitempty"`
-	ErrMsg  string            `json:"errmsg,omitempty"`
+	Cmd     string          `json:"cmd,omitempty"`
+	Headers wsFrameHeaders  `json:"headers"`
+	Body    json.RawMessage `json:"body,omitempty"`
+	ErrCode *int            `json:"errcode,omitempty"`
+	ErrMsg  string          `json:"errmsg,omitempty"`
 }
 
 type wsFrameHeaders struct {
@@ -362,18 +362,19 @@ func (p *WSPlatform) handleMsgCallback(frame wsFrame) {
 
 	switch body.MsgType {
 	case "text":
-		slog.Debug("wecom-ws: text received", "user", body.From.UserID, "len", len(body.Text.Content))
+		text := stripWeComAtMentions(body.Text.Content, p.botID, body.AibotID)
+		slog.Debug("wecom-ws: text received", "user", body.From.UserID, "len", len(text))
 		go p.handler(p, &core.Message{
 			SessionKey: sessionKey, Platform: "wecom",
 			MessageID: body.MsgID,
-			UserID: body.From.UserID, UserName: body.From.UserID,
+			UserID:    body.From.UserID, UserName: body.From.UserID,
 			ChatName: chatName,
-			Content: body.Text.Content, ReplyCtx: rctx,
+			Content:  text, ReplyCtx: rctx,
 		})
 
 	case "voice":
 		// WebSocket mode: voice messages arrive pre-transcribed by the server
-		text := body.Voice.Text
+		text := stripWeComAtMentions(body.Voice.Text, p.botID, body.AibotID)
 		if text == "" {
 			slog.Debug("wecom-ws: voice message with empty transcription, ignoring")
 			return
@@ -382,9 +383,9 @@ func (p *WSPlatform) handleMsgCallback(frame wsFrame) {
 		go p.handler(p, &core.Message{
 			SessionKey: sessionKey, Platform: "wecom",
 			MessageID: body.MsgID,
-			UserID: body.From.UserID, UserName: body.From.UserID,
+			UserID:    body.From.UserID, UserName: body.From.UserID,
 			ChatName: chatName,
-			Content: text, ReplyCtx: rctx, FromVoice: true,
+			Content:  text, ReplyCtx: rctx, FromVoice: true,
 		})
 
 	default:
diff --git a/platform/wecom/wecom.go b/platform/wecom/wecom.go
index d3d1fb408..83b14b3fc 100644
--- a/platform/wecom/wecom.go
+++ b/platform/wecom/wecom.go
@@ -303,12 +303,13 @@ func (p *Platform) handleMessage(w http.ResponseWriter, r *http.Request, msgSig,
 
 	switch msg.MsgType {
 	case "text":
-		slog.Debug("wecom: message received", "user", msg.FromUserName, "text_len", len(msg.Content))
+		text := stripWeComAtMentions(msg.Content, p.agentID)
+		slog.Debug("wecom: message received", "user", msg.FromUserName, "text_len", len(text))
 		go p.handler(p, &core.Message{
 			SessionKey: sessionKey, Platform: "wecom",
 			MessageID: strconv.FormatInt(msg.MsgId, 10),
-			UserID: msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
-			Content: msg.Content, ReplyCtx: rctx,
+			UserID:    msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
+			Content: text, ReplyCtx: rctx,
 		})
 
 	case "image":
@@ -322,8 +323,8 @@ func (p *Platform) handleMessage(w http.ResponseWriter, r *http.Request, msgSig,
 			p.handler(p, &core.Message{
 				SessionKey: sessionKey, Platform: "wecom",
 				MessageID: strconv.FormatInt(msg.MsgId, 10),
-				UserID: msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
-				Images:  []core.ImageAttachment{{MimeType: "image/jpeg", Data: imgData}},
+				UserID:    msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
+				Images:   []core.ImageAttachment{{MimeType: "image/jpeg", Data: imgData}},
 				ReplyCtx: rctx,
 			})
 		}()
@@ -343,7 +344,7 @@ func (p *Platform) handleMessage(w http.ResponseWriter, r *http.Request, msgSig,
 			p.handler(p, &core.Message{
 				SessionKey: sessionKey, Platform: "wecom",
 				MessageID: strconv.FormatInt(msg.MsgId, 10),
-				UserID: msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
+				UserID:    msg.FromUserName, UserName: p.resolveUserName(msg.FromUserName),
 				Audio:    &core.AudioAttachment{MimeType: "audio/" + format, Data: audioData, Format: format},
 				ReplyCtx: rctx,
 			})


Test patch:
diff --git a/platform/wecom/mention_strip_test.go b/platform/wecom/mention_strip_test.go
new file mode 100644
index 000000000..3f23553ab
--- /dev/null
+++ b/platform/wecom/mention_strip_test.go
@@ -0,0 +1,29 @@
+package wecom
+
+import "testing"
+
+func TestStripWeComAtMentions(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name string
+		in   string
+		ids  []string
+		want string
+	}{
+		{"empty", "", []string{"x"}, ""},
+		{"no ids", "允许", nil, "允许"},
+		{"suffix mention", "允许 @mybot", []string{"mybot"}, "允许"},
+		{"prefix mention", "@MyBot 允许", []string{"mybot"}, "允许"},
+		{"fullwidth at", "允许 ＠mybot", []string{"mybot"}, "允许"},
+		{"two ids second", "ok @a @b", []string{"a", "b"}, "ok"},
+		{"unrelated at", "email x@y.com", []string{"mybot"}, "email x@y.com"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := stripWeComAtMentions(tt.in, tt.ids...)
+			if got != tt.want {
+				t.Fatalf("got %q want %q", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/platform/wecom/websocket_test.go b/platform/wecom/websocket_test.go
index 3dca1251b..f7235a1f3 100644
--- a/platform/wecom/websocket_test.go
+++ b/platform/wecom/websocket_test.go
@@ -177,6 +177,47 @@ func TestHandleMsgCallback_GroupChat_ChatIDPreserved(t *testing.T) {
 	}
 }
 
+func TestHandleMsgCallback_StripsBotMention(t *testing.T) {
+	p := &WSPlatform{
+		allowFrom: "*",
+		botID:     "robot01",
+	}
+
+	captured := make(chan *core.Message, 1)
+	p.handler = func(_ core.Platform, msg *core.Message) {
+		captured <- msg
+	}
+
+	body := wsMsgCallbackBody{
+		MsgID:    "msg_mention",
+		ChatID:   "grp1",
+		ChatType: "group",
+		MsgType:  "text",
+		AibotID:  "robot01",
+	}
+	body.From.UserID = "u1"
+	body.Text.Content = "允许 @Robot01"
+	body.CreateTime = time.Now().Unix()
+
+	bodyBytes, _ := json.Marshal(body)
+	frame := wsFrame{
+		Cmd:     "aibot_msg_callback",
+		Headers: wsFrameHeaders{ReqID: "req_m"},
+		Body:    bodyBytes,
+	}
+
+	p.handleMsgCallback(frame)
+
+	select {
+	case msg := <-captured:
+		if msg.Content != "允许" {
+			t.Fatalf("expected stripped content %q, got %q", "允许", msg.Content)
+		}
+	case <-time.After(1 * time.Second):
+		t.Fatal("handler not called")
+	}
+}
+
 // ---------------------------------------------------------------------------
 // ReconstructReplyCtx
 // ---------------------------------------------------------------------------