diff --git a/bridge.go b/bridge.go index 860b105..e685718 100644 --- a/bridge.go +++ b/bridge.go @@ -10,8 +10,6 @@ import ( "time" maxbot "github.com/max-messenger/max-bot-api-client-go" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) // Config — настройки bridge, читаемые из env. @@ -48,7 +46,7 @@ const ( type Bridge struct { cfg Config repo Repository - tgBot *tgbotapi.BotAPI + tg TGSender maxApi *maxbot.Api httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут) apiClient *http.Client // для коротких API-запросов (малый таймаут) @@ -69,15 +67,15 @@ type Bridge struct { } // NewBridge создаёт экземпляр Bridge. -func NewBridge(cfg Config, repo Repository, tgBot *tgbotapi.BotAPI, maxApi *maxbot.Api) *Bridge { +func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api) *Bridge { // Derive webhook secret from tokens (stable across restarts) - h := sha256.Sum256([]byte(cfg.MaxToken + tgBot.Token)) + h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken())) secret := hex.EncodeToString(h[:8]) return &Bridge{ cfg: cfg, repo: repo, - tgBot: tgBot, + tg: tg, maxApi: maxApi, httpClient: &http.Client{ Timeout: 5 * time.Minute, // для download/upload больших файлов @@ -161,12 +159,12 @@ func (b *Bridge) isUserAllowed(tgUserID int64) bool { // checkUserAllowed проверяет доступ пользователя и отправляет сообщение об отказе если нужно. // Возвращает true если доступ разрешён, false — если запрещён (и уже отправил ответ). // userID == 0 трактуется как «нет отправителя» — доступ запрещается. -func (b *Bridge) checkUserAllowed(chatID, userID int64) bool { +func (b *Bridge) checkUserAllowed(ctx context.Context, chatID, userID int64, threadID int) bool { if userID != 0 && b.isUserAllowed(userID) { return true } slog.Debug("TG user not allowed", "uid", userID) - b.tgBot.Send(tgbotapi.NewMessage(chatID, "У вас нет прав доступа к боту.")) + b.tg.SendMessage(ctx, chatID, "У вас нет прав доступа к боту.", &SendOpts{ThreadID: threadID}) return false } @@ -181,25 +179,21 @@ func (b *Bridge) isCrosspostOwner(maxChatID, userID int64) bool { } // tgFileURL возвращает прямой URL файла из TG — через custom API если настроен. -func (b *Bridge) tgFileURL(fileID string) (string, error) { - file, err := b.tgBot.GetFile(tgbotapi.FileConfig{FileID: fileID}) +func (b *Bridge) tgFileURL(ctx context.Context, fileID string) (string, error) { + filePath, err := b.tg.GetFile(ctx, fileID) if err != nil { return "", err } - if b.cfg.TgAPIURL != "" { - // --local mode: file_path — абсолютный путь, отдаём через nginx - return b.cfg.TgAPIURL + "/" + file.FilePath, nil - } - return file.Link(b.tgBot.Token), nil + return b.tg.GetFileDirectURL(filePath), nil } // tgChatTitle возвращает title TG-чата/канала по ID. Пустая строка если не удалось. -func (b *Bridge) tgChatTitle(chatID int64) string { - chat, err := b.tgBot.GetChat(tgbotapi.ChatInfoConfig{ChatConfig: tgbotapi.ChatConfig{ChatID: chatID}}) +func (b *Bridge) tgChatTitle(ctx context.Context, chatID int64) string { + title, err := b.tg.GetChat(ctx, chatID) if err != nil { return "" } - return chat.Title + return title } func (b *Bridge) tgWebhookPath() string { @@ -211,34 +205,24 @@ func (b *Bridge) maxWebhookPath() string { } // registerCommands регистрирует команды бота в Telegram. -func (b *Bridge) registerCommands() { - // Команды для групп и личных чатов - groupCmds := tgbotapi.NewSetMyCommands( - tgbotapi.BotCommand{Command: "bridge", Description: "Связать чат с MAX-чатом"}, - tgbotapi.BotCommand{Command: "unbridge", Description: "Удалить связку чатов"}, - tgbotapi.BotCommand{Command: "crosspost", Description: "Список связок кросспостинга"}, - tgbotapi.BotCommand{Command: "help", Description: "Инструкция"}, - ) - if _, err := b.tgBot.Request(groupCmds); err != nil { +func (b *Bridge) registerCommands(ctx context.Context) { + cmds := []BotCommand{ + {Command: "bridge", Description: "Связать чат с MAX-чатом"}, + {Command: "unbridge", Description: "Удалить связку чатов"}, + {Command: "crosspost", Description: "Список связок кросспостинга"}, + {Command: "help", Description: "Инструкция"}, + } + if err := b.tg.SetMyCommands(ctx, cmds, nil); err != nil { slog.Error("TG setMyCommands (default) failed", "err", err) } - - // Команды для админов (группы + каналы) - channelCmds := tgbotapi.NewSetMyCommandsWithScope( - tgbotapi.NewBotCommandScopeAllChatAdministrators(), - tgbotapi.BotCommand{Command: "bridge", Description: "Связать чат с MAX-чатом"}, - tgbotapi.BotCommand{Command: "unbridge", Description: "Удалить связку чатов"}, - tgbotapi.BotCommand{Command: "crosspost", Description: "Список связок кросспостинга"}, - tgbotapi.BotCommand{Command: "help", Description: "Инструкция"}, - ) - if _, err := b.tgBot.Request(channelCmds); err != nil { + if err := b.tg.SetMyCommands(ctx, cmds, &CommandScope{Type: "all_chat_administrators"}); err != nil { slog.Error("TG setMyCommands (admins) failed", "err", err) } } // Run запускает TG и MAX listener'ы + периодическую очистку. func (b *Bridge) Run(ctx context.Context) { - b.registerCommands() + b.registerCommands(ctx) go func() { t := time.NewTicker(10 * time.Minute) defer t.Stop() diff --git a/format.go b/format.go index f6d06e3..e62ec29 100644 --- a/format.go +++ b/format.go @@ -4,11 +4,9 @@ import ( "strings" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) -func tgName(msg *tgbotapi.Message) string { +func tgName(msg *TGMessage) string { if msg.From == nil { if msg.SenderChat != nil { return msg.SenderChat.Title @@ -31,7 +29,7 @@ func formatAttribution(name, text string, newline bool) string { } // formatTgCaption — для пересылки (текст или caption) -func formatTgCaption(msg *tgbotapi.Message, prefix, newline bool) string { +func formatTgCaption(msg *TGMessage, prefix, newline bool) string { name := tgName(msg) text := msg.Text if text == "" { @@ -44,7 +42,7 @@ func formatTgCaption(msg *tgbotapi.Message, prefix, newline bool) string { } // formatTgMessage — для edit (полный формат) -func formatTgMessage(msg *tgbotapi.Message, prefix, newline bool) string { +func formatTgMessage(msg *TGMessage, prefix, newline bool) string { name := tgName(msg) text := msg.Text if text == "" { @@ -78,7 +76,7 @@ func formatMaxCaption(upd *maxschemes.MessageCreatedUpdate, prefix, newline bool } // formatTgCrosspostCaption — для кросспостинга каналов (без attribution и префиксов) -func formatTgCrosspostCaption(msg *tgbotapi.Message) string { +func formatTgCrosspostCaption(msg *TGMessage) string { text := msg.Text if text == "" { text = msg.Caption diff --git a/format_test.go b/format_test.go index a57799d..0c6de05 100644 --- a/format_test.go +++ b/format_test.go @@ -4,26 +4,25 @@ import ( "testing" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) func TestTgName(t *testing.T) { tests := []struct { name string - msg *tgbotapi.Message + msg *TGMessage expected string }{ { name: "first name only", - msg: &tgbotapi.Message{ - From: &tgbotapi.User{FirstName: "Ivan"}, + msg: &TGMessage{ + From: &UserInfo{FirstName: "Ivan"}, }, expected: "Ivan", }, { name: "first and last name", - msg: &tgbotapi.Message{ - From: &tgbotapi.User{FirstName: "Ivan", LastName: "Petrov"}, + msg: &TGMessage{ + From: &UserInfo{FirstName: "Ivan", LastName: "Petrov"}, }, expected: "Ivan Petrov", }, @@ -40,9 +39,9 @@ func TestTgName(t *testing.T) { } func TestFormatTgCaption(t *testing.T) { - msg := &tgbotapi.Message{ + msg := &TGMessage{ Text: "hello world", - From: &tgbotapi.User{FirstName: "Anna"}, + From: &UserInfo{FirstName: "Anna"}, } tests := []struct { @@ -65,10 +64,10 @@ func TestFormatTgCaption(t *testing.T) { } func TestFormatTgCaption_UsesCaption(t *testing.T) { - msg := &tgbotapi.Message{ + msg := &TGMessage{ Text: "", Caption: "photo caption", - From: &tgbotapi.User{FirstName: "Bob"}, + From: &UserInfo{FirstName: "Bob"}, } got := formatTgCaption(msg, false, false) @@ -81,43 +80,43 @@ func TestFormatTgCaption_UsesCaption(t *testing.T) { func TestFormatTgMessage(t *testing.T) { tests := []struct { name string - msg *tgbotapi.Message + msg *TGMessage prefix bool expected string }{ { name: "text with prefix", - msg: &tgbotapi.Message{ + msg: &TGMessage{ Text: "edited text", - From: &tgbotapi.User{FirstName: "Ivan"}, + From: &UserInfo{FirstName: "Ivan"}, }, prefix: true, expected: "[TG] Ivan: edited text", }, { name: "text without prefix", - msg: &tgbotapi.Message{ + msg: &TGMessage{ Text: "edited text", - From: &tgbotapi.User{FirstName: "Ivan"}, + From: &UserInfo{FirstName: "Ivan"}, }, prefix: false, expected: "Ivan: edited text", }, { name: "empty text returns empty", - msg: &tgbotapi.Message{ + msg: &TGMessage{ Text: "", - From: &tgbotapi.User{FirstName: "Ivan"}, + From: &UserInfo{FirstName: "Ivan"}, }, prefix: true, expected: "", }, { name: "caption fallback", - msg: &tgbotapi.Message{ + msg: &TGMessage{ Text: "", Caption: "cap", - From: &tgbotapi.User{FirstName: "Ivan"}, + From: &UserInfo{FirstName: "Ivan"}, }, prefix: false, expected: "Ivan: cap", @@ -200,22 +199,22 @@ func TestFormatMaxCaption(t *testing.T) { func TestFormatTgCrosspostCaption(t *testing.T) { tests := []struct { name string - msg *tgbotapi.Message + msg *TGMessage expected string }{ { name: "text", - msg: &tgbotapi.Message{Text: "Новый пост"}, + msg: &TGMessage{Text: "Новый пост"}, expected: "Новый пост", }, { name: "caption fallback", - msg: &tgbotapi.Message{Text: "", Caption: "фото"}, + msg: &TGMessage{Text: "", Caption: "фото"}, expected: "фото", }, { name: "empty", - msg: &tgbotapi.Message{Text: ""}, + msg: &TGMessage{Text: ""}, expected: "", }, } diff --git a/go.mod b/go.mod index 3ab61cb..2011931 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module bearlogin-bridge go 1.24.0 require ( - github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/go-telegram/bot v1.20.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/lib/pq v1.11.2 github.com/mattn/go-sqlite3 v1.14.34 diff --git a/go.sum b/go.sum index cd3a63b..a1f3916 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= -github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc= +github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= diff --git a/main.go b/main.go index 852c549..b8a37ec 100644 --- a/main.go +++ b/main.go @@ -13,8 +13,6 @@ import ( "syscall" maxbot "github.com/max-messenger/max-bot-api-client-go" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) func mustEnv(key string) string { @@ -119,7 +117,6 @@ func main() { slog.Info("Message format: newline") } - tgToken := mustEnv("TG_TOKEN") dbPath := envOr("DB_PATH", "bridge.db") var repo Repository @@ -141,21 +138,14 @@ func main() { } defer repo.Close() - var tgBot *tgbotapi.BotAPI - if tgAPI := os.Getenv("TG_API_URL"); tgAPI != "" { - tgBot, err = tgbotapi.NewBotAPIWithAPIEndpoint(tgToken, tgAPI+"/bot%s/%s") - if err != nil { - slog.Error("TG bot error", "err", err) - os.Exit(1) - } - slog.Info("Telegram bot started (custom API)", "username", tgBot.Self.UserName, "api", tgAPI) - } else { - tgBot, err = tgbotapi.NewBotAPI(tgToken) - if err != nil { - slog.Error("TG bot error", "err", err) - os.Exit(1) - } - slog.Info("Telegram bot started", "username", tgBot.Self.UserName) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tgToken := mustEnv("TG_TOKEN") + tg, err := NewTGBotSender(ctx, tgToken, cfg.TgAPIURL) + if err != nil { + slog.Error("TG bot error", "err", err) + os.Exit(1) } maxApi, err := maxbot.New(cfg.MaxToken) @@ -170,9 +160,6 @@ func main() { } slog.Info("MAX bot started", "name", maxInfo.Name) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { @@ -181,7 +168,7 @@ func main() { cancel() }() - bridge := NewBridge(cfg, repo, tgBot, maxApi) + bridge := NewBridge(cfg, repo, tg, maxApi) bridge.Run(ctx) slog.Info("Bridge stopped") } diff --git a/markup.go b/markup.go index 0803262..0493a40 100644 --- a/markup.go +++ b/markup.go @@ -8,14 +8,13 @@ import ( "unicode/utf16" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) // --- TG Entities → Markdown (для MAX) --- // tgEntitiesToMarkdown конвертирует TG text + entities в markdown-текст для MAX. // Обрабатывает edge cases: пробелы перед/после маркеров выносятся за пределы тегов. -func tgEntitiesToMarkdown(text string, entities []tgbotapi.MessageEntity) string { +func tgEntitiesToMarkdown(text string, entities []Entity) string { if len(entities) == 0 { return text } @@ -28,11 +27,11 @@ func tgEntitiesToMarkdown(text string, entities []tgbotapi.MessageEntity) string // Работаем в UTF-16 координатах type fragment struct { start, end int // UTF-16 offsets - entity *tgbotapi.MessageEntity + entity *Entity } // Сортируем entities по offset - sorted := make([]tgbotapi.MessageEntity, len(entities)) + sorted := make([]Entity, len(entities)) copy(sorted, entities) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Offset < sorted[j].Offset diff --git a/markup_test.go b/markup_test.go new file mode 100644 index 0000000..b406f5f --- /dev/null +++ b/markup_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "testing" + + maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" +) + +// --- tgEntitiesToMarkdown --- + +func TestTgEntitiesToMarkdown_NoEntities(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", nil) + if got != "hello world" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Empty(t *testing.T) { + got := tgEntitiesToMarkdown("hello", []Entity{}) + if got != "hello" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Bold(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "bold", Offset: 0, Length: 5}, + }) + if got != "**hello** world" { + t.Errorf("got %q, want %q", got, "**hello** world") + } +} + +func TestTgEntitiesToMarkdown_Italic(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "italic", Offset: 6, Length: 5}, + }) + if got != "hello _world_" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Code(t *testing.T) { + got := tgEntitiesToMarkdown("use fmt.Println please", []Entity{ + {Type: "code", Offset: 4, Length: 11}, + }) + if got != "use `fmt.Println` please" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Pre(t *testing.T) { + got := tgEntitiesToMarkdown("code: func main()", []Entity{ + {Type: "pre", Offset: 6, Length: 11}, + }) + want := "code: ```\nfunc main()\n```" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_Strikethrough(t *testing.T) { + got := tgEntitiesToMarkdown("old new", []Entity{ + {Type: "strikethrough", Offset: 0, Length: 3}, + }) + if got != "~~old~~ new" { + t.Errorf("got %q", got) + } +} + +func TestTgEntitiesToMarkdown_TextLink(t *testing.T) { + got := tgEntitiesToMarkdown("click here now", []Entity{ + {Type: "text_link", Offset: 6, Length: 4, URL: "https://example.com"}, + }) + want := "click [here](https://example.com) now" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_MultipleEntities(t *testing.T) { + got := tgEntitiesToMarkdown("hello world test", []Entity{ + {Type: "bold", Offset: 0, Length: 5}, + {Type: "italic", Offset: 6, Length: 5}, + }) + want := "**hello** _world_ test" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_TrailingSpaces(t *testing.T) { + // Entity covering "hello " (with trailing space) — space should be outside markers + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "bold", Offset: 0, Length: 6}, // "hello " + }) + want := "**hello** world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_LeadingSpaces(t *testing.T) { + got := tgEntitiesToMarkdown("a bold rest", []Entity{ + {Type: "bold", Offset: 1, Length: 6}, // " bold" + }) + want := "a **bold** rest" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_UnknownType(t *testing.T) { + got := tgEntitiesToMarkdown("hello world", []Entity{ + {Type: "mention", Offset: 0, Length: 5}, + }) + if got != "hello world" { + t.Errorf("unknown entity type should be skipped, got %q", got) + } +} + +func TestTgEntitiesToMarkdown_Emoji(t *testing.T) { + // Emoji "🔥" is 2 UTF-16 code units (surrogate pair) + text := "🔥hello" + got := tgEntitiesToMarkdown(text, []Entity{ + {Type: "bold", Offset: 2, Length: 5}, // "hello" starts at UTF-16 offset 2 + }) + want := "🔥**hello**" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestTgEntitiesToMarkdown_EntityBeyondText(t *testing.T) { + got := tgEntitiesToMarkdown("hi", []Entity{ + {Type: "bold", Offset: 0, Length: 100}, + }) + want := "**hi**" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// --- maxMarkupsToHTML --- + +func TestMaxMarkupsToHTML_NoMarkups(t *testing.T) { + got := maxMarkupsToHTML("hello ", nil) + if got != "hello <world>" { + t.Errorf("got %q", got) + } +} + +func TestMaxMarkupsToHTML_Bold(t *testing.T) { + got := maxMarkupsToHTML("hello world", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrong, From: 0, Length: 5}, + }) + want := "hello world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Italic(t *testing.T) { + got := maxMarkupsToHTML("hello world", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupEmphasized, From: 6, Length: 5}, + }) + want := "hello world" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Code(t *testing.T) { + got := maxMarkupsToHTML("use fmt.Println", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupMonospaced, From: 4, Length: 11}, + }) + want := "use fmt.Println" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Strikethrough(t *testing.T) { + got := maxMarkupsToHTML("old new", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrikethrough, From: 0, Length: 3}, + }) + want := "old new" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Link(t *testing.T) { + got := maxMarkupsToHTML("click here", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupLink, From: 6, Length: 4, URL: "https://example.com"}, + }) + want := `click here` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_EscapesHTML(t *testing.T) { + got := maxMarkupsToHTML("not bold", nil) + want := "<b>not bold</b>" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Multiple(t *testing.T) { + got := maxMarkupsToHTML("hello world test", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrong, From: 0, Length: 5}, + {Type: maxschemes.MarkupEmphasized, From: 6, Length: 5}, + }) + want := "hello world test" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Emoji(t *testing.T) { + // "🔥test" — emoji is surrogate pair (2 UTF-16 units) + text := "🔥test" + got := maxMarkupsToHTML(text, []maxschemes.MarkUp{ + {Type: maxschemes.MarkupStrong, From: 2, Length: 4}, // "test" + }) + want := "🔥test" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_Underline(t *testing.T) { + got := maxMarkupsToHTML("hello", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupUnderline, From: 0, Length: 5}, + }) + want := "hello" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestMaxMarkupsToHTML_LinkEscapesURL(t *testing.T) { + got := maxMarkupsToHTML("link", []maxschemes.MarkUp{ + {Type: maxschemes.MarkupLink, From: 0, Length: 4, URL: "https://example.com/?a=1&b=2"}, + }) + want := `link` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/max.go b/max.go index 2df8967..7b6a3a4 100644 --- a/max.go +++ b/max.go @@ -11,8 +11,6 @@ import ( maxbot "github.com/max-messenger/max-bot-api-client-go" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) func (b *Bridge) listenMax(ctx context.Context) { @@ -56,8 +54,7 @@ func (b *Bridge) listenMax(ctx context.Context) { if !ok { continue } - del := tgbotapi.NewDeleteMessage(tgChatID, tgMsgID) - if _, err := b.tgBot.Request(del); err != nil { + if err := b.tg.DeleteMessage(ctx, tgChatID, tgMsgID); err != nil { slog.Error("MAX→TG delete failed", "err", err, "maxMid", delUpd.MessageId, "tgChat", tgChatID) } else { slog.Info("MAX→TG deleted", "tgMsg", tgMsgID, "tgChat", tgChatID) @@ -119,33 +116,19 @@ func (b *Bridge) listenMax(ctx context.Context) { if dlErr != nil { slog.Error("MAX→TG edit media download failed", "err", dlErr) } else { - fb := tgbotapi.FileBytes{Name: name, Bytes: data} - var media interface{} + var mediaIM TGInputMedia switch mediaType { case "photo": - p := tgbotapi.NewInputMediaPhoto(fb) - p.Caption = fwd - media = p + mediaIM = TGInputMedia{Type: "photo", File: FileArg{Name: name, Bytes: data}, Caption: fwd} case "video": - v := tgbotapi.NewInputMediaVideo(fb) - v.Caption = fwd - media = v + mediaIM = TGInputMedia{Type: "video", File: FileArg{Name: name, Bytes: data}, Caption: fwd} case "document": - d := tgbotapi.NewInputMediaDocument(fb) - d.Caption = fwd - media = d + mediaIM = TGInputMedia{Type: "document", File: FileArg{Name: name, Bytes: data}, Caption: fwd} } - editMedia := tgbotapi.EditMessageMediaConfig{ - BaseEdit: tgbotapi.BaseEdit{ - ChatID: tgChatID, - MessageID: tgMsgID, - }, - Media: media, - } - if _, err := b.tgBot.Send(editMedia); err != nil { + if err := b.tg.EditMessageMedia(ctx, tgChatID, tgMsgID, mediaIM); err != nil { slog.Error("MAX→TG edit media failed", "err", err, "uid", editUpd.Message.Sender.UserId) // Fallback — отправляем как новое сообщение - go b.sendTgMediaFromURL(tgChatID, mediaURL, mediaType, fwd, "", 0, b.cfg.maxMaxFileBytes()) + go b.sendTgMediaFromURL(ctx, tgChatID, mediaURL, mediaType, fwd, "", 0, 0, b.cfg.maxMaxFileBytes()) } else { slog.Info("MAX→TG edited media", "tgMsg", tgMsgID, "type", mediaType, "uid", editUpd.Message.Sender.UserId) } @@ -156,8 +139,7 @@ func (b *Bridge) listenMax(ctx context.Context) { if text == "" { continue } - editMsg := tgbotapi.NewEditMessageText(tgChatID, tgMsgID, fwd) - if _, err := b.tgBot.Send(editMsg); err != nil { + if err := b.tg.EditMessageText(ctx, tgChatID, tgMsgID, fwd, nil); err != nil { slog.Error("MAX→TG edit failed", "err", err, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId) } else { slog.Info("MAX→TG edited", "tgMsg", tgMsgID, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId) @@ -372,7 +354,7 @@ func (b *Bridge) listenMax(ctx context.Context) { } else { for _, l := range links { kb := maxCrosspostKeyboard(b.maxApi, l.Direction, l.MaxChatID) - tgTitle := b.tgChatTitle(l.TgChatID) + tgTitle := b.tgChatTitle(ctx, l.TgChatID) statusText := maxCrosspostStatusText(l.TgChatID, l.Direction) if tgTitle != "" { statusText = fmt.Sprintf("TG: «%s» (%d)\n", tgTitle, l.TgChatID) + statusText @@ -857,6 +839,8 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC return } + threadID := b.repo.GetTgThreadID(tgChatID) + body := msgUpd.Message.Body chatID := msgUpd.Message.Recipient.ChatId text := strings.TrimSpace(body.Text) @@ -877,7 +861,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC } // Проверяем вложения - var sent tgbotapi.Message + var sentMsgID int var sendErr error mediaSent := false var qAttType, qAttURL string // для очереди при ошибке @@ -890,7 +874,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC } // Собираем вложения: фото/видео → albumMedia (отправляем вместе), остальные → soloMedia - var albumMedia []interface{} + var albumMedia []TGInputMedia var soloMedia []struct { url string attType string @@ -908,7 +892,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC if len(albumMedia) == 0 { qAttType, qAttURL = "photo", a.Payload.Url } - p := tgbotapi.NewInputMediaPhoto(tgbotapi.FileURL(a.Payload.Url)) + p := TGInputMedia{Type: "photo", File: FileArg{URL: a.Payload.Url}} albumMedia = append(albumMedia, p) } case *maxschemes.VideoAttachment: @@ -916,7 +900,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC if len(albumMedia) == 0 { qAttType, qAttURL = "video", a.Payload.Url } - v := tgbotapi.NewInputMediaVideo(tgbotapi.FileURL(a.Payload.Url)) + v := TGInputMedia{Type: "video", File: FileArg{URL: a.Payload.Url}} albumMedia = append(albumMedia, v) } case *maxschemes.AudioAttachment: @@ -960,25 +944,15 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC mediaSent = true // Caption и reply только к первому элементу if htmlCaption != "" || replyToID != 0 { - switch first := albumMedia[0].(type) { - case tgbotapi.InputMediaPhoto: - first.Caption = htmlCaption - if pm != "" { - first.ParseMode = pm - } - albumMedia[0] = first - case tgbotapi.InputMediaVideo: - first.Caption = htmlCaption - if pm != "" { - first.ParseMode = pm - } - albumMedia[0] = first + albumMedia[0].Caption = htmlCaption + if pm != "" { + albumMedia[0].ParseMode = pm } } if len(albumMedia) == 1 { // Одно вложение — отправляем обычным сообщением (альбом из 1 элемента не имеет reply) - sent, sendErr = b.sendTgMediaFromURL(tgChatID, qAttURL, qAttType, htmlCaption, pm, replyToID, b.cfg.maxMaxFileBytes()) + sentMsgID, sendErr = b.sendTgMediaFromURL(ctx, tgChatID, qAttURL, qAttType, htmlCaption, pm, replyToID, threadID, b.cfg.maxMaxFileBytes()) var e *ErrFileTooLarge if errors.As(sendErr, &e) { slog.Warn("MAX→TG media too big", "name", e.Name, "size", e.Size) @@ -989,18 +963,14 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC } } else { // Несколько — отправляем как media group (альбом) - cfg := tgbotapi.NewMediaGroup(tgChatID, albumMedia) - if replyToID != 0 { - cfg.ReplyToMessageID = replyToID - } - msgs, err := b.tgBot.SendMediaGroup(cfg) + msgIDs, err := b.tg.SendMediaGroup(ctx, tgChatID, albumMedia, &SendOpts{ThreadID: threadID, ReplyToID: replyToID}) if err != nil { slog.Error("MAX→TG album send failed", "err", err) sendErr = err m := maxbot.NewMessage().SetChat(chatID).SetText("Не удалось отправить медиаальбом в Telegram.") b.maxApi.Messages.Send(ctx, m) - } else if len(msgs) > 0 { - sent = msgs[0] + } else if len(msgIDs) > 0 { + sentMsgID = msgIDs[0] } } } @@ -1016,7 +986,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC smReplyTo = replyToID } firstSolo = false - s, err := b.sendTgMediaFromURL(tgChatID, sm.url, sm.attType, smCaption, pm, smReplyTo, b.cfg.maxMaxFileBytes(), sm.name) + s, err := b.sendTgMediaFromURL(ctx, tgChatID, sm.url, sm.attType, smCaption, pm, smReplyTo, threadID, b.cfg.maxMaxFileBytes(), sm.name) if err != nil { var e *ErrFileTooLarge if errors.As(err, &e) { @@ -1035,7 +1005,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC sendErr = err } } else if !mediaSent { - sent = s + sentMsgID = s mediaSent = true } } @@ -1048,14 +1018,9 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC // Если есть markups и caption = оригинальный текст (кросспостинг), конвертируем в HTML if len(body.Markups) > 0 && caption == text { htmlText := maxMarkupsToHTML(text, body.Markups) - tgMsg := tgbotapi.NewMessage(tgChatID, htmlText) - tgMsg.ParseMode = "HTML" - tgMsg.ReplyToMessageID = replyToID - sent, sendErr = b.tgBot.Send(tgMsg) + sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, htmlText, &SendOpts{ParseMode: "HTML", ReplyToID: replyToID, ThreadID: threadID}) } else { - tgMsg := tgbotapi.NewMessage(tgChatID, caption) - tgMsg.ReplyToMessageID = replyToID - sent, sendErr = b.tgBot.Send(tgMsg) + sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, caption, &SendOpts{ReplyToID: replyToID, ThreadID: threadID}) } } @@ -1064,7 +1029,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC slog.Error("MAX→TG send failed", "err", errStr, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID) // Группа преобразована в supergroup — автоматически мигрируем chat ID - var tgErr *tgbotapi.Error + var tgErr *TGError if errors.As(sendErr, &tgErr) && tgErr.MigrateToChatID != 0 { newChatID := tgErr.MigrateToChatID slog.Info("TG chat migrated, updating pair", "old", tgChatID, "new", newChatID) @@ -1109,7 +1074,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC b.cbFail(tgChatID) } else { b.cbSuccess(tgChatID) - slog.Info("MAX→TG sent", "msgID", sent.MessageID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID) - b.repo.SaveMsg(tgChatID, sent.MessageID, chatID, body.Mid) + slog.Info("MAX→TG sent", "msgID", sentMsgID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID) + b.repo.SaveMsg(tgChatID, sentMsgID, chatID, body.Mid) } } diff --git a/mediagroup.go b/mediagroup.go index 3b6f09d..5f878a4 100644 --- a/mediagroup.go +++ b/mediagroup.go @@ -9,19 +9,18 @@ import ( maxbot "github.com/max-messenger/max-bot-api-client-go" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) const mediaGroupTimeout = 1 * time.Second // mediaGroupItem хранит данные одного сообщения из альбома TG. type mediaGroupItem struct { - photoSizes []tgbotapi.PhotoSize + photoSizes []PhotoSize videoFileID string // для видео в альбомах caption string - replyToMsg *tgbotapi.Message - entities []tgbotapi.MessageEntity - msg *tgbotapi.Message + replyToMsg *TGMessage + entities []Entity + msg *TGMessage maxChatID int64 // если задан — используется напрямую (crosspost) crosspost bool // кросспостинг: без prefix, другой caption формат } @@ -95,7 +94,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { // Caption и entities берём из первого элемента, у которого caption не пустой var caption string - var entities []tgbotapi.MessageEntity + var entities []Entity for _, it := range items { if it.caption != "" { caption = it.caption @@ -131,7 +130,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { for _, it := range items { if len(it.photoSizes) > 0 { photo := it.photoSizes[len(it.photoSizes)-1] - fileURL, err := b.tgFileURL(photo.FileID) + fileURL, err := b.tgFileURL(ctx, photo.FileID) if err != nil { slog.Error("media group: tgFileURL failed", "err", err) continue @@ -185,8 +184,8 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) { if err != nil { slog.Error("TG→MAX media group send failed", "err", err) if b.cbFail(maxChatID) { - b.tgBot.Send(tgbotapi.NewMessage(items[0].msg.Chat.ID, - fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())))) + b.tg.SendMessage(ctx, items[0].msg.Chat.ID, + fmt.Sprintf("Не удалось переслать альбом в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), nil) } // Fallback — по одному for _, it := range items { diff --git a/postgres.go b/postgres.go index 0d7c511..ebb30f8 100644 --- a/postgres.go +++ b/postgres.go @@ -164,6 +164,19 @@ func (r *pgRepo) Unpair(platform string, chatID int64) bool { return n > 0 } +func (r *pgRepo) GetTgThreadID(tgChatID int64) int { + var id int + r.db.QueryRow("SELECT COALESCE(tg_thread_id, 0) FROM pairs WHERE tg_chat_id = $1", tgChatID).Scan(&id) + return id +} + +func (r *pgRepo) SetTgThreadID(tgChatID int64, threadID int) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE pairs SET tg_thread_id = $1 WHERE tg_chat_id = $2", threadID, tgChatID) + return err +} + func (r *pgRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error { _, err := r.db.Exec( "INSERT INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tg_chat_id, max_chat_id) DO NOTHING", diff --git a/queue.go b/queue.go index 22dafba..335d8b7 100644 --- a/queue.go +++ b/queue.go @@ -7,8 +7,6 @@ import ( "strconv" "strings" "time" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) const ( @@ -92,8 +90,7 @@ func (b *Bridge) processQueue(ctx context.Context) { slog.Warn("queue item expired", "id", item.ID, "dir", item.Direction, "attempts", item.Attempts, "age", age) b.repo.DeleteFromQueue(item.ID) if item.Direction == "tg2max" { - b.tgBot.Send(tgbotapi.NewMessage(item.SrcChatID, - fmt.Sprintf("Сообщение не доставлено в MAX после %d попыток.", item.Attempts))) + b.tg.SendMessage(ctx, item.SrcChatID, fmt.Sprintf("Сообщение не доставлено в MAX после %d попыток.", item.Attempts), nil) } continue } @@ -102,7 +99,7 @@ func (b *Bridge) processQueue(ctx context.Context) { case "tg2max": b.processQueueTg2Max(ctx, item, now) case "max2tg": - b.processQueueMax2Tg(item, now) + b.processQueueMax2Tg(ctx, item, now) } } } @@ -129,52 +126,28 @@ func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now tim b.repo.DeleteFromQueue(item.ID) } -func (b *Bridge) processQueueMax2Tg(item QueueItem, now time.Time) { - var sent tgbotapi.Message +func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now time.Time) { + var sentMsgID int var err error + threadID := b.repo.GetTgThreadID(item.DstChatID) + if item.AttType != "" && item.AttURL != "" { + opts := &SendOpts{Caption: item.Text, ParseMode: item.ParseMode, ThreadID: threadID} switch item.AttType { case "photo": - photo := tgbotapi.NewPhoto(item.DstChatID, tgbotapi.FileURL(item.AttURL)) - photo.Caption = item.Text - if item.ParseMode != "" { - photo.ParseMode = item.ParseMode - } - sent, err = b.tgBot.Send(photo) + sentMsgID, err = b.tg.SendPhoto(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) case "video": - video := tgbotapi.NewVideo(item.DstChatID, tgbotapi.FileURL(item.AttURL)) - video.Caption = item.Text - if item.ParseMode != "" { - video.ParseMode = item.ParseMode - } - sent, err = b.tgBot.Send(video) + sentMsgID, err = b.tg.SendVideo(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) case "audio": - audio := tgbotapi.NewAudio(item.DstChatID, tgbotapi.FileURL(item.AttURL)) - audio.Caption = item.Text - if item.ParseMode != "" { - audio.ParseMode = item.ParseMode - } - sent, err = b.tgBot.Send(audio) + sentMsgID, err = b.tg.SendAudio(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) case "file": - doc := tgbotapi.NewDocument(item.DstChatID, tgbotapi.FileURL(item.AttURL)) - doc.Caption = item.Text - if item.ParseMode != "" { - doc.ParseMode = item.ParseMode - } - sent, err = b.tgBot.Send(doc) + sentMsgID, err = b.tg.SendDocument(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) default: - // sticker и прочее — как фото - photo := tgbotapi.NewPhoto(item.DstChatID, tgbotapi.FileURL(item.AttURL)) - photo.Caption = item.Text - sent, err = b.tgBot.Send(photo) + sentMsgID, err = b.tg.SendPhoto(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts) } } else { - tgMsg := tgbotapi.NewMessage(item.DstChatID, item.Text) - if item.ParseMode != "" { - tgMsg.ParseMode = item.ParseMode - } - sent, err = b.tgBot.Send(tgMsg) + sentMsgID, err = b.tg.SendMessage(ctx, item.DstChatID, item.Text, &SendOpts{ParseMode: item.ParseMode, ThreadID: threadID}) } if err != nil { @@ -188,7 +161,7 @@ func (b *Bridge) processQueueMax2Tg(item QueueItem, now time.Time) { b.repo.IncrementAttempt(item.ID, now.Add(retryDelay(item.Attempts+1)).Unix()) return } - slog.Info("queue retry ok", "id", item.ID, "dir", "max2tg", "msgID", sent.MessageID) - b.repo.SaveMsg(item.DstChatID, sent.MessageID, item.SrcChatID, item.SrcMsgID) + slog.Info("queue retry ok", "id", item.ID, "dir", "max2tg", "msgID", sentMsgID) + b.repo.SaveMsg(item.DstChatID, sentMsgID, item.SrcChatID, item.SrcMsgID) b.repo.DeleteFromQueue(item.ID) } diff --git a/replacements.go b/replacements.go index 469d670..97da45c 100644 --- a/replacements.go +++ b/replacements.go @@ -10,7 +10,6 @@ import ( maxbot "github.com/max-messenger/max-bot-api-client-go" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) // parseCrosspostReplacements парсит JSON из БД в структуру. @@ -116,32 +115,32 @@ func replacementTags(r Replacement) string { } // tgReplacementsKeyboard строит inline-клавиатуру для управления заменами. -func tgReplacementsKeyboard(maxChatID int64) tgbotapi.InlineKeyboardMarkup { +func tgReplacementsKeyboard(maxChatID int64) *InlineKeyboardMarkup { id := fmt.Sprintf("%d", maxChatID) - return tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("+ TG→MAX", "cpra:tg>max:"+id), - tgbotapi.NewInlineKeyboardButtonData("+ MAX→TG", "cpra:max>tg:"+id), + return NewInlineKeyboard( + NewInlineRow( + NewInlineButton("+ TG→MAX", "cpra:tg>max:"+id), + NewInlineButton("+ MAX→TG", "cpra:max>tg:"+id), ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("🗑 Очистить всё", "cprc:"+id), - tgbotapi.NewInlineKeyboardButtonData("◀ Назад", "cprb:"+id), + NewInlineRow( + NewInlineButton("🗑 Очистить всё", "cprc:"+id), + NewInlineButton("◀ Назад", "cprb:"+id), ), ) } // tgReplItemKeyboard — кнопки для одной замены в TG. -func tgReplItemKeyboard(dir string, idx int, maxChatID string, currentTarget string) tgbotapi.InlineKeyboardMarkup { +func tgReplItemKeyboard(dir string, idx int, maxChatID string, currentTarget string) *InlineKeyboardMarkup { toggleLabel := "🔗 Только ссылки" toggleTarget := "links" if currentTarget == "links" { toggleLabel = "📝 Весь текст" toggleTarget = "all" } - return tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(toggleLabel, fmt.Sprintf("cprt:%s:%d:%s:%s", dir, idx, toggleTarget, maxChatID)), - tgbotapi.NewInlineKeyboardButtonData("❌ Удалить", fmt.Sprintf("cprd:%s:%d:%s", dir, idx, maxChatID)), + return NewInlineKeyboard( + NewInlineRow( + NewInlineButton(toggleLabel, fmt.Sprintf("cprt:%s:%d:%s:%s", dir, idx, toggleTarget, maxChatID)), + NewInlineButton("❌ Удалить", fmt.Sprintf("cprd:%s:%d:%s", dir, idx, maxChatID)), ), ) } diff --git a/repository.go b/repository.go index f8fb761..bcc213b 100644 --- a/repository.go +++ b/repository.go @@ -43,6 +43,9 @@ type Repository interface { Unpair(platform string, chatID int64) bool + GetTgThreadID(tgChatID int64) int + SetTgThreadID(tgChatID int64, threadID int) error + // Crosspost methods PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error GetCrosspostOwner(maxChatID int64) (maxOwner, tgOwner int64) diff --git a/sqlite.go b/sqlite.go index 5f24028..305713e 100644 --- a/sqlite.go +++ b/sqlite.go @@ -157,6 +157,19 @@ func (r *sqliteRepo) Unpair(platform string, chatID int64) bool { return n > 0 } +func (r *sqliteRepo) GetTgThreadID(tgChatID int64) int { + var id int + r.db.QueryRow("SELECT COALESCE(tg_thread_id, 0) FROM pairs WHERE tg_chat_id = ?", tgChatID).Scan(&id) + return id +} + +func (r *sqliteRepo) SetTgThreadID(tgChatID int64, threadID int) error { + r.mu.Lock() + defer r.mu.Unlock() + _, err := r.db.Exec("UPDATE pairs SET tg_thread_id = ? WHERE tg_chat_id = ?", threadID, tgChatID) + return err +} + func (r *sqliteRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error { _, err := r.db.Exec("INSERT OR REPLACE INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES (?, ?, ?, ?, ?)", tgChatID, maxChatID, time.Now().Unix(), ownerID, tgOwnerID) diff --git a/telegram.go b/telegram.go index 7920b8b..4322519 100644 --- a/telegram.go +++ b/telegram.go @@ -11,33 +11,24 @@ import ( maxbot "github.com/max-messenger/max-bot-api-client-go" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) func (b *Bridge) listenTelegram(ctx context.Context) { - var updates tgbotapi.UpdatesChannel + var updates <-chan TGUpdate if b.cfg.WebhookURL != "" { whPath := b.tgWebhookPath() whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath - wh, err := tgbotapi.NewWebhook(whURL) - if err != nil { - slog.Error("TG webhook config error", "err", err) - return - } - if _, err := b.tgBot.Request(wh); err != nil { + if err := b.tg.SetWebhook(ctx, whURL); err != nil { slog.Error("TG set webhook failed", "err", err) return } - updates = b.tgBot.ListenForWebhook(whPath) + updates = b.tg.StartWebhook(whPath) slog.Info("TG webhook mode") } else { // Удаляем webhook если был, переключаемся на polling - b.tgBot.Request(tgbotapi.DeleteWebhookConfig{}) - u := tgbotapi.NewUpdate(0) - u.Timeout = 60 - updates = b.tgBot.GetUpdatesChan(u) + b.tg.DeleteWebhook(ctx) + updates = b.tg.StartPolling(ctx) slog.Info("TG polling mode") } @@ -142,16 +133,16 @@ func (b *Bridge) listenTelegram(ctx context.Context) { } if text == "/whoami" { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, + b.tg.SendMessage(ctx, msg.Chat.ID, "MaxTelegramBridgeBot — мост между Telegram и MAX.\n"+ "Автор: Andrey Lugovskoy (@BEARlogin)\n"+ "Исходники: https://github.com/BEARlogin/max-telegram-bridge-bot\n"+ - "Лицензия: CC BY-NC 4.0")) + "Лицензия: CC BY-NC 4.0", &SendOpts{ThreadID: msg.MessageThreadID}) continue } if text == "/start" || text == "/help" { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, + b.tg.SendMessage(ctx, msg.Chat.ID, "Бот-мост между Telegram и MAX.\n\n"+ "Команды (группы):\n"+ "/bridge — создать ключ для связки чатов\n"+ @@ -178,7 +169,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { "3. В одном из чатов отправьте /bridge\n"+ "4. Бот выдаст ключ — отправьте /bridge <ключ> в другом чате\n"+ "5. Готово!\n\n"+ - "Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues")) + "Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues", &SendOpts{ThreadID: msg.MessageThreadID}) continue } @@ -188,7 +179,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { b.clearReplWait(msg.From.ID) rule, valid := parseReplacementInput(text) if !valid { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Неверный формат. Используйте:\nfrom | to\nили\n/regex/ | to")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Неверный формат. Используйте:\nfrom | to\nили\n/regex/ | to", &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) continue } rule.Target = w.target @@ -200,7 +191,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { } if err := b.repo.SetCrosspostReplacements(w.maxChatID, repl); err != nil { slog.Error("save replacements failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Ошибка сохранения.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Ошибка сохранения.", &SendOpts{ThreadID: msg.MessageThreadID}) continue } ruleType := "строка" @@ -211,48 +202,45 @@ func (b *Bridge) listenTelegram(ctx context.Context) { if w.direction == "max>tg" { dirLabel = "MAX → TG" } - m := tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Замена добавлена (%s, %s):\n%s%s", dirLabel, ruleType, rule.From, rule.To)) - m.ParseMode = "HTML" - b.tgBot.Send(m) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Замена добавлена (%s, %s):\n%s%s", dirLabel, ruleType, rule.From, rule.To), + &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) continue } } // /crosspost в личке TG — показать список связок if msg.Chat.Type == "private" && text == "/crosspost" { - if !b.checkUserAllowed(msg.Chat.ID, msg.From.ID) { + if !b.checkUserAllowed(ctx, msg.Chat.ID, msg.From.ID, msg.MessageThreadID) { continue } links := b.repo.ListCrossposts(msg.From.ID) if len(links) == 0 { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - "Нет активных связок.\n\nНастройка: перешлите пост из TG-канала сюда, затем в MAX-боте /crosspost ")) + b.tg.SendMessage(ctx, msg.Chat.ID, + "Нет активных связок.\n\nНастройка: перешлите пост из TG-канала сюда, затем в MAX-боте /crosspost ", &SendOpts{ThreadID: msg.MessageThreadID}) } else { for _, l := range links { kb := tgCrosspostKeyboard(l.Direction, l.MaxChatID) - tgTitle := b.tgChatTitle(l.TgChatID) + tgTitle := b.tgChatTitle(ctx, l.TgChatID) statusText := tgCrosspostStatusText(tgTitle, l.Direction) if tgTitle == "" { statusText += fmt.Sprintf("\nTG: %d ↔ MAX: %d", l.TgChatID, l.MaxChatID) } else { statusText += fmt.Sprintf("\nTG: «%s» (%d)\nMAX: %d", tgTitle, l.TgChatID, l.MaxChatID) } - m := tgbotapi.NewMessage(msg.Chat.ID, statusText) - m.ReplyMarkup = kb - b.tgBot.Send(m) + b.tg.SendMessage(ctx, msg.Chat.ID, statusText, &SendOpts{ReplyMarkup: kb, ThreadID: msg.MessageThreadID}) } } continue } // Пересланное сообщение из канала → показать ID или управление (только в личке) - if msg.Chat.Type == "private" && msg.ForwardFromChat != nil && msg.ForwardFromChat.Type == "channel" { - if !b.checkUserAllowed(msg.Chat.ID, msg.From.ID) { + if msg.Chat.Type == "private" && msg.ForwardOriginChat != nil && msg.ForwardOriginChat.Type == "channel" { + if !b.checkUserAllowed(ctx, msg.Chat.ID, msg.From.ID, msg.MessageThreadID) { continue } - channelID := msg.ForwardFromChat.ID - channelTitle := msg.ForwardFromChat.Title + channelID := msg.ForwardOriginChat.ID + channelTitle := msg.ForwardOriginChat.Title // Запоминаем TG user ID для этого канала (для owner при pairing) b.cpTgOwnerMu.Lock() @@ -264,16 +252,13 @@ func (b *Bridge) listenTelegram(ctx context.Context) { if maxChatID, direction, ok := b.repo.GetCrosspostMaxChat(channelID); ok { text := tgCrosspostStatusText(channelTitle, direction) kb := tgCrosspostKeyboard(direction, maxChatID) - m := tgbotapi.NewMessage(msg.Chat.ID, text) - m.ReplyMarkup = kb - b.tgBot.Send(m) + b.tg.SendMessage(ctx, msg.Chat.ID, text, &SendOpts{ReplyMarkup: kb, ThreadID: msg.MessageThreadID}) continue } - cpMsg := tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("TG-канал «%s»\nID: %d\n\nВ личке MAX-бота напишите:\n/crosspost %d\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.", channelTitle, channelID, channelID, b.cfg.MaxBotURL)) - cpMsg.ParseMode = "HTML" - b.tgBot.Send(cpMsg) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("TG-канал «%s»\nID: %d\n\nВ личке MAX-бота напишите:\n/crosspost %d\n\nMAX-бот: %s\n\nЗатем перешлите пост из MAX-канала в личку MAX-бота.", channelTitle, channelID, channelID, b.cfg.MaxBotURL), + &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) continue } @@ -281,46 +266,41 @@ func (b *Bridge) listenTelegram(ctx context.Context) { isGroup := isTgGroup(msg.Chat.Type) isAdmin := false if isGroup && msg.From != nil { - member, err := b.tgBot.GetChatMember(tgbotapi.GetChatMemberConfig{ - ChatConfigWithUser: tgbotapi.ChatConfigWithUser{ - ChatID: msg.Chat.ID, - UserID: msg.From.ID, - }, - }) + status, err := b.tg.GetChatMember(ctx, msg.Chat.ID, msg.From.ID) if err == nil { - isAdmin = isTgAdmin(member.Status) + isAdmin = isTgAdmin(status) } } // /bridge prefix on/off if text == "/bridge prefix on" || text == "/bridge prefix off" { - if !b.checkUserAllowed(msg.Chat.ID, tgUserID(msg)) { + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { continue } if isGroup && !isAdmin { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Эта команда доступна только админам группы.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Эта команда доступна только админам группы.", &SendOpts{ThreadID: msg.MessageThreadID}) continue } on := text == "/bridge prefix on" if b.repo.SetPrefix("tg", msg.Chat.ID, on) { if on { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Префикс [TG]/[MAX] включён.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Префикс [TG]/[MAX] включён.", &SendOpts{ThreadID: msg.MessageThreadID}) } else { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Префикс [TG]/[MAX] выключен.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Префикс [TG]/[MAX] выключен.", &SendOpts{ThreadID: msg.MessageThreadID}) } } else { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Чат не связан. Сначала выполните /bridge.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Чат не связан. Сначала выполните /bridge.", &SendOpts{ThreadID: msg.MessageThreadID}) } continue } // /bridge или /bridge if text == "/bridge" || strings.HasPrefix(text, "/bridge ") { - if !b.checkUserAllowed(msg.Chat.ID, tgUserID(msg)) { + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { continue } if isGroup && !isAdmin { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Эта команда доступна только админам группы.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Эта команда доступна только админам группы.", &SendOpts{ThreadID: msg.MessageThreadID}) continue } key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge")) @@ -331,32 +311,34 @@ func (b *Bridge) listenTelegram(ctx context.Context) { } if paired { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Связано! Сообщения теперь пересылаются.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Связано! Сообщения теперь пересылаются.", &SendOpts{ThreadID: msg.MessageThreadID}) + if msg.MessageThreadID != 0 { + b.repo.SetTgThreadID(msg.Chat.ID, msg.MessageThreadID) + } slog.Info("paired", "platform", "tg", "chat", msg.Chat.ID, "key", key) } else if generatedKey != "" { - keyMsg := tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Ключ для связки: %s\n\nОтправьте в MAX-чате:\n/bridge %s\n\nMAX-бот: %s", generatedKey, generatedKey, b.cfg.MaxBotURL)) - keyMsg.ParseMode = "HTML" - b.tgBot.Send(keyMsg) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Ключ для связки: %s\n\nОтправьте в MAX-чате:\n/bridge %s\n\nMAX-бот: %s", generatedKey, generatedKey, b.cfg.MaxBotURL), + &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID}) slog.Info("pending", "platform", "tg", "chat", msg.Chat.ID, "key", generatedKey) } else { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Ключ не найден или чат той же платформы.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Ключ не найден или чат той же платформы.", &SendOpts{ThreadID: msg.MessageThreadID}) } continue } if text == "/unbridge" { if isGroup && !isAdmin { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Эта команда доступна только админам группы.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Эта команда доступна только админам группы.", &SendOpts{ThreadID: msg.MessageThreadID}) continue } - if !b.checkUserAllowed(msg.Chat.ID, tgUserID(msg)) { + if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) { continue } if b.repo.Unpair("tg", msg.Chat.ID) { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Связка удалена.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Связка удалена.", &SendOpts{ThreadID: msg.MessageThreadID}) } else { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Этот чат не связан.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Этот чат не связан.", &SendOpts{ThreadID: msg.MessageThreadID}) } continue } @@ -404,7 +386,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) { } } -func tgUserID(msg *tgbotapi.Message) int64 { +func tgUserID(msg *TGMessage) int64 { if msg.From != nil { return msg.From.ID } @@ -412,7 +394,7 @@ func tgUserID(msg *tgbotapi.Message) int64 { } // forwardTgToMax пересылает TG-сообщение (текст/медиа) в MAX-чат. -func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxChatID int64, caption string) { +func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID int64, caption string) { if b.cbBlocked(maxChatID) { return } @@ -432,7 +414,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC warn = fmt.Sprintf("⚠️ Файл \"%s\" слишком большой для пересылки (%s). Максимальный размер файла %d МБ.", fileName, formatFileSize(fileSize), limit) } - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, warn)) + b.tg.SendMessage(ctx, msg.Chat.ID, warn, nil) return true } @@ -452,15 +434,15 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC m.AddPhoto(uploaded) } else { slog.Error("TG→MAX photo upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Не удалось отправить фото в MAX.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить фото в MAX.", nil) return } - } else if fileURL, err := b.tgFileURL(photo.FileID); err == nil { + } else if fileURL, err := b.tgFileURL(ctx, photo.FileID); err == nil { if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { m.AddPhoto(uploaded) } else { slog.Error("TG→MAX photo upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Не удалось отправить фото в MAX.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить фото в MAX.", nil) return } } @@ -474,8 +456,8 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC if err != nil { slog.Error("TG→MAX send failed", "err", err, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID) if b.cbFail(maxChatID) { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())))) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Не удалось переслать в MAX. Пересылка приостановлена на %d мин. Проверьте, что бот добавлен в MAX-чат и является админом.", int(cbCooldown.Minutes())), nil) } } else { b.cbSuccess(maxChatID) @@ -497,7 +479,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC mediaAttType = "video" } else { slog.Error("TG→MAX gif upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX.", name))) + b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить GIF \"%s\" в MAX.", name), nil) return } } else if msg.Sticker != nil { @@ -511,12 +493,12 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC mediaAttType = "video" } else { slog.Error("TG→MAX sticker upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Не удалось отправить стикер в MAX.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil) return } } else { // Обычный стикер WebP → отправляем как фото - if fileURL, err := b.tgFileURL(msg.Sticker.FileID); err == nil { + if fileURL, err := b.tgFileURL(ctx, msg.Sticker.FileID); err == nil { if uploaded, err := b.maxApi.Uploads.UploadPhotoFromUrl(ctx, fileURL); err == nil { m := maxbot.NewMessage().SetChat(maxChatID).SetText(caption) m.AddPhoto(uploaded) @@ -529,7 +511,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC result, err := b.maxApi.Messages.SendWithResult(ctx, m) if err != nil { slog.Error("TG→MAX sticker send failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Не удалось отправить стикер в MAX.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil) } else { slog.Info("TG→MAX sent", "mid", result.Body.Mid) b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid) @@ -537,7 +519,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC return } else { slog.Error("TG→MAX sticker photo upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Не удалось отправить стикер в MAX.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить стикер в MAX.", nil) return } } @@ -555,7 +537,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC mediaAttType = "video" } else { slog.Error("TG→MAX video upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX.", name))) + b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить видео \"%s\" в MAX.", name), nil) return } } else if msg.VideoNote != nil { @@ -567,7 +549,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC mediaAttType = "video" } else { slog.Error("TG→MAX video note upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Не удалось отправить кружок в MAX.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить кружок в MAX.", nil) return } } else if msg.Document != nil { @@ -592,8 +574,8 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC if b.cfg.MaxAllowedExts != nil && attType == "file" { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) if _, ok := b.cfg.MaxAllowedExts[ext]; !ok { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext))) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), nil) return } } @@ -603,13 +585,13 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC } else { var e *ErrForbiddenExtension if errors.As(err, &e) { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name))) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), nil) return } slog.Error("TG→MAX file upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX.", name))) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Не удалось отправить файл \"%s\" в MAX.", name), nil) return } } else if msg.Voice != nil { @@ -622,12 +604,12 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC } else { var e *ErrForbiddenExtension if errors.As(err, &e) { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", e.Name))) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", e.Name), nil) return } slog.Error("TG→MAX voice upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Не удалось отправить голосовое сообщение в MAX.")) + b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось отправить голосовое сообщение в MAX.", nil) return } } else if msg.Audio != nil { @@ -642,8 +624,8 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC if b.cfg.MaxAllowedExts != nil { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")) if _, ok := b.cfg.MaxAllowedExts[ext]; !ok { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext))) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (расширение .%s не разрешено).", name, ext), nil) return } } @@ -653,12 +635,12 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC } else { var e *ErrForbiddenExtension if errors.As(err, &e) { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name))) + b.tg.SendMessage(ctx, msg.Chat.ID, + fmt.Sprintf("Файл \"%s\" не поддерживается в MAX (запрещённое расширение).", name), nil) return } slog.Error("TG→MAX audio upload failed", "err", err) - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX.", name))) + b.tg.SendMessage(ctx, msg.Chat.ID, fmt.Sprintf("Не удалось отправить аудио \"%s\" в MAX.", name), nil) return } } @@ -732,8 +714,8 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC b.enqueueTg2Max(msg.Chat.ID, msg.MessageID, maxChatID, mdCaption, mediaAttType, mediaToken, replyTo, format) } if b.cbFail(maxChatID) { - b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, - "MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.")) + b.tg.SendMessage(ctx, msg.Chat.ID, + "MAX API недоступен. Сообщения в очереди, будут доставлены автоматически.", nil) } } else { b.cbSuccess(maxChatID) @@ -743,7 +725,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *tgbotapi.Message, maxC } // handleTgChannelPost обрабатывает посты из TG-каналов (только пересылка crosspost). -func (b *Bridge) handleTgChannelPost(ctx context.Context, msg *tgbotapi.Message) { +func (b *Bridge) handleTgChannelPost(ctx context.Context, msg *TGMessage) { // Команды в канале игнорируем — настройка через личку с ботом text := strings.TrimSpace(msg.Text) if strings.HasPrefix(text, "/") { @@ -799,7 +781,7 @@ func (b *Bridge) handleTgChannelPost(ctx context.Context, msg *tgbotapi.Message) } // handleTgCallback обрабатывает нажатия inline-кнопок (crosspost management). -func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQuery) { +func (b *Bridge) handleTgCallback(ctx context.Context, query *TGCallback) { if query.Message == nil || query.From == nil { return } @@ -824,7 +806,7 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ return } if !b.isCrosspostOwner(maxChatID, fromID) { - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Только владелец связки может изменять настройки.")) + b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может изменять настройки.") return } b.repo.SetCrosspostDirection(maxChatID, dir) @@ -833,9 +815,8 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ title := parseTgCrosspostTitle(query.Message.Text) text := tgCrosspostStatusText(title, dir) kb := tgCrosspostKeyboard(dir, maxChatID) - edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, text, kb) - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Готово")) + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "Готово") return } @@ -846,18 +827,17 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ return } if !b.isCrosspostOwner(maxChatID, fromID) { - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Только владелец связки может удалять.")) + b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может удалять.") return } - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Да, удалить", fmt.Sprintf("cpuc:%d", maxChatID)), - tgbotapi.NewInlineKeyboardButtonData("Отмена", fmt.Sprintf("cpux:%d", maxChatID)), + kb := NewInlineKeyboard( + NewInlineRow( + NewInlineButton("Да, удалить", fmt.Sprintf("cpuc:%d", maxChatID)), + NewInlineButton("Отмена", fmt.Sprintf("cpux:%d", maxChatID)), ), ) - edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, "Удалить кросспостинг?", kb) - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "")) + b.tg.EditMessageText(ctx, chatID, msgID, "Удалить кросспостинг?", &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") return } @@ -870,26 +850,18 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ repl := b.repo.GetCrosspostReplacements(maxChatID) id := strconv.FormatInt(maxChatID, 10) // Удаляем сообщение со связкой - b.tgBot.Request(tgbotapi.NewDeleteMessage(chatID, msgID)) + b.tg.DeleteMessage(ctx, chatID, msgID) // Заголовок с кнопками добавления kb := tgReplacementsKeyboard(maxChatID) - m := tgbotapi.NewMessage(chatID, formatReplacementsHeader(repl)) - m.ReplyMarkup = kb - b.tgBot.Send(m) + b.tg.SendMessage(ctx, chatID, formatReplacementsHeader(repl), &SendOpts{ReplyMarkup: kb}) // Каждая замена — отдельное сообщение с кнопкой удаления for i, r := range repl.TgToMax { - m := tgbotapi.NewMessage(chatID, formatReplacementItem(r, "tg>max")) - m.ParseMode = "HTML" - m.ReplyMarkup = tgReplItemKeyboard("tg>max", i, id, r.Target) - b.tgBot.Send(m) + b.tg.SendMessage(ctx, chatID, formatReplacementItem(r, "tg>max"), &SendOpts{ParseMode: "HTML", ReplyMarkup: tgReplItemKeyboard("tg>max", i, id, r.Target)}) } for i, r := range repl.MaxToTg { - m := tgbotapi.NewMessage(chatID, formatReplacementItem(r, "max>tg")) - m.ParseMode = "HTML" - m.ReplyMarkup = tgReplItemKeyboard("max>tg", i, id, r.Target) - b.tgBot.Send(m) + b.tg.SendMessage(ctx, chatID, formatReplacementItem(r, "max>tg"), &SendOpts{ParseMode: "HTML", ReplyMarkup: tgReplItemKeyboard("max>tg", i, id, r.Target)}) } - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "")) + b.tg.AnswerCallback(ctx, query.ID, "") return } @@ -925,14 +897,12 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ // Обновляем сообщение newText := formatReplacementItem(*r, dir) kb := tgReplItemKeyboard(dir, idx, id, r.Target) - edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, newText, kb) - edit.ParseMode = "HTML" - b.tgBot.Send(edit) + b.tg.EditMessageText(ctx, chatID, msgID, newText, &SendOpts{ParseMode: "HTML", ReplyMarkup: kb}) label := "весь текст" if newTarget == "links" { label = "только ссылки" } - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Тип: "+label)) + b.tg.AnswerCallback(ctx, query.ID, "Тип: "+label) return } @@ -958,9 +928,8 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ repl.MaxToTg = append(repl.MaxToTg[:idx], repl.MaxToTg[idx+1:]...) } b.repo.SetCrosspostReplacements(maxChatID, repl) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Замена удалена.") - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Удалено")) + b.tg.EditMessageText(ctx, chatID, msgID, "Замена удалена.", nil) + b.tg.AnswerCallback(ctx, query.ID, "Удалено") return } @@ -976,16 +945,15 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ if dir == "max>tg" { dirLabel = "MAX → TG" } - kb := tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("📝 Весь текст", "cprat:"+dir+":all:"+id), - tgbotapi.NewInlineKeyboardButtonData("🔗 Только ссылки", "cprat:"+dir+":links:"+id), + kb := NewInlineKeyboard( + NewInlineRow( + NewInlineButton("📝 Весь текст", "cprat:"+dir+":all:"+id), + NewInlineButton("🔗 Только ссылки", "cprat:"+dir+":links:"+id), ), ) - edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, - fmt.Sprintf("Добавление замены для %s.\nГде применять замену?", dirLabel), kb) - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "")) + b.tg.EditMessageText(ctx, chatID, msgID, + fmt.Sprintf("Добавление замены для %s.\nГде применять замену?", dirLabel), &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") return } @@ -1002,11 +970,10 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ return } b.setReplWait(fromID, maxChatID, dir, target) - edit := tgbotapi.NewEditMessageText(chatID, msgID, - fmt.Sprintf("Отправьте правило замены:\nfrom | to\n\nДля регулярного выражения:\n/regex/ | to\n\nНапример:\nutm_source=tg | utm_source=max")) - edit.ParseMode = "HTML" - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "")) + b.tg.EditMessageText(ctx, chatID, msgID, + fmt.Sprintf("Отправьте правило замены:\nfrom | to\n\nДля регулярного выражения:\n/regex/ | to\n\nНапример:\nutm_source=tg | utm_source=max"), + &SendOpts{ParseMode: "HTML"}) + b.tg.AnswerCallback(ctx, query.ID, "") return } @@ -1019,9 +986,8 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ b.repo.SetCrosspostReplacements(maxChatID, CrosspostReplacements{}) repl := b.repo.GetCrosspostReplacements(maxChatID) kb := tgReplacementsKeyboard(maxChatID) - edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, formatReplacementsHeader(repl), kb) - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Очищено")) + b.tg.EditMessageText(ctx, chatID, msgID, formatReplacementsHeader(repl), &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "Очищено") return } @@ -1038,9 +1004,8 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ title := parseTgCrosspostTitle(query.Message.Text) text := tgCrosspostStatusText(title, direction) + fmt.Sprintf("\nTG: ↔ MAX: %d", maxChatID) kb := tgCrosspostKeyboard(direction, maxChatID) - edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, text, kb) - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "")) + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") return } @@ -1051,14 +1016,13 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ return } if !b.isCrosspostOwner(maxChatID, fromID) { - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Только владелец связки может удалять.")) + b.tg.AnswerCallback(ctx, query.ID, "Только владелец связки может удалять.") return } slog.Info("TG crosspost unlink", "maxChatID", maxChatID, "by", fromID) b.repo.UnpairCrosspost(maxChatID, fromID) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Кросспостинг удалён.") - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "Удалено")) + b.tg.EditMessageText(ctx, chatID, msgID, "Кросспостинг удалён.", nil) + b.tg.AnswerCallback(ctx, query.ID, "Удалено") return } @@ -1071,23 +1035,21 @@ func (b *Bridge) handleTgCallback(ctx context.Context, query *tgbotapi.CallbackQ // Lookup current direction _, direction, ok := b.repo.GetCrosspostTgChat(maxChatID) if !ok { - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Кросспостинг не найден.") - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "")) + b.tg.EditMessageText(ctx, chatID, msgID, "Кросспостинг не найден.", nil) + b.tg.AnswerCallback(ctx, query.ID, "") return } title := parseTgCrosspostTitle(query.Message.Text) text := tgCrosspostStatusText(title, direction) kb := tgCrosspostKeyboard(direction, maxChatID) - edit := tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, text, kb) - b.tgBot.Send(edit) - b.tgBot.Request(tgbotapi.NewCallback(query.ID, "")) + b.tg.EditMessageText(ctx, chatID, msgID, text, &SendOpts{ReplyMarkup: kb}) + b.tg.AnswerCallback(ctx, query.ID, "") return } } // tgCrosspostKeyboard строит inline-клавиатуру для управления кросспостингом. -func tgCrosspostKeyboard(direction string, maxChatID int64) tgbotapi.InlineKeyboardMarkup { +func tgCrosspostKeyboard(direction string, maxChatID int64) *InlineKeyboardMarkup { lblTgMax := "TG → MAX" lblMaxTg := "MAX → TG" lblBoth := "⟷ Оба" @@ -1100,15 +1062,15 @@ func tgCrosspostKeyboard(direction string, maxChatID int64) tgbotapi.InlineKeybo lblBoth = "✓ ⟷ Оба" } id := strconv.FormatInt(maxChatID, 10) - return tgbotapi.NewInlineKeyboardMarkup( - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(lblTgMax, "cpd:tg>max:"+id), - tgbotapi.NewInlineKeyboardButtonData(lblMaxTg, "cpd:max>tg:"+id), - tgbotapi.NewInlineKeyboardButtonData(lblBoth, "cpd:both:"+id), + return NewInlineKeyboard( + NewInlineRow( + NewInlineButton(lblTgMax, "cpd:tg>max:"+id), + NewInlineButton(lblMaxTg, "cpd:max>tg:"+id), + NewInlineButton(lblBoth, "cpd:both:"+id), ), - tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("🔄 Замены", "cpr:"+id), - tgbotapi.NewInlineKeyboardButtonData("❌ Удалить", "cpu:"+id), + NewInlineRow( + NewInlineButton("🔄 Замены", "cpr:"+id), + NewInlineButton("❌ Удалить", "cpu:"+id), ), ) } @@ -1140,7 +1102,7 @@ func parseTgCrosspostTitle(text string) string { } // handleTgEditedChannelPost обрабатывает редактирования постов в TG-каналах. -func (b *Bridge) handleTgEditedChannelPost(ctx context.Context, edited *tgbotapi.Message) { +func (b *Bridge) handleTgEditedChannelPost(ctx context.Context, edited *TGMessage) { maxMsgID, ok := b.repo.LookupMaxMsgID(edited.Chat.ID, edited.MessageID) if !ok { return diff --git a/tgsender.go b/tgsender.go new file mode 100644 index 0000000..277bb69 --- /dev/null +++ b/tgsender.go @@ -0,0 +1,198 @@ +package main + +import ( + "context" + "fmt" +) + +// --- Custom types for TG adapter --- + +type ChatInfo struct { + ID int64 + Type string + Title string +} + +type UserInfo struct { + ID int64 + IsBot bool + UserName string + FirstName string + LastName string +} + +type PhotoSize struct { + FileID string + FileSize int +} + +type FileInfo struct { + FileID string + FileName string + FileSize int +} + +type DocInfo struct { + FileID string + FileName string + FileSize int + MimeType string +} + +type AudioInfo struct { + FileID string + FileName string + FileSize int +} + +type StickerInfo struct { + FileID string + FileSize int + IsAnimated bool +} + +type Entity struct { + Type string + Offset int + Length int + URL string +} + +type TGMessage struct { + MessageID int + MessageThreadID int + Chat ChatInfo + From *UserInfo + SenderChat *ChatInfo + Text string + Caption string + Photo []PhotoSize + Video *FileInfo + Document *DocInfo + Animation *FileInfo + Sticker *StickerInfo + Voice *FileInfo + Audio *AudioInfo + VideoNote *FileInfo + MediaGroupID string + ReplyToMessage *TGMessage + ForwardOriginChat *ChatInfo // replaces ForwardFromChat, from forward_origin + MigrateToChatID int64 + Entities []Entity + CaptionEntities []Entity +} + +type TGCallback struct { + ID string + From *UserInfo + Message *TGMessage + Data string +} + +type TGUpdate struct { + Message *TGMessage + EditedMessage *TGMessage + ChannelPost *TGMessage + EditedChannelPost *TGMessage + CallbackQuery *TGCallback +} + +// SendOpts — optional parameters for send methods. +type SendOpts struct { + ThreadID int + ReplyToID int + ParseMode string + Caption string + ReplyMarkup *InlineKeyboardMarkup +} + +type InlineKeyboardMarkup struct { + Rows [][]InlineKeyboardButton +} + +type InlineKeyboardButton struct { + Text string + CallbackData string +} + +// FileArg — source for file upload: either Bytes (upload) or URL (send from URL). +type FileArg struct { + Name string + Bytes []byte + URL string +} + +// TGInputMedia — item for media groups and edit-media. +type TGInputMedia struct { + Type string // "photo", "video", "audio", "document" + File FileArg + Caption string + ParseMode string +} + +type BotCommand struct { + Command string + Description string +} + +type CommandScope struct { + Type string // "", "all_chat_administrators" +} + +// TGError represents a Telegram API error. +type TGError struct { + Code int + Description string + MigrateToChatID int64 +} + +func (e *TGError) Error() string { + return fmt.Sprintf("telegram: %s (%d)", e.Description, e.Code) +} + +// --- Keyboard helpers --- + +func NewInlineKeyboard(rows ...[]InlineKeyboardButton) *InlineKeyboardMarkup { + return &InlineKeyboardMarkup{Rows: rows} +} + +func NewInlineRow(buttons ...InlineKeyboardButton) []InlineKeyboardButton { + return buttons +} + +func NewInlineButton(text, data string) InlineKeyboardButton { + return InlineKeyboardButton{Text: text, CallbackData: data} +} + +// --- Interface --- + +// TGSender abstracts Telegram Bot API. All TG calls go through this interface. +type TGSender interface { + // Send methods return message ID. + SendMessage(ctx context.Context, chatID int64, text string, opts *SendOpts) (int, error) + SendPhoto(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendVideo(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendAudio(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendDocument(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) + SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) + + EditMessageText(ctx context.Context, chatID int64, msgID int, text string, opts *SendOpts) error + EditMessageMedia(ctx context.Context, chatID int64, msgID int, media TGInputMedia) error + + DeleteMessage(ctx context.Context, chatID int64, msgID int) error + AnswerCallback(ctx context.Context, callbackID string, text string) error + + GetFile(ctx context.Context, fileID string) (filePath string, err error) + GetFileDirectURL(filePath string) string + GetChatMember(ctx context.Context, chatID, userID int64) (status string, err error) + SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error + GetChat(ctx context.Context, chatID int64) (title string, err error) + + SetWebhook(ctx context.Context, url string) error + DeleteWebhook(ctx context.Context) error + StartWebhook(path string) <-chan TGUpdate + StartPolling(ctx context.Context) <-chan TGUpdate + + BotUsername() string + BotToken() string +} diff --git a/tgsender_impl.go b/tgsender_impl.go new file mode 100644 index 0000000..82fe458 --- /dev/null +++ b/tgsender_impl.go @@ -0,0 +1,599 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +type tgBotSender struct { + b *bot.Bot + token string + username string + apiURL string + updates chan TGUpdate +} + +func NewTGBotSender(ctx context.Context, token, apiURL string) (*tgBotSender, error) { + s := &tgBotSender{ + token: token, + apiURL: apiURL, + updates: make(chan TGUpdate, 100), + } + + opts := []bot.Option{ + bot.WithDefaultHandler(func(ctx context.Context, b *bot.Bot, update *models.Update) { + tgu := convertUpdate(update) + select { + case s.updates <- tgu: + default: + slog.Warn("TG update channel full, dropping update") + } + }), + } + if apiURL != "" { + opts = append(opts, bot.WithServerURL(apiURL)) + } + opts = append(opts, bot.WithSkipGetMe()) + + b, err := bot.New(token, opts...) + if err != nil { + return nil, fmt.Errorf("bot.New: %w", err) + } + s.b = b + + me, err := b.GetMe(ctx) + if err != nil { + return nil, fmt.Errorf("TG getMe: %w", err) + } + s.username = me.Username + slog.Info("Telegram bot started", "username", me.Username) + + return s, nil +} + +func (s *tgBotSender) BotUsername() string { return s.username } +func (s *tgBotSender) BotToken() string { return s.token } + +// --- Updates --- + +func (s *tgBotSender) StartPolling(ctx context.Context) <-chan TGUpdate { + go s.b.Start(ctx) + return s.updates +} + +func (s *tgBotSender) StartWebhook(path string) <-chan TGUpdate { + http.HandleFunc(path, s.b.WebhookHandler()) + return s.updates +} + +func (s *tgBotSender) SetWebhook(ctx context.Context, url string) error { + _, err := s.b.SetWebhook(ctx, &bot.SetWebhookParams{URL: url}) + return wrapErr(err) +} + +func (s *tgBotSender) DeleteWebhook(ctx context.Context) error { + _, err := s.b.DeleteWebhook(ctx, &bot.DeleteWebhookParams{}) + return wrapErr(err) +} + +// --- Send --- + +func (s *tgBotSender) SendMessage(ctx context.Context, chatID int64, text string, opts *SendOpts) (int, error) { + p := &bot.SendMessageParams{ + ChatID: chatID, + Text: text, + } + applySendMessageOpts(p, opts) + msg, err := s.b.SendMessage(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendPhoto(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendPhotoParams{ + ChatID: chatID, + Photo: toInputFile(file), + } + applySendPhotoOpts(p, opts) + msg, err := s.b.SendPhoto(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendVideo(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendVideoParams{ + ChatID: chatID, + Video: toInputFile(file), + } + applySendVideoOpts(p, opts) + msg, err := s.b.SendVideo(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendAudio(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendAudioParams{ + ChatID: chatID, + Audio: toInputFile(file), + } + applySendAudioOpts(p, opts) + msg, err := s.b.SendAudio(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendDocument(ctx context.Context, chatID int64, file FileArg, opts *SendOpts) (int, error) { + p := &bot.SendDocumentParams{ + ChatID: chatID, + Document: toInputFile(file), + } + applySendDocumentOpts(p, opts) + msg, err := s.b.SendDocument(ctx, p) + if err != nil { + return 0, wrapErr(err) + } + return msg.ID, nil +} + +func (s *tgBotSender) SendMediaGroup(ctx context.Context, chatID int64, media []TGInputMedia, opts *SendOpts) ([]int, error) { + items := make([]models.InputMedia, 0, len(media)) + for _, m := range media { + items = append(items, toLibInputMedia(m)) + } + p := &bot.SendMediaGroupParams{ + ChatID: chatID, + Media: items, + } + if opts != nil { + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + } + msgs, err := s.b.SendMediaGroup(ctx, p) + if err != nil { + return nil, wrapErr(err) + } + ids := make([]int, len(msgs)) + for i, m := range msgs { + ids[i] = m.ID + } + return ids, nil +} + +// --- Edit --- + +func (s *tgBotSender) EditMessageText(ctx context.Context, chatID int64, msgID int, text string, opts *SendOpts) error { + p := &bot.EditMessageTextParams{ + ChatID: chatID, + MessageID: msgID, + Text: text, + } + if opts != nil { + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } + } + _, err := s.b.EditMessageText(ctx, p) + return wrapErr(err) +} + +func (s *tgBotSender) EditMessageMedia(ctx context.Context, chatID int64, msgID int, media TGInputMedia) error { + p := &bot.EditMessageMediaParams{ + ChatID: chatID, + MessageID: msgID, + Media: toLibInputMedia(media), + } + _, err := s.b.EditMessageMedia(ctx, p) + return wrapErr(err) +} + +// --- Other --- + +func (s *tgBotSender) DeleteMessage(ctx context.Context, chatID int64, msgID int) error { + _, err := s.b.DeleteMessage(ctx, &bot.DeleteMessageParams{ + ChatID: chatID, + MessageID: msgID, + }) + return wrapErr(err) +} + +func (s *tgBotSender) AnswerCallback(ctx context.Context, callbackID string, text string) error { + _, err := s.b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: callbackID, + Text: text, + }) + return wrapErr(err) +} + +func (s *tgBotSender) GetFile(ctx context.Context, fileID string) (string, error) { + f, err := s.b.GetFile(ctx, &bot.GetFileParams{FileID: fileID}) + if err != nil { + return "", wrapErr(err) + } + return f.FilePath, nil +} + +func (s *tgBotSender) GetFileDirectURL(filePath string) string { + if s.apiURL != "" { + return s.apiURL + "/" + filePath + } + return "https://api.telegram.org/file/bot" + s.token + "/" + filePath +} + +func (s *tgBotSender) GetChatMember(ctx context.Context, chatID, userID int64) (string, error) { + m, err := s.b.GetChatMember(ctx, &bot.GetChatMemberParams{ + ChatID: chatID, + UserID: userID, + }) + if err != nil { + return "", wrapErr(err) + } + return string(m.Type), nil +} + +func (s *tgBotSender) SetMyCommands(ctx context.Context, commands []BotCommand, scope *CommandScope) error { + cmds := make([]models.BotCommand, len(commands)) + for i, c := range commands { + cmds[i] = models.BotCommand{Command: c.Command, Description: c.Description} + } + p := &bot.SetMyCommandsParams{Commands: cmds} + if scope != nil && scope.Type == "all_chat_administrators" { + p.Scope = &models.BotCommandScopeAllChatAdministrators{} + } + _, err := s.b.SetMyCommands(ctx, p) + return wrapErr(err) +} + +func (s *tgBotSender) GetChat(ctx context.Context, chatID int64) (string, error) { + chat, err := s.b.GetChat(ctx, &bot.GetChatParams{ChatID: chatID}) + if err != nil { + return "", wrapErr(err) + } + return chat.Title, nil +} + +// --- Conversion helpers --- + +func toInputFile(f FileArg) models.InputFile { + if f.URL != "" { + return &models.InputFileString{Data: f.URL} + } + name := f.Name + if name == "" { + name = "file" + } + return &models.InputFileUpload{Filename: name, Data: bytes.NewReader(f.Bytes)} +} + +func toLibInputMedia(m TGInputMedia) models.InputMedia { + pm := models.ParseMode(m.ParseMode) + + // InputMedia structs use string Media field (URL or file_id) plus + // an io.Reader MediaAttachment for uploads. + if m.File.URL != "" { + // URL or file_id — set Media string directly, no attachment. + switch m.Type { + case "video": + return &models.InputMediaVideo{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + case "audio": + return &models.InputMediaAudio{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + case "document": + return &models.InputMediaDocument{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + default: + return &models.InputMediaPhoto{Media: m.File.URL, Caption: m.Caption, ParseMode: pm} + } + } + + // Upload — use attach:// protocol with MediaAttachment reader. + name := m.File.Name + if name == "" { + name = "file" + } + media := "attach://" + name + reader := bytes.NewReader(m.File.Bytes) + switch m.Type { + case "video": + return &models.InputMediaVideo{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + case "audio": + return &models.InputMediaAudio{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + case "document": + return &models.InputMediaDocument{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + default: + return &models.InputMediaPhoto{Media: media, Caption: m.Caption, ParseMode: pm, MediaAttachment: reader} + } +} + +func toLibKeyboard(kb *InlineKeyboardMarkup) *models.InlineKeyboardMarkup { + if kb == nil { + return nil + } + rows := make([][]models.InlineKeyboardButton, len(kb.Rows)) + for i, row := range kb.Rows { + btns := make([]models.InlineKeyboardButton, len(row)) + for j, b := range row { + btns[j] = models.InlineKeyboardButton{Text: b.Text, CallbackData: b.CallbackData} + } + rows[i] = btns + } + return &models.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +// --- Apply opts helpers --- + +func applySendMessageOpts(p *bot.SendMessageParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendPhotoOpts(p *bot.SendPhotoParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendVideoOpts(p *bot.SendVideoParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendAudioOpts(p *bot.SendAudioParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +func applySendDocumentOpts(p *bot.SendDocumentParams, opts *SendOpts) { + if opts == nil { + return + } + if opts.ThreadID != 0 { + p.MessageThreadID = opts.ThreadID + } + if opts.Caption != "" { + p.Caption = opts.Caption + } + if opts.ParseMode != "" { + p.ParseMode = models.ParseMode(opts.ParseMode) + } + if opts.ReplyToID != 0 { + p.ReplyParameters = &models.ReplyParameters{MessageID: opts.ReplyToID} + } + if opts.ReplyMarkup != nil { + p.ReplyMarkup = toLibKeyboard(opts.ReplyMarkup) + } +} + +// --- Error wrapping --- + +func wrapErr(err error) error { + if err == nil { + return nil + } + var me *bot.MigrateError + if errors.As(err, &me) { + return &TGError{ + Code: 400, + Description: me.Message, + MigrateToChatID: int64(me.MigrateToChatID), + } + } + if errors.Is(err, bot.ErrorForbidden) { + return &TGError{Code: 403, Description: err.Error()} + } + if errors.Is(err, bot.ErrorBadRequest) { + return &TGError{Code: 400, Description: err.Error()} + } + if errors.Is(err, bot.ErrorNotFound) { + return &TGError{Code: 404, Description: err.Error()} + } + var tmr *bot.TooManyRequestsError + if errors.As(err, &tmr) { + return &TGError{Code: 429, Description: tmr.Error()} + } + return err +} + +// --- Update conversion --- + +func convertUpdate(u *models.Update) TGUpdate { + return TGUpdate{ + Message: convertMsg(u.Message), + EditedMessage: convertMsg(u.EditedMessage), + ChannelPost: convertMsg(u.ChannelPost), + EditedChannelPost: convertMsg(u.EditedChannelPost), + CallbackQuery: convertCallback(u.CallbackQuery), + } +} + +func convertMsg(m *models.Message) *TGMessage { + if m == nil { + return nil + } + msg := &TGMessage{ + MessageID: m.ID, + MessageThreadID: m.MessageThreadID, + Chat: ChatInfo{ + ID: m.Chat.ID, + Type: string(m.Chat.Type), + Title: m.Chat.Title, + }, + Text: m.Text, + Caption: m.Caption, + MediaGroupID: m.MediaGroupID, + MigrateToChatID: m.MigrateToChatID, + } + + if m.From != nil { + msg.From = &UserInfo{ + ID: m.From.ID, + IsBot: m.From.IsBot, + UserName: m.From.Username, + FirstName: m.From.FirstName, + LastName: m.From.LastName, + } + } + + if m.SenderChat != nil { + msg.SenderChat = &ChatInfo{ + ID: m.SenderChat.ID, + Type: string(m.SenderChat.Type), + Title: m.SenderChat.Title, + } + } + + // ForwardOrigin -> ForwardOriginChat (for channel forwards) + if m.ForwardOrigin != nil && m.ForwardOrigin.MessageOriginChannel != nil { + ch := m.ForwardOrigin.MessageOriginChannel.Chat + msg.ForwardOriginChat = &ChatInfo{ + ID: ch.ID, + Type: string(ch.Type), + Title: ch.Title, + } + } + + // Photo + for _, p := range m.Photo { + msg.Photo = append(msg.Photo, PhotoSize{ + FileID: p.FileID, + FileSize: p.FileSize, + }) + } + + if m.Video != nil { + msg.Video = &FileInfo{FileID: m.Video.FileID, FileName: m.Video.FileName, FileSize: int(m.Video.FileSize)} + } + if m.Document != nil { + msg.Document = &DocInfo{FileID: m.Document.FileID, FileName: m.Document.FileName, FileSize: int(m.Document.FileSize), MimeType: m.Document.MimeType} + } + if m.Animation != nil { + msg.Animation = &FileInfo{FileID: m.Animation.FileID, FileName: m.Animation.FileName, FileSize: int(m.Animation.FileSize)} + } + if m.Sticker != nil { + msg.Sticker = &StickerInfo{FileID: m.Sticker.FileID, FileSize: m.Sticker.FileSize, IsAnimated: m.Sticker.IsAnimated} + } + if m.Voice != nil { + msg.Voice = &FileInfo{FileID: m.Voice.FileID, FileSize: int(m.Voice.FileSize)} + } + if m.Audio != nil { + msg.Audio = &AudioInfo{FileID: m.Audio.FileID, FileName: m.Audio.FileName, FileSize: int(m.Audio.FileSize)} + } + if m.VideoNote != nil { + msg.VideoNote = &FileInfo{FileID: m.VideoNote.FileID, FileSize: m.VideoNote.FileSize} + } + + if m.ReplyToMessage != nil { + msg.ReplyToMessage = convertMsg(m.ReplyToMessage) + } + + for _, e := range m.Entities { + msg.Entities = append(msg.Entities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL}) + } + for _, e := range m.CaptionEntities { + msg.CaptionEntities = append(msg.CaptionEntities, Entity{Type: string(e.Type), Offset: e.Offset, Length: e.Length, URL: e.URL}) + } + + return msg +} + +func convertCallback(cb *models.CallbackQuery) *TGCallback { + if cb == nil { + return nil + } + c := &TGCallback{ + ID: cb.ID, + Data: cb.Data, + } + if cb.From.ID != 0 { + c.From = &UserInfo{ + ID: cb.From.ID, + IsBot: cb.From.IsBot, + UserName: cb.From.Username, + FirstName: cb.From.FirstName, + LastName: cb.From.LastName, + } + } + if cb.Message.Message != nil { + c.Message = convertMsg(cb.Message.Message) + } + return c +} diff --git a/tgsender_impl_test.go b/tgsender_impl_test.go new file mode 100644 index 0000000..d866ecd --- /dev/null +++ b/tgsender_impl_test.go @@ -0,0 +1,590 @@ +package main + +import ( + "errors" + "testing" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// --- convertMsg --- + +func TestConvertMsg_Nil(t *testing.T) { + if got := convertMsg(nil); got != nil { + t.Errorf("convertMsg(nil) = %v, want nil", got) + } +} + +func TestConvertMsg_Basic(t *testing.T) { + m := &models.Message{ + ID: 42, + MessageThreadID: 7, + Chat: models.Chat{ID: -100, Type: "supergroup", Title: "Test"}, + Text: "hello", + Caption: "cap", + MediaGroupID: "mg1", + MigrateToChatID: -200, + } + got := convertMsg(m) + if got.MessageID != 42 { + t.Errorf("MessageID = %d, want 42", got.MessageID) + } + if got.MessageThreadID != 7 { + t.Errorf("MessageThreadID = %d, want 7", got.MessageThreadID) + } + if got.Chat.ID != -100 || got.Chat.Type != "supergroup" || got.Chat.Title != "Test" { + t.Errorf("Chat = %+v", got.Chat) + } + if got.Text != "hello" { + t.Errorf("Text = %q", got.Text) + } + if got.Caption != "cap" { + t.Errorf("Caption = %q", got.Caption) + } + if got.MediaGroupID != "mg1" { + t.Errorf("MediaGroupID = %q", got.MediaGroupID) + } + if got.MigrateToChatID != -200 { + t.Errorf("MigrateToChatID = %d", got.MigrateToChatID) + } + if got.From != nil { + t.Errorf("From should be nil when input From is nil") + } + if got.SenderChat != nil { + t.Errorf("SenderChat should be nil") + } +} + +func TestConvertMsg_From(t *testing.T) { + m := &models.Message{ + ID: 1, + From: &models.User{ + ID: 123, + IsBot: true, + Username: "testbot", + FirstName: "Test", + LastName: "Bot", + }, + Chat: models.Chat{ID: 1}, + } + got := convertMsg(m) + if got.From == nil { + t.Fatal("From is nil") + } + if got.From.ID != 123 { + t.Errorf("From.ID = %d", got.From.ID) + } + if !got.From.IsBot { + t.Error("From.IsBot = false") + } + if got.From.UserName != "testbot" { + t.Errorf("From.UserName = %q", got.From.UserName) + } + if got.From.FirstName != "Test" || got.From.LastName != "Bot" { + t.Errorf("From name = %q %q", got.From.FirstName, got.From.LastName) + } +} + +func TestConvertMsg_SenderChat(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + SenderChat: &models.Chat{ID: -500, Type: "channel", Title: "Chan"}, + } + got := convertMsg(m) + if got.SenderChat == nil { + t.Fatal("SenderChat nil") + } + if got.SenderChat.ID != -500 || got.SenderChat.Type != "channel" || got.SenderChat.Title != "Chan" { + t.Errorf("SenderChat = %+v", got.SenderChat) + } +} + +func TestConvertMsg_ForwardOriginChannel(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + ForwardOrigin: &models.MessageOrigin{ + MessageOriginChannel: &models.MessageOriginChannel{ + Chat: models.Chat{ID: -999, Type: "channel", Title: "News"}, + }, + }, + } + got := convertMsg(m) + if got.ForwardOriginChat == nil { + t.Fatal("ForwardOriginChat nil") + } + if got.ForwardOriginChat.ID != -999 || got.ForwardOriginChat.Title != "News" { + t.Errorf("ForwardOriginChat = %+v", got.ForwardOriginChat) + } +} + +func TestConvertMsg_ForwardOriginNonChannel(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + ForwardOrigin: &models.MessageOrigin{ + MessageOriginUser: &models.MessageOriginUser{}, + }, + } + got := convertMsg(m) + if got.ForwardOriginChat != nil { + t.Errorf("ForwardOriginChat should be nil for non-channel origin, got %+v", got.ForwardOriginChat) + } +} + +func TestConvertMsg_Media(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + Photo: []models.PhotoSize{ + {FileID: "p1", FileSize: 100}, + {FileID: "p2", FileSize: 200}, + }, + Video: &models.Video{FileID: "v1", FileName: "vid.mp4", FileSize: 5000}, + Document: &models.Document{FileID: "d1", FileName: "doc.pdf", FileSize: 3000, MimeType: "application/pdf"}, + Animation: &models.Animation{FileID: "a1", FileName: "anim.gif", FileSize: 1000}, + Sticker: &models.Sticker{FileID: "s1", FileSize: 50, IsAnimated: true}, + Voice: &models.Voice{FileID: "vo1", FileSize: 800}, + Audio: &models.Audio{FileID: "au1", FileName: "song.mp3", FileSize: 4000}, + VideoNote: &models.VideoNote{FileID: "vn1", FileSize: 600}, + } + got := convertMsg(m) + + if len(got.Photo) != 2 || got.Photo[0].FileID != "p1" || got.Photo[1].FileSize != 200 { + t.Errorf("Photo = %+v", got.Photo) + } + if got.Video == nil || got.Video.FileID != "v1" || got.Video.FileName != "vid.mp4" || got.Video.FileSize != 5000 { + t.Errorf("Video = %+v", got.Video) + } + if got.Document == nil || got.Document.FileID != "d1" || got.Document.MimeType != "application/pdf" { + t.Errorf("Document = %+v", got.Document) + } + if got.Animation == nil || got.Animation.FileID != "a1" { + t.Errorf("Animation = %+v", got.Animation) + } + if got.Sticker == nil || got.Sticker.FileID != "s1" || !got.Sticker.IsAnimated { + t.Errorf("Sticker = %+v", got.Sticker) + } + if got.Voice == nil || got.Voice.FileID != "vo1" || got.Voice.FileSize != 800 { + t.Errorf("Voice = %+v", got.Voice) + } + if got.Audio == nil || got.Audio.FileID != "au1" || got.Audio.FileName != "song.mp3" { + t.Errorf("Audio = %+v", got.Audio) + } + if got.VideoNote == nil || got.VideoNote.FileID != "vn1" { + t.Errorf("VideoNote = %+v", got.VideoNote) + } +} + +func TestConvertMsg_Entities(t *testing.T) { + m := &models.Message{ + ID: 1, + Chat: models.Chat{ID: 1}, + Text: "hello world", + Entities: []models.MessageEntity{ + {Type: "bold", Offset: 0, Length: 5}, + {Type: "text_link", Offset: 6, Length: 5, URL: "https://example.com"}, + }, + CaptionEntities: []models.MessageEntity{ + {Type: "italic", Offset: 0, Length: 3}, + }, + } + got := convertMsg(m) + if len(got.Entities) != 2 { + t.Fatalf("Entities len = %d, want 2", len(got.Entities)) + } + if got.Entities[0].Type != "bold" || got.Entities[0].Offset != 0 || got.Entities[0].Length != 5 { + t.Errorf("Entities[0] = %+v", got.Entities[0]) + } + if got.Entities[1].URL != "https://example.com" { + t.Errorf("Entities[1].URL = %q", got.Entities[1].URL) + } + if len(got.CaptionEntities) != 1 || got.CaptionEntities[0].Type != "italic" { + t.Errorf("CaptionEntities = %+v", got.CaptionEntities) + } +} + +func TestConvertMsg_ReplyToMessage(t *testing.T) { + m := &models.Message{ + ID: 10, + Chat: models.Chat{ID: 1}, + ReplyToMessage: &models.Message{ + ID: 5, + Chat: models.Chat{ID: 1}, + Text: "original", + }, + } + got := convertMsg(m) + if got.ReplyToMessage == nil { + t.Fatal("ReplyToMessage nil") + } + if got.ReplyToMessage.MessageID != 5 || got.ReplyToMessage.Text != "original" { + t.Errorf("ReplyToMessage = %+v", got.ReplyToMessage) + } +} + +// --- convertCallback --- + +func TestConvertCallback_Nil(t *testing.T) { + if got := convertCallback(nil); got != nil { + t.Errorf("convertCallback(nil) = %v, want nil", got) + } +} + +func TestConvertCallback_Full(t *testing.T) { + cb := &models.CallbackQuery{ + ID: "cb123", + Data: "cpd:both:999", + From: models.User{ID: 42, Username: "user1", FirstName: "John"}, + Message: models.MaybeInaccessibleMessage{ + Message: &models.Message{ + ID: 77, + Chat: models.Chat{ID: -100, Title: "Group"}, + Text: "old text", + }, + }, + } + got := convertCallback(cb) + if got.ID != "cb123" || got.Data != "cpd:both:999" { + t.Errorf("ID=%q Data=%q", got.ID, got.Data) + } + if got.From == nil || got.From.ID != 42 { + t.Errorf("From = %+v", got.From) + } + if got.Message == nil || got.Message.MessageID != 77 || got.Message.Text != "old text" { + t.Errorf("Message = %+v", got.Message) + } +} + +func TestConvertCallback_NoFrom(t *testing.T) { + cb := &models.CallbackQuery{ + ID: "cb1", + From: models.User{}, // zero value, ID=0 + } + got := convertCallback(cb) + if got.From != nil { + t.Errorf("From should be nil when ID=0, got %+v", got.From) + } +} + +func TestConvertCallback_InaccessibleMessage(t *testing.T) { + cb := &models.CallbackQuery{ + ID: "cb2", + From: models.User{ID: 1}, + Message: models.MaybeInaccessibleMessage{ + InaccessibleMessage: &models.InaccessibleMessage{ + Chat: models.Chat{ID: -100}, + MessageID: 55, + }, + }, + } + got := convertCallback(cb) + if got.Message != nil { + t.Errorf("Message should be nil for inaccessible message, got %+v", got.Message) + } +} + +// --- convertUpdate --- + +func TestConvertUpdate_Routes(t *testing.T) { + u := &models.Update{ + Message: &models.Message{ID: 1, Chat: models.Chat{ID: 1}}, + EditedMessage: &models.Message{ID: 2, Chat: models.Chat{ID: 1}}, + ChannelPost: &models.Message{ID: 3, Chat: models.Chat{ID: 1}}, + EditedChannelPost: &models.Message{ID: 4, Chat: models.Chat{ID: 1}}, + CallbackQuery: &models.CallbackQuery{ID: "cb5", From: models.User{ID: 1}}, + } + got := convertUpdate(u) + if got.Message == nil || got.Message.MessageID != 1 { + t.Errorf("Message = %+v", got.Message) + } + if got.EditedMessage == nil || got.EditedMessage.MessageID != 2 { + t.Errorf("EditedMessage = %+v", got.EditedMessage) + } + if got.ChannelPost == nil || got.ChannelPost.MessageID != 3 { + t.Errorf("ChannelPost = %+v", got.ChannelPost) + } + if got.EditedChannelPost == nil || got.EditedChannelPost.MessageID != 4 { + t.Errorf("EditedChannelPost = %+v", got.EditedChannelPost) + } + if got.CallbackQuery == nil || got.CallbackQuery.ID != "cb5" { + t.Errorf("CallbackQuery = %+v", got.CallbackQuery) + } +} + +func TestConvertUpdate_Empty(t *testing.T) { + got := convertUpdate(&models.Update{}) + if got.Message != nil || got.EditedMessage != nil || got.ChannelPost != nil || got.EditedChannelPost != nil || got.CallbackQuery != nil { + t.Errorf("empty update should produce all nils") + } +} + +// --- wrapErr --- + +func TestWrapErr_Nil(t *testing.T) { + if got := wrapErr(nil); got != nil { + t.Errorf("wrapErr(nil) = %v", got) + } +} + +func TestWrapErr_MigrateError(t *testing.T) { + err := &bot.MigrateError{Message: "group migrated", MigrateToChatID: -1001234} + got := wrapErr(err) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 400 { + t.Errorf("Code = %d, want 400", tgErr.Code) + } + if tgErr.MigrateToChatID != -1001234 { + t.Errorf("MigrateToChatID = %d", tgErr.MigrateToChatID) + } +} + +func TestWrapErr_Forbidden(t *testing.T) { + got := wrapErr(bot.ErrorForbidden) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T: %v", got, got) + } + if tgErr.Code != 403 { + t.Errorf("Code = %d, want 403", tgErr.Code) + } +} + +func TestWrapErr_BadRequest(t *testing.T) { + got := wrapErr(bot.ErrorBadRequest) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 400 { + t.Errorf("Code = %d, want 400", tgErr.Code) + } +} + +func TestWrapErr_NotFound(t *testing.T) { + got := wrapErr(bot.ErrorNotFound) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 404 { + t.Errorf("Code = %d, want 404", tgErr.Code) + } +} + +func TestWrapErr_TooManyRequests(t *testing.T) { + err := &bot.TooManyRequestsError{Message: "slow down", RetryAfter: 30} + got := wrapErr(err) + var tgErr *TGError + if !errors.As(got, &tgErr) { + t.Fatalf("expected *TGError, got %T", got) + } + if tgErr.Code != 429 { + t.Errorf("Code = %d, want 429", tgErr.Code) + } +} + +func TestWrapErr_UnknownError(t *testing.T) { + orig := errors.New("something weird") + got := wrapErr(orig) + if got != orig { + t.Errorf("unknown error should pass through, got %v", got) + } +} + +// --- toInputFile --- + +func TestToInputFile_URL(t *testing.T) { + f := toInputFile(FileArg{URL: "https://example.com/photo.jpg"}) + ifs, ok := f.(*models.InputFileString) + if !ok { + t.Fatalf("expected *InputFileString, got %T", f) + } + if ifs.Data != "https://example.com/photo.jpg" { + t.Errorf("Data = %q", ifs.Data) + } +} + +func TestToInputFile_Bytes(t *testing.T) { + f := toInputFile(FileArg{Name: "test.jpg", Bytes: []byte("data")}) + ifu, ok := f.(*models.InputFileUpload) + if !ok { + t.Fatalf("expected *InputFileUpload, got %T", f) + } + if ifu.Filename != "test.jpg" { + t.Errorf("Filename = %q", ifu.Filename) + } +} + +func TestToInputFile_DefaultName(t *testing.T) { + f := toInputFile(FileArg{Bytes: []byte("data")}) + ifu, ok := f.(*models.InputFileUpload) + if !ok { + t.Fatalf("expected *InputFileUpload, got %T", f) + } + if ifu.Filename != "file" { + t.Errorf("Filename = %q, want 'file'", ifu.Filename) + } +} + +// --- toLibInputMedia --- + +func TestToLibInputMedia_PhotoURL(t *testing.T) { + m := toLibInputMedia(TGInputMedia{ + Type: "photo", + File: FileArg{URL: "https://example.com/img.jpg"}, + Caption: "nice", + ParseMode: "HTML", + }) + p, ok := m.(*models.InputMediaPhoto) + if !ok { + t.Fatalf("expected *InputMediaPhoto, got %T", m) + } + if p.Media != "https://example.com/img.jpg" { + t.Errorf("Media = %q", p.Media) + } + if p.Caption != "nice" { + t.Errorf("Caption = %q", p.Caption) + } + if p.ParseMode != "HTML" { + t.Errorf("ParseMode = %q", p.ParseMode) + } +} + +func TestToLibInputMedia_VideoBytes(t *testing.T) { + m := toLibInputMedia(TGInputMedia{ + Type: "video", + File: FileArg{Name: "clip.mp4", Bytes: []byte("vid")}, + }) + v, ok := m.(*models.InputMediaVideo) + if !ok { + t.Fatalf("expected *InputMediaVideo, got %T", m) + } + if v.Media != "attach://clip.mp4" { + t.Errorf("Media = %q", v.Media) + } + if v.MediaAttachment == nil { + t.Error("MediaAttachment is nil") + } +} + +func TestToLibInputMedia_AudioURL(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "audio", File: FileArg{URL: "https://x.com/a.mp3"}}) + if _, ok := m.(*models.InputMediaAudio); !ok { + t.Fatalf("expected *InputMediaAudio, got %T", m) + } +} + +func TestToLibInputMedia_DocumentBytes(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "document", File: FileArg{Name: "f.pdf", Bytes: []byte("pdf")}}) + d, ok := m.(*models.InputMediaDocument) + if !ok { + t.Fatalf("expected *InputMediaDocument, got %T", m) + } + if d.Media != "attach://f.pdf" { + t.Errorf("Media = %q", d.Media) + } +} + +func TestToLibInputMedia_DefaultType(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "unknown", File: FileArg{URL: "https://x.com/img"}}) + if _, ok := m.(*models.InputMediaPhoto); !ok { + t.Fatalf("unknown type should default to photo, got %T", m) + } +} + +func TestToLibInputMedia_BytesDefaultName(t *testing.T) { + m := toLibInputMedia(TGInputMedia{Type: "photo", File: FileArg{Bytes: []byte("x")}}) + p, ok := m.(*models.InputMediaPhoto) + if !ok { + t.Fatalf("expected *InputMediaPhoto, got %T", m) + } + if p.Media != "attach://file" { + t.Errorf("Media = %q, want 'attach://file'", p.Media) + } +} + +// --- toLibKeyboard --- + +func TestToLibKeyboard_Nil(t *testing.T) { + if got := toLibKeyboard(nil); got != nil { + t.Errorf("toLibKeyboard(nil) = %v", got) + } +} + +func TestToLibKeyboard(t *testing.T) { + kb := &InlineKeyboardMarkup{ + Rows: [][]InlineKeyboardButton{ + { + {Text: "A", CallbackData: "a"}, + {Text: "B", CallbackData: "b"}, + }, + { + {Text: "C", CallbackData: "c"}, + }, + }, + } + got := toLibKeyboard(kb) + if len(got.InlineKeyboard) != 2 { + t.Fatalf("rows = %d, want 2", len(got.InlineKeyboard)) + } + if len(got.InlineKeyboard[0]) != 2 { + t.Fatalf("row0 cols = %d, want 2", len(got.InlineKeyboard[0])) + } + if got.InlineKeyboard[0][0].Text != "A" || got.InlineKeyboard[0][0].CallbackData != "a" { + t.Errorf("btn[0][0] = %+v", got.InlineKeyboard[0][0]) + } + if got.InlineKeyboard[1][0].Text != "C" { + t.Errorf("btn[1][0] = %+v", got.InlineKeyboard[1][0]) + } +} + +// --- keyboard helpers --- + +func TestNewInlineKeyboard(t *testing.T) { + kb := NewInlineKeyboard( + NewInlineRow(NewInlineButton("X", "x"), NewInlineButton("Y", "y")), + NewInlineRow(NewInlineButton("Z", "z")), + ) + if len(kb.Rows) != 2 { + t.Fatalf("rows = %d", len(kb.Rows)) + } + if kb.Rows[0][0].Text != "X" || kb.Rows[0][1].CallbackData != "y" { + t.Errorf("row0 = %+v", kb.Rows[0]) + } +} + +// --- TGError --- + +func TestTGError_Error(t *testing.T) { + e := &TGError{Code: 403, Description: "Forbidden: bot blocked"} + got := e.Error() + if got != "telegram: Forbidden: bot blocked (403)" { + t.Errorf("Error() = %q", got) + } +} + +// --- GetFileDirectURL --- + +func TestGetFileDirectURL_Default(t *testing.T) { + s := &tgBotSender{token: "123:ABC"} + got := s.GetFileDirectURL("photos/file_1.jpg") + want := "https://api.telegram.org/file/bot123:ABC/photos/file_1.jpg" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestGetFileDirectURL_CustomAPI(t *testing.T) { + s := &tgBotSender{token: "123:ABC", apiURL: "http://localhost:8081"} + got := s.GetFileDirectURL("photos/file_1.jpg") + want := "http://localhost:8081/photos/file_1.jpg" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/upload.go b/upload.go index ec5b905..4983636 100644 --- a/upload.go +++ b/upload.go @@ -13,7 +13,6 @@ import ( "time" maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes" - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) // downloadURL скачивает файл по URL и возвращает bytes. @@ -42,60 +41,44 @@ func (b *Bridge) downloadURL(url string) ([]byte, error) { // sendTgMediaFromURL скачивает файл с URL и отправляет в TG как upload. // maxBytes=0 means no size limit. fileName overrides name extracted from URL. -func (b *Bridge) sendTgMediaFromURL(tgChatID int64, mediaURL, mediaType, caption, parseMode string, replyToID int, maxBytes int64, fileName ...string) (tgbotapi.Message, error) { +func (b *Bridge) sendTgMediaFromURL(ctx context.Context, tgChatID int64, mediaURL, mediaType, caption, parseMode string, replyToID, threadID int, maxBytes int64, fileName ...string) (int, error) { slog.Debug("sendTgMediaFromURL start", "url", mediaURL, "type", mediaType, "tgChat", tgChatID) data, nameFromURL, err := b.downloadURLWithLimit(mediaURL, maxBytes) if err == nil { slog.Debug("sendTgMediaFromURL downloaded", "size", len(data), "name", nameFromURL) } if err != nil { - return tgbotapi.Message{}, fmt.Errorf("download media: %w", err) + return 0, fmt.Errorf("download media: %w", err) } name := nameFromURL if len(fileName) > 0 && fileName[0] != "" { name = fileName[0] } - fb := tgbotapi.FileBytes{Name: name, Bytes: data} + file := FileArg{Name: name, Bytes: data} switch mediaType { case "photo": - msg := tgbotapi.NewPhoto(tgChatID, fb) - msg.Caption = caption - if parseMode != "" { - msg.ParseMode = parseMode - } - msg.ReplyToMessageID = replyToID - return b.tgBot.Send(msg) + return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) case "video": - msg := tgbotapi.NewVideo(tgChatID, fb) - msg.Caption = caption - if parseMode != "" { - msg.ParseMode = parseMode - } - msg.ReplyToMessageID = replyToID - return b.tgBot.Send(msg) + return b.tg.SendVideo(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) case "audio": - msg := tgbotapi.NewAudio(tgChatID, fb) - msg.Caption = caption - if parseMode != "" { - msg.ParseMode = parseMode - } - msg.ReplyToMessageID = replyToID - return b.tgBot.Send(msg) + return b.tg.SendAudio(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) case "file": - msg := tgbotapi.NewDocument(tgChatID, fb) - msg.Caption = caption - if parseMode != "" { - msg.ParseMode = parseMode - } - msg.ReplyToMessageID = replyToID - return b.tgBot.Send(msg) + return b.tg.SendDocument(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID, + }) default: // sticker и прочее — как фото - msg := tgbotapi.NewPhoto(tgChatID, fb) - msg.Caption = caption - return b.tgBot.Send(msg) + return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{ + Caption: caption, ThreadID: threadID, + }) } } @@ -199,7 +182,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up // uploadTgPhotoToMax скачивает фото из TG и загружает в MAX через SDK (возвращает PhotoTokens). func (b *Bridge) uploadTgPhotoToMax(ctx context.Context, fileID string) (*maxschemes.PhotoTokens, error) { - fileURL, err := b.tgFileURL(fileID) + fileURL, err := b.tgFileURL(ctx, fileID) if err != nil { return nil, fmt.Errorf("tg getFileURL: %w", err) } @@ -220,7 +203,7 @@ func (b *Bridge) uploadTgPhotoToMax(ctx context.Context, fileID string) (*maxsch // uploadTgMediaToMax скачивает файл из TG и загружает в MAX func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadType maxschemes.UploadType, fileName string) (*maxschemes.UploadedInfo, error) { - fileURL, err := b.tgFileURL(fileID) + fileURL, err := b.tgFileURL(ctx, fileID) if err != nil { return nil, fmt.Errorf("tg getFileURL: %w", err) }