diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index a4138009e0..bb05ba7b29 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -28,6 +28,7 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co | allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | | proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | | use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting | +| group_trigger | object | No | Group trigger strategy (`mention_only`, `prefixes`, and Telegram forum topic overrides) | ## Setup @@ -37,6 +38,46 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co 4. Fill in the Token in the configuration file 5. (Optional) Configure `allow_from` to restrict which user IDs can interact (you can get IDs via `@userinfobot`) +## Group Trigger + +By default, the bot responds to every message in allowed group chats. Use +`group_trigger.mention_only` to make it respond only when mentioned: + +```json +{ + "channel_list": { + "telegram": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +For Telegram supergroups with forum topics, `group_trigger.topics` can override +the group trigger for a specific topic ID. Topic entries replace the channel-wide +trigger for that topic. + +This is useful when the bot should stay mention-only in most of a group, but be +active by default in a dedicated topic: + +```json +{ + "channel_list": { + "telegram": { + "group_trigger": { + "mention_only": true, + "topics": { + "1771": { "mention_only": false } + } + } + } + } +} +``` + +You can find a topic ID in Telegram update logs or by inspecting +`message_thread_id` from the Telegram Bot API update payload. + ## Built-in Commands Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`. diff --git a/docs/guides/chat-apps.md b/docs/guides/chat-apps.md index 62418f91a7..ed0c182933 100644 --- a/docs/guides/chat-apps.md +++ b/docs/guides/chat-apps.md @@ -149,6 +149,26 @@ You can also trigger by keyword prefixes (e.g. `!bot`): } ``` +Telegram forum groups can override this behavior per topic. For example, keep the +bot mention-only in the group, but let it answer normally in topic `1771`: + +```json +{ + "channel_list": { + "telegram": { + "group_trigger": { + "mention_only": true, + "topics": { + "1771": { "mention_only": false } + } + } + } + } +} +``` + +Topic entries replace the channel-level `group_trigger` for that topic. + **6. Run** ```bash diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 1cab1a4a6e..99a83b0612 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -957,6 +957,7 @@ BaseChannel is the shared abstraction layer for all channels, providing the foll | `IsAllowed(senderID string) bool` | Legacy allow-list check (supports `"id\|username"` and `"@username"` formats) | | `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) | | `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic | +| `ShouldRespondInGroupForTopic(isMentioned, content, topicID) (bool, string)` | Unified group chat trigger filtering with a topic-specific override | | `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction/Placeholder → publish to Bus | | `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager | | `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder injected by Manager | diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 3585fb0756..6c7b0f1bba 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -156,8 +156,26 @@ func (c *BaseChannel) MaxMessageLength() int { // - 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) { + return shouldRespondInGroup(c.groupTrigger, isMentioned, content) +} + +// ShouldRespondInGroupForTopic applies a topic-specific group trigger override +// when configured, then falls back to the channel-wide group trigger. +// +// Topic entries replace the channel-wide trigger for that topic. This keeps the +// current bool-based config semantics explicit: { "mention_only": false } is a +// deliberate permissive override, not an omitted value to merge from the parent. +func (c *BaseChannel) ShouldRespondInGroupForTopic(isMentioned bool, content string, topicID string) (bool, string) { gt := c.groupTrigger + if topicID != "" && gt.Topics != nil { + if topicTrigger, ok := gt.Topics[topicID]; ok { + gt = topicTrigger + } + } + return shouldRespondInGroup(gt, isMentioned, content) +} +func shouldRespondInGroup(gt config.GroupTriggerConfig, isMentioned bool, content string) (bool, string) { // Mentioned → always respond if isMentioned { return true, strings.TrimSpace(content) diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index 04500f775f..8e575cd7f3 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -178,6 +178,42 @@ func TestShouldRespondInGroup(t *testing.T) { } } +func TestShouldRespondInGroupForTopicOverride(t *testing.T) { + ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(config.GroupTriggerConfig{ + MentionOnly: true, + Topics: map[string]config.GroupTriggerConfig{ + "1771": {MentionOnly: false}, + "1772": {Prefixes: []string{"!bot"}}, + }, + })) + + respond, cleaned := ch.ShouldRespondInGroupForTopic(false, "hello", "1771") + if !respond { + t.Fatal("topic override should allow non-mentioned messages") + } + if cleaned != "hello" { + t.Fatalf("cleaned content = %q, want hello", cleaned) + } + + respond, _ = ch.ShouldRespondInGroupForTopic(false, "hello", "42") + if respond { + t.Fatal("non-overridden topic should keep channel mention_only behavior") + } + + respond, cleaned = ch.ShouldRespondInGroupForTopic(false, "!bot hello", "1772") + if !respond { + t.Fatal("topic override should allow matching prefix") + } + if cleaned != "hello" { + t.Fatalf("cleaned content = %q, want hello", cleaned) + } + + respond, _ = ch.ShouldRespondInGroupForTopic(false, "hello", "1772") + if respond { + t.Fatal("topic override should replace the parent trigger, not merge it") + } +} + func TestIsAllowedSender(t *testing.T) { tests := []struct { name string diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index cebebfed64..6fa6a5e955 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -831,12 +831,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // In group chats, apply unified group trigger filtering isMentioned := false + threadID := message.MessageThreadID if message.Chat.Type != "private" { isMentioned = c.isBotMentioned(message) if isMentioned { content = c.stripBotMention(content) } - respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + topicID := "" + if message.Chat.IsForum && threadID != 0 { + topicID = fmt.Sprintf("%d", threadID) + } + respond, cleaned := c.ShouldRespondInGroupForTopic(isMentioned, content, topicID) if !respond { return nil } @@ -865,7 +870,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // Only forum groups (IsForum) are handled; regular group reply threads // must share one session per group. compositeChatID := fmt.Sprintf("%d", chatID) - threadID := message.MessageThreadID if message.Chat.IsForum && threadID != 0 { compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) } diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 69c76b4300..57ea70c558 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -822,6 +822,62 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { assert.Equal(t, "42", inbound.Context.TopicID) } +func TestHandleMessage_ForumTopic_GroupTriggerOverride(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil, channels.WithGroupTrigger(config.GroupTriggerConfig{ + MentionOnly: true, + Topics: map[string]config.GroupTriggerConfig{ + "42": {MentionOnly: false}, + }, + })), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + allowed := &telego.Message{ + Text: "topic-only message", + MessageID: 10, + MessageThreadID: 42, + Chat: telego.Chat{ + ID: -1001234567890, + Type: "supergroup", + IsForum: true, + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + require.NoError(t, ch.handleMessage(context.Background(), allowed)) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok, "expected inbound message for overridden topic") + assert.Equal(t, "topic-only message", inbound.Content) + assert.Equal(t, "42", inbound.Context.TopicID) + + filtered := &telego.Message{ + Text: "other topic message", + MessageID: 11, + MessageThreadID: 43, + Chat: telego.Chat{ + ID: -1001234567890, + Type: "supergroup", + IsForum: true, + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + require.NoError(t, ch.handleMessage(context.Background(), filtered)) + select { + case msg := <-messageBus.InboundChan(): + t.Fatalf("unexpected inbound message for non-overridden topic: %+v", msg) + default: + } +} + func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ diff --git a/pkg/config/config.go b/pkg/config/config.go index acceee4d5a..53d8aa0740 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -319,8 +319,9 @@ func (d *AgentDefaults) GetModelName() string { // GroupTriggerConfig controls when the bot responds in group chats. type GroupTriggerConfig struct { - MentionOnly bool `json:"mention_only,omitempty"` - Prefixes []string `json:"prefixes,omitempty"` + MentionOnly bool `json:"mention_only,omitempty"` + Prefixes []string `json:"prefixes,omitempty"` + Topics map[string]GroupTriggerConfig `json:"topics,omitempty"` } // TypingConfig controls typing indicator behavior (Phase 10).