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 |
|
| 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) |
|
| 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 |
|
| 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
|
## 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
|
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`)
|
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
|
## Built-in Commands
|
||||||
|
|
||||||
Telegram auto-registers PicoClaw's top-level bot commands at startup, including `/start`, `/help`, `/show`, `/list`, and `/use`.
|
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**
|
**6. Run**
|
||||||
|
|
||||||
```bash
|
```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) |
|
| `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`) |
|
| `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) |
|
||||||
| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic |
|
| `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 |
|
| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction/Placeholder → publish to Bus |
|
||||||
| `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager |
|
| `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager |
|
||||||
| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder 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
|
// - If prefixes configured but no match and not mentioned → ignore
|
||||||
// - Otherwise (no group_trigger configured) → respond to all (permissive default)
|
// - Otherwise (no group_trigger configured) → respond to all (permissive default)
|
||||||
func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) {
|
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
|
// Mentioned → always respond
|
||||||
if isMentioned {
|
if isMentioned {
|
||||||
return true, strings.TrimSpace(content)
|
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) {
|
func TestIsAllowedSender(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -831,12 +831,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
|
|
||||||
// In group chats, apply unified group trigger filtering
|
// In group chats, apply unified group trigger filtering
|
||||||
isMentioned := false
|
isMentioned := false
|
||||||
|
threadID := message.MessageThreadID
|
||||||
if message.Chat.Type != "private" {
|
if message.Chat.Type != "private" {
|
||||||
isMentioned = c.isBotMentioned(message)
|
isMentioned = c.isBotMentioned(message)
|
||||||
if isMentioned {
|
if isMentioned {
|
||||||
content = c.stripBotMention(content)
|
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 {
|
if !respond {
|
||||||
return nil
|
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
|
// Only forum groups (IsForum) are handled; regular group reply threads
|
||||||
// must share one session per group.
|
// must share one session per group.
|
||||||
compositeChatID := fmt.Sprintf("%d", chatID)
|
compositeChatID := fmt.Sprintf("%d", chatID)
|
||||||
threadID := message.MessageThreadID
|
|
||||||
if message.Chat.IsForum && threadID != 0 {
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
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)
|
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) {
|
func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
|
||||||
messageBus := bus.NewMessageBus()
|
messageBus := bus.NewMessageBus()
|
||||||
ch := &TelegramChannel{
|
ch := &TelegramChannel{
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,7 @@ func (d *AgentDefaults) GetModelName() string {
|
||||||
type GroupTriggerConfig struct {
|
type GroupTriggerConfig struct {
|
||||||
MentionOnly bool `json:"mention_only,omitempty"`
|
MentionOnly bool `json:"mention_only,omitempty"`
|
||||||
Prefixes []string `json:"prefixes,omitempty"`
|
Prefixes []string `json:"prefixes,omitempty"`
|
||||||
|
Topics map[string]GroupTriggerConfig `json:"topics,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TypingConfig controls typing indicator behavior (Phase 10).
|
// TypingConfig controls typing indicator behavior (Phase 10).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue