Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
63f1477
docs: add design spec for stripping bot @mention in group chat commands
Qsnh Apr 20, 2026
4ea09e7
docs: add implementation plan for stripping bot @mention in group com…
Qsnh Apr 20, 2026
239ecf5
feat: strip bot @mention tokens before group chat command matching
Qsnh Apr 20, 2026
6a89bf9
feat(config): add BotName prompts to openbee config wizard
Qsnh Apr 20, 2026
e6e27a0
refactor: extract promptBotName helper and fix slice mutation in stri…
Qsnh Apr 20, 2026
a93f096
chore: remove strip-bot-mention implementation plan and design spec docs
Qsnh Apr 20, 2026
f74de29
feat(config): make BotName required in openbee config wizard
Qsnh Apr 20, 2026
25718a2
refactor(msgingest): make CommandHandler a required parameter in New()
Qsnh Apr 20, 2026
3adcfbf
feat(msgingest): bypass debounce for slash commands via IsCommand fas…
Qsnh Apr 20, 2026
ddeb073
refactor(msgingest): async command dispatch via buffered channel
Qsnh Apr 20, 2026
b82ecc3
feat(msgingest): strip bot mentions from all messages, not just commands
Qsnh Apr 20, 2026
30678f4
refactor(msgingest): pre-compile bot-name regexes and simplify IsCommand
Qsnh Apr 20, 2026
cd9f60b
docs(changelog): add unreleased entry for bot mention stripping in gr…
Qsnh Apr 20, 2026
8865e9c
refactor(msgingest,command): simplify review cleanup
Qsnh Apr 20, 2026
c2a1411
docs(changelog): remove redundant fixed entry for group chat commands
Qsnh Apr 20, 2026
8a31ae4
chore: merge main into feat/strip-bot-mention-group-commands
Qsnh Apr 20, 2026
af7d74b
fix(msgingest,config): simplify review fixes
Qsnh Apr 20, 2026
9a171fe
fix(config,i18n): restore Required validator, drop 'leave empty to sk…
Qsnh Apr 20, 2026
68204bc
refactor(msgingest,config): move stripBotMentions out of lock, make b…
Qsnh Apr 20, 2026
e00f901
fix(config): restore survey.Required for bot_name prompt
Qsnh Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions cmd/openbee/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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{
Expand All @@ -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{
Expand All @@ -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{
Expand All @@ -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

Expand Down Expand Up @@ -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
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions internal/domain/command/clear.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions internal/domain/command/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions internal/domain/msgingest/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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) {
Expand Down
91 changes: 70 additions & 21 deletions internal/domain/msgingest/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package msgingest

import (
"context"
"regexp"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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 != "" {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
Loading