refactor(channels): standardize group chat trigger filtering (Phase 8)

Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
This commit is contained in:
Hoshina
2026-02-23 04:11:11 +08:00
parent e00745489d
commit f8b656ec37
14 changed files with 446 additions and 106 deletions
+47
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
@@ -30,6 +31,11 @@ func WithMaxMessageLength(n int) BaseChannelOption {
return func(c *BaseChannel) { c.maxMessageLength = n }
}
// WithGroupTrigger sets the group trigger configuration for a channel.
func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {
return func(c *BaseChannel) { c.groupTrigger = gt }
}
// MessageLengthProvider is an opt-in interface that channels implement
// to advertise their maximum message length. The Manager uses this via
// type assertion to decide whether to split outbound messages.
@@ -44,6 +50,7 @@ type BaseChannel struct {
name string
allowList []string
maxMessageLength int
groupTrigger config.GroupTriggerConfig
mediaStore media.MediaStore
}
@@ -72,6 +79,46 @@ func (c *BaseChannel) MaxMessageLength() int {
return c.maxMessageLength
}
// ShouldRespondInGroup determines whether the bot should respond in a group chat.
// Each channel is responsible for:
// 1. Detecting isMentioned (platform-specific)
// 2. Stripping bot mention from content (platform-specific)
// 3. Calling this method to get the group response decision
//
// Logic:
// - If isMentioned → always respond
// - If mention_only configured and not mentioned → ignore
// - If prefixes configured → respond if content starts with any prefix (strip it)
// - If prefixes configured but no match and not mentioned → ignore
// - Otherwise (no group_trigger configured) → respond to all (permissive default)
func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) {
gt := c.groupTrigger
// Mentioned → always respond
if isMentioned {
return true, strings.TrimSpace(content)
}
// mention_only → require mention
if gt.MentionOnly {
return false, content
}
// Prefix matching
if len(gt.Prefixes) > 0 {
for _, prefix := range gt.Prefixes {
if prefix != "" && strings.HasPrefix(content, prefix) {
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
}
}
// Prefixes configured but none matched and not mentioned → ignore
return false, content
}
// No group_trigger configured → permissive (respond to all)
return true, strings.TrimSpace(content)
}
func (c *BaseChannel) Name() string {
return c.name
}
+126 -1
View File
@@ -1,6 +1,10 @@
package channels
import "testing"
import (
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestBaseChannelIsAllowed(t *testing.T) {
tests := []struct {
@@ -50,3 +54,124 @@ func TestBaseChannelIsAllowed(t *testing.T) {
})
}
}
func TestShouldRespondInGroup(t *testing.T) {
tests := []struct {
name string
gt config.GroupTriggerConfig
isMentioned bool
content string
wantRespond bool
wantContent string
}{
{
name: "no config - permissive default",
gt: config.GroupTriggerConfig{},
isMentioned: false,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "no config - mentioned",
gt: config.GroupTriggerConfig{},
isMentioned: true,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "mention_only - not mentioned",
gt: config.GroupTriggerConfig{MentionOnly: true},
isMentioned: false,
content: "hello world",
wantRespond: false,
wantContent: "hello world",
},
{
name: "mention_only - mentioned",
gt: config.GroupTriggerConfig{MentionOnly: true},
isMentioned: true,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "prefix match",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
isMentioned: false,
content: "/ask hello",
wantRespond: true,
wantContent: "hello",
},
{
name: "prefix no match - not mentioned",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
isMentioned: false,
content: "hello world",
wantRespond: false,
wantContent: "hello world",
},
{
name: "prefix no match - but mentioned",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
isMentioned: true,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "multiple prefixes - second matches",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask", "/bot"}},
isMentioned: false,
content: "/bot help me",
wantRespond: true,
wantContent: "help me",
},
{
name: "mention_only with prefixes - mentioned overrides",
gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
isMentioned: true,
content: "hello",
wantRespond: true,
wantContent: "hello",
},
{
name: "mention_only with prefixes - not mentioned, no prefix",
gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
isMentioned: false,
content: "hello",
wantRespond: false,
wantContent: "hello",
},
{
name: "empty prefix in list is skipped",
gt: config.GroupTriggerConfig{Prefixes: []string{"", "/ask"}},
isMentioned: false,
content: "/ask test",
wantRespond: true,
wantContent: "test",
},
{
name: "prefix strips leading whitespace after prefix",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask "}},
isMentioned: false,
content: "/ask hello",
wantRespond: true,
wantContent: "hello",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(tt.gt))
gotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content)
if gotRespond != tt.wantRespond {
t.Errorf("ShouldRespondInGroup() respond = %v, want %v", gotRespond, tt.wantRespond)
}
if gotContent != tt.wantContent {
t.Errorf("ShouldRespondInGroup() content = %q, want %q", gotContent, tt.wantContent)
}
})
}
}
+10 -1
View File
@@ -38,7 +38,10 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(20000))
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &DingTalkChannel{
BaseChannel: base,
@@ -165,6 +168,12 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
peer = bus.Peer{Kind: "group", ID: data.ConversationId}
// In group chats, apply unified group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return nil, nil
}
content = cleaned
}
logger.DebugCF("dingtalk", "Received message", map[string]any{
+17 -8
View File
@@ -39,7 +39,10 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
return nil, fmt.Errorf("failed to create discord session: %w", err)
}
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(2000))
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
channels.WithMaxMessageLength(2000),
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &DiscordChannel{
BaseChannel: base,
@@ -265,9 +268,11 @@ 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 != "" {
content := m.Content
// In guild (group) channels, apply unified group trigger filtering
// DMs (GuildID is empty) always get a response
if m.GuildID != "" {
isMentioned := false
for _, mention := range m.Mentions {
if mention.ID == c.botUserID {
@@ -275,12 +280,18 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
break
}
}
if !isMentioned {
logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
content = c.stripBotMention(content)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{
"user_id": m.Author.ID,
})
return
}
content = cleaned
} else {
// DMs: just strip bot mention without filtering
content = c.stripBotMention(content)
}
senderID := m.Author.ID
@@ -289,8 +300,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
senderName += "#" + m.Author.Discriminator
}
content := m.Content
content = c.stripBotMention(content)
mediaPaths := make([]string, 0, len(m.Attachments))
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
+9 -1
View File
@@ -32,7 +32,9 @@ type FeishuChannel struct {
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &FeishuChannel{
BaseChannel: base,
@@ -173,6 +175,12 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
peer = bus.Peer{Kind: "group", ID: chatID}
// In group chats, apply unified group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return nil
}
content = cleaned
}
logger.InfoCF("feishu", "Feishu message received", map[string]any{
+17 -9
View File
@@ -59,7 +59,10 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
}
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(5000))
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &LINEChannel{
BaseChannel: base,
@@ -262,14 +265,6 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return
}
// In group chats, only respond when the bot is mentioned
if isGroup && !c.isBotMentioned(msg) {
logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
"chat_id": chatID,
})
return
}
// Store reply token for later use
if event.ReplyToken != "" {
c.replyTokens.Store(chatID, replyTokenEntry{
@@ -339,6 +334,19 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return
}
// In group chats, apply unified group trigger filtering
if isGroup {
isMentioned := c.isBotMentioned(msg)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
"chat_id": chatID,
})
return
}
content = cleaned
}
metadata := map[string]string{
"platform": "line",
"source_type": event.Source.Type,
+5 -23
View File
@@ -97,7 +97,9 @@ type oneBotMessageSegment struct {
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
)
const dedupSize = 1024
return &OneBotChannel{
@@ -996,8 +998,8 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
metadata["sender_name"] = sender.Nickname
}
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
if !triggered {
respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content)
if !respond {
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
"sender": senderID,
"group": groupIDStr,
@@ -1069,23 +1071,3 @@ func truncate(s string, n int) string {
}
return string(runes[:n]) + "..."
}
func (c *OneBotChannel) checkGroupTrigger(
content string,
isBotMentioned bool,
) (triggered bool, strippedContent string) {
if isBotMentioned {
return true, strings.TrimSpace(content)
}
for _, prefix := range c.config.GroupTriggerPrefix {
if prefix == "" {
continue
}
if strings.HasPrefix(content, prefix) {
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
}
}
return false, content
}
+10 -1
View File
@@ -32,7 +32,9 @@ type QQChannel struct {
}
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &QQChannel{
BaseChannel: base,
@@ -204,6 +206,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
// GroupAT event means bot is always mentioned; apply group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(true, content)
if !respond {
return nil
}
content = cleaned
logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID,
"group": data.GroupID,
+13 -1
View File
@@ -47,7 +47,10 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
socketClient := socketmode.New(api)
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(40000))
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(40000),
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &SlackChannel{
BaseChannel: base,
@@ -279,6 +282,15 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content := ev.Text
content = c.stripBotMention(content)
// In non-DM channels, apply group trigger filtering
if !strings.HasPrefix(channelID, "D") {
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return
}
content = cleaned
}
var mediaPaths []string
scope := channels.BuildMediaScope("slack", chatID, messageTS)
+63
View File
@@ -81,6 +81,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
bus,
telegramCfg.AllowFrom,
channels.WithMaxMessageLength(4096),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
)
return &TelegramChannel{
@@ -417,6 +418,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = "[empty message]"
}
// In group chats, apply unified group trigger filtering
if message.Chat.Type != "private" {
isMentioned := c.isBotMentioned(message)
if isMentioned {
content = c.stripBotMention(content)
}
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
return nil
}
content = cleaned
}
logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": fmt.Sprintf("%d", chatID),
@@ -629,3 +643,52 @@ func escapeHTML(text string) string {
text = strings.ReplaceAll(text, ">", ">")
return text
}
// isBotMentioned checks if the bot is mentioned in the message via entities.
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
botUsername := c.bot.Username()
if botUsername == "" {
return false
}
entities := message.Entities
if entities == nil {
entities = message.CaptionEntities
}
for _, entity := range entities {
if entity.Type == "mention" {
// Extract the mention text from the message
text := message.Text
if text == "" {
text = message.Caption
}
runes := []rune(text)
end := entity.Offset + entity.Length
if end <= len(runes) {
mention := string(runes[entity.Offset:end])
if strings.EqualFold(mention, "@"+botUsername) {
return true
}
}
}
if entity.Type == "text_mention" && entity.User != nil {
if entity.User.Username == botUsername {
return true
}
}
}
return false
}
// stripBotMention removes the @bot mention from the content.
func (c *TelegramChannel) stripBotMention(content string) string {
botUsername := c.bot.Username()
if botUsername == "" {
return content
}
// Case-insensitive replacement
re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
content = re.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
+4 -1
View File
@@ -122,7 +122,10 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
}
base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048))
base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &WeComAppChannel{
BaseChannel: base,
+13 -1
View File
@@ -86,7 +86,10 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
return nil, fmt.Errorf("wecom token and webhook_url are required")
}
base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048))
base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
)
return &WeComBotChannel{
BaseChannel: base,
@@ -367,6 +370,15 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
// Build metadata
peer := bus.Peer{Kind: peerKind, ID: peerID}
// In group chats, apply unified group trigger filtering
if isGroupChat {
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return
}
content = cleaned
}
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": msg.MsgID,
+111 -59
View File
@@ -204,6 +204,23 @@ type ChannelsConfig struct {
WeComApp WeComAppConfig `json:"wecom_app"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
type GroupTriggerConfig struct {
MentionOnly bool `json:"mention_only,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
}
// TypingConfig controls typing indicator behavior (Phase 10).
type TypingConfig struct {
Enabled bool `json:"enabled,omitempty"`
}
// PlaceholderConfig controls placeholder message behavior (Phase 10).
type PlaceholderConfig struct {
Enabled bool `json:"enabled,omitempty"`
Text string `json:"text,omitempty"`
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
@@ -211,26 +228,33 @@ type WhatsAppConfig struct {
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
}
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"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
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"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type MaixCamConfig struct {
@@ -241,69 +265,82 @@ type MaixCamConfig struct {
}
type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
}
type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
}
type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type OneBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type WeComConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
}
type WeComAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
}
type HeartbeatConfig struct {
@@ -536,6 +573,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
@@ -549,6 +589,18 @@ func LoadConfig(path string) (*Config, error) {
return cfg, nil
}
func (c *Config) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
c.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
func SaveConfig(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
+1
View File
@@ -80,6 +80,7 @@ func DefaultConfig() *Config {
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
},
OneBot: OneBotConfig{
Enabled: false,