Migrate TG library to go-telegram/bot with forum topic support

Replace go-telegram-bot-api/v5 (2021, no forum topics) with
go-telegram/bot v1.20.0 (Bot API 9.5) via TGSender adapter pattern.

- New TGSender interface + tgBotSender implementation isolating library
- Native message_thread_id support: saved at /bridge, used in MAX→TG
  sends, echoed in all command responses, looked up in queue retries
- All 16 files migrated, zero old library references remain
- Proper error wrapping: MigrateError, Forbidden, BadRequest, NotFound,
  TooManyRequests → TGError with codes
- ForwardFromChat → ForwardOriginChat (Bot API MessageOrigin pattern)
- Repository: GetTgThreadID/SetTgThreadID (migration 000012 already applied)
- Tests for convertMsg, convertCallback, wrapErr, toInputFile,
  toLibInputMedia, tgEntitiesToMarkdown, maxMarkupsToHTML

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrey Lugovskoy 2026-04-02 17:48:33 +03:00
parent 0a7e43b708
commit f8e4ff0ebd
20 changed files with 1948 additions and 432 deletions

View file

@ -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()

View file

@ -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

View file

@ -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: "",
},
}

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

31
main.go
View file

@ -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")
}

View file

@ -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

252
markup_test.go Normal file
View file

@ -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 <world>", nil)
if got != "hello &lt;world&gt;" {
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 := "<b>hello</b> 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 <i>world</i>"
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 <code>fmt.Println</code>"
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 := "<s>old</s> 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 <a href="https://example.com">here</a>`
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestMaxMarkupsToHTML_EscapesHTML(t *testing.T) {
got := maxMarkupsToHTML("<b>not bold</b>", nil)
want := "&lt;b&gt;not bold&lt;/b&gt;"
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 := "<b>hello</b> <i>world</i> 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 := "🔥<b>test</b>"
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 := "<u>hello</u>"
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 := `<a href="https://example.com/?a=1&amp;b=2">link</a>`
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}

93
max.go
View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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",

View file

@ -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)
}

View file

@ -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)),
),
)
}

View file

@ -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)

View file

@ -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)

View file

@ -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, "Неверный формат. Используйте:\n<code>from | to</code>\nили\n<code>/regex/ | to</code>"))
b.tg.SendMessage(ctx, msg.Chat.ID, "Неверный формат. Используйте:\n<code>from | to</code>\nили\n<code>/regex/ | to</code>", &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<code>%s</code> → <code>%s</code>", 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<code>%s</code> → <code>%s</code>", 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 <ID>"))
b.tg.SendMessage(ctx, msg.Chat.ID,
"Нет активных связок.\n\nНастройка: перешлите пост из TG-канала сюда, затем в MAX-боте /crosspost <ID>", &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: <code>%d</code>\n\nВ личке MAX-бота напишите:\n<code>/crosspost %d</code>\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: <code>%d</code>\n\nВ личке MAX-бота напишите:\n<code>/crosspost %d</code>\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 <key>
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("Ключ для связки: <code>%s</code>\n\nОтправьте в MAX-чате:\n<code>/bridge %s</code>\n\nMAX-бот: %s", generatedKey, generatedKey, b.cfg.MaxBotURL))
keyMsg.ParseMode = "HTML"
b.tgBot.Send(keyMsg)
b.tg.SendMessage(ctx, msg.Chat.ID,
fmt.Sprintf("Ключ для связки: <code>%s</code>\n\nОтправьте в MAX-чате:\n<code>/bridge %s</code>\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("Отправьте правило замены:\n<code>from | to</code>\n\nДля регулярного выражения:\n<code>/regex/ | to</code>\n\nНапример:\n<code>utm_source=tg | utm_source=max</code>"))
edit.ParseMode = "HTML"
b.tgBot.Send(edit)
b.tgBot.Request(tgbotapi.NewCallback(query.ID, ""))
b.tg.EditMessageText(ctx, chatID, msgID,
fmt.Sprintf("Отправьте правило замены:\n<code>from | to</code>\n\nДля регулярного выражения:\n<code>/regex/ | to</code>\n\nНапример:\n<code>utm_source=tg | utm_source=max</code>"),
&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

198
tgsender.go Normal file
View file

@ -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
}

599
tgsender_impl.go Normal file
View file

@ -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
}

590
tgsender_impl_test.go Normal file
View file

@ -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)
}
}

View file

@ -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)
}