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" /> + +
+ + onChange("business_commands_enable", checked) + } + ariaLabel={t("channels.field.businessCommandsEnable")} + /> +
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 9af75283e..e0c80f9b8 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -377,6 +377,7 @@ "baseUrl": "API Base URL", "businessMode": "Business Mode", "businessOwner": "Business Owner", + "businessCommandsEnable": "Business Commands", "proxy": "HTTP Proxy", "mentionOnly": "Mention Only", "typingEnabled": "Typing Indicator", @@ -418,6 +419,7 @@ "baseUrl": "Platform API base URL. Official endpoint is used by default.", "businessMode": "Receive and reply to Telegram Business messages for connected business accounts.", "businessOwner": "Telegram user ID of the business account owner. Business messages from this user are ignored.", + "businessCommandsEnable": "Allow bot commands such as /new in Telegram Business chats.", "proxy": "HTTP proxy address for outbound network access.", "mentionOnly": "Only respond when the bot is explicitly mentioned in group chats.", "typingEnabled": "Display typing status while the assistant is generating a response.", diff --git a/web/frontend/src/i18n/locales/pt-br.json b/web/frontend/src/i18n/locales/pt-br.json index 44a365568..dfb54cbe6 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -369,6 +369,7 @@ "baseUrl": "URL Base da API", "businessMode": "Modo Business", "businessOwner": "Proprietário Business", + "businessCommandsEnable": "Comandos Business", "proxy": "Proxy HTTP", "mentionOnly": "Apenas com Menção", "typingEnabled": "Indicador de Digitação", @@ -410,6 +411,7 @@ "baseUrl": "URL base da API da plataforma. O endpoint oficial é usado por padrão.", "businessMode": "Receber e responder mensagens do Telegram Business para contas comerciais conectadas.", "businessOwner": "ID de usuário do Telegram do proprietário da conta comercial. Mensagens Business desse usuário são ignoradas.", + "businessCommandsEnable": "Permitir comandos do bot, como /new, em chats do Telegram Business.", "proxy": "Endereço de proxy HTTP para acesso de rede de saída.", "mentionOnly": "Responder apenas quando o bot for explicitamente mencionado em chats em grupo.", "typingEnabled": "Exibir status de digitação enquanto o assistente está gerando uma resposta.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index f0c9557c2..005273964 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -377,6 +377,7 @@ "baseUrl": "API Base URL", "businessMode": "Business Mode", "businessOwner": "Business Owner", + "businessCommandsEnable": "Business Commands", "proxy": "HTTP 代理", "mentionOnly": "仅提及时响应", "typingEnabled": "输入中提示", @@ -418,6 +419,7 @@ "baseUrl": "平台 API 地址,默认使用官方地址", "businessMode": "接收并回复已连接商业账号的 Telegram Business 消息", "businessOwner": "商业账号所有者的 Telegram 用户 ID。来自该用户的 Business 消息会被忽略。", + "businessCommandsEnable": "允许在 Telegram Business 聊天中处理 /new 等机器人命令。", "proxy": "HTTP 代理地址,用于网络访问", "mentionOnly": "在群聊中仅当明确提及时才响应", "typingEnabled": "在生成回复时显示“正在输入”状态",