From 361a53a2b37eec9edbccd4e890c92973f17c325a Mon Sep 17 00:00:00 2001 From: Roman Stolyarchuk Date: Mon, 11 May 2026 10:51:51 +0300 Subject: [PATCH] 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")} + /> +
+