From 2fb2a733d425ac6b023fd0adcc18a2ee3abc1618 Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Fri, 20 Feb 2026 19:18:37 +0800 Subject: [PATCH] feat(discord): add mention_only option for @-mention responses (#518) * feat(discord): add mention_only option for @-mention responses Add MentionOnly config option to Discord channel. When enabled, the bot only responds when explicitly @-mentioned, useful for shared servers. - Add MentionOnly bool field to DiscordConfig - Store botUserID on startup for mention checking - Check m.Mentions before processing messages when MentionOnly is true - Update config example and README documentation * fix(discord): resolve race condition and strip mention from content - Get botUserID before opening session to avoid race condition - Add stripBotMention to remove @mention from message content - Handles both <@USER_ID> and <@!USER_ID> mention formats * fix(discord): skip mention_only check for DMs DMs should always be responded to regardless of mention_only setting. Added check to skip the mention_only logic when GuildID is empty. * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Hua Audio <161028864+Huaaudio@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 7 ++++- config/config.example.json | 3 ++- pkg/channels/discord.go | 55 +++++++++++++++++++++++++++++++------- pkg/config/config.go | 7 ++--- pkg/config/defaults.go | 7 ++--- 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 468350409..a9065d2a4 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,8 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"], + "mention_only": false } } } @@ -347,6 +348,10 @@ picoclaw gateway * Bot Permissions: `Send Messages`, `Read Message History` * Open the generated invite URL and add the bot to your server +**Optional: Mention-only mode** + +Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called. + **6. Run** ```bash diff --git a/config/config.example.json b/config/config.example.json index fa87fbec7..07b75d785 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -57,7 +57,8 @@ "discord": { "enabled": false, "token": "YOUR_DISCORD_BOT_TOKEN", - "allow_from": [] + "allow_from": [], + "mention_only": false }, "qq": { "enabled": false, diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 9ddec662c..342ddb478 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "sync" "time" @@ -28,6 +29,7 @@ type DiscordChannel struct { ctx context.Context typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal + botUserID string // stored for mention checking } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { @@ -63,6 +65,14 @@ func (c *DiscordChannel) Start(ctx context.Context) error { logger.InfoC("discord", "Starting Discord bot") c.ctx = ctx + + // Get bot user ID before opening session to avoid race condition + botUser, err := c.session.User("@me") + if err != nil { + return fmt.Errorf("failed to get bot user: %w", err) + } + c.botUserID = botUser.ID + c.session.AddHandler(c.handleMessage) if err := c.session.Open(); err != nil { @@ -71,10 +81,6 @@ func (c *DiscordChannel) Start(ctx context.Context) error { c.setRunning(true) - botUser, err := c.session.User("@me") - if err != nil { - return fmt.Errorf("failed to get bot user: %w", err) - } logger.InfoCF("discord", "Discord bot connected", map[string]any{ "username": botUser.Username, "user_id": botUser.ID, @@ -131,7 +137,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro } func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { - // 使用传入的 ctx 进行超时控制 + // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() @@ -152,7 +158,7 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin } } -// appendContent 安全地追加内容到现有文本 +// appendContent safely appends content to existing text func appendContent(content, suffix string) string { if content == "" { return suffix @@ -169,7 +175,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - // 检查白名单,避免为被拒绝的用户下载附件和转录 + // Check allowlist first to avoid downloading attachments and transcribing for rejected users if !c.IsAllowed(m.Author.ID) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ "user_id": m.Author.ID, @@ -177,6 +183,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } + // If configured to only respond to mentions, check if bot is mentioned + // Skip this check for DMs (GuildID is empty) - DMs should always be responded to + if c.config.MentionOnly && m.GuildID != "" { + isMentioned := false + for _, mention := range m.Mentions { + if mention.ID == c.botUserID { + isMentioned = true + break + } + } + if !isMentioned { + logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{ + "user_id": m.Author.ID, + }) + return + } + } + senderID := m.Author.ID senderName := m.Author.Username if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { @@ -184,10 +208,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag } content := m.Content + content = c.stripBotMention(content) mediaPaths := make([]string, 0, len(m.Attachments)) localFiles := make([]string, 0, len(m.Attachments)) - // 确保临时文件在函数返回时被清理 + // Ensure temp files are cleaned up when function returns defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { @@ -211,7 +236,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if c.transcriber != nil && c.transcriber.IsAvailable() { ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) result, err := c.transcriber.Transcribe(ctx, localPath) - cancel() // 立即释放context资源,避免在for循环中泄漏 + cancel() // Release context resources immediately to avoid leaks in for loop if err != nil { logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ @@ -333,3 +358,15 @@ func (c *DiscordChannel) downloadAttachment(url, filename string) string { LoggerPrefix: "discord", }) } + +// stripBotMention removes the bot mention from the message content. +// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname). +func (c *DiscordChannel) stripBotMention(text string) string { + if c.botUserID == "" { + return text + } + // Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID> + text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "") + text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") + return strings.TrimSpace(text) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 9d5e5d42e..e44212605 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -215,9 +215,10 @@ type FeishuConfig struct { } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` } type MaixCamConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 07974b8eb..d66de9081 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -43,9 +43,10 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, Discord: DiscordConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + Token: "", + AllowFrom: FlexibleStringSlice{}, + MentionOnly: false, }, MaixCam: MaixCamConfig{ Enabled: false,