diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index a4138009e..e1ca6f5ab 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -2,7 +2,7 @@ # Telegram -The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../guides/providers.md#voice-transcription)), and built-in command handling. +The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../guides/providers.md#voice-transcription)), built-in command handling, and optional Telegram Business chats. ## Configuration @@ -14,8 +14,13 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co "type": "telegram", "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], - "proxy": "", - "use_markdown_v2": false + "settings": { + "proxy": "", + "use_markdown_v2": false, + "business_mode": false, + "business_owner": "123456789", + "business_commands_enable": false + } } } } @@ -61,6 +66,34 @@ Examples: explain how to squash the last 3 commits ``` +## Telegram Business Mode + +Set `settings.business_mode: true` to receive and reply to Telegram Business messages from connected business accounts. Business replies are sent with the incoming `business_connection_id`, and incoming business messages are marked as read when the bot has the `can_read_messages` business right. If marking a message as read fails, PicoClaw still processes the message. + +Use `settings.business_owner` to store the Telegram user ID of the business account owner. Business messages from that user are skipped, which prevents the bot from responding to messages you send manually from the connected business account. + +By default, bot commands in business chats are ignored. Set `settings.business_commands_enable: true` if you want commands such as `/new`, `/help`, `/show`, `/list`, and `/use` to be handled in Telegram Business chats. + +Example: + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "token": "YOUR_BOT_TOKEN", + "business_mode": true, + "business_owner": "123456789", + "business_commands_enable": true + } + } + } +} +``` + ## Advanced Formatting You can set `use_markdown_v2: true` to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks. diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 013aff205..bd5facb9d 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -794,10 +794,36 @@ func (c *TelegramChannel) handleBusinessMessage(ctx context.Context, message *te }) return nil } + if c.isDisabledBusinessCommand(message) { + logger.DebugCF("telegram", "Business bot command ignored because business_commands_enable is false", map[string]any{ + "business_connection_id": businessConnectionID, + "user_id": fmt.Sprintf("%d", message.From.ID), + }) + return nil + } c.markBusinessMessageRead(ctx, businessConnectionID, message.Chat.ID, message.MessageID) return c.handleTelegramMessage(ctx, message, businessConnectionID) } +func (c *TelegramChannel) isDisabledBusinessCommand(message *telego.Message) bool { + if c == nil || c.tgCfg == nil || c.tgCfg.BusinessCommandsEnable || message == nil { + return false + } + return isTelegramBotCommandMessage(message) +} + +func isTelegramBotCommandMessage(message *telego.Message) bool { + if message == nil { + return false + } + for _, entity := range message.Entities { + if entity.Type == telego.EntityTypeBotCommand { + return true + } + } + return strings.HasPrefix(strings.TrimSpace(message.Text), "/") +} + func (c *TelegramChannel) isBusinessOwnerMessage(message *telego.Message) bool { if c == nil || c.tgCfg == nil || message == nil || message.From == nil { return false diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go index a12f5d19d..049932581 100644 --- a/pkg/channels/telegram/telegram_dispatch_test.go +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -121,6 +121,92 @@ func TestHandleBusinessMessage_BusinessOwnerIgnoresMessage(t *testing.T) { } } +func TestHandleBusinessMessage_DisabledBusinessCommandsIgnoresCommand(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + tgCfg: &config.TelegramSettings{ + BusinessMode: true, + BusinessCommandsEnable: false, + }, + } + + msg := &telego.Message{ + Text: "/new", + MessageID: 20, + BusinessConnectionID: "biz-conn-1", + Entities: []telego.MessageEntity{{ + Type: telego.EntityTypeBotCommand, + Offset: 0, + Length: len("/new"), + }}, + Chat: telego.Chat{ + ID: 777, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + } + + if err := ch.handleBusinessMessage(context.Background(), msg); err != nil { + t.Fatalf("handleBusinessMessage error: %v", err) + } + + select { + case inbound := <-messageBus.InboundChan(): + t.Fatalf("expected disabled business commands to ignore message, got %#v", inbound) + default: + } +} + +func TestHandleBusinessMessage_EnabledBusinessCommandsForwardsCommand(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + tgCfg: &config.TelegramSettings{ + BusinessMode: true, + BusinessCommandsEnable: true, + }, + } + + msg := &telego.Message{ + Text: "/new", + MessageID: 21, + BusinessConnectionID: "biz-conn-1", + Entities: []telego.MessageEntity{{ + Type: telego.EntityTypeBotCommand, + Offset: 0, + Length: len("/new"), + }}, + Chat: telego.Chat{ + ID: 777, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + } + + if err := ch.handleBusinessMessage(context.Background(), msg); err != nil { + t.Fatalf("handleBusinessMessage error: %v", err) + } + + inbound, ok := <-messageBus.InboundChan() + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.Content != "/new" { + t.Fatalf("content=%q", inbound.Content) + } +} + func TestTelegramAllowedUpdates_BusinessMode(t *testing.T) { disabled := strings.Join(telegramAllowedUpdates(false), ",") if strings.Contains(disabled, telego.BusinessMessageUpdates) { diff --git a/pkg/config/config.go b/pkg/config/config.go index cd9d74b1a..648409a99 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -359,13 +359,14 @@ type WhatsAppSettings struct { } type TelegramSettings struct { - Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` - Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` - BusinessMode bool `json:"business_mode" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_MODE"` - BusinessOwner string `json:"business_owner" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_OWNER"` - UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` + BusinessMode bool `json:"business_mode" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_MODE"` + BusinessOwner string `json:"business_owner" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_OWNER"` + BusinessCommandsEnable bool `json:"business_commands_enable" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_COMMANDS_ENABLE"` + UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } type FeishuSettings struct { diff --git a/pkg/config/config_channel_test.go b/pkg/config/config_channel_test.go index a848ddeab..d05558dc3 100644 --- a/pkg/config/config_channel_test.go +++ b/pkg/config/config_channel_test.go @@ -15,13 +15,14 @@ import ( // ─── Test extend structs (simplified, settings + secure in one struct) ─── type testTelegramConfig struct { - BaseURL string `json:"base_url" yaml:"-"` - Proxy string `json:"proxy" yaml:"-"` - BusinessMode bool `json:"business_mode" yaml:"-"` - BusinessOwner string `json:"business_owner" yaml:"-"` - UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"` - Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` - Token SecureString `json:"token,omitzero" yaml:"token,omitempty"` + BaseURL string `json:"base_url" yaml:"-"` + Proxy string `json:"proxy" yaml:"-"` + BusinessMode bool `json:"business_mode" yaml:"-"` + BusinessOwner string `json:"business_owner" yaml:"-"` + BusinessCommandsEnable bool `json:"business_commands_enable" yaml:"-"` + UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"` + Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty"` } type testDiscordConfig struct { @@ -111,6 +112,7 @@ func TestChannel_JSON_Unmarshal(t *testing.T) { "base_url": "https://custom-api.example.com", "business_mode": true, "business_owner": "42", + "business_commands_enable": true, "use_markdown_v2": true, "streaming": {"enabled": true, "throttle_seconds": 2}, "token": "[NOT_HERE]" @@ -132,6 +134,7 @@ func TestChannel_JSON_Unmarshal(t *testing.T) { assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL) assert.True(t, cfg.BusinessMode) assert.Equal(t, "42", cfg.BusinessOwner) + assert.True(t, cfg.BusinessCommandsEnable) assert.True(t, cfg.UseMarkdownV2) assert.True(t, cfg.Streaming.Enabled) assert.Equal(t, 2, cfg.Streaming.ThrottleSeconds) diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index c0682d5de..9dd9cdac6 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -496,10 +496,11 @@ func defaultChannels() ChannelsConfig { "typing": map[string]any{"enabled": true}, "placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}}, "settings": map[string]any{ - "streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200}, - "business_mode": false, - "business_owner": "", - "use_markdown_v2": false, + "streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200}, + "business_mode": false, + "business_owner": "", + "business_commands_enable": false, + "use_markdown_v2": false, }, }, "feishu": map[string]any{}, diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 31ce47363..14679c484 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -107,6 +107,18 @@ export function TelegramForm({ placeholder="123456789" /> + +