feat(telegram): add topic group trigger overrides
This commit is contained in:
parent
96621eff21
commit
cf80fa7d6f
8 changed files with 182 additions and 5 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue