From 63f14779bb4d40a1f7045a4f42f5211c11c1ff95 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 00:19:50 -0700 Subject: [PATCH 01/19] docs: add design spec for stripping bot @mention in group chat commands Co-Authored-By: Claude Sonnet 4.6 --- ...ip-bot-mention-in-group-commands-design.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md diff --git a/docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md b/docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md new file mode 100644 index 00000000..d83e533f --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md @@ -0,0 +1,164 @@ +# Design: Strip Bot @Mention in Group Chat Commands + +**Date:** 2026-04-20 + +## Problem + +When a user sends a command to the bot via @mention in a group chat, the message content includes the `@BotName` prefix or suffix. This causes command parsing to fail because the command handlers check `fields[0]` for the command token (e.g., `/clear`, `/engine`). + +Examples of broken inputs: +- `@机器人 /clear` → parsed as non-command (fields[0] = `@机器人`) +- `/clear 张三 @机器人` → `/clear` runs but with garbled arg `张三 @机器人` +- `@机器人 /engine codex` → not recognized as command + +## Goals + +- Allow `/clear`, `/clear `, `/engine `, `/engine ` to work when prefixed, suffixed, or surrounded by `@BotName` tokens +- Exact case-sensitive match on configured bot name +- Do not alter the message content stored in the database (preserve original for audit and feeder processing) +- Each platform can independently configure its own bot name + +## Non-Goals + +- Stripping arbitrary @user mentions (only the bot's own name) +- Case-insensitive matching +- Runtime/dynamic bot name discovery (config-driven only) + +## Architecture + +### Configuration + +Add `BotName string` to each platform config struct in `internal/infra/config/config.go`: + +```go +type FeishuConfig struct { + Enabled bool `yaml:"enabled"` + AppID string `yaml:"app_id"` + AppSecret string `yaml:"app_secret"` + MaxMediaSize int `yaml:"max_media_size"` + BotName string `yaml:"bot_name"` // NEW +} +// Same addition to DingTalkConfig, WeComConfig, TelegramConfig, WeixinConfig +``` + +### Gateway: BotNames Option + +Add `botNames []string` field to `Gateway` struct and a new `WithBotNames` option in `internal/domain/msgingest/gateway.go`: + +```go +type Gateway struct { + // ... existing fields ... + botNames []string +} + +func WithBotNames(names []string) Option { + return func(g *Gateway) { + g.botNames = names + } +} +``` + +Add a pure stripping function (no side effects, easily testable): + +```go +// stripBotMentions removes any token matching "@" for each configured +// bot name. Only used for command matching; does not affect stored content. +func stripBotMentions(content string, botNames []string) string { + if len(botNames) == 0 { + return content + } + mentions := make(map[string]struct{}, len(botNames)) + for _, name := range botNames { + mentions["@"+name] = struct{}{} + } + fields := strings.Fields(content) + out := fields[:0] + for _, f := range fields { + if _, skip := mentions[f]; !skip { + out = append(out, f) + } + } + return strings.Join(out, " ") +} +``` + +In `onDebounce()`, strip before passing to command handler: + +```go +cmdContent := stripBotMentions(content, g.botNames) +if g.commandHandler != nil { + if g.commandHandler.HandleCommand(ctx, cmdContent, msgs[n-1]) { + return + } +} +``` + +### App Wiring + +In `internal/app/app.go`, collect bot names from all platform configs and pass to gateway: + +```go +var botNames []string +for _, n := range []string{ + cfg.Feishu.BotName, + cfg.DingTalk.BotName, + cfg.WeCom.BotName, + cfg.Telegram.BotName, + cfg.Weixin.BotName, +} { + if n != "" { + botNames = append(botNames, n) + } +} + +ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, + msgingest.WithCommandHandler(cmdChain), + msgingest.WithBotNames(botNames)) +``` + +## Data Flow + +``` +User sends in group: "@机器人 /clear 张三" + │ +Platform handler (feishu/dingtalk/wecom) + │ InboundMessage.Content = "@机器人 /clear 张三" (unchanged) + ▼ +Gateway.onDebounce() + │ cmdContent = stripBotMentions("@机器人 /clear 张三", ["机器人"]) + │ = "/clear 张三" + ▼ +CommandHandler.HandleCommand(ctx, "/clear 张三", msg) + │ fields[0] = "/clear" ✓ fields[1] = "张三" ✓ + ▼ +ClearHandler processes → clears worker "张三" session +``` + +## Error Handling + +- If `BotName` is empty in config, `stripBotMentions` is a no-op — backward compatible +- If the entire content is `@机器人` (no command), stripping yields `""` → command handler returns false → message stored and processed normally + +## Testing + +Add unit tests in `internal/domain/msgingest/` for `stripBotMentions`: + +| Input | BotNames | Expected Output | +|-------|----------|----------------| +| `@机器人 /clear` | `["机器人"]` | `/clear` | +| `/clear @机器人` | `["机器人"]` | `/clear` | +| `@机器人 /clear 张三` | `["机器人"]` | `/clear 张三` | +| `/clear 张三 @机器人` | `["机器人"]` | `/clear 张三` | +| `@机器人 /engine codex` | `["机器人"]` | `/engine codex` | +| `/clear 张三` | `["机器人"]` | `/clear 张三` (no-op) | +| `@机器人 /clear` | `[]` | `@机器人 /clear` (no-op) | +| `@机器人 /clear` | `["OpenBee"]` | `@机器人 /clear` (no match) | + +## Files Changed + +| File | Change | +|------|--------| +| `internal/infra/config/config.go` | Add `BotName string` to 5 platform config structs | +| `internal/domain/msgingest/gateway.go` | Add `botNames` field, `WithBotNames` option, `stripBotMentions` func, call in `onDebounce` | +| `internal/app/app.go` | Collect bot names, pass `WithBotNames` to ingest gateway | +| `internal/domain/msgingest/gateway_test.go` | Add `TestStripBotMentions` unit tests | From 4ea09e7602d30d76adecc58c540f9138c8f2a152 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 00:22:01 -0700 Subject: [PATCH 02/19] docs: add implementation plan for stripping bot @mention in group commands Co-Authored-By: Claude Sonnet 4.6 --- ...-20-strip-bot-mention-in-group-commands.md | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md diff --git a/docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md b/docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md new file mode 100644 index 00000000..1f8aa493 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md @@ -0,0 +1,389 @@ +# Strip Bot @Mention in Group Chat Commands Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow slash commands (`/clear`, `/engine`, etc.) to work in group chats when the user prefixes or suffixes the command with the bot's @mention (e.g., `@机器人 /clear 张三`). + +**Architecture:** Each platform config gets a `BotName` field. At startup, the app collects all bot names and passes them to the ingest gateway via a new `WithBotNames` option. Inside the gateway, before passing content to the command handler, a `stripBotMentions` helper removes any `@` tokens from the content. The stored message content in the DB is unchanged — stripping only affects command matching. + +**Tech Stack:** Go, `strings` stdlib, existing `msgingest.Option` pattern, yaml config tags. + +--- + +## File Map + +| File | Change | +|------|--------| +| `internal/infra/config/config.go` | Add `BotName string` to 5 platform config structs | +| `internal/domain/msgingest/gateway.go` | Add `botNames` field, `WithBotNames` option, `stripBotMentions` func, call in `onDebounce` | +| `internal/domain/msgingest/strip_test.go` | New file: unit tests for `stripBotMentions` (package msgingest internal test) | +| `internal/app/app.go` | Collect bot names from platform configs, pass `WithBotNames` to ingest gateway | + +--- + +### Task 1: Add `BotName` to platform config structs + +**Files:** +- Modify: `internal/infra/config/config.go:165-200` + +- [ ] **Step 1: Add `BotName` field to all five platform config structs** + +Open `internal/infra/config/config.go`. Find the five config structs (lines 165–200) and add `BotName string` to each: + +```go +type FeishuConfig struct { + Enabled bool `yaml:"enabled"` + AppID string `yaml:"app_id"` + AppSecret string `yaml:"app_secret"` + MaxMediaSize int `yaml:"max_media_size"` // maximum media download size in bytes; default 100 MB + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands +} + +type DingTalkConfig struct { + Enabled bool `yaml:"enabled"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands +} + +type WeComConfig struct { + Enabled bool `yaml:"enabled"` + BotID string `yaml:"bot_id"` + Secret string `yaml:"secret"` + WebSocketURL string `yaml:"websocket_url"` + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands +} + +type TelegramConfig struct { + Enabled bool `yaml:"enabled"` + Token string `yaml:"token"` + MaxMediaSize int `yaml:"max_media_size"` // bytes; default 50MB + AuthCode string `yaml:"auth_code"` // passcode for user authorization; empty = no auth required + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands +} + +type WeixinConfig struct { + Enabled bool `yaml:"enabled"` + Token string `yaml:"token"` + BaseURL string `yaml:"base_url"` + CDNBaseURL string `yaml:"cdn_base_url"` + RouteTag int `yaml:"route_tag"` + UserID string `yaml:"user_id"` + MaxMediaSize int `yaml:"max_media_size"` // bytes; default 100MB + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands +} +``` + +- [ ] **Step 2: Verify the project builds with no errors** + +```bash +cd /Users/tengyongzhi/work/bot-workspaces/openbee && go build ./... +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add internal/infra/config/config.go +git commit -m "feat(config): add BotName field to platform config structs" +``` + +--- + +### Task 2: Add `stripBotMentions` and `WithBotNames` to gateway + +**Files:** +- Modify: `internal/domain/msgingest/gateway.go:46-65` (struct + options) +- Modify: `internal/domain/msgingest/gateway.go:195-199` (onDebounce command dispatch) +- Create: `internal/domain/msgingest/strip_test.go` + +- [ ] **Step 1: Write failing tests for `stripBotMentions`** + +Create `internal/domain/msgingest/strip_test.go`: + +```go +package msgingest + +import ( + "testing" +) + +func TestStripBotMentions(t *testing.T) { + tests := []struct { + name string + content string + botNames []string + want string + }{ + { + name: "prefix mention stripped", + content: "@机器人 /clear", + botNames: []string{"机器人"}, + want: "/clear", + }, + { + name: "suffix mention stripped", + content: "/clear @机器人", + botNames: []string{"机器人"}, + want: "/clear", + }, + { + name: "prefix mention with arg", + content: "@机器人 /clear 张三", + botNames: []string{"机器人"}, + want: "/clear 张三", + }, + { + name: "suffix mention with arg", + content: "/clear 张三 @机器人", + botNames: []string{"机器人"}, + want: "/clear 张三", + }, + { + name: "middle mention with args", + content: "@机器人 /engine codex", + botNames: []string{"机器人"}, + want: "/engine codex", + }, + { + name: "no mention, no-op", + content: "/clear 张三", + botNames: []string{"机器人"}, + want: "/clear 张三", + }, + { + name: "empty botNames, no-op", + content: "@机器人 /clear", + botNames: []string{}, + want: "@机器人 /clear", + }, + { + name: "nil botNames, no-op", + content: "@机器人 /clear", + botNames: nil, + want: "@机器人 /clear", + }, + { + name: "case sensitive, no match", + content: "@机器人 /clear", + botNames: []string{"机器人Bot"}, + want: "@机器人 /clear", + }, + { + name: "multiple bot names, matches first", + content: "@OpenBee /engine codex", + botNames: []string{"机器人", "OpenBee"}, + want: "/engine codex", + }, + { + name: "entire content is just mention", + content: "@机器人", + botNames: []string{"机器人"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripBotMentions(tt.content, tt.botNames) + if got != tt.want { + t.Errorf("stripBotMentions(%q, %v) = %q, want %q", tt.content, tt.botNames, got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail (function not yet defined)** + +```bash +cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./internal/domain/msgingest/ -run TestStripBotMentions -v +``` + +Expected: compile error — `undefined: stripBotMentions`. + +- [ ] **Step 3: Add `botNames` field, `WithBotNames` option, and `stripBotMentions` to gateway.go** + +In `internal/domain/msgingest/gateway.go`: + +**3a.** Add `botNames []string` to the `Gateway` struct (after the `commandHandler` field at line 54): + +```go +// Gateway receives raw platform messages, deduplicates, debounces, and emits IngestedMessages. +type Gateway struct { + msgStore MessageStore + debounce time.Duration + sessions map[string]*debounceState + seen map[string]struct{} // in-memory dedup set keyed by platform_msg_id + seenPrev map[string]struct{} // previous generation, checked on lookup only + mu sync.Mutex + out chan IngestedMessage + commandHandler CommandHandler // optional; intercepts slash commands before DB write + botNames []string // @mention tokens to strip before command matching +} +``` + +**3b.** Add `WithBotNames` option after `WithCommandHandler` (around line 65): + +```go +// WithBotNames sets the bot display names whose @mentions are stripped from message +// content before command matching. Does not affect stored message content. +func WithBotNames(names []string) Option { + return func(g *Gateway) { g.botNames = names } +} +``` + +**3c.** Add `stripBotMentions` as a package-level function (after `WithBotNames`): + +```go +// stripBotMentions removes any token equal to "@" for each configured bot name. +// Used only for command matching; never mutates stored message content. +func stripBotMentions(content string, botNames []string) string { + if len(botNames) == 0 { + return content + } + mentions := make(map[string]struct{}, len(botNames)) + for _, name := range botNames { + mentions["@"+name] = struct{}{} + } + fields := strings.Fields(content) + out := fields[:0] + for _, f := range fields { + if _, skip := mentions[f]; !skip { + out = append(out, f) + } + } + return strings.Join(out, " ") +} +``` + +**3d.** Add `"strings"` to the import block at the top of `gateway.go` (it's not imported yet): + +```go +import ( + "context" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/theopenbee/openbee/internal/infra/logger" + "github.com/theopenbee/openbee/internal/infra/store" + "github.com/theopenbee/openbee/internal/platform" + "go.uber.org/zap" +) +``` + +- [ ] **Step 4: Run tests to confirm `stripBotMentions` passes** + +```bash +cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./internal/domain/msgingest/ -run TestStripBotMentions -v +``` + +Expected: all 11 subtests PASS. + +- [ ] **Step 5: Update `onDebounce` to strip bot mentions before command matching** + +In `internal/domain/msgingest/gateway.go`, replace lines 195–199: + +```go + if g.commandHandler != nil { + if g.commandHandler.HandleCommand(context.Background(), content, msgs[n-1]) { + return + } + } +``` + +with: + +```go + if g.commandHandler != nil { + cmdContent := stripBotMentions(content, g.botNames) + if g.commandHandler.HandleCommand(context.Background(), cmdContent, msgs[n-1]) { + return + } + } +``` + +- [ ] **Step 6: Run all msgingest tests to confirm no regressions** + +```bash +cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./internal/domain/msgingest/ -v +``` + +Expected: all existing tests plus `TestStripBotMentions` PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/domain/msgingest/gateway.go internal/domain/msgingest/strip_test.go +git commit -m "feat(msgingest): strip bot @mention tokens before command matching" +``` + +--- + +### Task 3: Wire bot names from config into the ingest gateway + +**Files:** +- Modify: `internal/app/app.go:160` + +- [ ] **Step 1: Collect bot names and pass `WithBotNames` to ingest gateway** + +In `internal/app/app.go`, replace line 160: + +```go +ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, msgingest.WithCommandHandler(cmdChain)) +``` + +with: + +```go +var botNames []string +for _, n := range []string{ + cfg.Bee.Platforms.Feishu.BotName, + cfg.Bee.Platforms.DingTalk.BotName, + cfg.Bee.Platforms.WeCom.BotName, + cfg.Bee.Platforms.Telegram.BotName, + cfg.Bee.Platforms.Weixin.BotName, +} { + if n != "" { + botNames = append(botNames, n) + } +} +ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, + msgingest.WithCommandHandler(cmdChain), + msgingest.WithBotNames(botNames)) +``` + +- [ ] **Step 2: Build to verify no compile errors** + +```bash +cd /Users/tengyongzhi/work/bot-workspaces/openbee && go build ./... +``` + +Expected: no errors. + +- [ ] **Step 3: Run full test suite** + +```bash +cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./... +``` + +Expected: all tests PASS, no failures. + +- [ ] **Step 4: Commit** + +```bash +git add internal/app/app.go +git commit -m "feat(app): wire platform bot names into ingest gateway for @mention stripping" +``` + +--- + +## Self-Review Checklist + +- [x] **Spec coverage:** Config field (Task 1) ✓, `stripBotMentions` + `WithBotNames` (Task 2) ✓, app wiring (Task 3) ✓, tests (Task 2) ✓ +- [x] **Placeholder scan:** No TBDs. All code blocks are complete. +- [x] **Type consistency:** `stripBotMentions(content string, botNames []string) string` used consistently in test and implementation. `WithBotNames(names []string) Option` matches the `Option func(*Gateway)` pattern. +- [x] **Backward compatibility:** `BotName` defaults to empty string → `WithBotNames(nil/empty)` → `stripBotMentions` is a no-op. No behavior change without config. From 239ecf5b0a007662bd90f919bb1b4b8f1978139c Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 00:25:54 -0700 Subject: [PATCH 03/19] feat: strip bot @mention tokens before group chat command matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add BotName config to each platform (feishu/dingtalk/wecom/telegram/weixin). Gateway strips exact @ tokens from message content before passing to command handlers, so commands like /clear and /engine work when prefixed or suffixed with @机器人. Stored message content is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- internal/app/app.go | 16 ++++- internal/domain/msgingest/gateway.go | 31 ++++++++- internal/domain/msgingest/strip_test.go | 90 +++++++++++++++++++++++++ internal/infra/config/config.go | 5 ++ 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 internal/domain/msgingest/strip_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 42cf50f6..05d0d5c6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -157,7 +157,21 @@ func BuildApp(cfg config.Config) (*App, error) { engineCmdHandler := command.NewEngineCommandHandler(s.workerStore, s.systemConfigStore, sendersByPlatform, mgr, busyChecker, engineCfg) clearCmdHandler := command.NewClearCommandHandler(s.workerStore, s.sessionStore, s.taskStore, mgr, disp, sendersByPlatform, engineCfg) cmdChain := msgingest.ChainHandlers(engineCmdHandler, clearCmdHandler) - ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, msgingest.WithCommandHandler(cmdChain)) + var botNames []string + for _, n := range []string{ + cfg.Bee.Platforms.Feishu.BotName, + cfg.Bee.Platforms.DingTalk.BotName, + cfg.Bee.Platforms.WeCom.BotName, + cfg.Bee.Platforms.Telegram.BotName, + cfg.Bee.Platforms.Weixin.BotName, + } { + if n != "" { + botNames = append(botNames, n) + } + } + ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, + msgingest.WithCommandHandler(cmdChain), + msgingest.WithBotNames(botNames)) localIngest := msgingest.New(s.msgStore, 100*time.Millisecond, msgingest.WithCommandHandler(cmdChain)) beeMCPSrv := mcp.NewBeeServer(s.workerStore, mgr, s.taskStore, s.msgStore, s.outboundMsgStore, sendersByPlatform, mgr, disp, s.execStore, s.memoryStore, s.sessionStore, s.departmentStore) diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 3b460126..5aab9c29 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -2,6 +2,7 @@ package msgingest import ( "context" + "strings" "sync" "time" @@ -52,6 +53,7 @@ type Gateway struct { mu sync.Mutex out chan IngestedMessage commandHandler CommandHandler // optional; intercepts slash commands before DB write + botNames []string // @mention tokens to strip before command matching } // Option configures a Gateway. @@ -64,6 +66,32 @@ func WithCommandHandler(h CommandHandler) Option { return func(g *Gateway) { g.commandHandler = h } } +// WithBotNames sets the bot display names whose @mentions are stripped from message +// content before command matching. Does not affect stored message content. +func WithBotNames(names []string) Option { + return func(g *Gateway) { g.botNames = names } +} + +// stripBotMentions removes any token equal to "@" for each configured bot name. +// Used only for command matching; never mutates stored message content. +func stripBotMentions(content string, botNames []string) string { + if len(botNames) == 0 { + return content + } + mentions := make(map[string]struct{}, len(botNames)) + for _, name := range botNames { + mentions["@"+name] = struct{}{} + } + fields := strings.Fields(content) + out := fields[:0] + for _, f := range fields { + if _, skip := mentions[f]; !skip { + out = append(out, f) + } + } + return strings.Join(out, " ") +} + // New constructs a Gateway. func New(msgStore MessageStore, debounce time.Duration, opts ...Option) *Gateway { g := &Gateway{ @@ -193,7 +221,8 @@ func (g *Gateway) onDebounce(sessionKey string, generation int) { } if g.commandHandler != nil { - if g.commandHandler.HandleCommand(context.Background(), content, msgs[n-1]) { + cmdContent := stripBotMentions(content, g.botNames) + if g.commandHandler.HandleCommand(context.Background(), cmdContent, msgs[n-1]) { return } } diff --git a/internal/domain/msgingest/strip_test.go b/internal/domain/msgingest/strip_test.go new file mode 100644 index 00000000..18577062 --- /dev/null +++ b/internal/domain/msgingest/strip_test.go @@ -0,0 +1,90 @@ +package msgingest + +import ( + "testing" +) + +func TestStripBotMentions(t *testing.T) { + tests := []struct { + name string + content string + botNames []string + want string + }{ + { + name: "prefix mention stripped", + content: "@机器人 /clear", + botNames: []string{"机器人"}, + want: "/clear", + }, + { + name: "suffix mention stripped", + content: "/clear @机器人", + botNames: []string{"机器人"}, + want: "/clear", + }, + { + name: "prefix mention with arg", + content: "@机器人 /clear 张三", + botNames: []string{"机器人"}, + want: "/clear 张三", + }, + { + name: "suffix mention with arg", + content: "/clear 张三 @机器人", + botNames: []string{"机器人"}, + want: "/clear 张三", + }, + { + name: "prefix mention engine command", + content: "@机器人 /engine codex", + botNames: []string{"机器人"}, + want: "/engine codex", + }, + { + name: "no mention, no-op", + content: "/clear 张三", + botNames: []string{"机器人"}, + want: "/clear 张三", + }, + { + name: "empty botNames, no-op", + content: "@机器人 /clear", + botNames: []string{}, + want: "@机器人 /clear", + }, + { + name: "nil botNames, no-op", + content: "@机器人 /clear", + botNames: nil, + want: "@机器人 /clear", + }, + { + name: "case sensitive no match", + content: "@机器人 /clear", + botNames: []string{"机器人Bot"}, + want: "@机器人 /clear", + }, + { + name: "multiple bot names matches second", + content: "@OpenBee /engine codex", + botNames: []string{"机器人", "OpenBee"}, + want: "/engine codex", + }, + { + name: "entire content is mention", + content: "@机器人", + botNames: []string{"机器人"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripBotMentions(tt.content, tt.botNames) + if got != tt.want { + t.Errorf("stripBotMentions(%q, %v) = %q, want %q", tt.content, tt.botNames, got, tt.want) + } + }) + } +} diff --git a/internal/infra/config/config.go b/internal/infra/config/config.go index fc3f4749..7d3b3147 100644 --- a/internal/infra/config/config.go +++ b/internal/infra/config/config.go @@ -167,12 +167,14 @@ type FeishuConfig struct { AppID string `yaml:"app_id"` AppSecret string `yaml:"app_secret"` MaxMediaSize int `yaml:"max_media_size"` // maximum media download size in bytes; default 100 MB + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands } type DingTalkConfig struct { Enabled bool `yaml:"enabled"` ClientID string `yaml:"client_id"` ClientSecret string `yaml:"client_secret"` + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands } type WeComConfig struct { @@ -180,6 +182,7 @@ type WeComConfig struct { BotID string `yaml:"bot_id"` Secret string `yaml:"secret"` WebSocketURL string `yaml:"websocket_url"` + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands } type TelegramConfig struct { @@ -187,6 +190,7 @@ type TelegramConfig struct { Token string `yaml:"token"` MaxMediaSize int `yaml:"max_media_size"` // bytes; default 50MB AuthCode string `yaml:"auth_code"` // passcode for user authorization; empty = no auth required + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands } type WeixinConfig struct { @@ -197,6 +201,7 @@ type WeixinConfig struct { RouteTag int `yaml:"route_tag"` UserID string `yaml:"user_id"` MaxMediaSize int `yaml:"max_media_size"` // bytes; default 100MB + BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands } type MCPConfig struct { From 6a89bf977a03d724c63e4fa7edb5aa35abc118bd Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 00:43:31 -0700 Subject: [PATCH 04/19] feat(config): add BotName prompts to openbee config wizard Add platform bot_name input to all 5 platforms in the interactive config wizard. Prompts appear after required fields, leave empty to skip @mention stripping. Updates configValues, loadExistingConfig, config template, and i18n strings (en + zh). Co-Authored-By: Claude Sonnet 4.6 --- cmd/openbee/config.go | 40 ++++++++++++++++++++++++++ internal/infra/config/config.yaml.tmpl | 5 ++++ internal/infra/i18n/locales/en.yaml | 1 + internal/infra/i18n/locales/zh.yaml | 1 + internal/infra/i18n/messages.go | 1 + 5 files changed, 48 insertions(+) diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index a0e70005..c843292d 100644 --- a/cmd/openbee/config.go +++ b/cmd/openbee/config.go @@ -54,24 +54,29 @@ type configValues struct { FeishuEnabled bool FeishuAppID string FeishuAppSecret string + FeishuBotName string DingtalkEnabled bool DingtalkClientID string DingtalkClientSecret string + DingtalkBotName string WecomEnabled bool WecomBotID string WecomSecret string + WecomBotName string TelegramEnabled bool TelegramToken string TelegramAuthCode string + TelegramBotName string WeixinEnabled bool WeixinToken string WeixinBaseURL string WeixinCDNBaseURL string WeixinUserID string + WeixinBotName string EngineDefault string EngineTimeoutBee string @@ -136,20 +141,25 @@ func loadExistingConfig(path string) *configValues { FeishuEnabled: cfg.Bee.Platforms.Feishu.Enabled, FeishuAppID: cfg.Bee.Platforms.Feishu.AppID, FeishuAppSecret: cfg.Bee.Platforms.Feishu.AppSecret, + FeishuBotName: cfg.Bee.Platforms.Feishu.BotName, DingtalkEnabled: cfg.Bee.Platforms.DingTalk.Enabled, DingtalkClientID: cfg.Bee.Platforms.DingTalk.ClientID, DingtalkClientSecret: cfg.Bee.Platforms.DingTalk.ClientSecret, + DingtalkBotName: cfg.Bee.Platforms.DingTalk.BotName, WecomEnabled: cfg.Bee.Platforms.WeCom.Enabled, WecomBotID: cfg.Bee.Platforms.WeCom.BotID, WecomSecret: cfg.Bee.Platforms.WeCom.Secret, + WecomBotName: cfg.Bee.Platforms.WeCom.BotName, TelegramEnabled: cfg.Bee.Platforms.Telegram.Enabled, TelegramToken: cfg.Bee.Platforms.Telegram.Token, TelegramAuthCode: cfg.Bee.Platforms.Telegram.AuthCode, + TelegramBotName: cfg.Bee.Platforms.Telegram.BotName, WeixinEnabled: cfg.Bee.Platforms.Weixin.Enabled, WeixinToken: cfg.Bee.Platforms.Weixin.Token, WeixinBaseURL: cfg.Bee.Platforms.Weixin.BaseURL, WeixinCDNBaseURL: cfg.Bee.Platforms.Weixin.CDNBaseURL, WeixinUserID: cfg.Bee.Platforms.Weixin.UserID, + WeixinBotName: cfg.Bee.Platforms.Weixin.BotName, EngineDefault: cfg.Bee.Engine.Default, EngineTimeoutBee: cfg.Bee.Engine.Timeout.Bee.String(), EngineTimeoutWorker: cfg.Bee.Engine.Timeout.Worker.String(), @@ -389,6 +399,12 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.FeishuAppSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } + if err := survey.AskOne(&survey.Input{ + Message: i18n.M.Prompt.BotName, + Default: vals.FeishuBotName, + }, &vals.FeishuBotName); err != nil { + return handleSurveyErr(err) + } case i18n.M.Prompt.PlatformDingTalk: vals.DingtalkEnabled = true if err := survey.AskOne(&survey.Input{ @@ -403,6 +419,12 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.DingtalkClientSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } + if err := survey.AskOne(&survey.Input{ + Message: i18n.M.Prompt.BotName, + Default: vals.DingtalkBotName, + }, &vals.DingtalkBotName); err != nil { + return handleSurveyErr(err) + } case i18n.M.Prompt.PlatformWeCom: vals.WecomEnabled = true if err := survey.AskOne(&survey.Input{ @@ -417,6 +439,12 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.WecomSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } + if err := survey.AskOne(&survey.Input{ + Message: i18n.M.Prompt.BotName, + Default: vals.WecomBotName, + }, &vals.WecomBotName); err != nil { + return handleSurveyErr(err) + } case i18n.M.Prompt.PlatformTelegram: vals.TelegramEnabled = true if err := survey.AskOne(&survey.Password{ @@ -438,6 +466,12 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.TelegramAuthCode); err != nil { return handleSurveyErr(err) } + if err := survey.AskOne(&survey.Input{ + Message: i18n.M.Prompt.BotName, + Default: vals.TelegramBotName, + }, &vals.TelegramBotName); err != nil { + return handleSurveyErr(err) + } case i18n.M.Prompt.PlatformWeixin: vals.WeixinEnabled = true @@ -489,6 +523,12 @@ func runConfig(cmd *cobra.Command, args []string) error { vals.WeixinBaseURL = "https://ilinkai.weixin.qq.com" } vals.WeixinCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c" + if err := survey.AskOne(&survey.Input{ + Message: i18n.M.Prompt.BotName, + Default: vals.WeixinBotName, + }, &vals.WeixinBotName); err != nil { + return handleSurveyErr(err) + } } } diff --git a/internal/infra/config/config.yaml.tmpl b/internal/infra/config/config.yaml.tmpl index 876ac2ca..df28e98e 100644 --- a/internal/infra/config/config.yaml.tmpl +++ b/internal/infra/config/config.yaml.tmpl @@ -42,24 +42,29 @@ bee: enabled: {{.FeishuEnabled}} app_id: "{{.FeishuAppID}}" app_secret: "{{.FeishuAppSecret}}" + bot_name: "{{.FeishuBotName}}" dingtalk: enabled: {{.DingtalkEnabled}} client_id: "{{.DingtalkClientID}}" client_secret: "{{.DingtalkClientSecret}}" + bot_name: "{{.DingtalkBotName}}" wecom: enabled: {{.WecomEnabled}} bot_id: "{{.WecomBotID}}" secret: "{{.WecomSecret}}" + bot_name: "{{.WecomBotName}}" telegram: enabled: {{.TelegramEnabled}} token: "{{.TelegramToken}}" auth_code: "{{.TelegramAuthCode}}" + bot_name: "{{.TelegramBotName}}" weixin: enabled: {{.WeixinEnabled}} token: "{{.WeixinToken}}" base_url: "{{.WeixinBaseURL}}" cdn_base_url: "{{.WeixinCDNBaseURL}}" user_id: "{{.WeixinUserID}}" + bot_name: "{{.WeixinBotName}}" feeder: max_concurrent_bee: {{.FeederMaxConcurrentBee}} media: diff --git a/internal/infra/i18n/locales/en.yaml b/internal/infra/i18n/locales/en.yaml index 783445d1..f3620a3e 100644 --- a/internal/infra/i18n/locales/en.yaml +++ b/internal/infra/i18n/locales/en.yaml @@ -70,6 +70,7 @@ prompt: weixin_reacquire: "Existing Weixin token found (%s). Re-acquire via QR code?" weixin_bot_token: "Weixin Bot Token:" weixin_user_id: "Weixin User ID:" + bot_name: "Bot name for @mention stripping in group chats (leave empty to skip):" username: "Username:" password_change_confirm: "Password already configured. Change it?" password_setup: "Password setup:" diff --git a/internal/infra/i18n/locales/zh.yaml b/internal/infra/i18n/locales/zh.yaml index 8e74036c..533054fd 100644 --- a/internal/infra/i18n/locales/zh.yaml +++ b/internal/infra/i18n/locales/zh.yaml @@ -70,6 +70,7 @@ prompt: weixin_reacquire: "已有微信 token(%s)。是否重新扫码登录?" weixin_bot_token: "微信 Bot Token:" weixin_user_id: "微信 User ID:" + bot_name: "机器人名称(用于群聊中消除 @提及,留空跳过):" username: "用户名:" password_change_confirm: "已配置密码,是否修改?" password_setup: "密码设置方式:" diff --git a/internal/infra/i18n/messages.go b/internal/infra/i18n/messages.go index 83b45772..af444bf2 100644 --- a/internal/infra/i18n/messages.go +++ b/internal/infra/i18n/messages.go @@ -80,6 +80,7 @@ type PromptMessages struct { WeixinReacquire string `yaml:"weixin_reacquire"` // contains %s placeholder WeixinBotToken string `yaml:"weixin_bot_token"` WeixinUserID string `yaml:"weixin_user_id"` + BotName string `yaml:"bot_name"` // Auth Username string `yaml:"username"` PasswordChangeConfirm string `yaml:"password_change_confirm"` From e6e27a0d573ee0fc946b3da9984a462d696e4374 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 00:57:50 -0700 Subject: [PATCH 05/19] refactor: extract promptBotName helper and fix slice mutation in stripBotMentions Co-Authored-By: Claude Sonnet 4.6 --- cmd/openbee/config.go | 42 +++++++++++----------------- internal/domain/msgingest/gateway.go | 5 ++-- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index c843292d..59073944 100644 --- a/cmd/openbee/config.go +++ b/cmd/openbee/config.go @@ -399,11 +399,8 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.FeishuAppSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } - if err := survey.AskOne(&survey.Input{ - Message: i18n.M.Prompt.BotName, - Default: vals.FeishuBotName, - }, &vals.FeishuBotName); err != nil { - return handleSurveyErr(err) + if err := promptBotName(&vals.FeishuBotName); err != nil { + return err } case i18n.M.Prompt.PlatformDingTalk: vals.DingtalkEnabled = true @@ -419,11 +416,8 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.DingtalkClientSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } - if err := survey.AskOne(&survey.Input{ - Message: i18n.M.Prompt.BotName, - Default: vals.DingtalkBotName, - }, &vals.DingtalkBotName); err != nil { - return handleSurveyErr(err) + if err := promptBotName(&vals.DingtalkBotName); err != nil { + return err } case i18n.M.Prompt.PlatformWeCom: vals.WecomEnabled = true @@ -439,11 +433,8 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.WecomSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } - if err := survey.AskOne(&survey.Input{ - Message: i18n.M.Prompt.BotName, - Default: vals.WecomBotName, - }, &vals.WecomBotName); err != nil { - return handleSurveyErr(err) + if err := promptBotName(&vals.WecomBotName); err != nil { + return err } case i18n.M.Prompt.PlatformTelegram: vals.TelegramEnabled = true @@ -466,11 +457,8 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.TelegramAuthCode); err != nil { return handleSurveyErr(err) } - if err := survey.AskOne(&survey.Input{ - Message: i18n.M.Prompt.BotName, - Default: vals.TelegramBotName, - }, &vals.TelegramBotName); err != nil { - return handleSurveyErr(err) + if err := promptBotName(&vals.TelegramBotName); err != nil { + return err } case i18n.M.Prompt.PlatformWeixin: vals.WeixinEnabled = true @@ -523,11 +511,8 @@ func runConfig(cmd *cobra.Command, args []string) error { vals.WeixinBaseURL = "https://ilinkai.weixin.qq.com" } vals.WeixinCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c" - if err := survey.AskOne(&survey.Input{ - Message: i18n.M.Prompt.BotName, - Default: vals.WeixinBotName, - }, &vals.WeixinBotName); err != nil { - return handleSurveyErr(err) + if err := promptBotName(&vals.WeixinBotName); err != nil { + return err } } } @@ -783,6 +768,13 @@ func handleSurveyErr(err error) error { return claude.HandleSurveyErr(err) } +func promptBotName(fieldPtr *string) error { + return handleSurveyErr(survey.AskOne(&survey.Input{ + Message: i18n.M.Prompt.BotName, + Default: *fieldPtr, + }, fieldPtr)) +} + type engineMapping struct{ name, label string } func engineMappings() []engineMapping { diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 5aab9c29..577e7f37 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -82,9 +82,8 @@ func stripBotMentions(content string, botNames []string) string { for _, name := range botNames { mentions["@"+name] = struct{}{} } - fields := strings.Fields(content) - out := fields[:0] - for _, f := range fields { + var out []string + for _, f := range strings.Fields(content) { if _, skip := mentions[f]; !skip { out = append(out, f) } From a93f096778e17d1e17326054b1fc31aeb72ceac5 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 01:02:25 -0700 Subject: [PATCH 06/19] chore: remove strip-bot-mention implementation plan and design spec docs Co-Authored-By: Claude Sonnet 4.6 --- ...-20-strip-bot-mention-in-group-commands.md | 389 ------------------ ...ip-bot-mention-in-group-commands-design.md | 164 -------- 2 files changed, 553 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md delete mode 100644 docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md diff --git a/docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md b/docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md deleted file mode 100644 index 1f8aa493..00000000 --- a/docs/superpowers/plans/2026-04-20-strip-bot-mention-in-group-commands.md +++ /dev/null @@ -1,389 +0,0 @@ -# Strip Bot @Mention in Group Chat Commands Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Allow slash commands (`/clear`, `/engine`, etc.) to work in group chats when the user prefixes or suffixes the command with the bot's @mention (e.g., `@机器人 /clear 张三`). - -**Architecture:** Each platform config gets a `BotName` field. At startup, the app collects all bot names and passes them to the ingest gateway via a new `WithBotNames` option. Inside the gateway, before passing content to the command handler, a `stripBotMentions` helper removes any `@` tokens from the content. The stored message content in the DB is unchanged — stripping only affects command matching. - -**Tech Stack:** Go, `strings` stdlib, existing `msgingest.Option` pattern, yaml config tags. - ---- - -## File Map - -| File | Change | -|------|--------| -| `internal/infra/config/config.go` | Add `BotName string` to 5 platform config structs | -| `internal/domain/msgingest/gateway.go` | Add `botNames` field, `WithBotNames` option, `stripBotMentions` func, call in `onDebounce` | -| `internal/domain/msgingest/strip_test.go` | New file: unit tests for `stripBotMentions` (package msgingest internal test) | -| `internal/app/app.go` | Collect bot names from platform configs, pass `WithBotNames` to ingest gateway | - ---- - -### Task 1: Add `BotName` to platform config structs - -**Files:** -- Modify: `internal/infra/config/config.go:165-200` - -- [ ] **Step 1: Add `BotName` field to all five platform config structs** - -Open `internal/infra/config/config.go`. Find the five config structs (lines 165–200) and add `BotName string` to each: - -```go -type FeishuConfig struct { - Enabled bool `yaml:"enabled"` - AppID string `yaml:"app_id"` - AppSecret string `yaml:"app_secret"` - MaxMediaSize int `yaml:"max_media_size"` // maximum media download size in bytes; default 100 MB - BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands -} - -type DingTalkConfig struct { - Enabled bool `yaml:"enabled"` - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands -} - -type WeComConfig struct { - Enabled bool `yaml:"enabled"` - BotID string `yaml:"bot_id"` - Secret string `yaml:"secret"` - WebSocketURL string `yaml:"websocket_url"` - BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands -} - -type TelegramConfig struct { - Enabled bool `yaml:"enabled"` - Token string `yaml:"token"` - MaxMediaSize int `yaml:"max_media_size"` // bytes; default 50MB - AuthCode string `yaml:"auth_code"` // passcode for user authorization; empty = no auth required - BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands -} - -type WeixinConfig struct { - Enabled bool `yaml:"enabled"` - Token string `yaml:"token"` - BaseURL string `yaml:"base_url"` - CDNBaseURL string `yaml:"cdn_base_url"` - RouteTag int `yaml:"route_tag"` - UserID string `yaml:"user_id"` - MaxMediaSize int `yaml:"max_media_size"` // bytes; default 100MB - BotName string `yaml:"bot_name"` // bot display name used to strip @mention in group commands -} -``` - -- [ ] **Step 2: Verify the project builds with no errors** - -```bash -cd /Users/tengyongzhi/work/bot-workspaces/openbee && go build ./... -``` - -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add internal/infra/config/config.go -git commit -m "feat(config): add BotName field to platform config structs" -``` - ---- - -### Task 2: Add `stripBotMentions` and `WithBotNames` to gateway - -**Files:** -- Modify: `internal/domain/msgingest/gateway.go:46-65` (struct + options) -- Modify: `internal/domain/msgingest/gateway.go:195-199` (onDebounce command dispatch) -- Create: `internal/domain/msgingest/strip_test.go` - -- [ ] **Step 1: Write failing tests for `stripBotMentions`** - -Create `internal/domain/msgingest/strip_test.go`: - -```go -package msgingest - -import ( - "testing" -) - -func TestStripBotMentions(t *testing.T) { - tests := []struct { - name string - content string - botNames []string - want string - }{ - { - name: "prefix mention stripped", - content: "@机器人 /clear", - botNames: []string{"机器人"}, - want: "/clear", - }, - { - name: "suffix mention stripped", - content: "/clear @机器人", - botNames: []string{"机器人"}, - want: "/clear", - }, - { - name: "prefix mention with arg", - content: "@机器人 /clear 张三", - botNames: []string{"机器人"}, - want: "/clear 张三", - }, - { - name: "suffix mention with arg", - content: "/clear 张三 @机器人", - botNames: []string{"机器人"}, - want: "/clear 张三", - }, - { - name: "middle mention with args", - content: "@机器人 /engine codex", - botNames: []string{"机器人"}, - want: "/engine codex", - }, - { - name: "no mention, no-op", - content: "/clear 张三", - botNames: []string{"机器人"}, - want: "/clear 张三", - }, - { - name: "empty botNames, no-op", - content: "@机器人 /clear", - botNames: []string{}, - want: "@机器人 /clear", - }, - { - name: "nil botNames, no-op", - content: "@机器人 /clear", - botNames: nil, - want: "@机器人 /clear", - }, - { - name: "case sensitive, no match", - content: "@机器人 /clear", - botNames: []string{"机器人Bot"}, - want: "@机器人 /clear", - }, - { - name: "multiple bot names, matches first", - content: "@OpenBee /engine codex", - botNames: []string{"机器人", "OpenBee"}, - want: "/engine codex", - }, - { - name: "entire content is just mention", - content: "@机器人", - botNames: []string{"机器人"}, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := stripBotMentions(tt.content, tt.botNames) - if got != tt.want { - t.Errorf("stripBotMentions(%q, %v) = %q, want %q", tt.content, tt.botNames, got, tt.want) - } - }) - } -} -``` - -- [ ] **Step 2: Run tests to confirm they fail (function not yet defined)** - -```bash -cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./internal/domain/msgingest/ -run TestStripBotMentions -v -``` - -Expected: compile error — `undefined: stripBotMentions`. - -- [ ] **Step 3: Add `botNames` field, `WithBotNames` option, and `stripBotMentions` to gateway.go** - -In `internal/domain/msgingest/gateway.go`: - -**3a.** Add `botNames []string` to the `Gateway` struct (after the `commandHandler` field at line 54): - -```go -// Gateway receives raw platform messages, deduplicates, debounces, and emits IngestedMessages. -type Gateway struct { - msgStore MessageStore - debounce time.Duration - sessions map[string]*debounceState - seen map[string]struct{} // in-memory dedup set keyed by platform_msg_id - seenPrev map[string]struct{} // previous generation, checked on lookup only - mu sync.Mutex - out chan IngestedMessage - commandHandler CommandHandler // optional; intercepts slash commands before DB write - botNames []string // @mention tokens to strip before command matching -} -``` - -**3b.** Add `WithBotNames` option after `WithCommandHandler` (around line 65): - -```go -// WithBotNames sets the bot display names whose @mentions are stripped from message -// content before command matching. Does not affect stored message content. -func WithBotNames(names []string) Option { - return func(g *Gateway) { g.botNames = names } -} -``` - -**3c.** Add `stripBotMentions` as a package-level function (after `WithBotNames`): - -```go -// stripBotMentions removes any token equal to "@" for each configured bot name. -// Used only for command matching; never mutates stored message content. -func stripBotMentions(content string, botNames []string) string { - if len(botNames) == 0 { - return content - } - mentions := make(map[string]struct{}, len(botNames)) - for _, name := range botNames { - mentions["@"+name] = struct{}{} - } - fields := strings.Fields(content) - out := fields[:0] - for _, f := range fields { - if _, skip := mentions[f]; !skip { - out = append(out, f) - } - } - return strings.Join(out, " ") -} -``` - -**3d.** Add `"strings"` to the import block at the top of `gateway.go` (it's not imported yet): - -```go -import ( - "context" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/theopenbee/openbee/internal/infra/logger" - "github.com/theopenbee/openbee/internal/infra/store" - "github.com/theopenbee/openbee/internal/platform" - "go.uber.org/zap" -) -``` - -- [ ] **Step 4: Run tests to confirm `stripBotMentions` passes** - -```bash -cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./internal/domain/msgingest/ -run TestStripBotMentions -v -``` - -Expected: all 11 subtests PASS. - -- [ ] **Step 5: Update `onDebounce` to strip bot mentions before command matching** - -In `internal/domain/msgingest/gateway.go`, replace lines 195–199: - -```go - if g.commandHandler != nil { - if g.commandHandler.HandleCommand(context.Background(), content, msgs[n-1]) { - return - } - } -``` - -with: - -```go - if g.commandHandler != nil { - cmdContent := stripBotMentions(content, g.botNames) - if g.commandHandler.HandleCommand(context.Background(), cmdContent, msgs[n-1]) { - return - } - } -``` - -- [ ] **Step 6: Run all msgingest tests to confirm no regressions** - -```bash -cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./internal/domain/msgingest/ -v -``` - -Expected: all existing tests plus `TestStripBotMentions` PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/domain/msgingest/gateway.go internal/domain/msgingest/strip_test.go -git commit -m "feat(msgingest): strip bot @mention tokens before command matching" -``` - ---- - -### Task 3: Wire bot names from config into the ingest gateway - -**Files:** -- Modify: `internal/app/app.go:160` - -- [ ] **Step 1: Collect bot names and pass `WithBotNames` to ingest gateway** - -In `internal/app/app.go`, replace line 160: - -```go -ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, msgingest.WithCommandHandler(cmdChain)) -``` - -with: - -```go -var botNames []string -for _, n := range []string{ - cfg.Bee.Platforms.Feishu.BotName, - cfg.Bee.Platforms.DingTalk.BotName, - cfg.Bee.Platforms.WeCom.BotName, - cfg.Bee.Platforms.Telegram.BotName, - cfg.Bee.Platforms.Weixin.BotName, -} { - if n != "" { - botNames = append(botNames, n) - } -} -ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, - msgingest.WithCommandHandler(cmdChain), - msgingest.WithBotNames(botNames)) -``` - -- [ ] **Step 2: Build to verify no compile errors** - -```bash -cd /Users/tengyongzhi/work/bot-workspaces/openbee && go build ./... -``` - -Expected: no errors. - -- [ ] **Step 3: Run full test suite** - -```bash -cd /Users/tengyongzhi/work/bot-workspaces/openbee && go test ./... -``` - -Expected: all tests PASS, no failures. - -- [ ] **Step 4: Commit** - -```bash -git add internal/app/app.go -git commit -m "feat(app): wire platform bot names into ingest gateway for @mention stripping" -``` - ---- - -## Self-Review Checklist - -- [x] **Spec coverage:** Config field (Task 1) ✓, `stripBotMentions` + `WithBotNames` (Task 2) ✓, app wiring (Task 3) ✓, tests (Task 2) ✓ -- [x] **Placeholder scan:** No TBDs. All code blocks are complete. -- [x] **Type consistency:** `stripBotMentions(content string, botNames []string) string` used consistently in test and implementation. `WithBotNames(names []string) Option` matches the `Option func(*Gateway)` pattern. -- [x] **Backward compatibility:** `BotName` defaults to empty string → `WithBotNames(nil/empty)` → `stripBotMentions` is a no-op. No behavior change without config. diff --git a/docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md b/docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md deleted file mode 100644 index d83e533f..00000000 --- a/docs/superpowers/specs/2026-04-20-strip-bot-mention-in-group-commands-design.md +++ /dev/null @@ -1,164 +0,0 @@ -# Design: Strip Bot @Mention in Group Chat Commands - -**Date:** 2026-04-20 - -## Problem - -When a user sends a command to the bot via @mention in a group chat, the message content includes the `@BotName` prefix or suffix. This causes command parsing to fail because the command handlers check `fields[0]` for the command token (e.g., `/clear`, `/engine`). - -Examples of broken inputs: -- `@机器人 /clear` → parsed as non-command (fields[0] = `@机器人`) -- `/clear 张三 @机器人` → `/clear` runs but with garbled arg `张三 @机器人` -- `@机器人 /engine codex` → not recognized as command - -## Goals - -- Allow `/clear`, `/clear `, `/engine `, `/engine ` to work when prefixed, suffixed, or surrounded by `@BotName` tokens -- Exact case-sensitive match on configured bot name -- Do not alter the message content stored in the database (preserve original for audit and feeder processing) -- Each platform can independently configure its own bot name - -## Non-Goals - -- Stripping arbitrary @user mentions (only the bot's own name) -- Case-insensitive matching -- Runtime/dynamic bot name discovery (config-driven only) - -## Architecture - -### Configuration - -Add `BotName string` to each platform config struct in `internal/infra/config/config.go`: - -```go -type FeishuConfig struct { - Enabled bool `yaml:"enabled"` - AppID string `yaml:"app_id"` - AppSecret string `yaml:"app_secret"` - MaxMediaSize int `yaml:"max_media_size"` - BotName string `yaml:"bot_name"` // NEW -} -// Same addition to DingTalkConfig, WeComConfig, TelegramConfig, WeixinConfig -``` - -### Gateway: BotNames Option - -Add `botNames []string` field to `Gateway` struct and a new `WithBotNames` option in `internal/domain/msgingest/gateway.go`: - -```go -type Gateway struct { - // ... existing fields ... - botNames []string -} - -func WithBotNames(names []string) Option { - return func(g *Gateway) { - g.botNames = names - } -} -``` - -Add a pure stripping function (no side effects, easily testable): - -```go -// stripBotMentions removes any token matching "@" for each configured -// bot name. Only used for command matching; does not affect stored content. -func stripBotMentions(content string, botNames []string) string { - if len(botNames) == 0 { - return content - } - mentions := make(map[string]struct{}, len(botNames)) - for _, name := range botNames { - mentions["@"+name] = struct{}{} - } - fields := strings.Fields(content) - out := fields[:0] - for _, f := range fields { - if _, skip := mentions[f]; !skip { - out = append(out, f) - } - } - return strings.Join(out, " ") -} -``` - -In `onDebounce()`, strip before passing to command handler: - -```go -cmdContent := stripBotMentions(content, g.botNames) -if g.commandHandler != nil { - if g.commandHandler.HandleCommand(ctx, cmdContent, msgs[n-1]) { - return - } -} -``` - -### App Wiring - -In `internal/app/app.go`, collect bot names from all platform configs and pass to gateway: - -```go -var botNames []string -for _, n := range []string{ - cfg.Feishu.BotName, - cfg.DingTalk.BotName, - cfg.WeCom.BotName, - cfg.Telegram.BotName, - cfg.Weixin.BotName, -} { - if n != "" { - botNames = append(botNames, n) - } -} - -ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, - msgingest.WithCommandHandler(cmdChain), - msgingest.WithBotNames(botNames)) -``` - -## Data Flow - -``` -User sends in group: "@机器人 /clear 张三" - │ -Platform handler (feishu/dingtalk/wecom) - │ InboundMessage.Content = "@机器人 /clear 张三" (unchanged) - ▼ -Gateway.onDebounce() - │ cmdContent = stripBotMentions("@机器人 /clear 张三", ["机器人"]) - │ = "/clear 张三" - ▼ -CommandHandler.HandleCommand(ctx, "/clear 张三", msg) - │ fields[0] = "/clear" ✓ fields[1] = "张三" ✓ - ▼ -ClearHandler processes → clears worker "张三" session -``` - -## Error Handling - -- If `BotName` is empty in config, `stripBotMentions` is a no-op — backward compatible -- If the entire content is `@机器人` (no command), stripping yields `""` → command handler returns false → message stored and processed normally - -## Testing - -Add unit tests in `internal/domain/msgingest/` for `stripBotMentions`: - -| Input | BotNames | Expected Output | -|-------|----------|----------------| -| `@机器人 /clear` | `["机器人"]` | `/clear` | -| `/clear @机器人` | `["机器人"]` | `/clear` | -| `@机器人 /clear 张三` | `["机器人"]` | `/clear 张三` | -| `/clear 张三 @机器人` | `["机器人"]` | `/clear 张三` | -| `@机器人 /engine codex` | `["机器人"]` | `/engine codex` | -| `/clear 张三` | `["机器人"]` | `/clear 张三` (no-op) | -| `@机器人 /clear` | `[]` | `@机器人 /clear` (no-op) | -| `@机器人 /clear` | `["OpenBee"]` | `@机器人 /clear` (no match) | - -## Files Changed - -| File | Change | -|------|--------| -| `internal/infra/config/config.go` | Add `BotName string` to 5 platform config structs | -| `internal/domain/msgingest/gateway.go` | Add `botNames` field, `WithBotNames` option, `stripBotMentions` func, call in `onDebounce` | -| `internal/app/app.go` | Collect bot names, pass `WithBotNames` to ingest gateway | -| `internal/domain/msgingest/gateway_test.go` | Add `TestStripBotMentions` unit tests | From f74de29f3fdff6664fb33adf445c56a201b844c6 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 01:03:30 -0700 Subject: [PATCH 07/19] feat(config): make BotName required in openbee config wizard Co-Authored-By: Claude Sonnet 4.6 --- cmd/openbee/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index 59073944..7ac38295 100644 --- a/cmd/openbee/config.go +++ b/cmd/openbee/config.go @@ -772,7 +772,7 @@ func promptBotName(fieldPtr *string) error { return handleSurveyErr(survey.AskOne(&survey.Input{ Message: i18n.M.Prompt.BotName, Default: *fieldPtr, - }, fieldPtr)) + }, fieldPtr, survey.WithValidator(survey.Required))) } type engineMapping struct{ name, label string } From 25718a2f07fc54407c563db254638e9db678c46e Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 01:10:50 -0700 Subject: [PATCH 08/19] refactor(msgingest): make CommandHandler a required parameter in New() Co-Authored-By: Claude Sonnet 4.6 --- internal/app/app.go | 5 ++-- internal/domain/msgingest/gateway.go | 30 +++++++++-------------- internal/domain/msgingest/gateway_test.go | 27 ++++++++++++-------- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 05d0d5c6..182902c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -169,10 +169,9 @@ func BuildApp(cfg config.Config) (*App, error) { botNames = append(botNames, n) } } - ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, - msgingest.WithCommandHandler(cmdChain), + ingest := msgingest.New(s.msgStore, cfg.Bee.MessageDebounce, cmdChain, msgingest.WithBotNames(botNames)) - localIngest := msgingest.New(s.msgStore, 100*time.Millisecond, msgingest.WithCommandHandler(cmdChain)) + localIngest := msgingest.New(s.msgStore, 100*time.Millisecond, cmdChain) beeMCPSrv := mcp.NewBeeServer(s.workerStore, mgr, s.taskStore, s.msgStore, s.outboundMsgStore, sendersByPlatform, mgr, disp, s.execStore, s.memoryStore, s.sessionStore, s.departmentStore) diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 577e7f37..89b5e1e3 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -52,20 +52,13 @@ type Gateway struct { seenPrev map[string]struct{} // previous generation, checked on lookup only mu sync.Mutex out chan IngestedMessage - commandHandler CommandHandler // optional; intercepts slash commands before DB write + commandHandler CommandHandler // intercepts slash commands before DB write botNames []string // @mention tokens to strip before command matching } // Option configures a Gateway. type Option func(*Gateway) -// WithCommandHandler sets an optional slash-command handler. -// When set, each debounced message is offered to the handler before DB write. -// If the handler returns true, the message is consumed and not stored. -func WithCommandHandler(h CommandHandler) Option { - return func(g *Gateway) { g.commandHandler = h } -} - // WithBotNames sets the bot display names whose @mentions are stripped from message // content before command matching. Does not affect stored message content. func WithBotNames(names []string) Option { @@ -92,13 +85,14 @@ func stripBotMentions(content string, botNames []string) string { } // New constructs a Gateway. -func New(msgStore MessageStore, debounce time.Duration, opts ...Option) *Gateway { +func New(msgStore MessageStore, debounce time.Duration, handler CommandHandler, opts ...Option) *Gateway { g := &Gateway{ - msgStore: msgStore, - debounce: debounce, - sessions: make(map[string]*debounceState), - seen: make(map[string]struct{}), - out: make(chan IngestedMessage, 64), + msgStore: msgStore, + debounce: debounce, + commandHandler: handler, + sessions: make(map[string]*debounceState), + seen: make(map[string]struct{}), + out: make(chan IngestedMessage, 64), } for _, o := range opts { o(g) @@ -219,11 +213,9 @@ func (g *Gateway) onDebounce(sessionKey string, generation int) { batch[i] = bm } - if g.commandHandler != nil { - cmdContent := stripBotMentions(content, g.botNames) - if g.commandHandler.HandleCommand(context.Background(), cmdContent, msgs[n-1]) { - return - } + cmdContent := stripBotMentions(content, g.botNames) + if g.commandHandler.HandleCommand(context.Background(), cmdContent, msgs[n-1]) { + return } inserted, err := g.msgStore.CreateBatch(context.Background(), batch) diff --git a/internal/domain/msgingest/gateway_test.go b/internal/domain/msgingest/gateway_test.go index 3b7187af..d33c6755 100644 --- a/internal/domain/msgingest/gateway_test.go +++ b/internal/domain/msgingest/gateway_test.go @@ -46,6 +46,13 @@ func (m *mockMsgStore) CreateBatch(_ context.Context, msgs []store.BatchMsg) (in return int64(len(msgs)), nil } +// noopHandler is a pass-through CommandHandler for tests that don't exercise command handling. +type noopHandler struct{} + +func (noopHandler) HandleCommand(_ context.Context, _ string, _ platform.InboundMessage) bool { + return false +} + func inbound(sessionKey, content, platformMsgID string) platform.InboundMessage { return platform.InboundMessage{ Platform: "test", @@ -59,7 +66,7 @@ func inbound(sessionKey, content, platformMsgID string) platform.InboundMessage // platform_msg_id in one debounce window result in exactly one row written. func TestGateway_Dedup_InMemory(t *testing.T) { st := newMock() - g := msgingest.New(st, 150*time.Millisecond) + g := msgingest.New(st, 150*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -85,7 +92,7 @@ func TestGateway_Dedup_InMemory(t *testing.T) { // one debounce window are merged into one IngestedMessage with combined content. func TestGateway_Debounce_EmitsSingleMergedMessage(t *testing.T) { st := newMock() - g := msgingest.New(st, 100*time.Millisecond) + g := msgingest.New(st, 100*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -114,7 +121,7 @@ func TestGateway_Debounce_EmitsSingleMergedMessage(t *testing.T) { // CreateBatch call: 2 merged rows + 1 received row, correct MergedInto. func TestGateway_Debounce_BatchWrite(t *testing.T) { st := newMock() - g := msgingest.New(st, 100*time.Millisecond) + g := msgingest.New(st, 100*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -165,7 +172,7 @@ func TestGateway_Debounce_BatchWrite(t *testing.T) { // exactly one received row and no merged rows. func TestGateway_Debounce_SingleMessage(t *testing.T) { st := newMock() - g := msgingest.New(st, 100*time.Millisecond) + g := msgingest.New(st, 100*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -193,7 +200,7 @@ func TestGateway_Debounce_SingleMessage(t *testing.T) { // during debounce suppresses the emit. func TestGateway_BatchWrite_Error_NormalPath(t *testing.T) { st := newMock().withError(errors.New("db down")) - g := msgingest.New(st, 100*time.Millisecond) + g := msgingest.New(st, 100*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -213,7 +220,7 @@ func TestGateway_BatchWrite_Error_NormalPath(t *testing.T) { func TestGateway_BatchWrite_PartialInsert(t *testing.T) { // 3 messages dispatched → batch of 3; mock returns only 2 inserted st := newMock().withPartialInsert(2) - g := msgingest.New(st, 100*time.Millisecond) + g := msgingest.New(st, 100*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -234,7 +241,7 @@ func TestGateway_BatchWrite_PartialInsert(t *testing.T) { // debounced normally (no special command handling). func TestGateway_ClearMessage_DebounceAsNormal(t *testing.T) { st := newMock() - g := msgingest.New(st, 100*time.Millisecond) + g := msgingest.New(st, 100*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -286,7 +293,7 @@ func (m *mockCommandHandler) getContents() []string { func TestGateway_CommandHandlerInterceptsBeforeDB(t *testing.T) { st := newMock() handler := newMockCommandHandler(true) - g := msgingest.New(st, 0, msgingest.WithCommandHandler(handler)) + g := msgingest.New(st, 0, handler) g.Dispatch(platform.InboundMessage{ Platform: "feishu", @@ -315,7 +322,7 @@ func TestGateway_CommandHandlerPassesThroughNonCommands(t *testing.T) { handler := newMockCommandHandler(false) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - g := msgingest.New(st, 0, msgingest.WithCommandHandler(handler)) + g := msgingest.New(st, 0, handler) go g.Run(ctx) g.Dispatch(platform.InboundMessage{ @@ -339,7 +346,7 @@ func TestGateway_CommandHandlerPassesThroughNonCommands(t *testing.T) { // a normal message within the debounce window is merged into one message. func TestGateway_ClearMessage_MergedWithDebounce(t *testing.T) { st := newMock() - g := msgingest.New(st, 200*time.Millisecond) + g := msgingest.New(st, 200*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) From 3adcfbfcda6deb5195e1dce937e80efb6c37c0c8 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 01:32:16 -0700 Subject: [PATCH 09/19] feat(msgingest): bypass debounce for slash commands via IsCommand fast-path Add IsCommand(content string) bool to the CommandHandler interface so Dispatch can detect and handle slash commands immediately, skipping the debounce window entirely. Concrete handlers (EngineCommandHandler, ClearCommandHandler, chainedHandler) implement the method with pure token matching; no side effects. Co-Authored-By: Claude Sonnet 4.6 --- internal/domain/command/clear.go | 6 ++++ internal/domain/command/engine.go | 6 ++++ internal/domain/msgingest/command.go | 12 ++++++++ internal/domain/msgingest/gateway.go | 7 +++++ internal/domain/msgingest/gateway_test.go | 35 +++++++++++++++++++++-- 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/internal/domain/command/clear.go b/internal/domain/command/clear.go index 6b3f080f..25a09a18 100644 --- a/internal/domain/command/clear.go +++ b/internal/domain/command/clear.go @@ -74,6 +74,12 @@ func NewClearCommandHandler( } } +// IsCommand implements msgingest.CommandHandler. +func (h *ClearCommandHandler) IsCommand(content string) bool { + fields := strings.Fields(content) + return len(fields) > 0 && fields[0] == CmdClear +} + func (h *ClearCommandHandler) HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool { fields := strings.Fields(content) if len(fields) == 0 || fields[0] != CmdClear { diff --git a/internal/domain/command/engine.go b/internal/domain/command/engine.go index 8b17b807..83663c56 100644 --- a/internal/domain/command/engine.go +++ b/internal/domain/command/engine.go @@ -108,6 +108,12 @@ func NewEngineCommandHandler( } } +// IsCommand implements msgingest.CommandHandler. +func (h *EngineCommandHandler) IsCommand(content string) bool { + fields := strings.Fields(content) + return len(fields) > 0 && fields[0] == CmdEngine +} + // HandleCommand implements msgingest.CommandHandler. // Returns true if content is a /engine command (whether or not it succeeded). func (h *EngineCommandHandler) HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool { diff --git a/internal/domain/msgingest/command.go b/internal/domain/msgingest/command.go index e57b32f9..b22d248f 100644 --- a/internal/domain/msgingest/command.go +++ b/internal/domain/msgingest/command.go @@ -10,6 +10,9 @@ import ( // HandleCommand returns true if the message was a recognized command and // was handled (the caller should skip normal message processing). type CommandHandler interface { + // IsCommand reports whether content looks like a recognized command, + // without side effects. Used for fast-path detection in Dispatch. + IsCommand(content string) bool HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool } @@ -21,6 +24,15 @@ type chainedHandler struct { handlers []CommandHandler } +func (c *chainedHandler) IsCommand(content string) bool { + for _, h := range c.handlers { + if h.IsCommand(content) { + return true + } + } + return false +} + func (c *chainedHandler) HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool { for _, h := range c.handlers { if h.HandleCommand(ctx, content, replyTo) { diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 89b5e1e3..7e4daec1 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -140,6 +140,13 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { g.seen[msg.PlatformMessageID] = struct{}{} } + cmdContent := stripBotMentions(msg.Content, g.botNames) + if g.commandHandler.IsCommand(cmdContent) { + g.mu.Unlock() + g.commandHandler.HandleCommand(context.Background(), cmdContent, msg) + return + } + // Accumulate into debounce state. state, ok := g.sessions[msg.SessionKey] if !ok { diff --git a/internal/domain/msgingest/gateway_test.go b/internal/domain/msgingest/gateway_test.go index d33c6755..d45487c3 100644 --- a/internal/domain/msgingest/gateway_test.go +++ b/internal/domain/msgingest/gateway_test.go @@ -49,9 +49,8 @@ func (m *mockMsgStore) CreateBatch(_ context.Context, msgs []store.BatchMsg) (in // noopHandler is a pass-through CommandHandler for tests that don't exercise command handling. type noopHandler struct{} -func (noopHandler) HandleCommand(_ context.Context, _ string, _ platform.InboundMessage) bool { - return false -} +func (noopHandler) IsCommand(_ string) bool { return false } +func (noopHandler) HandleCommand(_ context.Context, _ string, _ platform.InboundMessage) bool { return false } func inbound(sessionKey, content, platformMsgID string) platform.InboundMessage { return platform.InboundMessage{ @@ -274,6 +273,8 @@ func newMockCommandHandler(handled bool) *mockCommandHandler { } } +func (m *mockCommandHandler) IsCommand(_ string) bool { return m.handled } + func (m *mockCommandHandler) HandleCommand(_ context.Context, content string, _ platform.InboundMessage) bool { m.mu.Lock() m.contents = append(m.contents, content) @@ -344,6 +345,34 @@ func TestGateway_CommandHandlerPassesThroughNonCommands(t *testing.T) { // TestGateway_ClearMessage_MergedWithDebounce verifies that "clear" sent after // a normal message within the debounce window is merged into one message. +// TestGateway_Command_BypassesDebounce verifies that a recognized command is +// handled immediately in Dispatch (no debounce wait) and never written to DB. +func TestGateway_Command_BypassesDebounce(t *testing.T) { + st := newMock() + handler := newMockCommandHandler(true) + g := msgingest.New(st, 500*time.Millisecond, handler) + + start := time.Now() + g.Dispatch(platform.InboundMessage{ + Platform: "feishu", + SessionKey: "feishu:c1:u1", + Content: "/engine claude", + }) + + select { + case <-handler.called: + case <-time.After(200 * time.Millisecond): + t.Fatal("command handler not called within 200ms — debounce not bypassed") + } + + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Errorf("command took %v, expected < 200ms (debounce is 500ms)", elapsed) + } + if len(st.batches) != 0 { + t.Errorf("expected 0 DB writes for command, got %d", len(st.batches)) + } +} + func TestGateway_ClearMessage_MergedWithDebounce(t *testing.T) { st := newMock() g := msgingest.New(st, 200*time.Millisecond, noopHandler{}) From ddeb0730ef27966a0d25598937c451c696be34e0 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 01:45:52 -0700 Subject: [PATCH 10/19] refactor(msgingest): async command dispatch via buffered channel Route slash commands through a serialized buffered channel instead of calling HandleCommand synchronously in Dispatch, so the platform receiver goroutine returns immediately. Also removes the now-unreachable HandleCommand call in onDebounce that was dead code after the IsCommand fast-path was added. Co-Authored-By: Claude Sonnet 4.6 --- internal/domain/msgingest/gateway.go | 30 +++++++++++++++++------ internal/domain/msgingest/gateway_test.go | 6 +++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 7e4daec1..629edf57 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -43,6 +43,11 @@ type debounceState struct { content string // merged content string } +type commandTask struct { + content string + msg platform.InboundMessage +} + // Gateway receives raw platform messages, deduplicates, debounces, and emits IngestedMessages. type Gateway struct { msgStore MessageStore @@ -52,8 +57,9 @@ type Gateway struct { seenPrev map[string]struct{} // previous generation, checked on lookup only mu sync.Mutex out chan IngestedMessage - commandHandler CommandHandler // intercepts slash commands before DB write - botNames []string // @mention tokens to strip before command matching + cmdCh chan commandTask // serialized command dispatch queue + commandHandler CommandHandler // intercepts slash commands before DB write + botNames []string // @mention tokens to strip before command matching } // Option configures a Gateway. @@ -93,6 +99,7 @@ func New(msgStore MessageStore, debounce time.Duration, handler CommandHandler, sessions: make(map[string]*debounceState), seen: make(map[string]struct{}), out: make(chan IngestedMessage, 64), + cmdCh: make(chan commandTask, 32), } for _, o := range opts { o(g) @@ -105,10 +112,18 @@ func (g *Gateway) Out() <-chan IngestedMessage { return g.out } // Run blocks until ctx is cancelled, then closes Out(). func (g *Gateway) Run(ctx context.Context) { + go g.runCommandConsumer() <-ctx.Done() + close(g.cmdCh) close(g.out) } +func (g *Gateway) runCommandConsumer() { + for task := range g.cmdCh { + g.commandHandler.HandleCommand(context.Background(), task.content, task.msg) + } +} + // emit sends msg to the output channel non-blocking; drops and logs if the channel is full. func (g *Gateway) emit(msg IngestedMessage) { select { @@ -143,7 +158,11 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { cmdContent := stripBotMentions(msg.Content, g.botNames) if g.commandHandler.IsCommand(cmdContent) { g.mu.Unlock() - g.commandHandler.HandleCommand(context.Background(), cmdContent, msg) + select { + case g.cmdCh <- commandTask{cmdContent, msg}: + default: + log.Warn("command channel full, dropping command", zap.String("sessionKey", msg.SessionKey)) + } return } @@ -220,11 +239,6 @@ func (g *Gateway) onDebounce(sessionKey string, generation int) { batch[i] = bm } - cmdContent := stripBotMentions(content, g.botNames) - if g.commandHandler.HandleCommand(context.Background(), cmdContent, msgs[n-1]) { - return - } - inserted, err := g.msgStore.CreateBatch(context.Background(), batch) if err != nil { log.Error("CreateBatch error", zap.String("sessionKey", sessionKey), zap.Error(err)) diff --git a/internal/domain/msgingest/gateway_test.go b/internal/domain/msgingest/gateway_test.go index d45487c3..d4c587a8 100644 --- a/internal/domain/msgingest/gateway_test.go +++ b/internal/domain/msgingest/gateway_test.go @@ -295,6 +295,9 @@ func TestGateway_CommandHandlerInterceptsBeforeDB(t *testing.T) { st := newMock() handler := newMockCommandHandler(true) g := msgingest.New(st, 0, handler) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Run(ctx) g.Dispatch(platform.InboundMessage{ Platform: "feishu", @@ -351,6 +354,9 @@ func TestGateway_Command_BypassesDebounce(t *testing.T) { st := newMock() handler := newMockCommandHandler(true) g := msgingest.New(st, 500*time.Millisecond, handler) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Run(ctx) start := time.Now() g.Dispatch(platform.InboundMessage{ From b82ecc364196797e8a7bfebfb2a0031983c7e831 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 01:58:40 -0700 Subject: [PATCH 11/19] feat(msgingest): strip bot mentions from all messages, not just commands Apply stripBotMentions to both the debounce path and DB storage so that @BotName and surrounding whitespace are removed from IngestedMessage.Content and BatchMsg.Content for all inbound messages. Replace the strings.Fields word-split implementation with a regex that preserves multi-line formatting. Co-Authored-By: Claude Sonnet 4.6 --- internal/domain/msgingest/gateway.go | 30 +++++----- internal/domain/msgingest/gateway_test.go | 67 +++++++++++++++++++++++ internal/domain/msgingest/strip_test.go | 24 ++++++++ 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 629edf57..5b5cfc7f 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -2,6 +2,7 @@ package msgingest import ( "context" + "regexp" "strings" "sync" "time" @@ -71,23 +72,18 @@ func WithBotNames(names []string) Option { return func(g *Gateway) { g.botNames = names } } -// stripBotMentions removes any token equal to "@" for each configured bot name. -// Used only for command matching; never mutates stored message content. +// stripBotMentions removes every occurrence of "@" and any immediately +// surrounding whitespace (spaces, tabs, newlines) for each configured bot name. +// Applied to all messages before command matching, debounce accumulation, and DB storage. func stripBotMentions(content string, botNames []string) string { if len(botNames) == 0 { return content } - mentions := make(map[string]struct{}, len(botNames)) for _, name := range botNames { - mentions["@"+name] = struct{}{} + re := regexp.MustCompile(`\s*@` + regexp.QuoteMeta(name) + `\s*`) + content = re.ReplaceAllString(content, " ") } - var out []string - for _, f := range strings.Fields(content) { - if _, skip := mentions[f]; !skip { - out = append(out, f) - } - } - return strings.Join(out, " ") + return strings.TrimSpace(content) } // New constructs a Gateway. @@ -155,11 +151,11 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { g.seen[msg.PlatformMessageID] = struct{}{} } - cmdContent := stripBotMentions(msg.Content, g.botNames) - if g.commandHandler.IsCommand(cmdContent) { + stripped := stripBotMentions(msg.Content, g.botNames) + if g.commandHandler.IsCommand(stripped) { g.mu.Unlock() select { - case g.cmdCh <- commandTask{cmdContent, msg}: + case g.cmdCh <- commandTask{stripped, msg}: default: log.Warn("command channel full, dropping command", zap.String("sessionKey", msg.SessionKey)) } @@ -174,9 +170,9 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { } if state.content == "" { - state.content = msg.Content + state.content = stripped } else { - state.content = state.content + mergedSeparator + msg.Content + state.content = state.content + mergedSeparator + stripped } state.msgs = append(state.msgs, msg) @@ -224,7 +220,7 @@ func (g *Gateway) onDebounce(sessionKey string, generation int) { ID: ids[i], SessionKey: m.SessionKey, Platform: m.Platform, - Content: m.Content, + Content: stripBotMentions(m.Content, g.botNames), Raw: m.Raw, PlatformMsgID: m.PlatformMessageID, MessageTime: mt, diff --git a/internal/domain/msgingest/gateway_test.go b/internal/domain/msgingest/gateway_test.go index d4c587a8..207d7893 100644 --- a/internal/domain/msgingest/gateway_test.go +++ b/internal/domain/msgingest/gateway_test.go @@ -405,3 +405,70 @@ func TestGateway_ClearMessage_MergedWithDebounce(t *testing.T) { case <-time.After(300 * time.Millisecond): } } + +// TestGateway_BotMention_StrippedInEmitAndDB verifies that @BotName mentions are +// stripped from both IngestedMessage.Content and BatchMsg.Content for normal messages. +func TestGateway_BotMention_StrippedInEmitAndDB(t *testing.T) { + st := newMock() + g := msgingest.New(st, 100*time.Millisecond, noopHandler{}, + msgingest.WithBotNames([]string{"OpenBee"}), + ) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Run(ctx) + + g.Dispatch(inbound("s1", "@OpenBee hello world", "m1")) + + var emitted msgingest.IngestedMessage + select { + case emitted = <-g.Out(): + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout waiting for debounced message") + } + + if emitted.Content != "hello world" { + t.Errorf("IngestedMessage.Content = %q, want %q", emitted.Content, "hello world") + } + if len(st.batches) != 1 || len(st.batches[0]) != 1 { + t.Fatalf("expected 1 batch with 1 row, got %v", st.batches) + } + if got := st.batches[0][0].Content; got != "hello world" { + t.Errorf("BatchMsg.Content = %q, want %q", got, "hello world") + } +} + +// TestGateway_BotMention_MergedMessagesStripped verifies that merged messages each +// have their bot mentions stripped before being combined. +func TestGateway_BotMention_MergedMessagesStripped(t *testing.T) { + st := newMock() + g := msgingest.New(st, 150*time.Millisecond, noopHandler{}, + msgingest.WithBotNames([]string{"Bot"}), + ) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Run(ctx) + + g.Dispatch(inbound("s1", "@Bot hello", "m1")) + g.Dispatch(inbound("s1", "world @Bot", "m2")) + + var emitted msgingest.IngestedMessage + select { + case emitted = <-g.Out(): + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout waiting for debounced message") + } + + const want = "hello\n\n---\n\nworld" + if emitted.Content != want { + t.Errorf("IngestedMessage.Content = %q, want %q", emitted.Content, want) + } + if len(st.batches) != 1 || len(st.batches[0]) != 2 { + t.Fatalf("expected 1 batch with 2 rows, got %v", st.batches) + } + if got := st.batches[0][0].Content; got != "hello" { + t.Errorf("batch[0].Content = %q, want %q", got, "hello") + } + if got := st.batches[0][1].Content; got != "world" { + t.Errorf("batch[1].Content = %q, want %q", got, "world") + } +} diff --git a/internal/domain/msgingest/strip_test.go b/internal/domain/msgingest/strip_test.go index 18577062..0478a868 100644 --- a/internal/domain/msgingest/strip_test.go +++ b/internal/domain/msgingest/strip_test.go @@ -77,6 +77,30 @@ func TestStripBotMentions(t *testing.T) { botNames: []string{"机器人"}, want: "", }, + { + name: "mention mid-sentence no word boundary", + content: "prefix@机器人suffix", + botNames: []string{"机器人"}, + want: "prefix suffix", + }, + { + name: "mention on its own line", + content: "hello\n@机器人\nworld", + botNames: []string{"机器人"}, + want: "hello world", + }, + { + name: "mention with leading newline", + content: "@机器人\nhello", + botNames: []string{"机器人"}, + want: "hello", + }, + { + name: "mention with trailing newline", + content: "hello\n@机器人", + botNames: []string{"机器人"}, + want: "hello", + }, } for _, tt := range tests { From 30678f4ba90e83b40a5aeb4cf4eb3ef37d9ee643 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:07:10 -0700 Subject: [PATCH 12/19] refactor(msgingest): pre-compile bot-name regexes and simplify IsCommand Pre-compile @mention patterns once in WithBotNames instead of recompiling on every message dispatch. Also replace strings.Fields allocation in IsCommand with allocation-free strings.HasPrefix checks. Co-Authored-By: Claude Sonnet 4.6 --- internal/domain/command/clear.go | 4 +--- internal/domain/command/engine.go | 5 +---- internal/domain/msgingest/gateway.go | 28 +++++++++++++------------ internal/domain/msgingest/strip_test.go | 11 +++++++++- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/internal/domain/command/clear.go b/internal/domain/command/clear.go index 25a09a18..339c94ce 100644 --- a/internal/domain/command/clear.go +++ b/internal/domain/command/clear.go @@ -74,10 +74,8 @@ func NewClearCommandHandler( } } -// IsCommand implements msgingest.CommandHandler. func (h *ClearCommandHandler) IsCommand(content string) bool { - fields := strings.Fields(content) - return len(fields) > 0 && fields[0] == CmdClear + return content == CmdClear || strings.HasPrefix(content, CmdClear+" ") } func (h *ClearCommandHandler) HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool { diff --git a/internal/domain/command/engine.go b/internal/domain/command/engine.go index 83663c56..584d01f2 100644 --- a/internal/domain/command/engine.go +++ b/internal/domain/command/engine.go @@ -108,13 +108,10 @@ func NewEngineCommandHandler( } } -// IsCommand implements msgingest.CommandHandler. func (h *EngineCommandHandler) IsCommand(content string) bool { - fields := strings.Fields(content) - return len(fields) > 0 && fields[0] == CmdEngine + return content == CmdEngine || strings.HasPrefix(content, CmdEngine+" ") } -// HandleCommand implements msgingest.CommandHandler. // Returns true if content is a /engine command (whether or not it succeeded). func (h *EngineCommandHandler) HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool { fields := strings.Fields(content) diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 5b5cfc7f..320ff081 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -58,29 +58,31 @@ type Gateway struct { seenPrev map[string]struct{} // previous generation, checked on lookup only mu sync.Mutex out chan IngestedMessage - cmdCh chan commandTask // serialized command dispatch queue - commandHandler CommandHandler // intercepts slash commands before DB write - botNames []string // @mention tokens to strip before command matching + cmdCh chan commandTask // serialized command dispatch queue + commandHandler CommandHandler // intercepts slash commands before DB write + botNameREs []*regexp.Regexp // compiled @mention patterns, built once from bot names } // Option configures a Gateway. type Option func(*Gateway) // WithBotNames sets the bot display names whose @mentions are stripped from message -// content before command matching. Does not affect stored message content. +// content before command matching, debounce accumulation, and DB storage. func WithBotNames(names []string) Option { - return func(g *Gateway) { g.botNames = names } + res := make([]*regexp.Regexp, 0, len(names)) + for _, n := range names { + res = append(res, regexp.MustCompile(`\s*@`+regexp.QuoteMeta(n)+`\s*`)) + } + return func(g *Gateway) { g.botNameREs = res } } // stripBotMentions removes every occurrence of "@" and any immediately -// surrounding whitespace (spaces, tabs, newlines) for each configured bot name. -// Applied to all messages before command matching, debounce accumulation, and DB storage. -func stripBotMentions(content string, botNames []string) string { - if len(botNames) == 0 { +// surrounding whitespace (spaces, tabs, newlines) for each compiled bot-name pattern. +func stripBotMentions(content string, res []*regexp.Regexp) string { + if len(res) == 0 { return content } - for _, name := range botNames { - re := regexp.MustCompile(`\s*@` + regexp.QuoteMeta(name) + `\s*`) + for _, re := range res { content = re.ReplaceAllString(content, " ") } return strings.TrimSpace(content) @@ -151,7 +153,7 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { g.seen[msg.PlatformMessageID] = struct{}{} } - stripped := stripBotMentions(msg.Content, g.botNames) + stripped := stripBotMentions(msg.Content, g.botNameREs) if g.commandHandler.IsCommand(stripped) { g.mu.Unlock() select { @@ -220,7 +222,7 @@ func (g *Gateway) onDebounce(sessionKey string, generation int) { ID: ids[i], SessionKey: m.SessionKey, Platform: m.Platform, - Content: stripBotMentions(m.Content, g.botNames), + Content: stripBotMentions(m.Content, g.botNameREs), Raw: m.Raw, PlatformMsgID: m.PlatformMessageID, MessageTime: mt, diff --git a/internal/domain/msgingest/strip_test.go b/internal/domain/msgingest/strip_test.go index 0478a868..9e5d7163 100644 --- a/internal/domain/msgingest/strip_test.go +++ b/internal/domain/msgingest/strip_test.go @@ -1,9 +1,18 @@ package msgingest import ( + "regexp" "testing" ) +func compileBotNames(names []string) []*regexp.Regexp { + res := make([]*regexp.Regexp, 0, len(names)) + for _, n := range names { + res = append(res, regexp.MustCompile(`\s*@`+regexp.QuoteMeta(n)+`\s*`)) + } + return res +} + func TestStripBotMentions(t *testing.T) { tests := []struct { name string @@ -105,7 +114,7 @@ func TestStripBotMentions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := stripBotMentions(tt.content, tt.botNames) + got := stripBotMentions(tt.content, compileBotNames(tt.botNames)) if got != tt.want { t.Errorf("stripBotMentions(%q, %v) = %q, want %q", tt.content, tt.botNames, got, tt.want) } From cd9f60b37cd27ff295df6fc1ba688bd06675bf01 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:12:22 -0700 Subject: [PATCH 13/19] docs(changelog): add unreleased entry for bot mention stripping in group commands Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0debbc0c..c22102d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added +- Add platform bot name configuration that automatically strips `@BotName` mentions from incoming messages, fixing a bug where group chat commands were not recognized + +### Fixed +- Fix group chat commands not working when messages contain `@BotName` mention prefix + ## [0.0.27] - 2026-04-20 ### Fixed From 8865e9ccab4dc7e1b4725080711f448b9019bd60 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:12:58 -0700 Subject: [PATCH 14/19] refactor(msgingest,command): simplify review cleanup - Extract compileBotNameREs helper to eliminate duplication between WithBotNames and strip_test.go; add empty-string guard to prevent compiling a bare @-only pattern - Extract isExactOrPrefixed helper so ClearCommandHandler and EngineCommandHandler share a single IsCommand implementation - Thread gateway lifecycle context into runCommandConsumer so in-flight commands respect graceful shutdown - Remove what-comments on stripBotMentions, Run, and HandleCommand Co-Authored-By: Claude Sonnet 4.6 --- internal/domain/command/clear.go | 2 +- internal/domain/command/engine.go | 7 +++++-- internal/domain/msgingest/gateway.go | 23 ++++++++++++++--------- internal/domain/msgingest/strip_test.go | 11 +---------- 4 files changed, 21 insertions(+), 22 deletions(-) diff --git a/internal/domain/command/clear.go b/internal/domain/command/clear.go index 339c94ce..af241b59 100644 --- a/internal/domain/command/clear.go +++ b/internal/domain/command/clear.go @@ -75,7 +75,7 @@ func NewClearCommandHandler( } func (h *ClearCommandHandler) IsCommand(content string) bool { - return content == CmdClear || strings.HasPrefix(content, CmdClear+" ") + return isExactOrPrefixed(content, CmdClear) } func (h *ClearCommandHandler) HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool { diff --git a/internal/domain/command/engine.go b/internal/domain/command/engine.go index 584d01f2..ac6a0eb0 100644 --- a/internal/domain/command/engine.go +++ b/internal/domain/command/engine.go @@ -90,6 +90,10 @@ type EngineCommandHandler struct { engineCfg *enginecfg.Store } +func isExactOrPrefixed(content, cmd string) bool { + return content == cmd || strings.HasPrefix(content, cmd+" ") +} + func NewEngineCommandHandler( workers WorkerRepository, sysCfg SystemConfigWriter, @@ -109,10 +113,9 @@ func NewEngineCommandHandler( } func (h *EngineCommandHandler) IsCommand(content string) bool { - return content == CmdEngine || strings.HasPrefix(content, CmdEngine+" ") + return isExactOrPrefixed(content, CmdEngine) } -// Returns true if content is a /engine command (whether or not it succeeded). func (h *EngineCommandHandler) HandleCommand(ctx context.Context, content string, replyTo platform.InboundMessage) bool { fields := strings.Fields(content) if len(fields) == 0 || fields[0] != CmdEngine { diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 320ff081..5c803b40 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -66,18 +66,24 @@ type Gateway struct { // Option configures a Gateway. type Option func(*Gateway) -// WithBotNames sets the bot display names whose @mentions are stripped from message -// content before command matching, debounce accumulation, and DB storage. -func WithBotNames(names []string) Option { +func compileBotNameREs(names []string) []*regexp.Regexp { res := make([]*regexp.Regexp, 0, len(names)) for _, n := range names { + if n == "" { + continue + } res = append(res, regexp.MustCompile(`\s*@`+regexp.QuoteMeta(n)+`\s*`)) } + return res +} + +// WithBotNames sets the bot display names whose @mentions are stripped from message +// content before command matching, debounce accumulation, and DB storage. +func WithBotNames(names []string) Option { + res := compileBotNameREs(names) return func(g *Gateway) { g.botNameREs = res } } -// stripBotMentions removes every occurrence of "@" and any immediately -// surrounding whitespace (spaces, tabs, newlines) for each compiled bot-name pattern. func stripBotMentions(content string, res []*regexp.Regexp) string { if len(res) == 0 { return content @@ -108,17 +114,16 @@ func New(msgStore MessageStore, debounce time.Duration, handler CommandHandler, // Out returns the channel of outgoing IngestedMessages. func (g *Gateway) Out() <-chan IngestedMessage { return g.out } -// Run blocks until ctx is cancelled, then closes Out(). func (g *Gateway) Run(ctx context.Context) { - go g.runCommandConsumer() + go g.runCommandConsumer(ctx) <-ctx.Done() close(g.cmdCh) close(g.out) } -func (g *Gateway) runCommandConsumer() { +func (g *Gateway) runCommandConsumer(ctx context.Context) { for task := range g.cmdCh { - g.commandHandler.HandleCommand(context.Background(), task.content, task.msg) + g.commandHandler.HandleCommand(ctx, task.content, task.msg) } } diff --git a/internal/domain/msgingest/strip_test.go b/internal/domain/msgingest/strip_test.go index 9e5d7163..d88a04c7 100644 --- a/internal/domain/msgingest/strip_test.go +++ b/internal/domain/msgingest/strip_test.go @@ -1,18 +1,9 @@ package msgingest import ( - "regexp" "testing" ) -func compileBotNames(names []string) []*regexp.Regexp { - res := make([]*regexp.Regexp, 0, len(names)) - for _, n := range names { - res = append(res, regexp.MustCompile(`\s*@`+regexp.QuoteMeta(n)+`\s*`)) - } - return res -} - func TestStripBotMentions(t *testing.T) { tests := []struct { name string @@ -114,7 +105,7 @@ func TestStripBotMentions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := stripBotMentions(tt.content, compileBotNames(tt.botNames)) + got := stripBotMentions(tt.content, compileBotNameREs(tt.botNames)) if got != tt.want { t.Errorf("stripBotMentions(%q, %v) = %q, want %q", tt.content, tt.botNames, got, tt.want) } From c2a14118d974bc79227a3bf44be581536bd4816c Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:16:07 -0700 Subject: [PATCH 15/19] docs(changelog): remove redundant fixed entry for group chat commands Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c22102d8..8704d49e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,6 @@ ### Added - Add platform bot name configuration that automatically strips `@BotName` mentions from incoming messages, fixing a bug where group chat commands were not recognized -### Fixed -- Fix group chat commands not working when messages contain `@BotName` mention prefix - ## [0.0.27] - 2026-04-20 ### Fixed From af7d74bb4c1496a04530339a3baadb2a4cee66d5 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:21:58 -0700 Subject: [PATCH 16/19] fix(msgingest,config): simplify review fixes - promptBotName: remove survey.Required validator so users can leave bot name empty as the prompt message instructs ("leave empty to skip") - gateway: store stripped content in Dispatch so onDebounce uses it directly, avoiding a second regex pass per message per bot name - gateway: runCommandConsumer exits via ctx.Done() rather than draining the channel with a cancelled context; also removes the send-on-closed cmdCh panic risk since the channel is no longer closed in Run Co-Authored-By: Claude Sonnet 4.6 --- cmd/openbee/config.go | 2 +- internal/domain/msgingest/gateway.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index 7ac38295..59073944 100644 --- a/cmd/openbee/config.go +++ b/cmd/openbee/config.go @@ -772,7 +772,7 @@ func promptBotName(fieldPtr *string) error { return handleSurveyErr(survey.AskOne(&survey.Input{ Message: i18n.M.Prompt.BotName, Default: *fieldPtr, - }, fieldPtr, survey.WithValidator(survey.Required))) + }, fieldPtr)) } type engineMapping struct{ name, label string } diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index 5c803b40..f6faa6f2 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -117,13 +117,17 @@ func (g *Gateway) Out() <-chan IngestedMessage { return g.out } func (g *Gateway) Run(ctx context.Context) { go g.runCommandConsumer(ctx) <-ctx.Done() - close(g.cmdCh) close(g.out) } func (g *Gateway) runCommandConsumer(ctx context.Context) { - for task := range g.cmdCh { - g.commandHandler.HandleCommand(ctx, task.content, task.msg) + for { + select { + case task := <-g.cmdCh: + g.commandHandler.HandleCommand(ctx, task.content, task.msg) + case <-ctx.Done(): + return + } } } @@ -181,6 +185,7 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { } else { state.content = state.content + mergedSeparator + stripped } + msg.Content = stripped state.msgs = append(state.msgs, msg) if state.timer != nil { @@ -227,7 +232,7 @@ func (g *Gateway) onDebounce(sessionKey string, generation int) { ID: ids[i], SessionKey: m.SessionKey, Platform: m.Platform, - Content: stripBotMentions(m.Content, g.botNameREs), + Content: m.Content, Raw: m.Raw, PlatformMsgID: m.PlatformMessageID, MessageTime: mt, From 9a171fe6dde4dc380643e14384feb76162ea5cca Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:24:47 -0700 Subject: [PATCH 17/19] fix(config,i18n): restore Required validator, drop 'leave empty to skip' from bot_name prompt Bot name is mandatory; the prompt copy was wrong. Remove the misleading hint from both en/zh locales and keep survey.Required. Co-Authored-By: Claude Sonnet 4.6 --- cmd/openbee/config.go | 2 +- internal/infra/i18n/locales/en.yaml | 2 +- internal/infra/i18n/locales/zh.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index 59073944..7ac38295 100644 --- a/cmd/openbee/config.go +++ b/cmd/openbee/config.go @@ -772,7 +772,7 @@ func promptBotName(fieldPtr *string) error { return handleSurveyErr(survey.AskOne(&survey.Input{ Message: i18n.M.Prompt.BotName, Default: *fieldPtr, - }, fieldPtr)) + }, fieldPtr, survey.WithValidator(survey.Required))) } type engineMapping struct{ name, label string } diff --git a/internal/infra/i18n/locales/en.yaml b/internal/infra/i18n/locales/en.yaml index f3620a3e..a2e51a92 100644 --- a/internal/infra/i18n/locales/en.yaml +++ b/internal/infra/i18n/locales/en.yaml @@ -70,7 +70,7 @@ prompt: weixin_reacquire: "Existing Weixin token found (%s). Re-acquire via QR code?" weixin_bot_token: "Weixin Bot Token:" weixin_user_id: "Weixin User ID:" - bot_name: "Bot name for @mention stripping in group chats (leave empty to skip):" + bot_name: "Bot name for @mention stripping in group chats:" username: "Username:" password_change_confirm: "Password already configured. Change it?" password_setup: "Password setup:" diff --git a/internal/infra/i18n/locales/zh.yaml b/internal/infra/i18n/locales/zh.yaml index 533054fd..287bd052 100644 --- a/internal/infra/i18n/locales/zh.yaml +++ b/internal/infra/i18n/locales/zh.yaml @@ -70,7 +70,7 @@ prompt: weixin_reacquire: "已有微信 token(%s)。是否重新扫码登录?" weixin_bot_token: "微信 Bot Token:" weixin_user_id: "微信 User ID:" - bot_name: "机器人名称(用于群聊中消除 @提及,留空跳过):" + bot_name: "机器人名称(用于群聊中消除 @提及):" username: "用户名:" password_change_confirm: "已配置密码,是否修改?" password_setup: "密码设置方式:" From 68204bc157a3324de40d2e92e9d0055b70a04039 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:34:56 -0700 Subject: [PATCH 18/19] refactor(msgingest,config): move stripBotMentions out of lock, make bot_name optional - Move stripBotMentions() call before g.mu.Lock() in Dispatch(); botNameREs is immutable after construction so no lock is needed, and this keeps regex work out of the critical section - Drop survey.Required from promptBotName so users on DM-only platforms can leave bot_name empty Co-Authored-By: Claude Sonnet 4.6 --- cmd/openbee/config.go | 2 +- internal/domain/msgingest/gateway.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index 7ac38295..59073944 100644 --- a/cmd/openbee/config.go +++ b/cmd/openbee/config.go @@ -772,7 +772,7 @@ func promptBotName(fieldPtr *string) error { return handleSurveyErr(survey.AskOne(&survey.Input{ Message: i18n.M.Prompt.BotName, Default: *fieldPtr, - }, fieldPtr, survey.WithValidator(survey.Required))) + }, fieldPtr)) } type engineMapping struct{ name, label string } diff --git a/internal/domain/msgingest/gateway.go b/internal/domain/msgingest/gateway.go index f6faa6f2..b8eab97c 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -143,6 +143,7 @@ func (g *Gateway) emit(msg IngestedMessage) { // Dispatch is called by a platform receiver for each inbound message. // All seen-map and debounce-state mutations are protected by g.mu. func (g *Gateway) Dispatch(msg platform.InboundMessage) { + stripped := stripBotMentions(msg.Content, g.botNameREs) g.mu.Lock() if msg.PlatformMessageID != "" { @@ -162,7 +163,6 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { g.seen[msg.PlatformMessageID] = struct{}{} } - stripped := stripBotMentions(msg.Content, g.botNameREs) if g.commandHandler.IsCommand(stripped) { g.mu.Unlock() select { From e00f90151065fff4efacb9b0a1e798d14ee91328 Mon Sep 17 00:00:00 2001 From: xxx <616896861@qq.com> Date: Mon, 20 Apr 2026 02:40:12 -0700 Subject: [PATCH 19/19] fix(config): restore survey.Required for bot_name prompt Bot name must be provided when configuring a platform; the optional stripping behaviour only activates when the name is non-empty, but leaving it blank during setup is a misconfiguration. Co-Authored-By: Claude Sonnet 4.6 --- cmd/openbee/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index 59073944..7ac38295 100644 --- a/cmd/openbee/config.go +++ b/cmd/openbee/config.go @@ -772,7 +772,7 @@ func promptBotName(fieldPtr *string) error { return handleSurveyErr(survey.AskOne(&survey.Input{ Message: i18n.M.Prompt.BotName, Default: *fieldPtr, - }, fieldPtr)) + }, fieldPtr, survey.WithValidator(survey.Required))) } type engineMapping struct{ name, label string }