feat(telegram): add topic group trigger overrides

This commit is contained in:
Anton Bogdanovich 2026-05-05 22:30:27 -07:00
parent 96621eff21
commit cf80fa7d6f
8 changed files with 182 additions and 5 deletions

View file

@ -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`.

View file

@ -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

View file

@ -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 |

View file

@ -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) {
gt := c.groupTrigger
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)

View file

@ -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

View file

@ -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)
}

View file

@ -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{

View file

@ -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).