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
This commit is contained in:
Vernon Stinebaker 2026-02-20 16:10:21 +08:00
parent e599573ed4
commit 9d0903515a
5 changed files with 40 additions and 13 deletions

View file

@ -334,7 +334,8 @@ picoclaw gateway
"discord": { "discord": {
"enabled": true, "enabled": true,
"token": "YOUR_BOT_TOKEN", "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` * Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server * 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** **6. Run**
```bash ```bash

View file

@ -57,7 +57,8 @@
"discord": { "discord": {
"enabled": false, "enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN", "token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": [] "allow_from": [],
"mention_only": false
}, },
"maixcam": { "maixcam": {
"enabled": false, "enabled": false,

View file

@ -28,6 +28,7 @@ type DiscordChannel struct {
ctx context.Context ctx context.Context
typingMu sync.Mutex typingMu sync.Mutex
typingStop map[string]chan struct{} // chatID → stop signal typingStop map[string]chan struct{} // chatID → stop signal
botUserID string // stored for mention checking
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@ -75,6 +76,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to get bot user: %w", err) return fmt.Errorf("failed to get bot user: %w", err)
} }
c.botUserID = botUser.ID
logger.InfoCF("discord", "Discord bot connected", map[string]any{ logger.InfoCF("discord", "Discord bot connected", map[string]any{
"username": botUser.Username, "username": botUser.Username,
"user_id": botUser.ID, "user_id": botUser.ID,
@ -131,7 +133,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
} }
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { 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) sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel() defer cancel()
@ -152,7 +154,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 { func appendContent(content, suffix string) string {
if content == "" { if content == "" {
return suffix return suffix
@ -169,7 +171,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return return
} }
// 检查白名单,避免为被拒绝的用户下载附件和转录 // Check allowlist first to avoid downloading attachments and transcribing for rejected users
if !c.IsAllowed(m.Author.ID) { if !c.IsAllowed(m.Author.ID) {
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
"user_id": m.Author.ID, "user_id": m.Author.ID,
@ -177,6 +179,23 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return return
} }
// If configured to only respond to mentions, check if bot is mentioned
if c.config.MentionOnly {
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 senderID := m.Author.ID
senderName := m.Author.Username senderName := m.Author.Username
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
@ -187,7 +206,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
mediaPaths := make([]string, 0, len(m.Attachments)) mediaPaths := make([]string, 0, len(m.Attachments))
localFiles := make([]string, 0, len(m.Attachments)) localFiles := make([]string, 0, len(m.Attachments))
// 确保临时文件在函数返回时被清理 // Ensure temp files are cleaned up when function returns
defer func() { defer func() {
for _, file := range localFiles { for _, file := range localFiles {
if err := os.Remove(file); err != nil { if err := os.Remove(file); err != nil {
@ -211,7 +230,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
if c.transcriber != nil && c.transcriber.IsAvailable() { if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
result, err := c.transcriber.Transcribe(ctx, localPath) result, err := c.transcriber.Transcribe(ctx, localPath)
cancel() // 立即释放context资源避免在for循环中泄漏 cancel() // Release context resources immediately to avoid leaks in for loop
if err != nil { if err != nil {
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ logger.ErrorCF("discord", "Voice transcription failed", map[string]any{

View file

@ -215,9 +215,10 @@ type FeishuConfig struct {
} }
type DiscordConfig struct { type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` 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 { type MaixCamConfig struct {

View file

@ -43,9 +43,10 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
}, },
Discord: DiscordConfig{ Discord: DiscordConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
MentionOnly: false,
}, },
MaixCam: MaixCamConfig{ MaixCam: MaixCamConfig{
Enabled: false, Enabled: false,