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:
parent
84164e47f3
commit
361a53a2b3
9 changed files with 520 additions and 20 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 代理地址,用于网络访问",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue