Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/channels/telegram/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`.
Expand Down
20 changes: 20 additions & 0 deletions docs/guides/chat-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/channels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
18 changes: 18 additions & 0 deletions pkg/channels/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions pkg/channels/base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions pkg/channels/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
56 changes: 56 additions & 0 deletions pkg/channels/telegram/telegram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
5 changes: 3 additions & 2 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down