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:
BBQ
2026-03-02 15:04:20 +08:00
committed by GitHub
parent d3edd17d90
commit 802dfd995f
6 changed files with 350 additions and 77 deletions
+31 -7
View File
@@ -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
}
+40 -10
View File
@@ -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, ""},
+40 -35
View File
@@ -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
}
+146 -22
View File
@@ -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()