diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d93d0c0..460782c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [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 + ## [0.0.28] - 2026-04-20 ### Added diff --git a/cmd/openbee/config.go b/cmd/openbee/config.go index a0e70005..7ac38295 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,9 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.FeishuAppSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } + if err := promptBotName(&vals.FeishuBotName); err != nil { + return err + } case i18n.M.Prompt.PlatformDingTalk: vals.DingtalkEnabled = true if err := survey.AskOne(&survey.Input{ @@ -403,6 +416,9 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.DingtalkClientSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } + if err := promptBotName(&vals.DingtalkBotName); err != nil { + return err + } case i18n.M.Prompt.PlatformWeCom: vals.WecomEnabled = true if err := survey.AskOne(&survey.Input{ @@ -417,6 +433,9 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.WecomSecret, survey.WithValidator(survey.Required)); err != nil { return handleSurveyErr(err) } + if err := promptBotName(&vals.WecomBotName); err != nil { + return err + } case i18n.M.Prompt.PlatformTelegram: vals.TelegramEnabled = true if err := survey.AskOne(&survey.Password{ @@ -438,6 +457,9 @@ func runConfig(cmd *cobra.Command, args []string) error { }, &vals.TelegramAuthCode); err != nil { return handleSurveyErr(err) } + if err := promptBotName(&vals.TelegramBotName); err != nil { + return err + } case i18n.M.Prompt.PlatformWeixin: vals.WeixinEnabled = true @@ -489,6 +511,9 @@ 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 := promptBotName(&vals.WeixinBotName); err != nil { + return err + } } } @@ -743,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, survey.WithValidator(survey.Required))) +} + type engineMapping struct{ name, label string } func engineMappings() []engineMapping { diff --git a/internal/app/app.go b/internal/app/app.go index 42cf50f6..182902c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -157,8 +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)) - localIngest := msgingest.New(s.msgStore, 100*time.Millisecond, 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, cmdChain, + msgingest.WithBotNames(botNames)) + 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/command/clear.go b/internal/domain/command/clear.go index 35d91583..47b421cf 100644 --- a/internal/domain/command/clear.go +++ b/internal/domain/command/clear.go @@ -74,6 +74,10 @@ func NewClearCommandHandler( } } +func (h *ClearCommandHandler) IsCommand(content string) bool { + return isExactOrPrefixed(content, 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..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, @@ -108,8 +112,10 @@ func NewEngineCommandHandler( } } -// HandleCommand implements msgingest.CommandHandler. -// Returns true if content is a /engine command (whether or not it succeeded). +func (h *EngineCommandHandler) IsCommand(content string) bool { + return isExactOrPrefixed(content, CmdEngine) +} + 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/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 3b460126..b8eab97c 100644 --- a/internal/domain/msgingest/gateway.go +++ b/internal/domain/msgingest/gateway.go @@ -2,6 +2,8 @@ package msgingest import ( "context" + "regexp" + "strings" "sync" "time" @@ -42,6 +44,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 @@ -51,27 +58,52 @@ 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 + 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) -// 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 } +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 } +} + +func stripBotMentions(content string, res []*regexp.Regexp) string { + if len(res) == 0 { + return content + } + for _, re := range res { + content = re.ReplaceAllString(content, " ") + } + return strings.TrimSpace(content) } // 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), + cmdCh: make(chan commandTask, 32), } for _, o := range opts { o(g) @@ -82,12 +114,23 @@ func New(msgStore MessageStore, debounce time.Duration, opts ...Option) *Gateway // 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(ctx) <-ctx.Done() close(g.out) } +func (g *Gateway) runCommandConsumer(ctx context.Context) { + for { + select { + case task := <-g.cmdCh: + g.commandHandler.HandleCommand(ctx, task.content, task.msg) + case <-ctx.Done(): + return + } + } +} + // emit sends msg to the output channel non-blocking; drops and logs if the channel is full. func (g *Gateway) emit(msg IngestedMessage) { select { @@ -100,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 != "" { @@ -119,6 +163,16 @@ func (g *Gateway) Dispatch(msg platform.InboundMessage) { g.seen[msg.PlatformMessageID] = struct{}{} } + if g.commandHandler.IsCommand(stripped) { + g.mu.Unlock() + select { + case g.cmdCh <- commandTask{stripped, msg}: + default: + log.Warn("command channel full, dropping command", zap.String("sessionKey", msg.SessionKey)) + } + return + } + // Accumulate into debounce state. state, ok := g.sessions[msg.SessionKey] if !ok { @@ -127,10 +181,11 @@ 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 } + msg.Content = stripped state.msgs = append(state.msgs, msg) if state.timer != nil { @@ -192,12 +247,6 @@ func (g *Gateway) onDebounce(sessionKey string, generation int) { batch[i] = bm } - if g.commandHandler != nil { - if g.commandHandler.HandleCommand(context.Background(), content, 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 3b7187af..207d7893 100644 --- a/internal/domain/msgingest/gateway_test.go +++ b/internal/domain/msgingest/gateway_test.go @@ -46,6 +46,12 @@ 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) 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{ Platform: "test", @@ -59,7 +65,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 +91,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 +120,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 +171,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 +199,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 +219,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 +240,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) @@ -267,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) @@ -286,7 +294,10 @@ 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) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Run(ctx) g.Dispatch(platform.InboundMessage{ Platform: "feishu", @@ -315,7 +326,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{ @@ -337,9 +348,40 @@ 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) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Run(ctx) + + 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) + g := msgingest.New(st, 200*time.Millisecond, noopHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go g.Run(ctx) @@ -363,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 new file mode 100644 index 00000000..d88a04c7 --- /dev/null +++ b/internal/domain/msgingest/strip_test.go @@ -0,0 +1,114 @@ +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: "", + }, + { + 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 { + t.Run(tt.name, func(t *testing.T) { + 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) + } + }) + } +} 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 { 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..a2e51a92 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:" 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..287bd052 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"`