From fd405ef86056cb9488689573147b15e7c480a946 Mon Sep 17 00:00:00 2001 From: Roman Stolyarchuk Date: Sun, 10 May 2026 08:46:08 +0300 Subject: [PATCH 1/4] feat(telegram): add business mode support and related configurations - Introduced business mode in Telegram settings to handle business messages. - Updated Telegram channel to process business messages and connections. - Enhanced message handling to include business connection IDs. - Added tests for business message handling and allowed updates. - Updated frontend to include business mode toggle in the Telegram form. - Added translations for business mode in multiple languages. --- pkg/channels/telegram/telegram.go | 310 ++++++++++++++---- .../telegram/telegram_dispatch_test.go | 97 ++++++ pkg/channels/telegram/telegram_test.go | 46 +++ pkg/config/config.go | 1 + pkg/config/config_channel_test.go | 3 + pkg/config/defaults.go | 1 + .../channels/channel-forms/telegram-form.tsx | 10 + web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/pt-br.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + 10 files changed, 408 insertions(+), 66 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index cebebfed6..707dcdbaa 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -43,6 +43,31 @@ var ( reInlineCode = regexp.MustCompile("`([^`]+)`") ) +func telegramAllowedUpdates(businessMode bool) []string { + updates := []string{ + telego.MessageUpdates, + telego.EditedMessageUpdates, + telego.ChannelPostUpdates, + telego.EditedChannelPostUpdates, + telego.CallbackQueryUpdates, + telego.InlineQueryUpdates, + telego.ChosenInlineResultUpdates, + telego.ShippingQueryUpdates, + telego.PreCheckoutQueryUpdates, + telego.PollUpdates, + telego.PollAnswerUpdates, + } + if businessMode { + updates = append(updates, + telego.BusinessConnectionUpdates, + telego.BusinessMessageUpdates, + telego.EditedBusinessMessageUpdates, + telego.DeletedBusinessMessagesUpdates, + ) + } + return updates +} + type TelegramChannel struct { *channels.BaseChannel bot *telego.Bot @@ -123,7 +148,8 @@ func (c *TelegramChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ - Timeout: 30, + Timeout: 30, + AllowedUpdates: telegramAllowedUpdates(c.businessModeEnabled()), }) if err != nil { c.cancel() @@ -140,6 +166,20 @@ func (c *TelegramChannel) Start(ctx context.Context) error { bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) + if c.businessModeEnabled() { + bh.HandleBusinessMessage(func(ctx *th.Context, message telego.Message) error { + return c.handleBusinessMessage(ctx, &message) + }, th.AnyBusinessMessage()) + bh.HandleEditedBusinessMessage(func(ctx *th.Context, message telego.Message) error { + return c.handleBusinessMessage(ctx, &message) + }, th.AnyEditedBusinessMessage()) + bh.HandleBusinessConnection(func(ctx *th.Context, connection telego.BusinessConnection) error { + return c.handleBusinessConnection(ctx, connection) + }, th.AnyBusinessConnection()) + bh.HandleDeletedBusinessMessages(func(ctx *th.Context, deleted telego.BusinessMessagesDeleted) error { + return c.handleDeletedBusinessMessages(ctx, deleted) + }, th.AnyDeletedBusinessMessages()) + } c.SetRunning(true) logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ @@ -189,7 +229,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] useMarkdownV2 := c.tgCfg.UseMarkdownV2 - chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) + target, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -253,8 +293,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] if smallerLen <= 0 { msgID, err := c.sendChunk(ctx, sendChunkParams{ - chatID: chatID, - threadID: threadID, + target: target, content: content, replyToID: replyToID, mdFallback: chunk, @@ -293,8 +332,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] } msgID, err := c.sendChunk(ctx, sendChunkParams{ - chatID: chatID, - threadID: threadID, + target: target, content: content, replyToID: replyToID, mdFallback: chunk, @@ -318,8 +356,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] } type sendChunkParams struct { - chatID int64 - threadID int + target telegramOutboundTarget content string replyToID string mdFallback string @@ -332,8 +369,9 @@ func (c *TelegramChannel) sendChunk( ctx context.Context, params sendChunkParams, ) (string, error) { - tgMsg := tu.Message(tu.ID(params.chatID), params.content) - tgMsg.MessageThreadID = params.threadID + tgMsg := tu.Message(tu.ID(params.target.chatID), params.content) + tgMsg.MessageThreadID = params.target.threadID + tgMsg.BusinessConnectionID = params.target.businessConnectionID if params.useMarkdownV2 { tgMsg.WithParseMode(telego.ModeMarkdownV2) } else { @@ -375,13 +413,14 @@ const maxTypingDuration = 5 * time.Minute // The goroutine also exits automatically after maxTypingDuration if cancel is // never called (e.g. when the LLM fails or times out without publishing). func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { - cid, threadID, err := parseTelegramChatID(chatID) + target, err := resolveTelegramOutboundTarget(chatID, nil) if err != nil { return func() {}, err } - action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) - action.MessageThreadID = threadID + action := tu.ChatAction(tu.ID(target.chatID), telego.ChatActionTyping) + action.MessageThreadID = target.threadID + action.BusinessConnectionID = target.businessConnectionID // Send the first typing action immediately _ = c.bot.SendChatAction(ctx, action) @@ -398,8 +437,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( case <-maxCtx.Done(): return case <-ticker.C: - a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) - a.MessageThreadID = threadID + a := tu.ChatAction(tu.ID(target.chatID), telego.ChatActionTyping) + a.MessageThreadID = target.threadID + a.BusinessConnectionID = target.businessConnectionID _ = c.bot.SendChatAction(typingCtx, a) } } @@ -411,7 +451,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { useMarkdownV2 := c.tgCfg.UseMarkdownV2 - cid, _, err := parseTelegramChatID(chatID) + target, err := resolveTelegramOutboundTarget(chatID, nil) if err != nil { return err } @@ -420,7 +460,8 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag return err } parsedContent := parseContent(content, useMarkdownV2) - editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent) + editMsg := tu.EditMessageText(tu.ID(target.chatID), mid, parsedContent) + editMsg.BusinessConnectionID = target.businessConnectionID if useMarkdownV2 { editMsg.WithParseMode(telego.ModeMarkdownV2) } else { @@ -439,7 +480,9 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag // Network errors or timeouts should NOT trigger a retry with different content. if strings.Contains(err.Error(), "Bad Request") { logParseFailed(err, useMarkdownV2) - _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + plainEdit := tu.EditMessageText(tu.ID(target.chatID), mid, content) + plainEdit.BusinessConnectionID = target.businessConnectionID + _, err = c.bot.EditMessageText(ctx, plainEdit) } } @@ -467,7 +510,7 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag // DeleteMessage implements channels.MessageDeleter. func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { - cid, _, err := parseTelegramChatID(chatID) + target, err := resolveTelegramOutboundTarget(chatID, nil) if err != nil { return err } @@ -475,8 +518,14 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess if err != nil { return err } + if target.businessConnectionID != "" { + return c.bot.DeleteBusinessMessages(ctx, &telego.DeleteBusinessMessagesParams{ + BusinessConnectionID: target.businessConnectionID, + MessageIDs: []int{mid}, + }) + } return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{ - ChatID: tu.ID(cid), + ChatID: tu.ID(target.chatID), MessageID: mid, }) } @@ -575,13 +624,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s text := phCfg.GetRandomText() - cid, threadID, err := parseTelegramChatID(chatID) + target, err := resolveTelegramOutboundTarget(chatID, nil) if err != nil { return "", err } - phMsg := tu.Message(tu.ID(cid), text) - phMsg.MessageThreadID = threadID + phMsg := tu.Message(tu.ID(target.chatID), text) + phMsg.MessageThreadID = target.threadID + phMsg.BusinessConnectionID = target.businessConnectionID pMsg, err := c.bot.SendMessage(ctx, phMsg) if err != nil { return "", err @@ -598,7 +648,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context) trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID) - chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) + target, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -632,10 +682,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe switch part.Type { case "image": params := &telego.SendPhotoParams{ - ChatID: tu.ID(chatID), - MessageThreadID: threadID, - Photo: telego.InputFile{File: file}, - Caption: part.Caption, + BusinessConnectionID: target.businessConnectionID, + ChatID: tu.ID(target.chatID), + MessageThreadID: target.threadID, + Photo: telego.InputFile{File: file}, + Caption: part.Caption, } tgResult, err = c.bot.SendPhoto(ctx, params) if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") { @@ -645,10 +696,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } docParams := &telego.SendDocumentParams{ - ChatID: tu.ID(chatID), - MessageThreadID: threadID, - Document: telego.InputFile{File: file}, - Caption: part.Caption, + BusinessConnectionID: target.businessConnectionID, + ChatID: tu.ID(target.chatID), + MessageThreadID: target.threadID, + Document: telego.InputFile{File: file}, + Caption: part.Caption, } tgResult, err = c.bot.SendDocument(ctx, docParams) } @@ -658,35 +710,39 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe fn := strings.ToLower(part.Filename) if strings.Contains(fn, "voice") && (strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".oga")) { vparams := &telego.SendVoiceParams{ - ChatID: tu.ID(chatID), - MessageThreadID: threadID, - Voice: telego.InputFile{File: file}, - Caption: part.Caption, + BusinessConnectionID: target.businessConnectionID, + ChatID: tu.ID(target.chatID), + MessageThreadID: target.threadID, + Voice: telego.InputFile{File: file}, + Caption: part.Caption, } tgResult, err = c.bot.SendVoice(ctx, vparams) } else { params := &telego.SendAudioParams{ - ChatID: tu.ID(chatID), - MessageThreadID: threadID, - Audio: telego.InputFile{File: file}, - Caption: part.Caption, + BusinessConnectionID: target.businessConnectionID, + ChatID: tu.ID(target.chatID), + MessageThreadID: target.threadID, + Audio: telego.InputFile{File: file}, + Caption: part.Caption, } tgResult, err = c.bot.SendAudio(ctx, params) } case "video": params := &telego.SendVideoParams{ - ChatID: tu.ID(chatID), - MessageThreadID: threadID, - Video: telego.InputFile{File: file}, - Caption: part.Caption, + BusinessConnectionID: target.businessConnectionID, + ChatID: tu.ID(target.chatID), + MessageThreadID: target.threadID, + Video: telego.InputFile{File: file}, + Caption: part.Caption, } tgResult, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ - ChatID: tu.ID(chatID), - MessageThreadID: threadID, - Document: telego.InputFile{File: file}, - Caption: part.Caption, + BusinessConnectionID: target.businessConnectionID, + ChatID: tu.ID(target.chatID), + MessageThreadID: target.threadID, + Document: telego.InputFile{File: file}, + Caption: part.Caption, } tgResult, err = c.bot.SendDocument(ctx, params) } @@ -713,6 +769,52 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { + return c.handleTelegramMessage(ctx, message, "") +} + +func (c *TelegramChannel) businessModeEnabled() bool { + return c != nil && c.tgCfg != nil && c.tgCfg.BusinessMode +} + +func (c *TelegramChannel) handleBusinessMessage(ctx context.Context, message *telego.Message) error { + if !c.businessModeEnabled() { + return nil + } + if message == nil { + return fmt.Errorf("message is nil") + } + businessConnectionID := strings.TrimSpace(message.BusinessConnectionID) + if businessConnectionID == "" { + return fmt.Errorf("business message missing business_connection_id") + } + return c.handleTelegramMessage(ctx, message, businessConnectionID) +} + +func (c *TelegramChannel) handleBusinessConnection(_ context.Context, connection telego.BusinessConnection) error { + if !c.businessModeEnabled() { + return nil + } + logger.InfoCF("telegram", "Business connection updated", map[string]any{ + "business_connection_id": connection.ID, + "user_chat_id": connection.UserChatID, + "is_enabled": connection.IsEnabled, + }) + return nil +} + +func (c *TelegramChannel) handleDeletedBusinessMessages(_ context.Context, deleted telego.BusinessMessagesDeleted) error { + if !c.businessModeEnabled() { + return nil + } + logger.DebugCF("telegram", "Business messages deleted", map[string]any{ + "business_connection_id": deleted.BusinessConnectionID, + "chat_id": deleted.Chat.ID, + "message_count": len(deleted.MessageIDs), + }) + return nil +} + +func (c *TelegramChannel) handleTelegramMessage(ctx context.Context, message *telego.Message, businessConnectionID string) error { if message == nil { return fmt.Errorf("message is nil") } @@ -745,7 +847,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content := "" mediaPaths := []string{} - chatIDStr := fmt.Sprintf("%d", chatID) + chatIDStr := formatTelegramDeliveryChatID(chatID, businessConnectionID, 0) messageIDStr := fmt.Sprintf("%d", message.MessageID) scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) @@ -864,10 +966,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // route to the correct topic and each topic gets its own session. // Only forum groups (IsForum) are handled; regular group reply threads // must share one session per group. - compositeChatID := fmt.Sprintf("%d", chatID) + compositeChatID := formatTelegramDeliveryChatID(chatID, businessConnectionID, 0) threadID := message.MessageThreadID if message.Chat.IsForum && threadID != 0 { - compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) + compositeChatID = formatTelegramDeliveryChatID(chatID, businessConnectionID, threadID) } logger.DebugCF("telegram", "Received message", map[string]any{ @@ -889,16 +991,22 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + if businessConnectionID != "" { + metadata["business_connection_id"] = businessConnectionID + } inboundCtx := bus.InboundContext{ Channel: c.Name(), - ChatID: fmt.Sprintf("%d", chatID), + ChatID: formatTelegramDeliveryChatID(chatID, businessConnectionID, 0), ChatType: peerKind, SenderID: platformID, MessageID: messageID, Mentioned: isMentioned, Raw: metadata, } + if businessConnectionID != "" { + inboundCtx.Account = businessConnectionID + } if message.Chat.IsForum && threadID != 0 { inboundCtx.TopicID = fmt.Sprintf("%d", threadID) } @@ -1123,11 +1231,11 @@ func (c *TelegramChannel) PrepareToolFeedbackMessageContent(content string) stri } func telegramToolFeedbackChatKey(chatID string, outboundCtx *bus.InboundContext) string { - resolvedChatID, threadID, err := resolveTelegramOutboundTarget(chatID, outboundCtx) - if err != nil || threadID == 0 { + target, err := resolveTelegramOutboundTarget(chatID, outboundCtx) + if err != nil { return strings.TrimSpace(chatID) } - return fmt.Sprintf("%d/%d", resolvedChatID, threadID) + return target.String() } func (c *TelegramChannel) ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string { @@ -1153,26 +1261,93 @@ func parseTelegramChatID(chatID string) (int64, int, error) { return cid, tid, nil } -func resolveTelegramOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (int64, int, error) { +type telegramOutboundTarget struct { + businessConnectionID string + chatID int64 + threadID int +} + +func (t telegramOutboundTarget) String() string { + return formatTelegramDeliveryChatID(t.chatID, t.businessConnectionID, t.threadID) +} + +func formatTelegramDeliveryChatID(chatID int64, businessConnectionID string, threadID int) string { + base := fmt.Sprintf("%d", chatID) + if strings.TrimSpace(businessConnectionID) != "" { + base = fmt.Sprintf("business:%s:%d", url.QueryEscape(strings.TrimSpace(businessConnectionID)), chatID) + } + if threadID != 0 { + return fmt.Sprintf("%s/%d", base, threadID) + } + return base +} + +func parseTelegramOutboundTarget(chatID string) (telegramOutboundTarget, error) { + targetChatID := strings.TrimSpace(chatID) + if strings.HasPrefix(targetChatID, "business:") { + rest := strings.TrimPrefix(targetChatID, "business:") + parts := strings.SplitN(rest, ":", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return telegramOutboundTarget{}, fmt.Errorf("invalid business chat ID %q", chatID) + } + businessConnectionID, unescapeErr := url.QueryUnescape(strings.TrimSpace(parts[0])) + if unescapeErr != nil { + return telegramOutboundTarget{}, fmt.Errorf("invalid business connection ID in chat ID %q: %w", chatID, unescapeErr) + } + cid, threadID, err := parseTelegramChatID(parts[1]) + if err != nil { + return telegramOutboundTarget{}, err + } + return telegramOutboundTarget{ + businessConnectionID: businessConnectionID, + chatID: cid, + threadID: threadID, + }, nil + } + + cid, threadID, err := parseTelegramChatID(targetChatID) + if err != nil { + return telegramOutboundTarget{}, err + } + return telegramOutboundTarget{chatID: cid, threadID: threadID}, nil +} + +func businessConnectionIDFromContext(outboundCtx *bus.InboundContext) string { + if outboundCtx == nil { + return "" + } + if outboundCtx.Raw != nil { + if businessConnectionID := strings.TrimSpace(outboundCtx.Raw["business_connection_id"]); businessConnectionID != "" { + return businessConnectionID + } + } + return strings.TrimSpace(outboundCtx.Account) +} + +func resolveTelegramOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (telegramOutboundTarget, error) { targetChatID := strings.TrimSpace(chatID) if targetChatID == "" && outboundCtx != nil { targetChatID = strings.TrimSpace(outboundCtx.ChatID) } - resolvedChatID, resolvedThreadID, err := parseTelegramChatID(targetChatID) + resolved, err := parseTelegramOutboundTarget(targetChatID) if err != nil { - return 0, 0, err + return telegramOutboundTarget{}, err } - if resolvedThreadID != 0 || outboundCtx == nil { - return resolvedChatID, resolvedThreadID, nil + if resolved.businessConnectionID == "" { + resolved.businessConnectionID = businessConnectionIDFromContext(outboundCtx) + } + if resolved.threadID != 0 || outboundCtx == nil { + return resolved, nil } topicID := strings.TrimSpace(outboundCtx.TopicID) if topicID == "" { - return resolvedChatID, resolvedThreadID, nil + return resolved, nil } if threadID, convErr := strconv.Atoi(topicID); convErr == nil { - return resolvedChatID, threadID, nil + resolved.threadID = threadID + return resolved, nil } - return resolvedChatID, resolvedThreadID, nil + return resolved, nil } func logParseFailed(err error, useMarkdownV2 bool) { @@ -1284,16 +1459,19 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann return nil, fmt.Errorf("streaming disabled in config") } - cid, threadID, err := parseTelegramChatID(chatID) + target, err := resolveTelegramOutboundTarget(chatID, nil) if err != nil { return nil, err } + if target.businessConnectionID != "" { + return nil, fmt.Errorf("streaming is not supported for telegram business chats") + } streamCfg := c.tgCfg.Streaming return &telegramStreamer{ bot: c.bot, - chatID: cid, - threadID: threadID, + chatID: target.chatID, + threadID: target.threadID, draftID: cryptoRandInt(), throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second, minGrowth: streamCfg.MinGrowthChars, diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go index 0eb1de5ea..eae6fd0e3 100644 --- a/pkg/channels/telegram/telegram_dispatch_test.go +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -2,12 +2,14 @@ package telegram import ( "context" + "strings" "testing" "github.com/mymmrac/telego" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { @@ -16,6 +18,7 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), chatIDs: make(map[string]int64), ctx: context.Background(), + tgCfg: &config.TelegramSettings{BusinessMode: true}, } msg := &telego.Message{ @@ -46,3 +49,97 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) { t.Fatalf("content=%q", inbound.Content) } } + +func TestHandleBusinessMessage_DisabledBusinessModeIgnoresMessage(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: false}, + } + + msg := &telego.Message{ + Text: "ignored business message", + MessageID: 18, + BusinessConnectionID: "biz-conn-1", + 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 mode to ignore message, got %#v", inbound) + default: + } +} + +func TestTelegramAllowedUpdates_BusinessMode(t *testing.T) { + disabled := strings.Join(telegramAllowedUpdates(false), ",") + if strings.Contains(disabled, telego.BusinessMessageUpdates) { + t.Fatalf("disabled updates include business messages: %s", disabled) + } + + enabled := strings.Join(telegramAllowedUpdates(true), ",") + if !strings.Contains(enabled, telego.BusinessMessageUpdates) { + t.Fatalf("enabled updates do not include business messages: %s", enabled) + } + if !strings.Contains(enabled, telego.DeletedBusinessMessagesUpdates) { + t.Fatalf("enabled updates do not include deleted business messages: %s", enabled) + } +} + +func TestHandleBusinessMessage_ForwardsWithBusinessContext(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}, + } + + msg := &telego.Message{ + Text: "hello from business", + MessageID: 17, + BusinessConnectionID: "biz-conn-1", + 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.ChatID != "business:biz-conn-1:777" { + t.Fatalf("chat_id=%q", inbound.ChatID) + } + if inbound.Context.Account != "biz-conn-1" { + t.Fatalf("account=%q", inbound.Context.Account) + } + if inbound.Context.Raw["business_connection_id"] != "biz-conn-1" { + t.Fatalf("business_connection_id=%q", inbound.Context.Raw["business_connection_id"]) + } + if inbound.Content != "hello from business" { + t.Fatalf("content=%q", inbound.Content) + } +} diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 69c76b430..1393312ad 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -271,6 +271,41 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") } +func TestSend_BusinessMessageIncludesBusinessConnectionID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "business:biz-conn-1:777", + Content: "hello business", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "business:biz-conn-1:777", + Account: "biz-conn-1", + Raw: map[string]string{ + "business_connection_id": "biz-conn-1", + }, + }, + }) + + require.NoError(t, err) + require.Len(t, caller.calls, 1) + + var params struct { + ChatID int64 `json:"chat_id"` + BusinessConnectionID string `json:"business_connection_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(777), params.ChatID) + assert.Equal(t, "biz-conn-1", params.BusinessConnectionID) + assert.Equal(t, "hello business", params.Text) +} + func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { @@ -686,6 +721,17 @@ func TestParseTelegramChatID_InvalidThreadID(t *testing.T) { assert.Contains(t, err.Error(), "invalid thread ID") } +func TestParseTelegramOutboundTarget_BusinessConnectionIDWithColon(t *testing.T) { + formatted := formatTelegramDeliveryChatID(777, "biz:conn:1", 42) + + target, err := parseTelegramOutboundTarget(formatted) + + require.NoError(t, err) + assert.Equal(t, int64(777), target.chatID) + assert.Equal(t, 42, target.threadID) + assert.Equal(t, "biz:conn:1", target.businessConnectionID) +} + func TestSend_WithForumThreadID(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { diff --git a/pkg/config/config.go b/pkg/config/config.go index c9d90e0f8..d96c6d7c5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -363,6 +363,7 @@ type TelegramSettings struct { 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"` UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } diff --git a/pkg/config/config_channel_test.go b/pkg/config/config_channel_test.go index fd3cd8246..e942ffe3f 100644 --- a/pkg/config/config_channel_test.go +++ b/pkg/config/config_channel_test.go @@ -17,6 +17,7 @@ import ( type testTelegramConfig struct { BaseURL string `json:"base_url" yaml:"-"` Proxy string `json:"proxy" yaml:"-"` + BusinessMode bool `json:"business_mode" yaml:"-"` UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"` Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` Token SecureString `json:"token,omitzero" yaml:"token,omitempty"` @@ -107,6 +108,7 @@ func TestChannel_JSON_Unmarshal(t *testing.T) { "reasoning_channel_id": "-100xxx", "settings": { "base_url": "https://custom-api.example.com", + "business_mode": true, "use_markdown_v2": true, "streaming": {"enabled": true, "throttle_seconds": 2}, "token": "[NOT_HERE]" @@ -126,6 +128,7 @@ func TestChannel_JSON_Unmarshal(t *testing.T) { var cfg testTelegramConfig require.NoError(t, ch.Decode(&cfg)) assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL) + assert.True(t, cfg.BusinessMode) 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 8e2494ae5..d4def2790 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -497,6 +497,7 @@ func defaultChannels() ChannelsConfig { "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, "use_markdown_v2": false, }, }, 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 f9c7c778a..b45d3dd74 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -86,6 +86,16 @@ export function TelegramForm({ placeholder="https://api.telegram.org" /> + +
+ onChange("business_mode", checked)} + ariaLabel={t("channels.field.businessMode")} + /> +
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 985efdfe2..744d89050 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -375,6 +375,7 @@ "verificationToken": "Verification Token", "encryptKey": "Encrypt Key", "baseUrl": "API Base URL", + "businessMode": "Business Mode", "proxy": "HTTP Proxy", "mentionOnly": "Mention Only", "typingEnabled": "Typing Indicator", @@ -414,6 +415,7 @@ "verificationToken": "Verification token for event callbacks.", "encryptKey": "Encryption key used to decrypt callback payloads.", "baseUrl": "Platform API base URL. Official endpoint is used by default.", + "businessMode": "Receive and reply to Telegram Business messages for connected business accounts.", "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 c091625bb..c5fc59e83 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -367,6 +367,7 @@ "verificationToken": "Token de Verificação", "encryptKey": "Chave de Criptografia", "baseUrl": "URL Base da API", + "businessMode": "Modo Business", "proxy": "Proxy HTTP", "mentionOnly": "Apenas com Menção", "typingEnabled": "Indicador de Digitação", @@ -406,6 +407,7 @@ "verificationToken": "Token de verificação para callbacks de eventos.", "encryptKey": "Chave de criptografia usada para descriptografar payloads de callback.", "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.", "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 9af5c39fe..6e70d5c76 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -375,6 +375,7 @@ "verificationToken": "Verification Token", "encryptKey": "Encrypt Key", "baseUrl": "API Base URL", + "businessMode": "Business Mode", "proxy": "HTTP 代理", "mentionOnly": "仅提及时响应", "typingEnabled": "输入中提示", @@ -414,6 +415,7 @@ "verificationToken": "事件回调验证令牌", "encryptKey": "消息加密密钥,用于解密回调内容", "baseUrl": "平台 API 地址,默认使用官方地址", + "businessMode": "接收并回复已连接商业账号的 Telegram Business 消息", "proxy": "HTTP 代理地址,用于网络访问", "mentionOnly": "在群聊中仅当明确提及时才响应", "typingEnabled": "在生成回复时显示“正在输入”状态", From 4aa6ffb0433f7a49a373406bc84bda9050341118 Mon Sep 17 00:00:00 2001 From: Roman Stolyarchuk Date: Sun, 10 May 2026 08:53:42 +0300 Subject: [PATCH 2/4] feat(telegram): mark business messages as read and add tests for handling - Implement markBusinessMessageRead function to mark messages as read - Add tests for successful message read and failure scenarios --- pkg/channels/telegram/telegram.go | 24 ++++++++ pkg/channels/telegram/telegram_test.go | 84 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 707dcdbaa..85ca25142 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -787,9 +787,33 @@ func (c *TelegramChannel) handleBusinessMessage(ctx context.Context, message *te if businessConnectionID == "" { return fmt.Errorf("business message missing business_connection_id") } + c.markBusinessMessageRead(ctx, businessConnectionID, message.Chat.ID, message.MessageID) return c.handleTelegramMessage(ctx, message, businessConnectionID) } +func (c *TelegramChannel) markBusinessMessageRead( + ctx context.Context, + businessConnectionID string, + chatID int64, + messageID int, +) { + if c == nil || c.bot == nil || strings.TrimSpace(businessConnectionID) == "" || chatID == 0 || messageID == 0 { + return + } + if err := c.bot.ReadBusinessMessage(ctx, &telego.ReadBusinessMessageParams{ + BusinessConnectionID: businessConnectionID, + ChatID: chatID, + MessageID: messageID, + }); err != nil { + logger.DebugCF("telegram", "Failed to mark business message as read", map[string]any{ + "business_connection_id": businessConnectionID, + "chat_id": chatID, + "message_id": messageID, + "error": err.Error(), + }) + } +} + func (c *TelegramChannel) handleBusinessConnection(_ context.Context, connection telego.BusinessConnection) error { if !c.businessModeEnabled() { return nil diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 1393312ad..a70d55c0b 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -150,6 +150,90 @@ func newTestChannelWithConstructor( } } +func TestHandleBusinessMessage_MarksMessageRead(t *testing.T) { + messageBus := bus.NewMessageBus() + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + if !strings.Contains(url, "readBusinessMessage") { + t.Fatalf("unexpected API call: %s", url) + } + b, err := json.Marshal(true) + require.NoError(t, err) + return &ta.Response{Ok: true, Result: b}, nil + }, + } + ch := newTestChannel(t, caller) + ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil) + ch.BaseChannel.SetOwner(ch) + ch.ctx = context.Background() + ch.tgCfg.BusinessMode = true + + msg := &telego.Message{ + Text: "hello from business", + MessageID: 17, + BusinessConnectionID: "biz-conn-1", + Chat: telego.Chat{ + ID: 777, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + } + + require.NoError(t, ch.handleBusinessMessage(context.Background(), msg)) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "readBusinessMessage") + + var params struct { + BusinessConnectionID string `json:"business_connection_id"` + ChatID int64 `json:"chat_id"` + MessageID int `json:"message_id"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, "biz-conn-1", params.BusinessConnectionID) + assert.Equal(t, int64(777), params.ChatID) + assert.Equal(t, 17, params.MessageID) + + inbound := <-messageBus.InboundChan() + assert.Equal(t, "hello from business", inbound.Content) +} + +func TestHandleBusinessMessage_ReadFailureStillForwardsMessage(t *testing.T) { + messageBus := bus.NewMessageBus() + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return nil, errors.New("missing can_read_messages") + }, + } + ch := newTestChannel(t, caller) + ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil) + ch.BaseChannel.SetOwner(ch) + ch.ctx = context.Background() + ch.tgCfg.BusinessMode = true + + msg := &telego.Message{ + Text: "still forward me", + MessageID: 18, + BusinessConnectionID: "biz-conn-1", + Chat: telego.Chat{ + ID: 777, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + } + + require.NoError(t, ch.handleBusinessMessage(context.Background(), msg)) + require.Len(t, caller.calls, 1) + + inbound := <-messageBus.InboundChan() + assert.Equal(t, "still forward me", inbound.Content) +} + func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) { constructor := &multipartRecordingConstructor{} caller := &stubCaller{ From d170c057d9ea306735b08639f2209ac87c9fc785 Mon Sep 17 00:00:00 2001 From: Roman Stolyarchuk Date: Sun, 10 May 2026 10:24:31 +0300 Subject: [PATCH 3/4] feat(telegram): add business owner configuration and message handling - Introduced `business_owner` field in Telegram settings to specify the owner ID. - Updated `handleBusinessMessage` to ignore messages from the business owner. - Added tests to verify that messages from the owner are correctly ignored. - Enhanced Telegram form to include input for business owner ID. - Updated localization files for English, Portuguese, and Chinese to include business owner labels. --- pkg/channels/telegram/telegram.go | 18 +++++++++ .../telegram/telegram_dispatch_test.go | 37 +++++++++++++++++++ pkg/config/config.go | 1 + pkg/config/config_channel_test.go | 3 ++ pkg/config/defaults.go | 1 + .../channels/channel-forms/telegram-form.tsx | 11 ++++++ web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/pt-br.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + 9 files changed, 77 insertions(+) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 85ca25142..013aff205 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -787,10 +787,28 @@ func (c *TelegramChannel) handleBusinessMessage(ctx context.Context, message *te if businessConnectionID == "" { return fmt.Errorf("business message missing business_connection_id") } + if c.isBusinessOwnerMessage(message) { + logger.DebugCF("telegram", "Business message ignored from configured owner", 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) isBusinessOwnerMessage(message *telego.Message) bool { + if c == nil || c.tgCfg == nil || message == nil || message.From == nil { + return false + } + ownerID := strings.TrimSpace(c.tgCfg.BusinessOwner) + if ownerID == "" { + return false + } + return ownerID == fmt.Sprintf("%d", message.From.ID) +} + func (c *TelegramChannel) markBusinessMessageRead( ctx context.Context, businessConnectionID string, diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go index eae6fd0e3..a12f5d19d 100644 --- a/pkg/channels/telegram/telegram_dispatch_test.go +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -84,6 +84,43 @@ func TestHandleBusinessMessage_DisabledBusinessModeIgnoresMessage(t *testing.T) } } +func TestHandleBusinessMessage_BusinessOwnerIgnoresMessage(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, + BusinessOwner: "42", + }, + } + + msg := &telego.Message{ + Text: "owner should be ignored", + MessageID: 19, + BusinessConnectionID: "biz-conn-1", + Chat: telego.Chat{ + ID: 777, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Owner", + }, + } + + if err := ch.handleBusinessMessage(context.Background(), msg); err != nil { + t.Fatalf("handleBusinessMessage error: %v", err) + } + + select { + case inbound := <-messageBus.InboundChan(): + t.Fatalf("expected owner business message to be ignored, got %#v", inbound) + default: + } +} + 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 d96c6d7c5..cd9d74b1a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -364,6 +364,7 @@ type TelegramSettings struct { 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"` } diff --git a/pkg/config/config_channel_test.go b/pkg/config/config_channel_test.go index e942ffe3f..a848ddeab 100644 --- a/pkg/config/config_channel_test.go +++ b/pkg/config/config_channel_test.go @@ -18,6 +18,7 @@ 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"` @@ -109,6 +110,7 @@ func TestChannel_JSON_Unmarshal(t *testing.T) { "settings": { "base_url": "https://custom-api.example.com", "business_mode": true, + "business_owner": "42", "use_markdown_v2": true, "streaming": {"enabled": true, "throttle_seconds": 2}, "token": "[NOT_HERE]" @@ -129,6 +131,7 @@ func TestChannel_JSON_Unmarshal(t *testing.T) { require.NoError(t, ch.Decode(&cfg)) 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.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 d4def2790..c0682d5de 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -498,6 +498,7 @@ func defaultChannels() ChannelsConfig { "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, }, }, 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 b45d3dd74..31ce47363 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -96,6 +96,17 @@ export function TelegramForm({ ariaLabel={t("channels.field.businessMode")} /> + + + onChange("business_owner", e.target.value)} + placeholder="123456789" + /> + diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 744d89050..9af75283e 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -376,6 +376,7 @@ "encryptKey": "Encrypt Key", "baseUrl": "API Base URL", "businessMode": "Business Mode", + "businessOwner": "Business Owner", "proxy": "HTTP Proxy", "mentionOnly": "Mention Only", "typingEnabled": "Typing Indicator", @@ -416,6 +417,7 @@ "encryptKey": "Encryption key used to decrypt callback payloads.", "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.", "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 c5fc59e83..44a365568 100644 --- a/web/frontend/src/i18n/locales/pt-br.json +++ b/web/frontend/src/i18n/locales/pt-br.json @@ -368,6 +368,7 @@ "encryptKey": "Chave de Criptografia", "baseUrl": "URL Base da API", "businessMode": "Modo Business", + "businessOwner": "Proprietário Business", "proxy": "Proxy HTTP", "mentionOnly": "Apenas com Menção", "typingEnabled": "Indicador de Digitação", @@ -408,6 +409,7 @@ "encryptKey": "Chave de criptografia usada para descriptografar payloads de callback.", "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.", "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 6e70d5c76..f0c9557c2 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -376,6 +376,7 @@ "encryptKey": "Encrypt Key", "baseUrl": "API Base URL", "businessMode": "Business Mode", + "businessOwner": "Business Owner", "proxy": "HTTP 代理", "mentionOnly": "仅提及时响应", "typingEnabled": "输入中提示", @@ -416,6 +417,7 @@ "encryptKey": "消息加密密钥,用于解密回调内容", "baseUrl": "平台 API 地址,默认使用官方地址", "businessMode": "接收并回复已连接商业账号的 Telegram Business 消息", + "businessOwner": "商业账号所有者的 Telegram 用户 ID。来自该用户的 Business 消息会被忽略。", "proxy": "HTTP 代理地址,用于网络访问", "mentionOnly": "在群聊中仅当明确提及时才响应", "typingEnabled": "在生成回复时显示“正在输入”状态", From 84164e47f3e24fe0ee3d5aaa417bc904cdf3836a Mon Sep 17 00:00:00 2001 From: Roman Stolyarchuk Date: Sun, 10 May 2026 11:17:17 +0300 Subject: [PATCH 4/4] feat(telegram): add options to disable bot commands in business chats - Modified Telegram settings to include business mode and commands enable options - Implemented logic to handle business messages and commands in telegram.go - Added tests for business commands handling in telegram_dispatch_test.go - Updated configuration tests to include new business commands settings - Enhanced frontend form to allow configuration of business commands - Added translations for business commands in multiple languages - Updated README to include information about Telegram Business mode --- docs/channels/telegram/README.md | 39 ++++++++- pkg/channels/telegram/telegram.go | 26 ++++++ .../telegram/telegram_dispatch_test.go | 86 +++++++++++++++++++ pkg/config/config.go | 15 ++-- pkg/config/config_channel_test.go | 17 ++-- pkg/config/defaults.go | 9 +- .../channels/channel-forms/telegram-form.tsx | 12 +++ web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/pt-br.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + 10 files changed, 189 insertions(+), 21 deletions(-) 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": "在生成回复时显示“正在输入”状态",