diff --git a/internal/channel/adapters/telegram/config.go b/internal/channel/adapters/telegram/config.go index 9e807476..20d22033 100644 --- a/internal/channel/adapters/telegram/config.go +++ b/internal/channel/adapters/telegram/config.go @@ -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 } diff --git a/internal/channel/adapters/telegram/directory.go b/internal/channel/adapters/telegram/directory.go index 7901cdeb..26220b53 100644 --- a/internal/channel/adapters/telegram/directory.go +++ b/internal/channel/adapters/telegram/directory.go @@ -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} diff --git a/internal/channel/adapters/telegram/directory_test.go b/internal/channel/adapters/telegram/directory_test.go index ebaf313e..936b267e 100644 --- a/internal/channel/adapters/telegram/directory_test.go +++ b/internal/channel/adapters/telegram/directory_test.go @@ -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, ""}, diff --git a/internal/channel/adapters/telegram/stream.go b/internal/channel/adapters/telegram/stream.go index 991464d8..ea774a9d 100644 --- a/internal/channel/adapters/telegram/stream.go +++ b/internal/channel/adapters/telegram/stream.go @@ -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 } diff --git a/internal/channel/adapters/telegram/telegram.go b/internal/channel/adapters/telegram/telegram.go index 41bc5bae..58e9946f 100644 --- a/internal/channel/adapters/telegram/telegram.go +++ b/internal/channel/adapters/telegram/telegram.go @@ -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 } diff --git a/internal/channel/adapters/telegram/telegram_test.go b/internal/channel/adapters/telegram/telegram_test.go index 88b02aae..a5052478 100644 --- a/internal/channel/adapters/telegram/telegram_test.go +++ b/internal/channel/adapters/telegram/telegram_test.go @@ -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()