From fd405ef86056cb9488689573147b15e7c480a946 Mon Sep 17 00:00:00 2001 From: Roman Stolyarchuk Date: Sun, 10 May 2026 08:46:08 +0300 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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": "在生成回复时显示“正在输入”状态", From 361a53a2b37eec9edbccd4e890c92973f17c325a Mon Sep 17 00:00:00 2001 From: Roman Stolyarchuk Date: Mon, 11 May 2026 10:51:51 +0300 Subject: [PATCH 5/6] feat(telegram): add guest mode support - Add guest_mode configuration option for Telegram channel - Implement guest query handling when guest_mode is enabled - Add unit tests for guest mode functionality - Update UI form with guest mode toggle - Add translations for guest mode strings --- pkg/channels/telegram/telegram.go | 337 +++++++++++++++++- .../telegram/telegram_dispatch_test.go | 128 ++++++- pkg/channels/telegram/telegram_test.go | 57 ++- pkg/config/config.go | 1 + 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 + 9 files changed, 520 insertions(+), 20 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index bd5facb9d..79c0dae88 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -1,9 +1,11 @@ package telegram import ( + "bytes" "context" "crypto/rand" "encoding/binary" + "encoding/json" "errors" "fmt" "io" @@ -30,6 +32,8 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +const telegramGuestMessageUpdates = "guest_message" + var ( reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`) reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) @@ -43,7 +47,7 @@ var ( reInlineCode = regexp.MustCompile("`([^`]+)`") ) -func telegramAllowedUpdates(businessMode bool) []string { +func telegramAllowedUpdates(businessMode, guestMode bool) []string { updates := []string{ telego.MessageUpdates, telego.EditedMessageUpdates, @@ -65,6 +69,9 @@ func telegramAllowedUpdates(businessMode bool) []string { telego.DeletedBusinessMessagesUpdates, ) } + if guestMode { + updates = append(updates, telegramGuestMessageUpdates) + } return updates } @@ -82,6 +89,7 @@ type TelegramChannel struct { registerFunc func(context.Context, []commands.Definition) error commandRegDelayFn func(int) time.Duration commandRegCancel context.CancelFunc + httpClient *http.Client } func NewTelegramChannel( @@ -137,6 +145,7 @@ func NewTelegramChannel( bc: bc, chatIDs: make(map[string]int64), tgCfg: telegramCfg, + httpClient: newTelegramHTTPClient(telegramCfg), } ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) return ch, nil @@ -147,13 +156,20 @@ func (c *TelegramChannel) Start(ctx context.Context) error { c.ctx, c.cancel = context.WithCancel(ctx) - updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ + updateParams := &telego.GetUpdatesParams{ Timeout: 30, - AllowedUpdates: telegramAllowedUpdates(c.businessModeEnabled()), - }) - if err != nil { - c.cancel() - return fmt.Errorf("failed to start long polling: %w", err) + AllowedUpdates: telegramAllowedUpdates(c.businessModeEnabled(), c.guestModeEnabled()), + } + var updates <-chan telego.Update + if c.guestModeEnabled() { + updates = c.updatesViaLongPollingWithGuest(c.ctx, updateParams) + } else { + var err error + updates, err = c.bot.UpdatesViaLongPolling(c.ctx, updateParams) + if err != nil { + c.cancel() + return fmt.Errorf("failed to start long polling: %w", err) + } } bh, err := th.NewBotHandler(c.bot, updates) @@ -222,6 +238,138 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { return nil } +func newTelegramHTTPClient(telegramCfg *config.TelegramSettings) *http.Client { + if telegramCfg == nil || strings.TrimSpace(telegramCfg.Proxy) == "" { + return http.DefaultClient + } + proxyURL, err := url.Parse(telegramCfg.Proxy) + if err != nil { + return http.DefaultClient + } + return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} +} + +func (c *TelegramChannel) telegramAPIBaseURL() string { + if c != nil && c.tgCfg != nil { + if baseURL := strings.TrimRight(strings.TrimSpace(c.tgCfg.BaseURL), "/"); baseURL != "" { + return baseURL + } + } + return "https://api.telegram.org" +} + +func (c *TelegramChannel) postTelegramAPI(ctx context.Context, method string, params any, result any) error { + if c == nil || c.bot == nil { + return fmt.Errorf("telegram bot is nil") + } + body, err := json.Marshal(params) + if err != nil { + return err + } + endpoint := fmt.Sprintf("%s/bot%s/%s", c.telegramAPIBaseURL(), c.bot.Token(), method) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + client := c.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var apiResp struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + ErrorCode int `json:"error_code,omitempty"` + Description string `json:"description,omitempty"` + } + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return err + } + if !apiResp.OK { + if apiResp.Description != "" { + return fmt.Errorf("telegram %s: %s", method, apiResp.Description) + } + return fmt.Errorf("telegram %s: HTTP %d", method, resp.StatusCode) + } + if result != nil && len(apiResp.Result) > 0 { + if err := json.Unmarshal(apiResp.Result, result); err != nil { + return err + } + } + return nil +} + +type telegramRawUpdate struct { + telego.Update + GuestMessage *telegramGuestMessage `json:"guest_message,omitempty"` +} + +func (c *TelegramChannel) updatesViaLongPollingWithGuest( + ctx context.Context, + params *telego.GetUpdatesParams, +) <-chan telego.Update { + updates := make(chan telego.Update, 100) + go c.pollTelegramUpdatesWithGuest(ctx, params, updates) + return updates +} + +func (c *TelegramChannel) pollTelegramUpdatesWithGuest( + ctx context.Context, + params *telego.GetUpdatesParams, + updates chan<- telego.Update, +) { + defer close(updates) + if params == nil { + params = &telego.GetUpdatesParams{Timeout: 30} + } + pollParams := *params + for { + select { + case <-ctx.Done(): + return + default: + } + + var rawUpdates []telegramRawUpdate + if err := c.postTelegramAPI(ctx, "getUpdates", &pollParams, &rawUpdates); err != nil { + if ctx.Err() != nil { + return + } + logger.ErrorCF("telegram", "Guest-mode long polling failed", map[string]any{ + "error": err.Error(), + }) + time.Sleep(time.Second) + continue + } + + for _, update := range rawUpdates { + if update.UpdateID >= pollParams.Offset { + pollParams.Offset = update.UpdateID + 1 + } + if update.GuestMessage != nil { + if err := c.handleGuestMessage(ctx, update.GuestMessage); err != nil { + logger.ErrorCF("telegram", "Guest message handling failed", map[string]any{ + "error": err.Error(), + }) + } + continue + } + select { + case <-ctx.Done(): + return + case updates <- update.Update: + } + } + } +} + func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning @@ -239,6 +387,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] } isToolFeedback := outboundMessageIsToolFeedback(msg) + if target.guestQueryID != "" { + if isToolFeedback { + return nil, nil + } + return c.answerGuestQuery(ctx, target.guestQueryID, msg.Content, useMarkdownV2) + } toolFeedbackContent := msg.Content if isToolFeedback { toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096) @@ -401,6 +555,55 @@ func (c *TelegramChannel) sendChunk( return strconv.Itoa(pMsg.MessageID), nil } +func (c *TelegramChannel) answerGuestQuery( + ctx context.Context, + guestQueryID string, + content string, + useMarkdownV2 bool, +) ([]string, error) { + guestQueryID = strings.TrimSpace(guestQueryID) + if guestQueryID == "" { + return nil, fmt.Errorf("missing guest query ID: %w", channels.ErrSendFailed) + } + chunks := channels.SplitMessage(content, 4000) + if len(chunks) > 0 { + content = chunks[0] + } + parsedContent := parseContent(content, useMarkdownV2) + parseMode := telego.ModeHTML + if useMarkdownV2 { + parseMode = telego.ModeMarkdownV2 + } + params := map[string]any{ + "guest_query_id": guestQueryID, + "result": map[string]any{ + "type": "article", + "id": "picoclaw-reply", + "title": "PicoClaw", + "input_message_content": map[string]any{ + "message_text": parsedContent, + "parse_mode": parseMode, + }, + }, + } + var result struct { + MessageID int `json:"message_id,omitempty"` + } + if err := c.postTelegramAPI(ctx, "answerGuestQuery", params, &result); err != nil { + logParseFailed(err, useMarkdownV2) + params["result"].(map[string]any)["input_message_content"] = map[string]any{ + "message_text": content, + } + if retryErr := c.postTelegramAPI(ctx, "answerGuestQuery", params, &result); retryErr != nil { + return nil, fmt.Errorf("telegram answer guest query: %w", channels.ErrTemporary) + } + } + if result.MessageID == 0 { + return nil, nil + } + return []string{strconv.Itoa(result.MessageID)}, nil +} + // maxTypingDuration limits how long the typing indicator can run. // Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel. // Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent. @@ -417,6 +620,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( if err != nil { return func() {}, err } + if target.guestQueryID != "" { + return func() {}, nil + } action := tu.ChatAction(tu.ID(target.chatID), telego.ChatActionTyping) action.MessageThreadID = target.threadID @@ -455,10 +661,16 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag if err != nil { return err } + if target.guestQueryID != "" { + return nil + } mid, err := strconv.Atoi(messageID) if err != nil { return err } + if target.guestQueryID != "" { + return nil + } parsedContent := parseContent(content, useMarkdownV2) editMsg := tu.EditMessageText(tu.ID(target.chatID), mid, parsedContent) editMsg.BusinessConnectionID = target.businessConnectionID @@ -628,6 +840,9 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s if err != nil { return "", err } + if target.guestQueryID != "" { + return "", nil + } phMsg := tu.Message(tu.ID(target.chatID), text) phMsg.MessageThreadID = target.threadID @@ -769,13 +984,17 @@ 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, "") + return c.handleTelegramMessage(ctx, message, "", "") } func (c *TelegramChannel) businessModeEnabled() bool { return c != nil && c.tgCfg != nil && c.tgCfg.BusinessMode } +func (c *TelegramChannel) guestModeEnabled() bool { + return c != nil && c.tgCfg != nil && c.tgCfg.GuestMode +} + func (c *TelegramChannel) handleBusinessMessage(ctx context.Context, message *telego.Message) error { if !c.businessModeEnabled() { return nil @@ -802,7 +1021,42 @@ func (c *TelegramChannel) handleBusinessMessage(ctx context.Context, message *te return nil } c.markBusinessMessageRead(ctx, businessConnectionID, message.Chat.ID, message.MessageID) - return c.handleTelegramMessage(ctx, message, businessConnectionID) + return c.handleTelegramMessage(ctx, message, businessConnectionID, "") +} + +type telegramGuestMessage struct { + telego.Message + GuestQueryID string `json:"guest_query_id,omitempty"` +} + +func (m *telegramGuestMessage) UnmarshalJSON(data []byte) error { + var message telego.Message + if err := json.Unmarshal(data, &message); err != nil { + return err + } + var guestFields struct { + GuestQueryID string `json:"guest_query_id,omitempty"` + } + if err := json.Unmarshal(data, &guestFields); err != nil { + return err + } + m.Message = message + m.GuestQueryID = guestFields.GuestQueryID + return nil +} + +func (c *TelegramChannel) handleGuestMessage(ctx context.Context, message *telegramGuestMessage) error { + if !c.guestModeEnabled() { + return nil + } + if message == nil { + return fmt.Errorf("guest message is nil") + } + guestQueryID := strings.TrimSpace(message.GuestQueryID) + if guestQueryID == "" { + return fmt.Errorf("guest message missing guest_query_id") + } + return c.handleTelegramMessage(ctx, &message.Message, "", guestQueryID) } func (c *TelegramChannel) isDisabledBusinessCommand(message *telego.Message) bool { @@ -882,7 +1136,12 @@ func (c *TelegramChannel) handleDeletedBusinessMessages(_ context.Context, delet return nil } -func (c *TelegramChannel) handleTelegramMessage(ctx context.Context, message *telego.Message, businessConnectionID string) error { +func (c *TelegramChannel) handleTelegramMessage( + ctx context.Context, + message *telego.Message, + businessConnectionID string, + guestQueryID string, +) error { if message == nil { return fmt.Errorf("message is nil") } @@ -915,7 +1174,7 @@ func (c *TelegramChannel) handleTelegramMessage(ctx context.Context, message *te content := "" mediaPaths := []string{} - chatIDStr := formatTelegramDeliveryChatID(chatID, businessConnectionID, 0) + chatIDStr := formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, 0) messageIDStr := fmt.Sprintf("%d", message.MessageID) scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) @@ -1034,10 +1293,10 @@ func (c *TelegramChannel) handleTelegramMessage(ctx context.Context, message *te // 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 := formatTelegramDeliveryChatID(chatID, businessConnectionID, 0) + compositeChatID := formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, 0) threadID := message.MessageThreadID if message.Chat.IsForum && threadID != 0 { - compositeChatID = formatTelegramDeliveryChatID(chatID, businessConnectionID, threadID) + compositeChatID = formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, threadID) } logger.DebugCF("telegram", "Received message", map[string]any{ @@ -1062,10 +1321,13 @@ func (c *TelegramChannel) handleTelegramMessage(ctx context.Context, message *te if businessConnectionID != "" { metadata["business_connection_id"] = businessConnectionID } + if guestQueryID != "" { + metadata["guest_query_id"] = guestQueryID + } inboundCtx := bus.InboundContext{ Channel: c.Name(), - ChatID: formatTelegramDeliveryChatID(chatID, businessConnectionID, 0), + ChatID: formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, 0), ChatType: peerKind, SenderID: platformID, MessageID: messageID, @@ -1075,6 +1337,9 @@ func (c *TelegramChannel) handleTelegramMessage(ctx context.Context, message *te if businessConnectionID != "" { inboundCtx.Account = businessConnectionID } + if guestQueryID != "" { + inboundCtx.Account = guestQueryID + } if message.Chat.IsForum && threadID != 0 { inboundCtx.TopicID = fmt.Sprintf("%d", threadID) } @@ -1331,17 +1596,20 @@ func parseTelegramChatID(chatID string) (int64, int, error) { type telegramOutboundTarget struct { businessConnectionID string + guestQueryID string chatID int64 threadID int } func (t telegramOutboundTarget) String() string { - return formatTelegramDeliveryChatID(t.chatID, t.businessConnectionID, t.threadID) + return formatTelegramDeliveryChatID(t.chatID, t.businessConnectionID, t.guestQueryID, t.threadID) } -func formatTelegramDeliveryChatID(chatID int64, businessConnectionID string, threadID int) string { +func formatTelegramDeliveryChatID(chatID int64, businessConnectionID string, guestQueryID string, threadID int) string { base := fmt.Sprintf("%d", chatID) - if strings.TrimSpace(businessConnectionID) != "" { + if strings.TrimSpace(guestQueryID) != "" { + base = fmt.Sprintf("guest:%s:%d", url.QueryEscape(strings.TrimSpace(guestQueryID)), chatID) + } else if strings.TrimSpace(businessConnectionID) != "" { base = fmt.Sprintf("business:%s:%d", url.QueryEscape(strings.TrimSpace(businessConnectionID)), chatID) } if threadID != 0 { @@ -1372,6 +1640,26 @@ func parseTelegramOutboundTarget(chatID string) (telegramOutboundTarget, error) threadID: threadID, }, nil } + if strings.HasPrefix(targetChatID, "guest:") { + rest := strings.TrimPrefix(targetChatID, "guest:") + parts := strings.SplitN(rest, ":", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return telegramOutboundTarget{}, fmt.Errorf("invalid guest chat ID %q", chatID) + } + guestQueryID, unescapeErr := url.QueryUnescape(strings.TrimSpace(parts[0])) + if unescapeErr != nil { + return telegramOutboundTarget{}, fmt.Errorf("invalid guest query ID in chat ID %q: %w", chatID, unescapeErr) + } + cid, threadID, err := parseTelegramChatID(parts[1]) + if err != nil { + return telegramOutboundTarget{}, err + } + return telegramOutboundTarget{ + guestQueryID: guestQueryID, + chatID: cid, + threadID: threadID, + }, nil + } cid, threadID, err := parseTelegramChatID(targetChatID) if err != nil { @@ -1380,6 +1668,18 @@ func parseTelegramOutboundTarget(chatID string) (telegramOutboundTarget, error) return telegramOutboundTarget{chatID: cid, threadID: threadID}, nil } +func guestQueryIDFromContext(outboundCtx *bus.InboundContext) string { + if outboundCtx == nil { + return "" + } + if outboundCtx.Raw != nil { + if guestQueryID := strings.TrimSpace(outboundCtx.Raw["guest_query_id"]); guestQueryID != "" { + return guestQueryID + } + } + return "" +} + func businessConnectionIDFromContext(outboundCtx *bus.InboundContext) string { if outboundCtx == nil { return "" @@ -1404,6 +1704,9 @@ func resolveTelegramOutboundTarget(chatID string, outboundCtx *bus.InboundContex if resolved.businessConnectionID == "" { resolved.businessConnectionID = businessConnectionIDFromContext(outboundCtx) } + if resolved.guestQueryID == "" { + resolved.guestQueryID = guestQueryIDFromContext(outboundCtx) + } if resolved.threadID != 0 || outboundCtx == nil { return resolved, nil } diff --git a/pkg/channels/telegram/telegram_dispatch_test.go b/pkg/channels/telegram/telegram_dispatch_test.go index 049932581..df2656759 100644 --- a/pkg/channels/telegram/telegram_dispatch_test.go +++ b/pkg/channels/telegram/telegram_dispatch_test.go @@ -2,6 +2,7 @@ package telegram import ( "context" + "encoding/json" "strings" "testing" @@ -208,12 +209,12 @@ func TestHandleBusinessMessage_EnabledBusinessCommandsForwardsCommand(t *testing } func TestTelegramAllowedUpdates_BusinessMode(t *testing.T) { - disabled := strings.Join(telegramAllowedUpdates(false), ",") + disabled := strings.Join(telegramAllowedUpdates(false, false), ",") if strings.Contains(disabled, telego.BusinessMessageUpdates) { t.Fatalf("disabled updates include business messages: %s", disabled) } - enabled := strings.Join(telegramAllowedUpdates(true), ",") + enabled := strings.Join(telegramAllowedUpdates(true, false), ",") if !strings.Contains(enabled, telego.BusinessMessageUpdates) { t.Fatalf("enabled updates do not include business messages: %s", enabled) } @@ -222,6 +223,129 @@ func TestTelegramAllowedUpdates_BusinessMode(t *testing.T) { } } +func TestTelegramAllowedUpdates_GuestMode(t *testing.T) { + disabled := strings.Join(telegramAllowedUpdates(false, false), ",") + if strings.Contains(disabled, telegramGuestMessageUpdates) { + t.Fatalf("disabled updates include guest messages: %s", disabled) + } + + enabled := strings.Join(telegramAllowedUpdates(false, true), ",") + if !strings.Contains(enabled, telegramGuestMessageUpdates) { + t.Fatalf("enabled updates do not include guest messages: %s", enabled) + } +} + +func TestHandleGuestMessage_DisabledGuestModeIgnoresMessage(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{GuestMode: false}, + } + + msg := &telegramGuestMessage{ + Message: telego.Message{ + Text: "ignored guest message", + MessageID: 18, + Chat: telego.Chat{ + ID: 777, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + }, + GuestQueryID: "guest-query-1", + } + + if err := ch.handleGuestMessage(context.Background(), msg); err != nil { + t.Fatalf("handleGuestMessage error: %v", err) + } + + select { + case inbound := <-messageBus.InboundChan(): + t.Fatalf("expected disabled guest mode to ignore message, got %#v", inbound) + default: + } +} + +func TestHandleGuestMessage_ForwardsWithGuestContext(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{GuestMode: true}, + } + + msg := &telegramGuestMessage{ + Message: telego.Message{ + Text: "hello from guest", + MessageID: 17, + Chat: telego.Chat{ + ID: 777, + Type: "private", + }, + From: &telego.User{ + ID: 42, + FirstName: "Alice", + }, + }, + GuestQueryID: "guest-query-1", + } + + if err := ch.handleGuestMessage(context.Background(), msg); err != nil { + t.Fatalf("handleGuestMessage error: %v", err) + } + + inbound, ok := <-messageBus.InboundChan() + if !ok { + t.Fatal("expected inbound message to be forwarded") + } + if inbound.ChatID != "guest:guest-query-1:777" { + t.Fatalf("chat_id=%q", inbound.ChatID) + } + if inbound.Context.Account != "guest-query-1" { + t.Fatalf("account=%q", inbound.Context.Account) + } + if inbound.Context.Raw["guest_query_id"] != "guest-query-1" { + t.Fatalf("guest_query_id=%q", inbound.Context.Raw["guest_query_id"]) + } + if inbound.Content != "hello from guest" { + t.Fatalf("content=%q", inbound.Content) + } +} + +func TestTelegramRawUpdateDecodesGuestQueryID(t *testing.T) { + payload := []byte(`{ + "update_id": 1001, + "guest_message": { + "message_id": 17, + "guest_query_id": "guest-query-1", + "date": 1760000000, + "chat": {"id": 777, "type": "private"}, + "from": {"id": 42, "is_bot": false, "first_name": "Alice"}, + "text": "hello from guest" + } + }`) + + var update telegramRawUpdate + if err := json.Unmarshal(payload, &update); err != nil { + t.Fatalf("unmarshal raw update: %v", err) + } + if update.GuestMessage == nil { + t.Fatal("guest message was not decoded") + } + if update.GuestMessage.GuestQueryID != "guest-query-1" { + t.Fatalf("guest_query_id=%q", update.GuestMessage.GuestQueryID) + } + if update.GuestMessage.Text != "hello from guest" { + t.Fatalf("text=%q", update.GuestMessage.Text) + } +} + func TestHandleBusinessMessage_ForwardsWithBusinessContext(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index a70d55c0b..53ffa03df 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "strconv" @@ -390,6 +392,59 @@ func TestSend_BusinessMessageIncludesBusinessConnectionID(t *testing.T) { assert.Equal(t, "hello business", params.Text) } +func TestSend_GuestMessageUsesAnswerGuestQuery(t *testing.T) { + var requestPath string + var params struct { + GuestQueryID string `json:"guest_query_id"` + Result struct { + Type string `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + InputMessageContent struct { + MessageText string `json:"message_text"` + ParseMode string `json:"parse_mode"` + } `json:"input_message_content"` + } `json:"result"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(¶ms)) + _, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":55}}`)) + })) + defer server.Close() + + ch := newTestChannel(t, &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + t.Fatalf("guest reply must not use telego caller: %s", url) + return nil, nil + }, + }) + ch.tgCfg.BaseURL = server.URL + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "guest:guest-query-1:777", + Content: "hello guest", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "guest:guest-query-1:777", + Account: "guest-query-1", + Raw: map[string]string{ + "guest_query_id": "guest-query-1", + }, + }, + }) + + require.NoError(t, err) + assert.Equal(t, []string{"55"}, ids) + assert.Equal(t, "/bot"+testToken+"/answerGuestQuery", requestPath) + assert.Equal(t, "guest-query-1", params.GuestQueryID) + assert.Equal(t, "article", params.Result.Type) + assert.Equal(t, "picoclaw-reply", params.Result.ID) + assert.Equal(t, "PicoClaw", params.Result.Title) + assert.Equal(t, "hello guest", params.Result.InputMessageContent.MessageText) + assert.Equal(t, telego.ModeHTML, params.Result.InputMessageContent.ParseMode) +} + func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { @@ -806,7 +861,7 @@ func TestParseTelegramChatID_InvalidThreadID(t *testing.T) { } func TestParseTelegramOutboundTarget_BusinessConnectionIDWithColon(t *testing.T) { - formatted := formatTelegramDeliveryChatID(777, "biz:conn:1", 42) + formatted := formatTelegramDeliveryChatID(777, "biz:conn:1", "", 42) target, err := parseTelegramOutboundTarget(formatted) diff --git a/pkg/config/config.go b/pkg/config/config.go index 648409a99..195b414b3 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"` + GuestMode bool `json:"guest_mode" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_GUEST_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"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9dd9cdac6..c21fa7fe0 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, + "guest_mode": false, "business_owner": "", "business_commands_enable": 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 14679c484..ee4e3d5f1 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -97,6 +97,16 @@ export function TelegramForm({ /> +
+ onChange("guest_mode", checked)} + ariaLabel={t("channels.field.guestMode")} + /> +
+ Date: Mon, 11 May 2026 10:56:34 +0300 Subject: [PATCH 6/6] docs(telegram): align Telegram README files with new config options - Add config examples to Telegram Business Mode sections - Add config examples to Telegram Guest Mode sections - Update Guest Mode description with placeholder/typing indicator note - Align formatting and content with README.md across all languages --- docs/channels/telegram/README.fr.md | 75 +++++++++++++++++++++++--- docs/channels/telegram/README.ja.md | 75 +++++++++++++++++++++++--- docs/channels/telegram/README.md | 50 ++++++++++++----- docs/channels/telegram/README.pt-br.md | 75 +++++++++++++++++++++++--- docs/channels/telegram/README.vi.md | 75 +++++++++++++++++++++++--- docs/channels/telegram/README.zh.md | 75 +++++++++++++++++++++++--- 6 files changed, 373 insertions(+), 52 deletions(-) diff --git a/docs/channels/telegram/README.fr.md b/docs/channels/telegram/README.fr.md index 51db2082f..db1c83a20 100644 --- a/docs/channels/telegram/README.fr.md +++ b/docs/channels/telegram/README.fr.md @@ -12,10 +12,15 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun "telegram": { "enabled": true, "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, + "guest_mode": false + } } } } @@ -24,10 +29,13 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun | Champ | Type | Requis | Description | | --------------- | ------ | ------ | ------------------------------------------------------------------------ | | enabled | bool | Oui | Activer ou non le canal Telegram | -| token | string | Oui | Token de l'API Bot Telegram | | allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs | -| proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) | -| use_markdown_v2 | bool | Non | Activer le formatage Telegram MarkdownV2 | +| settings.proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) | +| settings.use_markdown_v2 | bool | Non | Activer le formatage Telegram MarkdownV2 | +| settings.business_mode | bool | Non | Activer la gestion des messages Telegram Business | +| settings.business_owner | string | Non | ID utilisateur Telegram du propriétaire Business à ignorer | +| settings.business_commands_enable | bool | Non | Autoriser les commandes du bot dans les chats Telegram Business | +| settings.guest_mode | bool | Non | Activer la gestion et les réponses des messages Telegram Guest | ## Configuration initiale @@ -37,6 +45,56 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun 4. Renseigner le Token dans le fichier de configuration 5. (Optionnel) Configurer `allow_from` pour restreindre les identifiants utilisateur autorisés à interagir (les IDs peuvent être obtenus via `@userinfobot`) +## Mode Telegram Business + +Définissez `settings.business_mode: true` pour recevoir et répondre aux messages Telegram Business des comptes connectés. Les réponses utilisent le `business_connection_id` entrant, et PicoClaw marque les messages Business comme lus lorsque le bot dispose du droit `can_read_messages`. + +Définissez `settings.business_owner` avec l'ID utilisateur Telegram du propriétaire du compte Business. Les messages Business envoyés par cet utilisateur sont ignorés, ce qui évite de répondre aux messages envoyés manuellement depuis le compte connecté. + +Par défaut, les commandes du bot sont ignorées dans les chats Business. Définissez `settings.business_commands_enable: true` pour autoriser `/new`, `/help`, `/show`, `/list` et `/use`. + +Exemple : + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "business_mode": true, + "business_owner": "123456789", + "business_commands_enable": true + } + } + } +} +``` + +## Mode Telegram Guest + +Définissez `settings.guest_mode: true` pour recevoir les mises à jour `guest_message` et répondre avec la méthode Telegram `answerGuestQuery`. Les messages Guest proviennent de chats où le bot n'est pas membre, donc PicoClaw crée des sessions séparées avec le `guest_query_id` entrant. + +Lorsque `settings.guest_mode` est `false`, PicoClaw ne demande pas les mises à jour Guest et ignore tout message Guest décodé. Les indicateurs de saisie et de placeholder sont ignorés pour les réponses Guest car Telegram requiert une seule réponse `answerGuestQuery`. + +Exemple : + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "guest_mode": true + } + } + } +} +``` + ## Formatage avancées Vous pouvez définir `use_markdown_v2: true` pour activer les options de formatage améliorées. Cela permet au bot d'utiliser toutes les fonctionnalités de Telegram MarkdownV2, y compris les styles imbriqués, les spoilers et les blocs de largeur fixe personnalisés. @@ -47,9 +105,10 @@ Vous pouvez définir `use_markdown_v2: true` pour activer les options de formata "telegram": { "enabled": true, "type": "telegram", - "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "use_markdown_v2": true + "settings": { + "use_markdown_v2": true + } } } } diff --git a/docs/channels/telegram/README.ja.md b/docs/channels/telegram/README.ja.md index 03303f255..5c999e97d 100644 --- a/docs/channels/telegram/README.ja.md +++ b/docs/channels/telegram/README.ja.md @@ -12,10 +12,15 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ "telegram": { "enabled": true, "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, + "guest_mode": false + } } } } @@ -24,10 +29,13 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ | フィールド | 型 | 必須 | 説明 | | --------------- | ------ | ---- | ----------------------------------------------------------------- | | enabled | bool | はい | Telegram チャンネルを有効にするかどうか | -| token | string | はい | Telegram Bot API トークン | | allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 | -| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | -| use_markdown_v2 | bool | いいえ | Telegram MarkdownV2 フォーマットを有効にする | +| settings.proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) | +| settings.use_markdown_v2 | bool | いいえ | Telegram MarkdownV2 フォーマットを有効にする | +| settings.business_mode | bool | いいえ | Telegram Business メッセージ処理を有効にする | +| settings.business_owner | string | いいえ | 無視する Business アカウント所有者の Telegram ユーザー ID | +| settings.business_commands_enable | bool | いいえ | Telegram Business チャットで Bot コマンドを処理する | +| settings.guest_mode | bool | いいえ | Telegram Guest メッセージ処理と返信を有効にする | ## セットアップ手順 @@ -37,6 +45,56 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ 4. 設定ファイルにトークンを入力する 5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限する(ID は `@userinfobot` で取得可能) +## Telegram Business モード + +`settings.business_mode: true` を設定すると、接続された Business アカウントの Telegram Business メッセージを受信して返信できます。返信には受信した `business_connection_id` が使われ、Bot に `can_read_messages` 権限がある場合は受信 Business メッセージを既読にします。 + +`settings.business_owner` には Business アカウント所有者の Telegram ユーザー ID を設定します。このユーザーからの Business メッセージは無視されるため、接続済みアカウントから手動送信したメッセージへの自動返信を避けられます。 + +Business チャット内の Bot コマンドは既定で無視されます。`/new`、`/help`、`/show`、`/list`、`/use` を処理したい場合は `settings.business_commands_enable: true` を設定してください。 + +例: + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "business_mode": true, + "business_owner": "123456789", + "business_commands_enable": true + } + } + } +} +``` + +## Telegram Guest モード + +`settings.guest_mode: true` を設定すると、`guest_message` 更新を受信し、Telegram の `answerGuestQuery` メソッドで返信できます。Guest メッセージは Bot が参加していないチャットから届くため、PicoClaw は受信した `guest_query_id` を使って別セッションとして扱います。 + +`settings.guest_mode` が `false` の場合、Guest 更新は要求されず、デコードされた Guest メッセージも無視されます。Telegram は単一の `answerGuestQuery` 応答のみを必要とするため、Guest 応答のプレースホルダーと入力インジケーターはスキップされます。 + +例: + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "guest_mode": true + } + } + } +} +``` + ## 高度なフォーマット `use_markdown_v2: true` を設定することで、增强されたフォーマットオプションを有効にできます。これにより、ボットは Telegram MarkdownV2 の全機能(ネストされたスタイル、スポイラー、カスタム固定幅ブロックなど)を利用できます。 @@ -47,9 +105,10 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ "telegram": { "enabled": true, "type": "telegram", - "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "use_markdown_v2": true + "settings": { + "use_markdown_v2": true + } } } } diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index e1ca6f5ab..e18aa0ac4 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -12,12 +12,12 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co "telegram": { "enabled": true, "type": "telegram", - "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", "allow_from": ["123456789"], "settings": { "proxy": "", "use_markdown_v2": false, "business_mode": false, + "guest_mode": false, "business_owner": "123456789", "business_commands_enable": false } @@ -26,13 +26,16 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co } ``` -| Field | Type | Required | Description | -| ---------------- | ------ | -------- | ------------------------------------------------------------------ | -| enabled | bool | Yes | Whether to enable the Telegram channel | -| token | string | Yes | Telegram Bot API Token | -| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | -| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | -| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting | +| Field | Type | Required | Description | +| ------------------------ | ------ | -------- | ------------------------------------------------------------------------ | +| enabled | bool | Yes | Whether to enable the Telegram channel | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| settings.proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) | +| settings.use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting | +| settings.business_mode | bool | No | Enable Telegram Business message handling | +| settings.business_owner | string | No | Telegram user ID of the Business account owner to ignore | +| settings.business_commands_enable | bool | No | Allow bot commands in Telegram Business chats | +| settings.guest_mode | bool | No | Enable Telegram Guest Mode update handling and replies | ## Setup @@ -78,13 +81,12 @@ Example: ```json { - "channels": { + "channel_list": { "telegram": { "enabled": true, "type": "telegram", "allow_from": ["123456789"], "settings": { - "token": "YOUR_BOT_TOKEN", "business_mode": true, "business_owner": "123456789", "business_commands_enable": true @@ -94,6 +96,29 @@ Example: } ``` +## Telegram Guest Mode + +Set `settings.guest_mode: true` to receive `guest_message` updates and reply with Telegram's `answerGuestQuery` method. Guest messages come from chats where the bot is not a member, so PicoClaw keeps them in separate sessions using the incoming `guest_query_id`. + +When `settings.guest_mode` is false, guest updates are not requested and any decoded guest message is ignored. Placeholder and typing indicators are skipped for guest replies because Telegram requires a single `answerGuestQuery` response. + +Example: + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "guest_mode": 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. @@ -104,9 +129,10 @@ You can set `use_markdown_v2: true` to enable enhanced formatting options. This "telegram": { "enabled": true, "type": "telegram", - "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "use_markdown_v2": true + "settings": { + "use_markdown_v2": true + } } } } diff --git a/docs/channels/telegram/README.pt-br.md b/docs/channels/telegram/README.pt-br.md index 4af8d7a25..a21095c8e 100644 --- a/docs/channels/telegram/README.pt-br.md +++ b/docs/channels/telegram/README.pt-br.md @@ -12,10 +12,15 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica "telegram": { "enabled": true, "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, + "guest_mode": false + } } } } @@ -24,10 +29,13 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica | Campo | Tipo | Obrigatório | Descrição | | --------------- | ------ | ----------- | -------------------------------------------------------------------------- | | enabled | bool | Sim | Se o canal Telegram deve ser habilitado | -| token | string | Sim | Token da API de Bot do Telegram | | allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários | -| proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) | -| use_markdown_v2 | bool | Não | Habilitar formatação Telegram MarkdownV2 | +| settings.proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) | +| settings.use_markdown_v2 | bool | Não | Habilitar formatação Telegram MarkdownV2 | +| settings.business_mode | bool | Não | Habilitar tratamento de mensagens Telegram Business | +| settings.business_owner | string | Não | ID de usuário Telegram do proprietário Business a ser ignorado | +| settings.business_commands_enable | bool | Não | Permitir comandos do bot em chats Telegram Business | +| settings.guest_mode | bool | Não | Habilitar tratamento e respostas de mensagens Telegram Guest | ## Configuração inicial @@ -37,6 +45,56 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica 4. Preencha o Token no arquivo de configuração 5. (Opcional) Configure `allow_from` para restringir quais IDs de usuário podem interagir (os IDs podem ser obtidos via `@userinfobot`) +## Modo Telegram Business + +Defina `settings.business_mode: true` para receber e responder mensagens Telegram Business de contas comerciais conectadas. As respostas usam o `business_connection_id` recebido, e as mensagens Business são marcadas como lidas quando o bot tem o direito `can_read_messages`. + +Use `settings.business_owner` para informar o ID de usuário Telegram do proprietário da conta Business. Mensagens Business desse usuário são ignoradas, evitando respostas automáticas a mensagens enviadas manualmente pela conta conectada. + +Por padrão, comandos do bot em chats Business são ignorados. Defina `settings.business_commands_enable: true` para processar `/new`, `/help`, `/show`, `/list` e `/use`. + +Exemplo : + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "business_mode": true, + "business_owner": "123456789", + "business_commands_enable": true + } + } + } +} +``` + +## Modo Telegram Guest + +Defina `settings.guest_mode: true` para receber atualizações `guest_message` e responder com o método Telegram `answerGuestQuery`. Mensagens Guest vêm de chats onde o bot não é membro, então o PicoClaw mantém sessões separadas usando o `guest_query_id` recebido. + +Quando `settings.guest_mode` é `false`, atualizações Guest não são solicitadas e qualquer mensagem Guest decodificada é ignorada. Indicadores de digitação e placeholders são ignorados para respostas Guest porque o Telegram requer uma única resposta `answerGuestQuery`. + +Exemplo : + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "guest_mode": true + } + } + } +} +``` + ## Formatação Avançada Você pode definir `use_markdown_v2: true` para habilitar opções de formatação aprimoradas. Isso permite que o bot utilize todos os recursos do Telegram MarkdownV2, incluindo estilos aninhados, spoilers e blocos de largura fixa personalizados. @@ -47,9 +105,10 @@ Você pode definir `use_markdown_v2: true` para habilitar opções de formataç "telegram": { "enabled": true, "type": "telegram", - "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "use_markdown_v2": true + "settings": { + "use_markdown_v2": true + } } } } diff --git a/docs/channels/telegram/README.vi.md b/docs/channels/telegram/README.vi.md index c6a276754..06311dc4c 100644 --- a/docs/channels/telegram/README.vi.md +++ b/docs/channels/telegram/README.vi.md @@ -12,10 +12,15 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d "telegram": { "enabled": true, "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, + "guest_mode": false + } } } } @@ -24,10 +29,13 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d | Trường | Kiểu | Bắt buộc | Mô tả | | -------------- | ------ | -------- | ------------------------------------------------------------------------ | | enabled | bool | Có | Có bật kênh Telegram hay không | -| token | string | Có | Token API Bot Telegram | | allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả | -| proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) | -| use_markdown_v2 | bool | Không | Bật định dạng Telegram MarkdownV2 | +| settings.proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) | +| settings.use_markdown_v2 | bool | Không | Bật định dạng Telegram MarkdownV2 | +| settings.business_mode | bool | Không | Bật xử lý tin nhắn Telegram Business | +| settings.business_owner | string | Không | ID người dùng Telegram của chủ tài khoản Business cần bỏ qua | +| settings.business_commands_enable | bool | Không | Cho phép lệnh bot trong chat Telegram Business | +| settings.guest_mode | bool | Không | Bật xử lý và trả lời tin nhắn Telegram Guest | ## Hướng dẫn thiết lập @@ -37,6 +45,56 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d 4. Điền Token vào file cấu hình 5. (Tùy chọn) Cấu hình `allow_from` để giới hạn ID người dùng được phép tương tác (có thể lấy ID qua `@userinfobot`) +## Chế độ Telegram Business + +Đặt `settings.business_mode: true` để nhận và trả lời tin nhắn Telegram Business từ các tài khoản doanh nghiệp đã kết nối. Phản hồi dùng `business_connection_id` nhận được, và PicoClaw đánh dấu tin nhắn Business là đã đọc khi bot có quyền `can_read_messages`. + +Đặt `settings.business_owner` thành ID người dùng Telegram của chủ tài khoản Business. Tin nhắn Business từ người dùng này sẽ bị bỏ qua để tránh bot trả lời các tin nhắn bạn gửi thủ công từ tài khoản đã kết nối. + +Theo mặc định, lệnh bot trong chat Business bị bỏ qua. Đặt `settings.business_commands_enable: true` để xử lý `/new`, `/help`, `/show`, `/list` và `/use`. + +Ví dụ : + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "business_mode": true, + "business_owner": "123456789", + "business_commands_enable": true + } + } + } +} +``` + +## Chế độ Telegram Guest + +Đặt `settings.guest_mode: true` để nhận cập nhật `guest_message` và trả lời bằng phương thức Telegram `answerGuestQuery`. Tin nhắn Guest đến từ chat mà bot không phải là thành viên, nên PicoClaw tách phiên bằng `guest_query_id` nhận được. + +Khi `settings.guest_mode` là `false`, cập nhật Guest không được yêu cầu và mọi tin nhắn Guest được giải mã sẽ bị bỏ qua. Các chỉ báo gõ và placeholder được bỏ qua cho phản hồi Guest vì Telegram yêu cầu một phản hồi `answerGuestQuery` duy nhất. + +Ví dụ : + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "guest_mode": true + } + } + } +} +``` + ## Định dạng nâng cao Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn định dạng nâng cao. Điều này cho phép bot sử dụng toàn bộ các tính năng của Telegram MarkdownV2, bao gồm các kiểu lồng nhau, spoiler và các khối chiều rộng cố định tùy chỉnh. @@ -47,9 +105,10 @@ Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn đ "telegram": { "enabled": true, "type": "telegram", - "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "use_markdown_v2": true + "settings": { + "use_markdown_v2": true + } } } } diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md index 543e16e47..6dfe20f4f 100644 --- a/docs/channels/telegram/README.zh.md +++ b/docs/channels/telegram/README.zh.md @@ -12,10 +12,15 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器 "telegram": { "enabled": true, "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, + "guest_mode": false + } } } } @@ -24,10 +29,13 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器 | 字段 | 类型 | 必填 | 描述 | | ---------------- | ------ | ---- | --------------------------------------------------------- | | enabled | bool | 是 | 是否启用 Telegram 频道 | -| token | string | 是 | Telegram 机器人 API Token | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | -| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | -| use_markdown_v2 | bool | 否 | 启用 Telegram MarkdownV2 格式化 | +| settings.proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | +| settings.use_markdown_v2 | bool | 否 | 启用 Telegram MarkdownV2 格式化 | +| settings.business_mode | bool | 否 | 启用 Telegram Business 消息处理 | +| settings.business_owner | string | 否 | 需要忽略的 Business 账号所有者 Telegram 用户 ID | +| settings.business_commands_enable | bool | 否 | 允许在 Telegram Business 聊天中处理机器人命令 | +| settings.guest_mode | bool | 否 | 启用 Telegram Guest 消息处理和回复 | ## 设置流程 @@ -37,6 +45,56 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器 4. 将 Token 填入配置文件中 5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID) +## Telegram Business Mode + +设置 `settings.business_mode: true` 后,PicoClaw 会接收并回复已连接商业账号的 Telegram Business 消息。回复会使用传入的 `business_connection_id`,当机器人具备 `can_read_messages` 权限时,传入的 Business 消息会被标记为已读。 + +使用 `settings.business_owner` 保存商业账号所有者的 Telegram 用户 ID。来自该用户的 Business 消息会被忽略,避免机器人回复你从已连接商业账号手动发送的消息。 + +默认情况下,Business 聊天中的机器人命令会被忽略。设置 `settings.business_commands_enable: true` 后,可处理 `/new`、`/help`、`/show`、`/list` 和 `/use`。 + +示例: + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "business_mode": true, + "business_owner": "123456789", + "business_commands_enable": true + } + } + } +} +``` + +## Telegram Guest Mode + +设置 `settings.guest_mode: true` 后,PicoClaw 会接收 `guest_message` 更新,并使用 Telegram 的 `answerGuestQuery` 方法回复。Guest 消息来自机器人未加入的聊天,因此 PicoClaw 会使用传入的 `guest_query_id` 建立独立会话。 + +当 `settings.guest_mode` 为 `false` 时,PicoClaw 不会请求 Guest 更新,并会忽略任何已解码的 Guest 消息。Guest 回复的占位符和打字指示器会被跳过,因为 Telegram 需要单一的 `answerGuestQuery` 响应。 + +示例: + +```json +{ + "channel_list": { + "telegram": { + "enabled": true, + "type": "telegram", + "allow_from": ["123456789"], + "settings": { + "guest_mode": true + } + } + } +} +``` + ## 内置命令 Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start`、`/help`、`/show`、`/list` 和 `/use`。 @@ -67,9 +125,10 @@ explain how to squash the last 3 commits "telegram": { "enabled": true, "type": "telegram", - "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "use_markdown_v2": true + "settings": { + "use_markdown_v2": true + } } } }