Files
Memoh/internal/channel/adapters/telegram/telegram.go
T
BBQ 1bb90c70f4 fix(text): avoid breaking UTF-8 during truncation
Use rune-aware truncation for user-facing text and log previews so multibyte content is not corrupted in memory context, Telegram messages, or diagnostics.
2026-03-09 12:39:51 +08:00

1606 lines
49 KiB
Go

package telegram
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/memohai/memoh/internal/channel"
"github.com/memohai/memoh/internal/channel/adapters/common"
"github.com/memohai/memoh/internal/media"
"github.com/memohai/memoh/internal/textutil"
)
const (
telegramMaxMessageLength = 4096
telegramMediaGroupCollectWindow = 700 * time.Millisecond
)
var (
telegramBotLogger = newSlogBotLogger(nil)
telegramLoggerInitOnce sync.Once
)
type telegramMediaGroupBuffer struct {
messages []*tgbotapi.Message
timer *time.Timer
}
// assetOpener reads stored asset bytes by content hash.
type assetOpener interface {
Open(ctx context.Context, botID, contentHash string) (io.ReadCloser, media.Asset, error)
}
// TelegramAdapter implements the channel.Adapter, channel.Sender, and channel.Receiver interfaces for Telegram.
type TelegramAdapter struct {
logger *slog.Logger
mu sync.RWMutex
bots map[string]*tgbotapi.BotAPI // keyed by bot token
fileEndpoints map[string]string // token → file endpoint format string
assets assetOpener
}
// NewTelegramAdapter creates a TelegramAdapter with the given logger.
func NewTelegramAdapter(log *slog.Logger) *TelegramAdapter {
if log == nil {
log = slog.Default()
}
adapter := &TelegramAdapter{
logger: log.With(slog.String("adapter", "telegram")),
bots: make(map[string]*tgbotapi.BotAPI),
fileEndpoints: make(map[string]string),
}
initTelegramBotLogger(adapter.logger)
return adapter
}
func initTelegramBotLogger(log *slog.Logger) {
telegramLoggerInitOnce.Do(func() {
_ = tgbotapi.SetLogger(telegramBotLogger)
})
telegramBotLogger.SetLogger(log)
}
// SetAssetOpener injects the media asset reader for storage-first file delivery.
func (a *TelegramAdapter) SetAssetOpener(opener assetOpener) {
a.assets = opener
}
var getOrCreateBotForTest func(a *TelegramAdapter, token, configID string) (*tgbotapi.BotAPI, error)
func (a *TelegramAdapter) getOrCreateBot(cfg Config, configID string) (*tgbotapi.BotAPI, error) {
if getOrCreateBotForTest != nil {
return getOrCreateBotForTest(a, cfg.BotToken, configID)
}
a.mu.RLock()
bot, ok := a.bots[cfg.BotToken]
a.mu.RUnlock()
if ok {
return bot, nil
}
a.mu.Lock()
defer a.mu.Unlock()
if bot, ok := a.bots[cfg.BotToken]; ok {
return bot, nil
}
bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(cfg.BotToken, cfg.apiEndpoint())
if err != nil {
if a.logger != nil {
a.logger.Error("create bot failed", slog.String("config_id", configID), slog.Any("error", err))
}
return nil, err
}
a.bots[cfg.BotToken] = bot
a.fileEndpoints[cfg.BotToken] = cfg.fileEndpoint()
return bot, nil
}
// getFileDirectURL resolves a file ID to a direct download URL,
// respecting the custom file endpoint for reverse proxy setups.
func (a *TelegramAdapter) getFileDirectURL(bot *tgbotapi.BotAPI, fileID string) (string, error) {
file, err := bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
return "", err
}
a.mu.RLock()
endpoint := a.fileEndpoints[bot.Token]
a.mu.RUnlock()
if endpoint == "" {
endpoint = tgbotapi.FileEndpoint
}
return formatTelegramFileURL(endpoint, bot.Token, file.FilePath), nil
}
func formatTelegramFileURL(endpoint, token, filePath string) string {
placeholderCount := strings.Count(endpoint, "%s")
switch {
case placeholderCount >= 2:
return fmt.Sprintf(endpoint, token, filePath)
case placeholderCount == 1:
return fmt.Sprintf(endpoint, filePath)
default:
base := strings.TrimRight(strings.TrimSpace(endpoint), "/")
if base == "" {
return filePath
}
return base + "/" + strings.TrimLeft(filePath, "/")
}
}
// Type returns the Telegram channel type.
func (*TelegramAdapter) Type() channel.ChannelType {
return Type
}
// Descriptor returns the Telegram channel metadata.
func (*TelegramAdapter) Descriptor() channel.Descriptor {
return channel.Descriptor{
Type: Type,
DisplayName: "Telegram",
Capabilities: channel.ChannelCapabilities{
Text: true,
Markdown: true,
Reply: true,
Attachments: true,
Media: true,
Streaming: true,
BlockStreaming: true,
},
ConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"botToken": {
Type: channel.FieldSecret,
Required: true,
Title: "Bot Token",
},
"apiBaseURL": {
Type: channel.FieldString,
Required: false,
Title: "API Base URL",
Description: "Reverse proxy base URL for the Telegram Bot API. Required in regions where Telegram is blocked (e.g. China mainland). Default: https://api.telegram.org",
},
},
},
UserConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"username": {Type: channel.FieldString},
"user_id": {Type: channel.FieldString},
"chat_id": {Type: channel.FieldString},
},
},
TargetSpec: channel.TargetSpec{
Format: "chat_id | @username",
Hints: []channel.TargetHint{
{Label: "Chat ID", Example: "123456789"},
{Label: "Username", Example: "@alice"},
},
},
}
}
// NormalizeConfig validates and normalizes a Telegram channel configuration map.
func (*TelegramAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
return normalizeConfig(raw)
}
// NormalizeUserConfig validates and normalizes a Telegram user-binding configuration map.
func (*TelegramAdapter) NormalizeUserConfig(raw map[string]any) (map[string]any, error) {
return normalizeUserConfig(raw)
}
// NormalizeTarget normalizes a Telegram delivery target string.
func (*TelegramAdapter) NormalizeTarget(raw string) string {
return normalizeTarget(raw)
}
// ResolveTarget derives a delivery target from a Telegram user-binding configuration.
func (*TelegramAdapter) ResolveTarget(userConfig map[string]any) (string, error) {
return resolveTarget(userConfig)
}
// MatchBinding reports whether a Telegram user binding matches the given criteria.
func (*TelegramAdapter) MatchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
return matchBinding(config, criteria)
}
// BuildUserConfig constructs a Telegram user-binding config from an Identity.
func (*TelegramAdapter) BuildUserConfig(identity channel.Identity) map[string]any {
return buildUserConfig(identity)
}
// Connect starts long-polling for Telegram updates and forwards messages to the handler.
func (a *TelegramAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler) (channel.Connection, error) {
if a.logger != nil {
a.logger.Info("start", slog.String("config_id", cfg.ID))
}
telegramCfg, err := parseConfig(cfg.Credentials)
if err != nil {
if a.logger != nil {
a.logger.Error("decode config failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
return nil, err
}
bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(telegramCfg.BotToken, telegramCfg.apiEndpoint())
if err != nil {
if a.logger != nil {
a.logger.Error("create bot failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
return nil, err
}
a.mu.Lock()
a.fileEndpoints[telegramCfg.BotToken] = telegramCfg.fileEndpoint()
a.mu.Unlock()
updateConfig := tgbotapi.NewUpdate(0)
updateConfig.Timeout = 30
updates := bot.GetUpdatesChan(updateConfig)
connCtx, cancel := context.WithCancel(ctx)
mediaGroups := make(map[string]*telegramMediaGroupBuffer)
var mediaGroupsMu sync.Mutex
flushMediaGroup := func(groupKey string) {
var batch []*tgbotapi.Message
mediaGroupsMu.Lock()
buffer, ok := mediaGroups[groupKey]
if ok {
delete(mediaGroups, groupKey)
batch = append(batch, buffer.messages...)
}
mediaGroupsMu.Unlock()
if !ok || len(batch) == 0 {
return
}
msg, ok := a.buildTelegramMediaGroupInboundMessage(bot, cfg, batch)
if !ok {
return
}
a.dispatchInbound(connCtx, cfg, handler, msg)
}
flushAllMediaGroups := func() {
mediaGroupsMu.Lock()
keys := make([]string, 0, len(mediaGroups))
for key, buffer := range mediaGroups {
keys = append(keys, key)
if buffer != nil && buffer.timer != nil {
buffer.timer.Stop()
}
}
mediaGroupsMu.Unlock()
for _, key := range keys {
flushMediaGroup(key)
}
}
flushMediaGroupsByChat := func(chatID int64) {
if chatID == 0 {
return
}
mediaGroupsMu.Lock()
keys := make([]string, 0, len(mediaGroups))
for key, buffer := range mediaGroups {
if !isTelegramMediaGroupForChat(key, chatID) {
continue
}
keys = append(keys, key)
if buffer != nil && buffer.timer != nil {
buffer.timer.Stop()
}
}
mediaGroupsMu.Unlock()
for _, key := range keys {
flushMediaGroup(key)
}
}
queueMediaGroup := func(msg *tgbotapi.Message) bool {
groupKey := telegramMediaGroupKey(msg)
if groupKey == "" {
return false
}
mediaGroupsMu.Lock()
buffer, ok := mediaGroups[groupKey]
if !ok {
buffer = &telegramMediaGroupBuffer{}
mediaGroups[groupKey] = buffer
}
buffer.messages = append(buffer.messages, msg)
if buffer.timer != nil {
buffer.timer.Stop()
}
buffer.timer = time.AfterFunc(telegramMediaGroupCollectWindow, func() {
flushMediaGroup(groupKey)
})
mediaGroupsMu.Unlock()
return true
}
go func() {
for {
select {
case <-connCtx.Done():
flushAllMediaGroups()
return
case update, ok := <-updates:
if !ok {
flushAllMediaGroups()
if a.logger != nil {
a.logger.Info("updates channel closed", slog.String("config_id", cfg.ID))
}
return
}
if update.Message == nil {
continue
}
if queueMediaGroup(update.Message) {
continue
}
flushMediaGroupsByChat(telegramChatID(update.Message))
msg, ok := a.buildTelegramInboundMessage(bot, cfg, update.Message)
if !ok {
continue
}
a.dispatchInbound(connCtx, cfg, handler, msg)
}
}
}()
stop := func(_ context.Context) error {
if a.logger != nil {
a.logger.Info("stop", slog.String("config_id", cfg.ID))
}
bot.StopReceivingUpdates()
cancel()
// Drain remaining updates so the library's polling goroutine can
// finish writing and exit. Without this, the in-flight long-poll
// HTTP request keeps the old getUpdates session alive, causing
// "Conflict: terminated by other getUpdates request" when a new
// connection starts with the same bot token.
for range updates {
}
return nil
}
return channel.NewConnection(cfg, stop), nil
}
func telegramMediaGroupKey(msg *tgbotapi.Message) string {
if msg == nil {
return ""
}
mediaGroupID := strings.TrimSpace(msg.MediaGroupID)
if mediaGroupID == "" {
return ""
}
chatID := telegramChatID(msg)
return fmt.Sprintf("%d:%s", chatID, mediaGroupID)
}
func telegramChatID(msg *tgbotapi.Message) int64 {
if msg == nil || msg.Chat == nil {
return 0
}
return msg.Chat.ID
}
func isTelegramMediaGroupForChat(groupKey string, chatID int64) bool {
if chatID == 0 || strings.TrimSpace(groupKey) == "" {
return false
}
return strings.HasPrefix(groupKey, fmt.Sprintf("%d:", chatID))
}
func (a *TelegramAdapter) dispatchInbound(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler, msg channel.InboundMessage) {
a.logTelegramInbound(cfg.ID, msg)
go func() {
if err := handler(ctx, cfg, msg); err != nil && a.logger != nil {
a.logger.Error("handle inbound failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
}()
}
func (a *TelegramAdapter) buildTelegramInboundMessage(bot *tgbotapi.BotAPI, cfg channel.ChannelConfig, raw *tgbotapi.Message) (channel.InboundMessage, bool) {
text := strings.TrimSpace(raw.Text)
caption := strings.TrimSpace(raw.Caption)
if text == "" && caption != "" {
text = caption
}
attachments := a.collectTelegramAttachments(bot, raw)
return a.toInboundTelegramMessage(bot, cfg, raw, text, attachments, nil)
}
func (a *TelegramAdapter) buildTelegramMediaGroupInboundMessage(
bot *tgbotapi.BotAPI,
cfg channel.ChannelConfig,
raw []*tgbotapi.Message,
) (channel.InboundMessage, bool) {
if len(raw) == 0 {
return channel.InboundMessage{}, false
}
items := make([]*tgbotapi.Message, 0, len(raw))
for _, msg := range raw {
if msg != nil {
items = append(items, msg)
}
}
if len(items) == 0 {
return channel.InboundMessage{}, false
}
sort.SliceStable(items, func(i, j int) bool {
return items[i].MessageID < items[j].MessageID
})
anchor := items[0]
text := ""
attachments := make([]channel.Attachment, 0, len(items))
isMentioned := false
isReplyToBot := false
botUsername := ""
botID := int64(0)
if bot != nil {
botUsername = bot.Self.UserName
botID = bot.Self.ID
}
for _, msg := range items {
candidate := strings.TrimSpace(msg.Text)
if candidate == "" {
candidate = strings.TrimSpace(msg.Caption)
}
if text == "" && candidate != "" {
text = candidate
anchor = msg
}
attachments = append(attachments, a.collectTelegramAttachments(bot, msg)...)
if !isMentioned {
isMentioned = isTelegramBotMentioned(msg, botUsername)
}
if !isReplyToBot {
isReplyToBot = msg.ReplyToMessage != nil &&
msg.ReplyToMessage.From != nil &&
msg.ReplyToMessage.From.ID == botID
}
}
metadata := map[string]any{
"is_mentioned": isMentioned,
"is_reply_to_bot": isReplyToBot,
"media_group_id": strings.TrimSpace(anchor.MediaGroupID),
"media_group_size": len(items),
}
return a.toInboundTelegramMessage(bot, cfg, anchor, text, attachments, metadata)
}
func (a *TelegramAdapter) toInboundTelegramMessage(
bot *tgbotapi.BotAPI,
_ channel.ChannelConfig,
raw *tgbotapi.Message,
text string,
attachments []channel.Attachment,
metadata map[string]any,
) (channel.InboundMessage, bool) {
if raw == nil {
return channel.InboundMessage{}, false
}
text = strings.TrimSpace(text)
if text == "" && len(attachments) == 0 {
return channel.InboundMessage{}, false
}
// Prepend quoted message context so the AI can see what is being replied to,
// and include quoted attachments so the LLM can see the actual media.
if raw.ReplyToMessage != nil {
if quotedText := a.buildTelegramQuotedText(raw.ReplyToMessage); quotedText != "" {
text = quotedText + "\n" + text
}
if quotedAttachments := a.collectTelegramAttachments(bot, raw.ReplyToMessage); len(quotedAttachments) > 0 {
attachments = append(attachments, quotedAttachments...)
}
}
// Prepend forward origin so the AI knows where the message was forwarded from.
if forwardCtx := buildTelegramForwardContext(raw); forwardCtx != "" {
text = forwardCtx + "\n" + text
}
subjectID, displayName, attrs := resolveTelegramSender(raw)
chatID := ""
chatType := ""
chatName := ""
if raw.Chat != nil {
chatID = strconv.FormatInt(raw.Chat.ID, 10)
chatType = strings.TrimSpace(raw.Chat.Type)
chatName = strings.TrimSpace(raw.Chat.Title)
}
replyRef := buildTelegramReplyRef(raw, chatID)
botUsername := ""
botID := int64(0)
if bot != nil {
botUsername = bot.Self.UserName
botID = bot.Self.ID
}
isReplyToBot := raw.ReplyToMessage != nil &&
raw.ReplyToMessage.From != nil &&
raw.ReplyToMessage.From.ID == botID
isMentioned := isTelegramBotMentioned(raw, botUsername)
meta := map[string]any{
"is_mentioned": isMentioned,
"is_reply_to_bot": isReplyToBot,
}
for key, value := range metadata {
meta[key] = value
}
mentionParts := extractTelegramMentionParts(raw)
return channel.InboundMessage{
Channel: Type,
Message: channel.Message{
ID: strconv.Itoa(raw.MessageID),
Format: channel.MessageFormatPlain,
Text: text,
Parts: mentionParts,
Attachments: attachments,
Reply: replyRef,
},
ReplyTarget: chatID,
Sender: channel.Identity{
SubjectID: subjectID,
DisplayName: displayName,
Attributes: attrs,
},
Conversation: channel.Conversation{
ID: chatID,
Type: chatType,
Name: chatName,
},
ReceivedAt: time.Unix(int64(raw.Date), 0).UTC(),
Source: "telegram",
Metadata: meta,
}, true
}
func (a *TelegramAdapter) logTelegramInbound(configID string, msg channel.InboundMessage) {
if a.logger == nil {
return
}
a.logger.Info(
"inbound received",
slog.String("config_id", configID),
slog.String("chat_type", msg.Conversation.Type),
slog.String("chat_id", msg.Conversation.ID),
slog.String("user_id", msg.Sender.Attribute("user_id")),
slog.String("username", msg.Sender.Attribute("username")),
slog.String("text", common.SummarizeText(msg.Message.Text)),
slog.Int("attachments", len(msg.Message.Attachments)),
)
}
// Send delivers an outbound message to Telegram, handling text, attachments, and replies.
func (a *TelegramAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg channel.OutboundMessage) error {
telegramCfg, err := parseConfig(cfg.Credentials)
if err != nil {
if a.logger != nil {
a.logger.Error("decode config failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
return err
}
to := strings.TrimSpace(msg.Target)
if to == "" {
return errors.New("telegram target is required")
}
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
if err != nil {
return err
}
if msg.Message.IsEmpty() {
return errors.New("message is required")
}
text := strings.TrimSpace(msg.Message.PlainText())
text, parseMode := formatTelegramOutput(text, msg.Message.Format)
replyTo := parseReplyToMessageID(msg.Message.Reply)
if len(msg.Message.Attachments) > 0 {
usedCaption := false
for i, att := range msg.Message.Attachments {
caption := ""
if !usedCaption && text != "" {
caption = text
usedCaption = true
}
applyReply := replyTo
if i > 0 {
applyReply = 0
}
if err := sendTelegramAttachmentWithAssets(ctx, bot, to, att, caption, applyReply, parseMode, a.assets); err != nil {
if a.logger != nil {
a.logger.Error("send attachment failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
return err
}
}
if text != "" && !usedCaption {
return sendTelegramText(bot, to, text, replyTo, parseMode)
}
return nil
}
return sendTelegramText(bot, to, text, replyTo, parseMode)
}
// OpenStream opens a Telegram streaming session.
// For private chats, uses sendMessageDraft to stream partial content with smooth
// animation, then sends a final permanent message via sendMessage.
// For group/channel chats, sends one message then edits it in place as deltas
// arrive (editMessageText), avoiding one message per delta and rate limits.
func (a *TelegramAdapter) OpenStream(ctx context.Context, cfg channel.ChannelConfig, target string, opts channel.StreamOptions) (channel.OutboundStream, error) {
target = strings.TrimSpace(target)
if target == "" {
return nil, errors.New("telegram target is required")
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
isPrivateChat := false
var chatID int64
if opts.Metadata != nil {
if ct, ok := opts.Metadata["conversation_type"].(string); ok && ct == "private" {
if parsed, err := strconv.ParseInt(target, 10, 64); err == nil {
isPrivateChat = true
chatID = parsed
}
}
}
return &telegramOutboundStream{
adapter: a,
cfg: cfg,
target: target,
reply: opts.Reply,
parseMode: "",
isPrivateChat: isPrivateChat,
streamChatID: chatID,
draftID: 1,
}, nil
}
func resolveTelegramSender(msg *tgbotapi.Message) (string, string, map[string]string) {
attrs := map[string]string{}
if msg == nil {
return "", "", attrs
}
if msg.Chat != nil {
attrs["chat_id"] = strconv.FormatInt(msg.Chat.ID, 10)
}
if msg.From != nil {
userID := strconv.FormatInt(msg.From.ID, 10)
username := strings.TrimSpace(msg.From.UserName)
if userID != "" {
attrs["user_id"] = userID
}
if username != "" {
attrs["username"] = username
}
displayName := resolveTelegramDisplayName(msg.From)
externalID := userID
if externalID == "" {
externalID = username
}
return externalID, displayName, attrs
}
if msg.SenderChat != nil {
senderChatID := strconv.FormatInt(msg.SenderChat.ID, 10)
if senderChatID != "" {
attrs["sender_chat_id"] = senderChatID
}
if msg.SenderChat.UserName != "" {
attrs["sender_chat_username"] = strings.TrimSpace(msg.SenderChat.UserName)
}
if msg.SenderChat.Title != "" {
attrs["sender_chat_title"] = strings.TrimSpace(msg.SenderChat.Title)
}
displayName := strings.TrimSpace(msg.SenderChat.Title)
if displayName == "" {
displayName = strings.TrimSpace(msg.SenderChat.UserName)
}
externalID := senderChatID
if externalID == "" {
externalID = attrs["sender_chat_username"]
}
if externalID == "" {
externalID = attrs["chat_id"]
}
return externalID, displayName, attrs
}
return "", "", attrs
}
func parseReplyToMessageID(reply *channel.ReplyRef) int {
if reply == nil {
return 0
}
raw := strings.TrimSpace(reply.MessageID)
if raw == "" {
return 0
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0
}
return value
}
func sendTelegramText(bot *tgbotapi.BotAPI, target string, text string, replyTo int, parseMode string) error {
_, _, err := sendTelegramTextReturnMessage(bot, target, text, replyTo, parseMode)
return err
}
var sendTextForTest func(bot *tgbotapi.BotAPI, target string, text string, replyTo int, parseMode string) (int64, int, error)
// sendTelegramTextReturnMessage sends a text message and returns the chat ID and message ID for later editing.
func sendTelegramTextReturnMessage(bot *tgbotapi.BotAPI, target string, text string, replyTo int, parseMode string) (chatID int64, messageID int, err error) {
text = truncateTelegramText(sanitizeTelegramText(text))
if sendTextForTest != nil {
return sendTextForTest(bot, target, text, replyTo, parseMode)
}
var sent tgbotapi.Message
if strings.HasPrefix(target, "@") {
message := tgbotapi.NewMessageToChannel(target, text)
message.ParseMode = parseMode
if replyTo > 0 {
message.ReplyToMessageID = replyTo
}
sent, err = bot.Send(message)
if err != nil {
return 0, 0, err
}
} else {
chatID, err = strconv.ParseInt(target, 10, 64)
if err != nil {
return 0, 0, errors.New("telegram target must be @username or chat_id")
}
message := tgbotapi.NewMessage(chatID, text)
message.ParseMode = parseMode
if replyTo > 0 {
message.ReplyToMessageID = replyTo
}
sent, err = bot.Send(message)
if err != nil {
return 0, 0, err
}
}
if sent.Chat != nil {
chatID = sent.Chat.ID
}
messageID = sent.MessageID
return chatID, messageID, nil
}
var sendEditForTest func(bot *tgbotapi.BotAPI, edit tgbotapi.EditMessageTextConfig) error
// editTelegramMessageText sends an edit request. It handles "message is not modified"
// silently but returns 429 and other errors to the caller for higher-level retry decisions.
func editTelegramMessageText(bot *tgbotapi.BotAPI, chatID int64, messageID int, text string, parseMode string) error {
text = truncateTelegramText(sanitizeTelegramText(text))
edit := tgbotapi.NewEditMessageText(chatID, messageID, text)
edit.ParseMode = parseMode
send := sendEditForTest
if send == nil {
send = func(b *tgbotapi.BotAPI, e tgbotapi.EditMessageTextConfig) error { _, err := b.Send(e); return err }
}
err := send(bot, edit)
if err != nil && isTelegramMessageNotModified(err) {
return nil
}
return err
}
var sendDraftForTest func(bot *tgbotapi.BotAPI, chatID int64, draftID int, text string, parseMode string) error
// sendTelegramDraft calls the sendMessageDraft Bot API method to stream a
// partial message to a private chat while it is being generated.
func sendTelegramDraft(bot *tgbotapi.BotAPI, chatID int64, draftID int, text string, parseMode string) error {
text = truncateTelegramText(sanitizeTelegramText(text))
if strings.TrimSpace(text) == "" {
return nil
}
if sendDraftForTest != nil {
return sendDraftForTest(bot, chatID, draftID, text, parseMode)
}
params := tgbotapi.Params{}
_ = params.AddFirstValid("chat_id", chatID)
params.AddNonZero("draft_id", draftID)
params.AddNonEmpty("text", text)
params.AddNonEmpty("parse_mode", parseMode)
_, err := bot.MakeRequest("sendMessageDraft", params)
return err
}
func isTelegramMessageNotModified(err error) bool {
if err == nil {
return false
}
var apiErr tgbotapi.Error
if errors.As(err, &apiErr) {
return apiErr.Code == 400 && strings.Contains(apiErr.Message, "message is not modified")
}
return false
}
func isTelegramTooManyRequests(err error) bool {
if err == nil {
return false
}
var apiErr tgbotapi.Error
if errors.As(err, &apiErr) {
return apiErr.Code == 429
}
return false
}
func getTelegramRetryAfter(err error) time.Duration {
if err == nil {
return 0
}
var apiErr tgbotapi.Error
if errors.As(err, &apiErr) && apiErr.RetryAfter > 0 {
return time.Duration(apiErr.RetryAfter) * time.Second
}
return 0
}
func sendTelegramAttachmentWithAssets(ctx context.Context, bot *tgbotapi.BotAPI, target string, att channel.Attachment, caption string, replyTo int, parseMode string, opener assetOpener) error {
return sendTelegramAttachmentImpl(ctx, bot, target, att, caption, replyTo, parseMode, opener)
}
func sendTelegramAttachmentImpl(ctx context.Context, bot *tgbotapi.BotAPI, target string, att channel.Attachment, caption string, replyTo int, parseMode string, opener assetOpener) error {
urlRef := strings.TrimSpace(att.URL)
keyRef := strings.TrimSpace(att.PlatformKey)
sourcePlatform := strings.TrimSpace(att.SourcePlatform)
base64Ref := strings.TrimSpace(att.Base64)
assetID := strings.TrimSpace(att.ContentHash)
if urlRef == "" && keyRef == "" && base64Ref == "" && assetID == "" {
return errors.New("attachment reference is required")
}
if strings.TrimSpace(caption) == "" && strings.TrimSpace(att.Caption) != "" {
caption = strings.TrimSpace(att.Caption)
}
var botID string
if att.Metadata != nil {
if bid, ok := att.Metadata["bot_id"].(string); ok {
botID = bid
}
}
file, err := resolveTelegramFile(ctx, urlRef, keyRef, base64Ref, sourcePlatform, att, assetID, botID, opener)
if err != nil {
return err
}
isChannel := strings.HasPrefix(target, "@")
switch att.Type {
case channel.AttachmentImage:
var photo tgbotapi.PhotoConfig
if isChannel {
photo = tgbotapi.NewPhotoToChannel(target, file)
} else {
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return errors.New("telegram target must be @username or chat_id")
}
photo = tgbotapi.NewPhoto(chatID, file)
}
photo.Caption = caption
photo.ParseMode = parseMode
if replyTo > 0 {
photo.ReplyToMessageID = replyTo
}
_, err := bot.Send(photo)
return err
case channel.AttachmentFile, "":
var document tgbotapi.DocumentConfig
if isChannel {
document = tgbotapi.DocumentConfig{
BaseFile: tgbotapi.BaseFile{
BaseChat: tgbotapi.BaseChat{ChannelUsername: target},
File: file,
},
}
} else {
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return errors.New("telegram target must be @username or chat_id")
}
document = tgbotapi.NewDocument(chatID, file)
}
document.Caption = caption
document.ParseMode = parseMode
if replyTo > 0 {
document.ReplyToMessageID = replyTo
}
_, sendErr := bot.Send(document)
return sendErr
case channel.AttachmentAudio:
audio, err := buildTelegramAudio(target, file)
if err != nil {
return err
}
audio.Caption = caption
audio.ParseMode = parseMode
if replyTo > 0 {
audio.ReplyToMessageID = replyTo
}
_, err = bot.Send(audio)
return err
case channel.AttachmentVoice:
voice, err := buildTelegramVoice(target, file)
if err != nil {
return err
}
voice.Caption = caption
voice.ParseMode = parseMode
if replyTo > 0 {
voice.ReplyToMessageID = replyTo
}
_, err = bot.Send(voice)
return err
case channel.AttachmentVideo:
video, err := buildTelegramVideo(target, file)
if err != nil {
return err
}
video.Caption = caption
video.ParseMode = parseMode
if replyTo > 0 {
video.ReplyToMessageID = replyTo
}
_, err = bot.Send(video)
return err
case channel.AttachmentGIF:
animation, err := buildTelegramAnimation(target, file)
if err != nil {
return err
}
animation.Caption = caption
animation.ParseMode = parseMode
if replyTo > 0 {
animation.ReplyToMessageID = replyTo
}
_, err = bot.Send(animation)
return err
default:
return fmt.Errorf("unsupported attachment type: %s", att.Type)
}
}
// resolveTelegramFile determines the best tgbotapi.RequestFileData for an attachment.
// Priority: PlatformKey > ContentHash (storage) > public URL > base64 data URL.
func resolveTelegramFile(ctx context.Context, urlRef, keyRef, base64Ref, sourcePlatform string, att channel.Attachment, assetID, botID string, opener assetOpener) (tgbotapi.RequestFileData, error) {
if keyRef != "" && (sourcePlatform == "" || strings.EqualFold(sourcePlatform, Type.String())) {
return tgbotapi.FileID(keyRef), nil
}
if assetID != "" && opener != nil {
reader, asset, err := opener.Open(ctx, botID, assetID)
if err == nil {
data, readErr := io.ReadAll(io.LimitReader(reader, media.MaxAssetBytes+1))
_ = reader.Close()
if readErr == nil && len(data) > 0 {
name := strings.TrimSpace(att.Name)
if name == "" {
name = fileNameFromMime(asset.Mime, string(att.Type))
}
return tgbotapi.FileBytes{Name: name, Bytes: data}, nil
}
}
}
if urlRef != "" && !strings.HasPrefix(strings.ToLower(urlRef), "data:") && !strings.HasPrefix(urlRef, "/") {
return tgbotapi.FileURL(urlRef), nil
}
raw := base64Ref
if raw == "" {
raw = urlRef
}
if raw != "" && strings.HasPrefix(strings.ToLower(raw), "data:") {
decoded, err := decodeDataURLBytes(raw)
if err != nil {
return nil, fmt.Errorf("decode data url for telegram upload: %w", err)
}
name := strings.TrimSpace(att.Name)
if name == "" {
name = fileNameFromMime(att.Mime, string(att.Type))
}
return tgbotapi.FileBytes{Name: name, Bytes: decoded}, nil
}
if urlRef != "" {
return tgbotapi.FileURL(urlRef), nil
}
return nil, errors.New("no usable attachment reference for telegram")
}
func decodeDataURLBytes(dataURL string) ([]byte, error) {
value := dataURL
if idx := strings.Index(value, ","); idx >= 0 {
value = value[idx+1:]
}
return io.ReadAll(io.LimitReader(
base64StdDecoder(strings.NewReader(value)),
media.MaxAssetBytes+1,
))
}
func base64StdDecoder(r io.Reader) io.Reader {
return base64.NewDecoder(base64.StdEncoding, r)
}
func fileNameFromMime(mime, fallbackType string) string {
mime = strings.ToLower(strings.TrimSpace(mime))
switch {
case strings.HasPrefix(mime, "image/png"):
return "image.png"
case strings.HasPrefix(mime, "image/jpeg"), strings.HasPrefix(mime, "image/jpg"):
return "image.jpg"
case strings.HasPrefix(mime, "image/gif"):
return "image.gif"
case strings.HasPrefix(mime, "image/webp"):
return "image.webp"
case strings.HasPrefix(mime, "audio/"):
return "audio.mp3"
case strings.HasPrefix(mime, "video/"):
return "video.mp4"
default:
if strings.TrimSpace(fallbackType) == "image" {
return "image.png"
}
return "file.bin"
}
}
func buildTelegramReplyRef(msg *tgbotapi.Message, chatID string) *channel.ReplyRef {
if msg == nil || msg.ReplyToMessage == nil {
return nil
}
return &channel.ReplyRef{
MessageID: strconv.Itoa(msg.ReplyToMessage.MessageID),
Target: strings.TrimSpace(chatID),
}
}
const telegramQuotedTextMaxLength = 200
// buildTelegramQuotedText extracts a textual summary of the replied-to message
// so the AI can see what message the user is replying to.
// Returns an empty string when no useful context can be extracted.
func (a *TelegramAdapter) buildTelegramQuotedText(replyTo *tgbotapi.Message) string {
if replyTo == nil {
return ""
}
senderName := resolveTelegramDisplayName(replyTo.From)
text := strings.TrimSpace(replyTo.Text)
if text == "" {
text = strings.TrimSpace(replyTo.Caption)
}
if text == "" {
// Reuse collectTelegramAttachments with nil bot to detect content
// types without making API calls to resolve file URLs.
attachments := a.collectTelegramAttachments(nil, replyTo)
if len(attachments) > 0 {
types := make([]string, 0, len(attachments))
for _, att := range attachments {
types = append(types, string(att.Type))
}
text = "[" + strings.Join(types, ", ") + "]"
}
}
if text == "" {
return ""
}
if len([]rune(text)) > telegramQuotedTextMaxLength {
text = string([]rune(text)[:telegramQuotedTextMaxLength]) + "..."
}
if senderName != "" {
return fmt.Sprintf("[Reply to %s: %s]", senderName, text)
}
return fmt.Sprintf("[Reply to: %s]", text)
}
// resolveTelegramDisplayName returns a display name for a Telegram user.
// Format: "FirstName LastName (@username)" when both are available,
// "FirstName LastName" when only name is set, "@username" when only username is set.
func resolveTelegramDisplayName(user *tgbotapi.User) string {
if user == nil {
return ""
}
name := strings.TrimSpace(user.FirstName + " " + user.LastName)
username := strings.TrimSpace(user.UserName)
if name != "" && username != "" {
return name + " (@" + username + ")"
}
if name != "" {
return name
}
if username != "" {
return "@" + username
}
return ""
}
// buildTelegramForwardContext returns a text prefix describing the forward origin.
// Handles three cases: forwarded from a user, from a channel, or from a hidden sender.
// Returns an empty string when the message is not forwarded.
func buildTelegramForwardContext(msg *tgbotapi.Message) string {
if msg == nil {
return ""
}
if msg.ForwardFrom != nil {
name := resolveTelegramDisplayName(msg.ForwardFrom)
if name != "" {
return fmt.Sprintf("[Forwarded from %s]", name)
}
}
if msg.ForwardFromChat != nil {
title := strings.TrimSpace(msg.ForwardFromChat.Title)
username := strings.TrimSpace(msg.ForwardFromChat.UserName)
if title != "" && username != "" {
return fmt.Sprintf("[Forwarded from %s (@%s)]", title, username)
}
if title != "" {
return fmt.Sprintf("[Forwarded from %s]", title)
}
if username != "" {
return fmt.Sprintf("[Forwarded from @%s]", username)
}
}
senderName := strings.TrimSpace(msg.ForwardSenderName)
if senderName != "" {
return fmt.Sprintf("[Forwarded from %s]", senderName)
}
return ""
}
func buildTelegramAudio(target string, file tgbotapi.RequestFileData) (tgbotapi.AudioConfig, error) {
if strings.HasPrefix(target, "@") {
audio := tgbotapi.NewAudio(0, file)
audio.ChannelUsername = target
return audio, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return tgbotapi.AudioConfig{}, errors.New("telegram target must be @username or chat_id")
}
return tgbotapi.NewAudio(chatID, file), nil
}
func buildTelegramVoice(target string, file tgbotapi.RequestFileData) (tgbotapi.VoiceConfig, error) {
if strings.HasPrefix(target, "@") {
voice := tgbotapi.NewVoice(0, file)
voice.ChannelUsername = target
return voice, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return tgbotapi.VoiceConfig{}, errors.New("telegram target must be @username or chat_id")
}
return tgbotapi.NewVoice(chatID, file), nil
}
func buildTelegramVideo(target string, file tgbotapi.RequestFileData) (tgbotapi.VideoConfig, error) {
if strings.HasPrefix(target, "@") {
video := tgbotapi.NewVideo(0, file)
video.ChannelUsername = target
return video, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return tgbotapi.VideoConfig{}, errors.New("telegram target must be @username or chat_id")
}
return tgbotapi.NewVideo(chatID, file), nil
}
func buildTelegramAnimation(target string, file tgbotapi.RequestFileData) (tgbotapi.AnimationConfig, error) {
if strings.HasPrefix(target, "@") {
animation := tgbotapi.NewAnimation(0, file)
animation.ChannelUsername = target
return animation, nil
}
chatID, err := strconv.ParseInt(target, 10, 64)
if err != nil {
return tgbotapi.AnimationConfig{}, errors.New("telegram target must be @username or chat_id")
}
return tgbotapi.NewAnimation(chatID, file), nil
}
func resolveTelegramParseMode(format channel.MessageFormat) string {
switch format {
case channel.MessageFormatMarkdown:
return tgbotapi.ModeMarkdown
default:
return ""
}
}
// extractTelegramMentionParts extracts structured mention parts from Telegram message entities.
func extractTelegramMentionParts(msg *tgbotapi.Message) []channel.MessagePart {
if msg == nil {
return nil
}
text := msg.Text
if text == "" {
text = msg.Caption
}
entities := make([]tgbotapi.MessageEntity, 0, len(msg.Entities)+len(msg.CaptionEntities))
entities = append(entities, msg.Entities...)
entities = append(entities, msg.CaptionEntities...)
var parts []channel.MessagePart
for _, entity := range entities {
switch entity.Type {
case "mention":
if text != "" && entity.Offset >= 0 && entity.Offset+entity.Length <= len([]rune(text)) {
runes := []rune(text)
mentionText := string(runes[entity.Offset : entity.Offset+entity.Length])
parts = append(parts, channel.MessagePart{
Type: channel.MessagePartMention,
Text: mentionText,
})
}
case "text_mention":
if entity.User != nil {
name := strings.TrimSpace(entity.User.FirstName + " " + entity.User.LastName)
if name == "" {
name = entity.User.UserName
}
displayText := "@" + name
meta := map[string]any{
"user_id": strconv.FormatInt(entity.User.ID, 10),
}
if entity.User.UserName != "" {
meta["username"] = entity.User.UserName
}
parts = append(parts, channel.MessagePart{
Type: channel.MessagePartMention,
Text: displayText,
Metadata: meta,
})
}
}
}
return parts
}
func isTelegramBotMentioned(msg *tgbotapi.Message, botUsername string) bool {
if msg == nil {
return false
}
normalizedBot := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(botUsername), "@"))
if normalizedBot != "" {
text := strings.TrimSpace(msg.Text)
if text == "" {
text = strings.TrimSpace(msg.Caption)
}
if text != "" {
if strings.Contains(strings.ToLower(text), "@"+normalizedBot) {
return true
}
}
}
entities := make([]tgbotapi.MessageEntity, 0, len(msg.Entities)+len(msg.CaptionEntities))
entities = append(entities, msg.Entities...)
entities = append(entities, msg.CaptionEntities...)
for _, entity := range entities {
if entity.Type == "text_mention" && entity.User != nil && entity.User.IsBot {
return true
}
}
return false
}
func (a *TelegramAdapter) collectTelegramAttachments(bot *tgbotapi.BotAPI, msg *tgbotapi.Message) []channel.Attachment {
if msg == nil {
return nil
}
attachments := make([]channel.Attachment, 0, 1)
if len(msg.Photo) > 0 {
photo := pickTelegramPhoto(msg.Photo)
att := a.buildTelegramAttachment(bot, channel.AttachmentImage, photo.FileID, "", "", int64(photo.FileSize))
att.Width = photo.Width
att.Height = photo.Height
attachments = append(attachments, att)
}
if msg.Document != nil {
att := a.buildTelegramAttachment(bot, channel.AttachmentFile, msg.Document.FileID, msg.Document.FileName, msg.Document.MimeType, int64(msg.Document.FileSize))
attachments = append(attachments, att)
}
if msg.Audio != nil {
att := a.buildTelegramAttachment(bot, channel.AttachmentAudio, msg.Audio.FileID, msg.Audio.FileName, msg.Audio.MimeType, int64(msg.Audio.FileSize))
att.DurationMs = int64(msg.Audio.Duration) * 1000
attachments = append(attachments, att)
}
if msg.Voice != nil {
att := a.buildTelegramAttachment(bot, channel.AttachmentVoice, msg.Voice.FileID, "", msg.Voice.MimeType, int64(msg.Voice.FileSize))
att.DurationMs = int64(msg.Voice.Duration) * 1000
attachments = append(attachments, att)
}
if msg.Video != nil {
att := a.buildTelegramAttachment(bot, channel.AttachmentVideo, msg.Video.FileID, msg.Video.FileName, msg.Video.MimeType, int64(msg.Video.FileSize))
att.Width = msg.Video.Width
att.Height = msg.Video.Height
att.DurationMs = int64(msg.Video.Duration) * 1000
attachments = append(attachments, att)
}
if msg.Animation != nil {
att := a.buildTelegramAttachment(bot, channel.AttachmentGIF, msg.Animation.FileID, msg.Animation.FileName, msg.Animation.MimeType, int64(msg.Animation.FileSize))
att.Width = msg.Animation.Width
att.Height = msg.Animation.Height
att.DurationMs = int64(msg.Animation.Duration) * 1000
attachments = append(attachments, att)
}
if msg.Sticker != nil {
att := a.buildTelegramAttachment(bot, channel.AttachmentImage, msg.Sticker.FileID, "", "", int64(msg.Sticker.FileSize))
att.Width = msg.Sticker.Width
att.Height = msg.Sticker.Height
attachments = append(attachments, att)
}
caption := strings.TrimSpace(msg.Caption)
if caption != "" {
for i := range attachments {
attachments[i].Caption = caption
}
}
return attachments
}
func (a *TelegramAdapter) buildTelegramAttachment(bot *tgbotapi.BotAPI, attType channel.AttachmentType, fileID, name, mime string, size int64) channel.Attachment {
url := ""
if bot != nil && strings.TrimSpace(fileID) != "" {
value, err := a.getFileDirectURL(bot, fileID)
if err != nil {
if a.logger != nil {
a.logger.Warn("resolve file url failed", slog.Any("error", err))
}
} else {
url = value
}
}
att := channel.Attachment{
Type: attType,
URL: strings.TrimSpace(url),
PlatformKey: strings.TrimSpace(fileID),
SourcePlatform: Type.String(),
Name: strings.TrimSpace(name),
Mime: strings.TrimSpace(mime),
Size: size,
Metadata: map[string]any{},
}
if fileID != "" {
att.Metadata["file_id"] = fileID
}
return channel.NormalizeInboundChannelAttachment(att)
}
// ResolveAttachment resolves a Telegram attachment reference to a byte stream.
// It supports platform_key-based references and URL fallback.
func (a *TelegramAdapter) ResolveAttachment(ctx context.Context, cfg channel.ChannelConfig, attachment channel.Attachment) (channel.AttachmentPayload, error) {
fileID := strings.TrimSpace(attachment.PlatformKey)
if fileID == "" && strings.TrimSpace(attachment.URL) == "" {
return channel.AttachmentPayload{}, errors.New("telegram attachment requires platform_key or url")
}
telegramCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return channel.AttachmentPayload{}, err
}
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
if err != nil {
return channel.AttachmentPayload{}, err
}
downloadURL := strings.TrimSpace(attachment.URL)
if downloadURL == "" {
downloadURL, err = a.getFileDirectURL(bot, fileID)
if err != nil {
return channel.AttachmentPayload{}, fmt.Errorf("resolve telegram file url: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return channel.AttachmentPayload{}, fmt.Errorf("build download request: %w", err)
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req) //nolint:gosec // G704: URL is a Telegram file download URL from the Telegram Bot API
if err != nil {
return channel.AttachmentPayload{}, fmt.Errorf("download attachment: %w", err)
}
if resp.StatusCode != http.StatusOK {
defer func() {
_ = resp.Body.Close()
}()
_, _ = io.Copy(io.Discard, resp.Body)
return channel.AttachmentPayload{}, fmt.Errorf("download attachment status: %d", resp.StatusCode)
}
maxBytes := media.MaxAssetBytes
if resp.ContentLength > maxBytes {
defer func() {
_ = resp.Body.Close()
}()
_, _ = io.Copy(io.Discard, resp.Body)
return channel.AttachmentPayload{}, fmt.Errorf("%w: max %d bytes", media.ErrAssetTooLarge, maxBytes)
}
mime := strings.TrimSpace(attachment.Mime)
if mime == "" {
mime = strings.TrimSpace(resp.Header.Get("Content-Type"))
if idx := strings.Index(mime, ";"); idx >= 0 {
mime = strings.TrimSpace(mime[:idx])
}
}
size := attachment.Size
if size <= 0 && resp.ContentLength > 0 {
size = resp.ContentLength
}
return channel.AttachmentPayload{
Reader: resp.Body,
Mime: mime,
Name: strings.TrimSpace(attachment.Name),
Size: size,
}, nil
}
// DiscoverSelf retrieves the bot's own identity from the Telegram platform.
func (a *TelegramAdapter) DiscoverSelf(_ context.Context, credentials map[string]any) (map[string]any, string, error) {
cfg, err := parseConfig(credentials)
if err != nil {
return nil, "", err
}
bot, err := a.getOrCreateBot(cfg, "discover")
if err != nil {
return nil, "", fmt.Errorf("telegram discover self: %w", err)
}
identity := map[string]any{
"user_id": strconv.FormatInt(bot.Self.ID, 10),
"username": bot.Self.UserName,
}
name := strings.TrimSpace(bot.Self.FirstName + " " + bot.Self.LastName)
if name != "" {
identity["name"] = name
}
avatarURL := a.resolveUserAvatarURL(bot, bot.Self.ID)
if avatarURL != "" {
identity["avatar_url"] = avatarURL
}
return identity, strconv.FormatInt(bot.Self.ID, 10), nil
}
// resolveUserAvatarURL fetches the first profile photo for a Telegram user and returns a direct URL.
func (a *TelegramAdapter) resolveUserAvatarURL(bot *tgbotapi.BotAPI, userID int64) string {
photos, err := bot.GetUserProfilePhotos(tgbotapi.UserProfilePhotosConfig{
UserID: userID,
Limit: 1,
})
if err != nil || photos.TotalCount == 0 || len(photos.Photos) == 0 {
return ""
}
best := pickTelegramPhoto(photos.Photos[0])
if best.FileID == "" {
return ""
}
url, err := a.getFileDirectURL(bot, best.FileID)
if err != nil {
return ""
}
return url
}
func pickTelegramPhoto(items []tgbotapi.PhotoSize) tgbotapi.PhotoSize {
if len(items) == 0 {
return tgbotapi.PhotoSize{}
}
best := items[0]
for _, item := range items[1:] {
if item.FileSize > best.FileSize {
best = item
continue
}
if item.Width*item.Height > best.Width*best.Height {
best = item
}
}
return best
}
// sanitizeTelegramText ensures text is valid UTF-8 for the Telegram API.
// Strips invalid byte sequences and trailing incomplete multi-byte characters
// that may occur at streaming chunk boundaries.
func sanitizeTelegramText(text string) string {
if utf8.ValidString(text) {
return text
}
return strings.ToValidUTF8(text, "")
}
// truncateTelegramText truncates text to telegramMaxMessageLength on a valid
// UTF-8 rune boundary, appending "..." when truncation occurs.
func truncateTelegramText(text string) string {
return textutil.TruncateRunesWithSuffix(text, telegramMaxMessageLength, "...")
}
// ProcessingStarted sends a "typing" chat action to indicate processing.
func (a *TelegramAdapter) ProcessingStarted(_ context.Context, cfg channel.ChannelConfig, _ channel.InboundMessage, info channel.ProcessingStatusInfo) (channel.ProcessingStatusHandle, error) {
chatID := strings.TrimSpace(info.ReplyTarget)
if chatID == "" {
return channel.ProcessingStatusHandle{}, nil
}
telegramCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return channel.ProcessingStatusHandle{}, err
}
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
if err != nil {
return channel.ProcessingStatusHandle{}, err
}
if err := sendTelegramTyping(bot, chatID); err != nil && a.logger != nil {
a.logger.Warn("send typing action failed", slog.String("config_id", cfg.ID), slog.Any("error", err))
}
return channel.ProcessingStatusHandle{}, nil
}
// ProcessingCompleted is a no-op for Telegram (typing indicator clears automatically).
func (*TelegramAdapter) ProcessingCompleted(_ context.Context, _ channel.ChannelConfig, _ channel.InboundMessage, _ channel.ProcessingStatusInfo, _ channel.ProcessingStatusHandle) error {
return nil
}
// ProcessingFailed is a no-op for Telegram (typing indicator clears automatically).
func (*TelegramAdapter) ProcessingFailed(_ context.Context, _ channel.ChannelConfig, _ channel.InboundMessage, _ channel.ProcessingStatusInfo, _ channel.ProcessingStatusHandle, _ error) error {
return nil
}
func sendTelegramTyping(bot *tgbotapi.BotAPI, chatID string) error {
chatIDInt, err := strconv.ParseInt(chatID, 10, 64)
if err != nil {
return err
}
action := tgbotapi.NewChatAction(chatIDInt, tgbotapi.ChatTyping)
_, err = bot.Request(action)
return err
}
func setTelegramReaction(bot *tgbotapi.BotAPI, chatID, messageID, emoji string) error {
params := tgbotapi.Params{}
params.AddNonEmpty("chat_id", chatID)
params.AddNonEmpty("message_id", messageID)
params.AddNonEmpty("reaction", fmt.Sprintf(`[{"type":"emoji","emoji":"%s"}]`, emoji))
_, err := bot.MakeRequest("setMessageReaction", params)
return err
}
func clearTelegramReaction(bot *tgbotapi.BotAPI, chatID, messageID string) error {
params := tgbotapi.Params{}
params.AddNonEmpty("chat_id", chatID)
params.AddNonEmpty("message_id", messageID)
params.AddNonEmpty("reaction", "[]")
_, err := bot.MakeRequest("setMessageReaction", params)
return err
}
// React adds an emoji reaction to a message (implements channel.Reactor).
func (a *TelegramAdapter) React(_ context.Context, cfg channel.ChannelConfig, target string, messageID string, emoji string) error {
telegramCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
if err != nil {
return err
}
return setTelegramReaction(bot, target, messageID, emoji)
}
// Unreact removes the bot's reaction from a message (implements channel.Reactor).
// The emoji parameter is ignored; Telegram clears all bot reactions at once.
func (a *TelegramAdapter) Unreact(_ context.Context, cfg channel.ChannelConfig, target string, messageID string, _ string) error {
telegramCfg, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
if err != nil {
return err
}
return clearTelegramReaction(bot, target, messageID)
}