mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(telegram): support custom API base URL for reverse proxy setups (#160)
Allow configuring a custom Telegram Bot API base URL (`apiBaseURL`) per channel, enabling users behind restricted networks to route requests through a reverse proxy (e.g. Nginx, Cloudflare Workers). Both API calls and file downloads respect the configured endpoint. When omitted, the official https://api.telegram.org is used. Closes #159
This commit is contained in:
@@ -7,9 +7,30 @@ import (
|
||||
"github.com/memohai/memoh/internal/channel"
|
||||
)
|
||||
|
||||
const defaultAPIBaseURL = "https://api.telegram.org"
|
||||
|
||||
// Config holds the Telegram bot credentials extracted from a channel configuration.
|
||||
type Config struct {
|
||||
BotToken string
|
||||
BotToken string
|
||||
APIBaseURL string // Reverse proxy base URL for regions where Telegram is blocked (e.g. China mainland)
|
||||
}
|
||||
|
||||
// apiEndpoint returns the Sprintf-formatted API endpoint derived from the base URL.
|
||||
func (c Config) apiEndpoint() string {
|
||||
base := c.APIBaseURL
|
||||
if base == "" {
|
||||
base = defaultAPIBaseURL
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/bot%s/%s"
|
||||
}
|
||||
|
||||
// fileEndpoint returns the Sprintf-formatted file download endpoint derived from the base URL.
|
||||
func (c Config) fileEndpoint() string {
|
||||
base := c.APIBaseURL
|
||||
if base == "" {
|
||||
base = defaultAPIBaseURL
|
||||
}
|
||||
return strings.TrimRight(base, "/") + "/file/bot%s/%s"
|
||||
}
|
||||
|
||||
// UserConfig holds the identifiers used to target a Telegram user or group.
|
||||
@@ -24,9 +45,13 @@ func normalizeConfig(raw map[string]any) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
out := map[string]any{
|
||||
"botToken": cfg.BotToken,
|
||||
}, nil
|
||||
}
|
||||
if cfg.APIBaseURL != "" {
|
||||
out["apiBaseURL"] = cfg.APIBaseURL
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeUserConfig(raw map[string]any) (map[string]any, error) {
|
||||
@@ -109,7 +134,8 @@ func parseConfig(raw map[string]any) (Config, error) {
|
||||
if token == "" {
|
||||
return Config{}, fmt.Errorf("telegram botToken is required")
|
||||
}
|
||||
return Config{BotToken: token}, nil
|
||||
apiBaseURL := strings.TrimSpace(channel.ReadString(raw, "apiBaseURL", "api_base_url"))
|
||||
return Config{BotToken: token, APIBaseURL: apiBaseURL}, nil
|
||||
}
|
||||
|
||||
func parseUserConfig(raw map[string]any) (UserConfig, error) {
|
||||
@@ -156,9 +182,7 @@ func normalizeTarget(raw string) string {
|
||||
// which may be negative (e.g. supergroup IDs like -1002280927535).
|
||||
func isTelegramChatID(s string) bool {
|
||||
digits := s
|
||||
if strings.HasPrefix(digits, "-") {
|
||||
digits = digits[1:]
|
||||
}
|
||||
digits = strings.TrimPrefix(digits, "-")
|
||||
if len(digits) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ func (a *TelegramAdapter) ListGroups(ctx context.Context, cfg channel.ChannelCon
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ListGroupMembers returns administrators of the given group (Telegram only exposes admin list, not full members).
|
||||
// ListGroupMembers returns group managers for the given group (Telegram only exposes this list, not all members).
|
||||
func (a *TelegramAdapter) ListGroupMembers(ctx context.Context, cfg channel.ChannelConfig, groupID string, query channel.DirectoryQuery) ([]channel.DirectoryEntry, error) {
|
||||
telegramCfg, err := parseConfig(cfg.Credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID)
|
||||
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func (a *TelegramAdapter) ListGroupMembers(ctx context.Context, cfg channel.Chan
|
||||
}
|
||||
members, err := bot.GetChatAdministrators(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram get chat administrators: %w", err)
|
||||
return nil, fmt.Errorf("telegram get chat managers: %w", err)
|
||||
}
|
||||
limit := directoryLimit(query.Limit)
|
||||
entries := make([]channel.DirectoryEntry, 0, limit)
|
||||
@@ -66,7 +66,7 @@ func (a *TelegramAdapter) ListGroupMembers(ctx context.Context, cfg channel.Chan
|
||||
if m.User == nil {
|
||||
continue
|
||||
}
|
||||
e := telegramUserToEntry(m.User)
|
||||
e := a.telegramUserToEntryWithAvatar(bot, m.User)
|
||||
if query.Query != "" && !strings.Contains(strings.ToLower(e.Name+e.Handle), strings.ToLower(query.Query)) {
|
||||
continue
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func (a *TelegramAdapter) ResolveEntry(ctx context.Context, cfg channel.ChannelC
|
||||
if err != nil {
|
||||
return channel.DirectoryEntry{}, err
|
||||
}
|
||||
bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID)
|
||||
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
|
||||
if err != nil {
|
||||
return channel.DirectoryEntry{}, err
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func (a *TelegramAdapter) resolveTelegramUser(ctx context.Context, bot *tgbotapi
|
||||
if member.User == nil {
|
||||
return channel.DirectoryEntry{}, fmt.Errorf("telegram get chat member: empty user")
|
||||
}
|
||||
return telegramUserToEntry(member.User), nil
|
||||
return a.telegramUserToEntryWithAvatar(bot, member.User), nil
|
||||
}
|
||||
chatConfig := tgbotapi.ChatInfoConfig{ChatConfig: tgbotapi.ChatConfig{ChatID: chatID}}
|
||||
chat, err := bot.GetChat(chatConfig)
|
||||
@@ -136,10 +136,11 @@ func (a *TelegramAdapter) resolveTelegramUser(ctx context.Context, bot *tgbotapi
|
||||
}
|
||||
idStr := strconv.FormatInt(chat.ID, 10)
|
||||
return channel.DirectoryEntry{
|
||||
Kind: channel.DirectoryEntryUser,
|
||||
ID: idStr,
|
||||
Name: name,
|
||||
Handle: handle,
|
||||
Kind: channel.DirectoryEntryUser,
|
||||
ID: idStr,
|
||||
Name: name,
|
||||
Handle: handle,
|
||||
AvatarURL: a.resolveUserAvatarURL(bot, chat.ID),
|
||||
Metadata: map[string]any{
|
||||
"chat_id": idStr,
|
||||
"username": chat.UserName,
|
||||
@@ -168,15 +169,36 @@ func (a *TelegramAdapter) resolveTelegramGroup(ctx context.Context, bot *tgbotap
|
||||
if handle != "" && !strings.HasPrefix(handle, "@") {
|
||||
handle = "@" + handle
|
||||
}
|
||||
avatarURL := a.resolveChatPhotoURL(bot, chat.Photo)
|
||||
return channel.DirectoryEntry{
|
||||
Kind: channel.DirectoryEntryGroup,
|
||||
ID: idStr,
|
||||
Name: name,
|
||||
Handle: handle,
|
||||
AvatarURL: avatarURL,
|
||||
Metadata: map[string]any{"chat_id": idStr, "type": chat.Type},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveChatPhotoURL resolves a Telegram ChatPhoto to a direct URL.
|
||||
func (a *TelegramAdapter) resolveChatPhotoURL(bot *tgbotapi.BotAPI, photo *tgbotapi.ChatPhoto) string {
|
||||
if photo == nil {
|
||||
return ""
|
||||
}
|
||||
fileID := photo.BigFileID
|
||||
if fileID == "" {
|
||||
fileID = photo.SmallFileID
|
||||
}
|
||||
if fileID == "" {
|
||||
return ""
|
||||
}
|
||||
url, err := a.getFileDirectURL(bot, fileID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// parseTelegramChatInput parses input as chat_id (numeric) or @channel_username. Returns (chatID, superGroupUsername).
|
||||
func parseTelegramChatInput(input string) (chatID int64, superGroupUsername string) {
|
||||
input = strings.TrimSpace(input)
|
||||
@@ -215,6 +237,14 @@ func parseTelegramUserInput(input string) (chatID, userID int64) {
|
||||
return id, 0
|
||||
}
|
||||
|
||||
func (a *TelegramAdapter) telegramUserToEntryWithAvatar(bot *tgbotapi.BotAPI, u *tgbotapi.User) channel.DirectoryEntry {
|
||||
entry := telegramUserToEntry(u)
|
||||
if bot != nil && u != nil {
|
||||
entry.AvatarURL = a.resolveUserAvatarURL(bot, u.ID)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func telegramUserToEntry(u *tgbotapi.User) channel.DirectoryEntry {
|
||||
if u == nil {
|
||||
return channel.DirectoryEntry{Kind: channel.DirectoryEntryUser}
|
||||
|
||||
@@ -32,8 +32,8 @@ func Test_directoryLimit(t *testing.T) {
|
||||
|
||||
func Test_parseTelegramChatInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantID int64
|
||||
input string
|
||||
wantID int64
|
||||
wantUsername string
|
||||
}{
|
||||
{"123456789", 123456789, ""},
|
||||
|
||||
@@ -21,19 +21,18 @@ const telegramStreamPendingSuffix = "\n……"
|
||||
var testEditFunc func(bot *tgbotapi.BotAPI, chatID int64, msgID int, text string, parseMode string) error
|
||||
|
||||
type telegramOutboundStream struct {
|
||||
adapter *TelegramAdapter
|
||||
cfg channel.ChannelConfig
|
||||
target string
|
||||
reply *channel.ReplyRef
|
||||
parseMode string
|
||||
closed atomic.Bool
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
streamChatID int64
|
||||
streamMsgID int
|
||||
lastEdited string
|
||||
lastEditedAt time.Time
|
||||
lastTypingActionAt time.Time
|
||||
adapter *TelegramAdapter
|
||||
cfg channel.ChannelConfig
|
||||
target string
|
||||
reply *channel.ReplyRef
|
||||
parseMode string
|
||||
closed atomic.Bool
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
streamChatID int64
|
||||
streamMsgID int
|
||||
lastEdited string
|
||||
lastEditedAt time.Time
|
||||
}
|
||||
|
||||
func (s *telegramOutboundStream) getBot(ctx context.Context) (bot *tgbotapi.BotAPI, err error) {
|
||||
@@ -41,7 +40,7 @@ func (s *telegramOutboundStream) getBot(ctx context.Context) (bot *tgbotapi.BotA
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bot, err = s.adapter.getOrCreateBot(telegramCfg.BotToken, s.cfg.ID)
|
||||
bot, err = s.adapter.getOrCreateBot(telegramCfg, s.cfg.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -57,30 +56,25 @@ func (s *telegramOutboundStream) getBotAndReply(ctx context.Context) (bot *tgbot
|
||||
return bot, replyTo, nil
|
||||
}
|
||||
|
||||
func (s *telegramOutboundStream) refreshTypingAction(ctx context.Context) {
|
||||
func (s *telegramOutboundStream) refreshTypingAction(ctx context.Context) error {
|
||||
// When ensureStreamMessage is called, always means that the message has not been completely generated
|
||||
// so always refresh the "typing" action to improve the user experience
|
||||
s.mu.Lock()
|
||||
if time.Since(s.lastTypingActionAt) < telegramStreamEditThrottle {
|
||||
// typing action lasts for 5 seconds
|
||||
s.mu.Unlock()
|
||||
return
|
||||
bot, err := s.getBot(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.lastTypingActionAt = time.Now()
|
||||
chatID := s.streamChatID
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
bot, err := s.getBot(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
action := tgbotapi.NewChatAction(chatID, tgbotapi.ChatTyping)
|
||||
_, _ = bot.Request(action)
|
||||
}()
|
||||
action := tgbotapi.NewChatAction(s.streamChatID, tgbotapi.ChatTyping)
|
||||
_, err = bot.Request(action)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *telegramOutboundStream) ensureStreamMessage(ctx context.Context, text string) error {
|
||||
s.mu.Lock()
|
||||
go func() {
|
||||
if err := s.refreshTypingAction(ctx); err != nil {
|
||||
slog.Debug("refresh typing action failed", slog.Any("err", err))
|
||||
}
|
||||
}()
|
||||
if s.streamMsgID != 0 {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
@@ -227,7 +221,6 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.StreamE
|
||||
case channel.StreamEventStatus:
|
||||
return nil
|
||||
case channel.StreamEventToolCallStart:
|
||||
s.refreshTypingAction(ctx)
|
||||
s.mu.Lock()
|
||||
bufText := strings.TrimSpace(s.buf.String())
|
||||
hasMsg := s.streamMsgID != 0
|
||||
@@ -270,12 +263,24 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.StreamE
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case channel.StreamEventPhaseStart, channel.StreamEventPhaseEnd:
|
||||
case channel.StreamEventPhaseStart:
|
||||
return nil
|
||||
case channel.StreamEventPhaseEnd:
|
||||
if event.Phase == channel.StreamPhaseText {
|
||||
s.mu.Lock()
|
||||
finalText := strings.TrimSpace(s.buf.String())
|
||||
s.mu.Unlock()
|
||||
if finalText != "" {
|
||||
if err := s.ensureStreamMessage(ctx, finalText); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.editStreamMessageFinal(ctx, finalText)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case channel.StreamEventProcessingFailed, channel.StreamEventAgentStart, channel.StreamEventAgentEnd, channel.StreamEventProcessingStarted, channel.StreamEventProcessingCompleted:
|
||||
return nil
|
||||
case channel.StreamEventDelta:
|
||||
s.refreshTypingAction(ctx)
|
||||
if event.Delta == "" || event.Phase == channel.StreamPhaseReasoning {
|
||||
return nil
|
||||
}
|
||||
@@ -330,7 +335,7 @@ func (s *telegramOutboundStream) Push(ctx context.Context, event channel.StreamE
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot, err := s.adapter.getOrCreateBot(telegramCfg.BotToken, s.cfg.ID)
|
||||
bot, err := s.adapter.getOrCreateBot(telegramCfg, s.cfg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -37,10 +37,11 @@ type assetOpener interface {
|
||||
|
||||
// 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
|
||||
assets assetOpener
|
||||
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.
|
||||
@@ -49,8 +50,9 @@ func NewTelegramAdapter(log *slog.Logger) *TelegramAdapter {
|
||||
log = slog.Default()
|
||||
}
|
||||
adapter := &TelegramAdapter{
|
||||
logger: log.With(slog.String("adapter", "telegram")),
|
||||
bots: make(map[string]*tgbotapi.BotAPI),
|
||||
logger: log.With(slog.String("adapter", "telegram")),
|
||||
bots: make(map[string]*tgbotapi.BotAPI),
|
||||
fileEndpoints: make(map[string]string),
|
||||
}
|
||||
_ = tgbotapi.SetLogger(&slogBotLogger{log: adapter.logger})
|
||||
return adapter
|
||||
@@ -63,32 +65,49 @@ func (a *TelegramAdapter) SetAssetOpener(opener assetOpener) {
|
||||
|
||||
var getOrCreateBotForTest func(a *TelegramAdapter, token, configID string) (*tgbotapi.BotAPI, error)
|
||||
|
||||
func (a *TelegramAdapter) getOrCreateBot(token, configID string) (*tgbotapi.BotAPI, error) {
|
||||
func (a *TelegramAdapter) getOrCreateBot(cfg Config, configID string) (*tgbotapi.BotAPI, error) {
|
||||
if getOrCreateBotForTest != nil {
|
||||
return getOrCreateBotForTest(a, token, configID)
|
||||
return getOrCreateBotForTest(a, cfg.BotToken, configID)
|
||||
}
|
||||
a.mu.RLock()
|
||||
bot, ok := a.bots[token]
|
||||
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[token]; ok {
|
||||
if bot, ok := a.bots[cfg.BotToken]; ok {
|
||||
return bot, nil
|
||||
}
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
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[token] = bot
|
||||
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 fmt.Sprintf(endpoint, bot.Token, file.FilePath), nil
|
||||
}
|
||||
|
||||
// Type returns the Telegram channel type.
|
||||
func (a *TelegramAdapter) Type() channel.ChannelType {
|
||||
return Type
|
||||
@@ -116,6 +135,12 @@ func (a *TelegramAdapter) Descriptor() channel.Descriptor {
|
||||
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{
|
||||
@@ -178,13 +203,16 @@ func (a *TelegramAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
bot, err := tgbotapi.NewBotAPI(telegramCfg.BotToken)
|
||||
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)
|
||||
@@ -461,16 +489,18 @@ func (a *TelegramAdapter) toInboundTelegramMessage(
|
||||
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,
|
||||
},
|
||||
BotID: cfg.BotID,
|
||||
ReplyTarget: chatID,
|
||||
Sender: channel.Identity{
|
||||
SubjectID: subjectID,
|
||||
@@ -517,7 +547,7 @@ func (a *TelegramAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, m
|
||||
if to == "" {
|
||||
return fmt.Errorf("telegram target is required")
|
||||
}
|
||||
bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID)
|
||||
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -870,7 +900,7 @@ func resolveTelegramFile(urlRef, keyRef, base64Ref, sourcePlatform string, att c
|
||||
if keyRef != "" && (sourcePlatform == "" || strings.EqualFold(sourcePlatform, Type.String())) {
|
||||
return tgbotapi.FileID(keyRef), nil
|
||||
}
|
||||
if assetID != "" && botID != "" && opener != nil {
|
||||
if assetID != "" && opener != nil {
|
||||
reader, asset, err := opener.Open(context.Background(), botID, assetID)
|
||||
if err == nil {
|
||||
data, readErr := io.ReadAll(io.LimitReader(reader, media.MaxAssetBytes+1))
|
||||
@@ -1017,6 +1047,55 @@ func resolveTelegramParseMode(format channel.MessageFormat) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1102,7 +1181,7 @@ func (a *TelegramAdapter) collectTelegramAttachments(bot *tgbotapi.BotAPI, msg *
|
||||
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 := bot.GetFileDirectURL(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))
|
||||
@@ -1138,13 +1217,13 @@ func (a *TelegramAdapter) ResolveAttachment(ctx context.Context, cfg channel.Cha
|
||||
if err != nil {
|
||||
return channel.AttachmentPayload{}, err
|
||||
}
|
||||
bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID)
|
||||
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
|
||||
if err != nil {
|
||||
return channel.AttachmentPayload{}, err
|
||||
}
|
||||
downloadURL := strings.TrimSpace(attachment.URL)
|
||||
if downloadURL == "" {
|
||||
downloadURL, err = bot.GetFileDirectURL(fileID)
|
||||
downloadURL, err = a.getFileDirectURL(bot, fileID)
|
||||
if err != nil {
|
||||
return channel.AttachmentPayload{}, fmt.Errorf("resolve telegram file url: %w", err)
|
||||
}
|
||||
@@ -1192,6 +1271,51 @@ func (a *TelegramAdapter) ResolveAttachment(ctx context.Context, cfg channel.Cha
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DiscoverSelf retrieves the bot's own identity from the Telegram platform.
|
||||
func (a *TelegramAdapter) DiscoverSelf(ctx 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{}
|
||||
@@ -1244,7 +1368,7 @@ func (a *TelegramAdapter) ProcessingStarted(ctx context.Context, cfg channel.Cha
|
||||
if err != nil {
|
||||
return channel.ProcessingStatusHandle{}, err
|
||||
}
|
||||
bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID)
|
||||
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
|
||||
if err != nil {
|
||||
return channel.ProcessingStatusHandle{}, err
|
||||
}
|
||||
@@ -1298,7 +1422,7 @@ func (a *TelegramAdapter) React(ctx context.Context, cfg channel.ChannelConfig,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID)
|
||||
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1312,7 +1436,7 @@ func (a *TelegramAdapter) Unreact(ctx context.Context, cfg channel.ChannelConfig
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID)
|
||||
bot, err := a.getOrCreateBot(telegramCfg, cfg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ func TestBuildTelegramMediaGroupInboundMessageAggregatesAttachments(t *testing.T
|
||||
Token: "test",
|
||||
Self: tgbotapi.User{ID: 1001, UserName: "memohbot"},
|
||||
}
|
||||
cfg := channel.ChannelConfig{BotID: "bot-1"}
|
||||
cfg := channel.ChannelConfig{}
|
||||
first := &tgbotapi.Message{
|
||||
MessageID: 101,
|
||||
MediaGroupID: "group-1",
|
||||
@@ -411,6 +411,96 @@ func TestTelegramAdapter_NormalizeAndResolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Endpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
wantAPI string
|
||||
wantFile string
|
||||
}{
|
||||
{"default", "", "https://api.telegram.org/bot%s/%s", "https://api.telegram.org/file/bot%s/%s"},
|
||||
{"custom", "https://tg.example.com", "https://tg.example.com/bot%s/%s", "https://tg.example.com/file/bot%s/%s"},
|
||||
{"trailing slash", "https://tg.example.com/", "https://tg.example.com/bot%s/%s", "https://tg.example.com/file/bot%s/%s"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{BotToken: "tok", APIBaseURL: tt.baseURL}
|
||||
if got := cfg.apiEndpoint(); got != tt.wantAPI {
|
||||
t.Fatalf("apiEndpoint() = %q, want %q", got, tt.wantAPI)
|
||||
}
|
||||
if got := cfg.fileEndpoint(); got != tt.wantFile {
|
||||
t.Fatalf("fileEndpoint() = %q, want %q", got, tt.wantFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfig_APIBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("camelCase key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg, err := parseConfig(map[string]any{"botToken": "t1", "apiBaseURL": "https://proxy.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.APIBaseURL != "https://proxy.example.com" {
|
||||
t.Fatalf("unexpected APIBaseURL: %q", cfg.APIBaseURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("snake_case key", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg, err := parseConfig(map[string]any{"bot_token": "t2", "api_base_url": "https://proxy2.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.APIBaseURL != "https://proxy2.example.com" {
|
||||
t.Fatalf("unexpected APIBaseURL: %q", cfg.APIBaseURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty base URL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg, err := parseConfig(map[string]any{"botToken": "t3"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.APIBaseURL != "" {
|
||||
t.Fatalf("expected empty APIBaseURL, got %q", cfg.APIBaseURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeConfig_APIBaseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("present", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
norm, err := normalizeConfig(map[string]any{"botToken": "t1", "apiBaseURL": "https://proxy.example.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if norm["apiBaseURL"] != "https://proxy.example.com" {
|
||||
t.Fatalf("expected apiBaseURL in output: %#v", norm)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omitted when empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
norm, err := normalizeConfig(map[string]any{"botToken": "t2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := norm["apiBaseURL"]; exists {
|
||||
t.Fatalf("empty apiBaseURL should be omitted: %#v", norm)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsTelegramMessageNotModified(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user