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
This commit is contained in:
Roman Stolyarchuk 2026-05-11 10:51:51 +03:00
parent 84164e47f3
commit 361a53a2b3
9 changed files with 520 additions and 20 deletions

View file

@ -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
}

View file

@ -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{

View file

@ -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(&params))
_, _ = 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)

View file

@ -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"`

View file

@ -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,

View file

@ -97,6 +97,16 @@ export function TelegramForm({
/>
</div>
<div>
<SwitchCardField
label={t("channels.field.guestMode")}
hint={t("channels.form.desc.guestMode")}
checked={asBool(config.guest_mode)}
onCheckedChange={(checked) => onChange("guest_mode", checked)}
ariaLabel={t("channels.field.guestMode")}
/>
</div>
<Field
label={t("channels.field.businessOwner")}
hint={t("channels.form.desc.businessOwner")}

View file

@ -376,6 +376,7 @@
"encryptKey": "Encrypt Key",
"baseUrl": "API Base URL",
"businessMode": "Business Mode",
"guestMode": "Guest Mode",
"businessOwner": "Business Owner",
"businessCommandsEnable": "Business Commands",
"proxy": "HTTP Proxy",
@ -418,6 +419,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.",
"guestMode": "Receive and reply to Telegram guest messages from chats where the bot is not a member.",
"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.",

View file

@ -368,6 +368,7 @@
"encryptKey": "Chave de Criptografia",
"baseUrl": "URL Base da API",
"businessMode": "Modo Business",
"guestMode": "Modo Guest",
"businessOwner": "Proprietário Business",
"businessCommandsEnable": "Comandos Business",
"proxy": "Proxy HTTP",
@ -410,6 +411,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.",
"guestMode": "Receber e responder mensagens Guest do Telegram em chats onde o bot não é membro.",
"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.",

View file

@ -376,6 +376,7 @@
"encryptKey": "Encrypt Key",
"baseUrl": "API Base URL",
"businessMode": "Business Mode",
"guestMode": "Guest Mode",
"businessOwner": "Business Owner",
"businessCommandsEnable": "Business Commands",
"proxy": "HTTP 代理",
@ -418,6 +419,7 @@
"encryptKey": "消息加密密钥,用于解密回调内容",
"baseUrl": "平台 API 地址,默认使用官方地址",
"businessMode": "接收并回复已连接商业账号的 Telegram Business 消息",
"guestMode": "接收并回复机器人未加入聊天中的 Telegram Guest 消息",
"businessOwner": "商业账号所有者的 Telegram 用户 ID。来自该用户的 Business 消息会被忽略。",
"businessCommandsEnable": "允许在 Telegram Business 聊天中处理 /new 等机器人命令。",
"proxy": "HTTP 代理地址,用于网络访问",