mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-04-26 10:50:57 +00:00
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:
parent
0a7e43b708
commit
f8e4ff0ebd
20 changed files with 1948 additions and 432 deletions
60
bridge.go
60
bridge.go
|
|
@ -10,8 +10,6 @@ import (
|
|||
"time"
|
||||
|
||||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// Config — настройки bridge, читаемые из env.
|
||||
|
|
@ -48,7 +46,7 @@ const (
|
|||
type Bridge struct {
|
||||
cfg Config
|
||||
repo Repository
|
||||
tgBot *tgbotapi.BotAPI
|
||||
tg TGSender
|
||||
maxApi *maxbot.Api
|
||||
httpClient *http.Client // для скачивания/загрузки файлов (большой таймаут)
|
||||
apiClient *http.Client // для коротких API-запросов (малый таймаут)
|
||||
|
|
@ -69,15 +67,15 @@ type Bridge struct {
|
|||
}
|
||||
|
||||
// NewBridge создаёт экземпляр Bridge.
|
||||
func NewBridge(cfg Config, repo Repository, tgBot *tgbotapi.BotAPI, maxApi *maxbot.Api) *Bridge {
|
||||
func NewBridge(cfg Config, repo Repository, tg TGSender, maxApi *maxbot.Api) *Bridge {
|
||||
// Derive webhook secret from tokens (stable across restarts)
|
||||
h := sha256.Sum256([]byte(cfg.MaxToken + tgBot.Token))
|
||||
h := sha256.Sum256([]byte(cfg.MaxToken + tg.BotToken()))
|
||||
secret := hex.EncodeToString(h[:8])
|
||||
|
||||
return &Bridge{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
tgBot: tgBot,
|
||||
tg: tg,
|
||||
maxApi: maxApi,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 5 * time.Minute, // для download/upload больших файлов
|
||||
|
|
@ -161,12 +159,12 @@ func (b *Bridge) isUserAllowed(tgUserID int64) bool {
|
|||
// checkUserAllowed проверяет доступ пользователя и отправляет сообщение об отказе если нужно.
|
||||
// Возвращает true если доступ разрешён, false — если запрещён (и уже отправил ответ).
|
||||
// userID == 0 трактуется как «нет отправителя» — доступ запрещается.
|
||||
func (b *Bridge) checkUserAllowed(chatID, userID int64) bool {
|
||||
func (b *Bridge) checkUserAllowed(ctx context.Context, chatID, userID int64, threadID int) bool {
|
||||
if userID != 0 && b.isUserAllowed(userID) {
|
||||
return true
|
||||
}
|
||||
slog.Debug("TG user not allowed", "uid", userID)
|
||||
b.tgBot.Send(tgbotapi.NewMessage(chatID, "У вас нет прав доступа к боту."))
|
||||
b.tg.SendMessage(ctx, chatID, "У вас нет прав доступа к боту.", &SendOpts{ThreadID: threadID})
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -181,25 +179,21 @@ func (b *Bridge) isCrosspostOwner(maxChatID, userID int64) bool {
|
|||
}
|
||||
|
||||
// tgFileURL возвращает прямой URL файла из TG — через custom API если настроен.
|
||||
func (b *Bridge) tgFileURL(fileID string) (string, error) {
|
||||
file, err := b.tgBot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||
func (b *Bridge) tgFileURL(ctx context.Context, fileID string) (string, error) {
|
||||
filePath, err := b.tg.GetFile(ctx, fileID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b.cfg.TgAPIURL != "" {
|
||||
// --local mode: file_path — абсолютный путь, отдаём через nginx
|
||||
return b.cfg.TgAPIURL + "/" + file.FilePath, nil
|
||||
}
|
||||
return file.Link(b.tgBot.Token), nil
|
||||
return b.tg.GetFileDirectURL(filePath), nil
|
||||
}
|
||||
|
||||
// tgChatTitle возвращает title TG-чата/канала по ID. Пустая строка если не удалось.
|
||||
func (b *Bridge) tgChatTitle(chatID int64) string {
|
||||
chat, err := b.tgBot.GetChat(tgbotapi.ChatInfoConfig{ChatConfig: tgbotapi.ChatConfig{ChatID: chatID}})
|
||||
func (b *Bridge) tgChatTitle(ctx context.Context, chatID int64) string {
|
||||
title, err := b.tg.GetChat(ctx, chatID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return chat.Title
|
||||
return title
|
||||
}
|
||||
|
||||
func (b *Bridge) tgWebhookPath() string {
|
||||
|
|
@ -211,34 +205,24 @@ func (b *Bridge) maxWebhookPath() string {
|
|||
}
|
||||
|
||||
// registerCommands регистрирует команды бота в Telegram.
|
||||
func (b *Bridge) registerCommands() {
|
||||
// Команды для групп и личных чатов
|
||||
groupCmds := tgbotapi.NewSetMyCommands(
|
||||
tgbotapi.BotCommand{Command: "bridge", Description: "Связать чат с MAX-чатом"},
|
||||
tgbotapi.BotCommand{Command: "unbridge", Description: "Удалить связку чатов"},
|
||||
tgbotapi.BotCommand{Command: "crosspost", Description: "Список связок кросспостинга"},
|
||||
tgbotapi.BotCommand{Command: "help", Description: "Инструкция"},
|
||||
)
|
||||
if _, err := b.tgBot.Request(groupCmds); err != nil {
|
||||
func (b *Bridge) registerCommands(ctx context.Context) {
|
||||
cmds := []BotCommand{
|
||||
{Command: "bridge", Description: "Связать чат с MAX-чатом"},
|
||||
{Command: "unbridge", Description: "Удалить связку чатов"},
|
||||
{Command: "crosspost", Description: "Список связок кросспостинга"},
|
||||
{Command: "help", Description: "Инструкция"},
|
||||
}
|
||||
if err := b.tg.SetMyCommands(ctx, cmds, nil); err != nil {
|
||||
slog.Error("TG setMyCommands (default) failed", "err", err)
|
||||
}
|
||||
|
||||
// Команды для админов (группы + каналы)
|
||||
channelCmds := tgbotapi.NewSetMyCommandsWithScope(
|
||||
tgbotapi.NewBotCommandScopeAllChatAdministrators(),
|
||||
tgbotapi.BotCommand{Command: "bridge", Description: "Связать чат с MAX-чатом"},
|
||||
tgbotapi.BotCommand{Command: "unbridge", Description: "Удалить связку чатов"},
|
||||
tgbotapi.BotCommand{Command: "crosspost", Description: "Список связок кросспостинга"},
|
||||
tgbotapi.BotCommand{Command: "help", Description: "Инструкция"},
|
||||
)
|
||||
if _, err := b.tgBot.Request(channelCmds); err != nil {
|
||||
if err := b.tg.SetMyCommands(ctx, cmds, &CommandScope{Type: "all_chat_administrators"}); err != nil {
|
||||
slog.Error("TG setMyCommands (admins) failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run запускает TG и MAX listener'ы + периодическую очистку.
|
||||
func (b *Bridge) Run(ctx context.Context) {
|
||||
b.registerCommands()
|
||||
b.registerCommands(ctx)
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
|
|
|
|||
10
format.go
10
format.go
|
|
@ -4,11 +4,9 @@ import (
|
|||
"strings"
|
||||
|
||||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func tgName(msg *tgbotapi.Message) string {
|
||||
func tgName(msg *TGMessage) string {
|
||||
if msg.From == nil {
|
||||
if msg.SenderChat != nil {
|
||||
return msg.SenderChat.Title
|
||||
|
|
@ -31,7 +29,7 @@ func formatAttribution(name, text string, newline bool) string {
|
|||
}
|
||||
|
||||
// formatTgCaption — для пересылки (текст или caption)
|
||||
func formatTgCaption(msg *tgbotapi.Message, prefix, newline bool) string {
|
||||
func formatTgCaption(msg *TGMessage, prefix, newline bool) string {
|
||||
name := tgName(msg)
|
||||
text := msg.Text
|
||||
if text == "" {
|
||||
|
|
@ -44,7 +42,7 @@ func formatTgCaption(msg *tgbotapi.Message, prefix, newline bool) string {
|
|||
}
|
||||
|
||||
// formatTgMessage — для edit (полный формат)
|
||||
func formatTgMessage(msg *tgbotapi.Message, prefix, newline bool) string {
|
||||
func formatTgMessage(msg *TGMessage, prefix, newline bool) string {
|
||||
name := tgName(msg)
|
||||
text := msg.Text
|
||||
if text == "" {
|
||||
|
|
@ -78,7 +76,7 @@ func formatMaxCaption(upd *maxschemes.MessageCreatedUpdate, prefix, newline bool
|
|||
}
|
||||
|
||||
// formatTgCrosspostCaption — для кросспостинга каналов (без attribution и префиксов)
|
||||
func formatTgCrosspostCaption(msg *tgbotapi.Message) string {
|
||||
func formatTgCrosspostCaption(msg *TGMessage) string {
|
||||
text := msg.Text
|
||||
if text == "" {
|
||||
text = msg.Caption
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -3,7 +3,7 @@ module bearlogin-bridge
|
|||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/go-telegram/bot v1.20.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -27,8 +27,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/go-telegram/bot v1.20.0 h1:4Pea/qTidSspr4WBJw9FbHUMNhYeqszBqQUfsQEyFbc=
|
||||
github.com/go-telegram/bot v1.20.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
|
|
|
|||
31
main.go
31
main.go
|
|
@ -13,8 +13,6 @@ import (
|
|||
"syscall"
|
||||
|
||||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func mustEnv(key string) string {
|
||||
|
|
@ -119,7 +117,6 @@ func main() {
|
|||
slog.Info("Message format: newline")
|
||||
}
|
||||
|
||||
tgToken := mustEnv("TG_TOKEN")
|
||||
dbPath := envOr("DB_PATH", "bridge.db")
|
||||
|
||||
var repo Repository
|
||||
|
|
@ -141,21 +138,14 @@ func main() {
|
|||
}
|
||||
defer repo.Close()
|
||||
|
||||
var tgBot *tgbotapi.BotAPI
|
||||
if tgAPI := os.Getenv("TG_API_URL"); tgAPI != "" {
|
||||
tgBot, err = tgbotapi.NewBotAPIWithAPIEndpoint(tgToken, tgAPI+"/bot%s/%s")
|
||||
if err != nil {
|
||||
slog.Error("TG bot error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("Telegram bot started (custom API)", "username", tgBot.Self.UserName, "api", tgAPI)
|
||||
} else {
|
||||
tgBot, err = tgbotapi.NewBotAPI(tgToken)
|
||||
if err != nil {
|
||||
slog.Error("TG bot error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("Telegram bot started", "username", tgBot.Self.UserName)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
tgToken := mustEnv("TG_TOKEN")
|
||||
tg, err := NewTGBotSender(ctx, tgToken, cfg.TgAPIURL)
|
||||
if err != nil {
|
||||
slog.Error("TG bot error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
maxApi, err := maxbot.New(cfg.MaxToken)
|
||||
|
|
@ -170,9 +160,6 @@ func main() {
|
|||
}
|
||||
slog.Info("MAX bot started", "name", maxInfo.Name)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
|
|
@ -181,7 +168,7 @@ func main() {
|
|||
cancel()
|
||||
}()
|
||||
|
||||
bridge := NewBridge(cfg, repo, tgBot, maxApi)
|
||||
bridge := NewBridge(cfg, repo, tg, maxApi)
|
||||
bridge.Run(ctx)
|
||||
slog.Info("Bridge stopped")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
252
markup_test.go
Normal 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 <world>" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxMarkupsToHTML_Bold(t *testing.T) {
|
||||
got := maxMarkupsToHTML("hello world", []maxschemes.MarkUp{
|
||||
{Type: maxschemes.MarkupStrong, From: 0, Length: 5},
|
||||
})
|
||||
want := "<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 := "<b>not bold</b>"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxMarkupsToHTML_Multiple(t *testing.T) {
|
||||
got := maxMarkupsToHTML("hello world test", []maxschemes.MarkUp{
|
||||
{Type: maxschemes.MarkupStrong, From: 0, Length: 5},
|
||||
{Type: maxschemes.MarkupEmphasized, From: 6, Length: 5},
|
||||
})
|
||||
want := "<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&b=2">link</a>`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
93
max.go
93
max.go
|
|
@ -11,8 +11,6 @@ import (
|
|||
|
||||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (b *Bridge) listenMax(ctx context.Context) {
|
||||
|
|
@ -56,8 +54,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
del := tgbotapi.NewDeleteMessage(tgChatID, tgMsgID)
|
||||
if _, err := b.tgBot.Request(del); err != nil {
|
||||
if err := b.tg.DeleteMessage(ctx, tgChatID, tgMsgID); err != nil {
|
||||
slog.Error("MAX→TG delete failed", "err", err, "maxMid", delUpd.MessageId, "tgChat", tgChatID)
|
||||
} else {
|
||||
slog.Info("MAX→TG deleted", "tgMsg", tgMsgID, "tgChat", tgChatID)
|
||||
|
|
@ -119,33 +116,19 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
if dlErr != nil {
|
||||
slog.Error("MAX→TG edit media download failed", "err", dlErr)
|
||||
} else {
|
||||
fb := tgbotapi.FileBytes{Name: name, Bytes: data}
|
||||
var media interface{}
|
||||
var mediaIM TGInputMedia
|
||||
switch mediaType {
|
||||
case "photo":
|
||||
p := tgbotapi.NewInputMediaPhoto(fb)
|
||||
p.Caption = fwd
|
||||
media = p
|
||||
mediaIM = TGInputMedia{Type: "photo", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
|
||||
case "video":
|
||||
v := tgbotapi.NewInputMediaVideo(fb)
|
||||
v.Caption = fwd
|
||||
media = v
|
||||
mediaIM = TGInputMedia{Type: "video", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
|
||||
case "document":
|
||||
d := tgbotapi.NewInputMediaDocument(fb)
|
||||
d.Caption = fwd
|
||||
media = d
|
||||
mediaIM = TGInputMedia{Type: "document", File: FileArg{Name: name, Bytes: data}, Caption: fwd}
|
||||
}
|
||||
editMedia := tgbotapi.EditMessageMediaConfig{
|
||||
BaseEdit: tgbotapi.BaseEdit{
|
||||
ChatID: tgChatID,
|
||||
MessageID: tgMsgID,
|
||||
},
|
||||
Media: media,
|
||||
}
|
||||
if _, err := b.tgBot.Send(editMedia); err != nil {
|
||||
if err := b.tg.EditMessageMedia(ctx, tgChatID, tgMsgID, mediaIM); err != nil {
|
||||
slog.Error("MAX→TG edit media failed", "err", err, "uid", editUpd.Message.Sender.UserId)
|
||||
// Fallback — отправляем как новое сообщение
|
||||
go b.sendTgMediaFromURL(tgChatID, mediaURL, mediaType, fwd, "", 0, b.cfg.maxMaxFileBytes())
|
||||
go b.sendTgMediaFromURL(ctx, tgChatID, mediaURL, mediaType, fwd, "", 0, 0, b.cfg.maxMaxFileBytes())
|
||||
} else {
|
||||
slog.Info("MAX→TG edited media", "tgMsg", tgMsgID, "type", mediaType, "uid", editUpd.Message.Sender.UserId)
|
||||
}
|
||||
|
|
@ -156,8 +139,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
if text == "" {
|
||||
continue
|
||||
}
|
||||
editMsg := tgbotapi.NewEditMessageText(tgChatID, tgMsgID, fwd)
|
||||
if _, err := b.tgBot.Send(editMsg); err != nil {
|
||||
if err := b.tg.EditMessageText(ctx, tgChatID, tgMsgID, fwd, nil); err != nil {
|
||||
slog.Error("MAX→TG edit failed", "err", err, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId)
|
||||
} else {
|
||||
slog.Info("MAX→TG edited", "tgMsg", tgMsgID, "uid", editUpd.Message.Sender.UserId, "maxChat", editUpd.Message.Recipient.ChatId)
|
||||
|
|
@ -372,7 +354,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
} else {
|
||||
for _, l := range links {
|
||||
kb := maxCrosspostKeyboard(b.maxApi, l.Direction, l.MaxChatID)
|
||||
tgTitle := b.tgChatTitle(l.TgChatID)
|
||||
tgTitle := b.tgChatTitle(ctx, l.TgChatID)
|
||||
statusText := maxCrosspostStatusText(l.TgChatID, l.Direction)
|
||||
if tgTitle != "" {
|
||||
statusText = fmt.Sprintf("TG: «%s» (%d)\n", tgTitle, l.TgChatID) + statusText
|
||||
|
|
@ -857,6 +839,8 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
return
|
||||
}
|
||||
|
||||
threadID := b.repo.GetTgThreadID(tgChatID)
|
||||
|
||||
body := msgUpd.Message.Body
|
||||
chatID := msgUpd.Message.Recipient.ChatId
|
||||
text := strings.TrimSpace(body.Text)
|
||||
|
|
@ -877,7 +861,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
}
|
||||
|
||||
// Проверяем вложения
|
||||
var sent tgbotapi.Message
|
||||
var sentMsgID int
|
||||
var sendErr error
|
||||
mediaSent := false
|
||||
var qAttType, qAttURL string // для очереди при ошибке
|
||||
|
|
@ -890,7 +874,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
}
|
||||
|
||||
// Собираем вложения: фото/видео → albumMedia (отправляем вместе), остальные → soloMedia
|
||||
var albumMedia []interface{}
|
||||
var albumMedia []TGInputMedia
|
||||
var soloMedia []struct {
|
||||
url string
|
||||
attType string
|
||||
|
|
@ -908,7 +892,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
if len(albumMedia) == 0 {
|
||||
qAttType, qAttURL = "photo", a.Payload.Url
|
||||
}
|
||||
p := tgbotapi.NewInputMediaPhoto(tgbotapi.FileURL(a.Payload.Url))
|
||||
p := TGInputMedia{Type: "photo", File: FileArg{URL: a.Payload.Url}}
|
||||
albumMedia = append(albumMedia, p)
|
||||
}
|
||||
case *maxschemes.VideoAttachment:
|
||||
|
|
@ -916,7 +900,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
if len(albumMedia) == 0 {
|
||||
qAttType, qAttURL = "video", a.Payload.Url
|
||||
}
|
||||
v := tgbotapi.NewInputMediaVideo(tgbotapi.FileURL(a.Payload.Url))
|
||||
v := TGInputMedia{Type: "video", File: FileArg{URL: a.Payload.Url}}
|
||||
albumMedia = append(albumMedia, v)
|
||||
}
|
||||
case *maxschemes.AudioAttachment:
|
||||
|
|
@ -960,25 +944,15 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
mediaSent = true
|
||||
// Caption и reply только к первому элементу
|
||||
if htmlCaption != "" || replyToID != 0 {
|
||||
switch first := albumMedia[0].(type) {
|
||||
case tgbotapi.InputMediaPhoto:
|
||||
first.Caption = htmlCaption
|
||||
if pm != "" {
|
||||
first.ParseMode = pm
|
||||
}
|
||||
albumMedia[0] = first
|
||||
case tgbotapi.InputMediaVideo:
|
||||
first.Caption = htmlCaption
|
||||
if pm != "" {
|
||||
first.ParseMode = pm
|
||||
}
|
||||
albumMedia[0] = first
|
||||
albumMedia[0].Caption = htmlCaption
|
||||
if pm != "" {
|
||||
albumMedia[0].ParseMode = pm
|
||||
}
|
||||
}
|
||||
|
||||
if len(albumMedia) == 1 {
|
||||
// Одно вложение — отправляем обычным сообщением (альбом из 1 элемента не имеет reply)
|
||||
sent, sendErr = b.sendTgMediaFromURL(tgChatID, qAttURL, qAttType, htmlCaption, pm, replyToID, b.cfg.maxMaxFileBytes())
|
||||
sentMsgID, sendErr = b.sendTgMediaFromURL(ctx, tgChatID, qAttURL, qAttType, htmlCaption, pm, replyToID, threadID, b.cfg.maxMaxFileBytes())
|
||||
var e *ErrFileTooLarge
|
||||
if errors.As(sendErr, &e) {
|
||||
slog.Warn("MAX→TG media too big", "name", e.Name, "size", e.Size)
|
||||
|
|
@ -989,18 +963,14 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
}
|
||||
} else {
|
||||
// Несколько — отправляем как media group (альбом)
|
||||
cfg := tgbotapi.NewMediaGroup(tgChatID, albumMedia)
|
||||
if replyToID != 0 {
|
||||
cfg.ReplyToMessageID = replyToID
|
||||
}
|
||||
msgs, err := b.tgBot.SendMediaGroup(cfg)
|
||||
msgIDs, err := b.tg.SendMediaGroup(ctx, tgChatID, albumMedia, &SendOpts{ThreadID: threadID, ReplyToID: replyToID})
|
||||
if err != nil {
|
||||
slog.Error("MAX→TG album send failed", "err", err)
|
||||
sendErr = err
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText("Не удалось отправить медиаальбом в Telegram.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
} else if len(msgs) > 0 {
|
||||
sent = msgs[0]
|
||||
} else if len(msgIDs) > 0 {
|
||||
sentMsgID = msgIDs[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1016,7 +986,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
smReplyTo = replyToID
|
||||
}
|
||||
firstSolo = false
|
||||
s, err := b.sendTgMediaFromURL(tgChatID, sm.url, sm.attType, smCaption, pm, smReplyTo, b.cfg.maxMaxFileBytes(), sm.name)
|
||||
s, err := b.sendTgMediaFromURL(ctx, tgChatID, sm.url, sm.attType, smCaption, pm, smReplyTo, threadID, b.cfg.maxMaxFileBytes(), sm.name)
|
||||
if err != nil {
|
||||
var e *ErrFileTooLarge
|
||||
if errors.As(err, &e) {
|
||||
|
|
@ -1035,7 +1005,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
sendErr = err
|
||||
}
|
||||
} else if !mediaSent {
|
||||
sent = s
|
||||
sentMsgID = s
|
||||
mediaSent = true
|
||||
}
|
||||
}
|
||||
|
|
@ -1048,14 +1018,9 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
// Если есть markups и caption = оригинальный текст (кросспостинг), конвертируем в HTML
|
||||
if len(body.Markups) > 0 && caption == text {
|
||||
htmlText := maxMarkupsToHTML(text, body.Markups)
|
||||
tgMsg := tgbotapi.NewMessage(tgChatID, htmlText)
|
||||
tgMsg.ParseMode = "HTML"
|
||||
tgMsg.ReplyToMessageID = replyToID
|
||||
sent, sendErr = b.tgBot.Send(tgMsg)
|
||||
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, htmlText, &SendOpts{ParseMode: "HTML", ReplyToID: replyToID, ThreadID: threadID})
|
||||
} else {
|
||||
tgMsg := tgbotapi.NewMessage(tgChatID, caption)
|
||||
tgMsg.ReplyToMessageID = replyToID
|
||||
sent, sendErr = b.tgBot.Send(tgMsg)
|
||||
sentMsgID, sendErr = b.tg.SendMessage(ctx, tgChatID, caption, &SendOpts{ReplyToID: replyToID, ThreadID: threadID})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1064,7 +1029,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
slog.Error("MAX→TG send failed", "err", errStr, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID)
|
||||
|
||||
// Группа преобразована в supergroup — автоматически мигрируем chat ID
|
||||
var tgErr *tgbotapi.Error
|
||||
var tgErr *TGError
|
||||
if errors.As(sendErr, &tgErr) && tgErr.MigrateToChatID != 0 {
|
||||
newChatID := tgErr.MigrateToChatID
|
||||
slog.Info("TG chat migrated, updating pair", "old", tgChatID, "new", newChatID)
|
||||
|
|
@ -1109,7 +1074,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
b.cbFail(tgChatID)
|
||||
} else {
|
||||
b.cbSuccess(tgChatID)
|
||||
slog.Info("MAX→TG sent", "msgID", sent.MessageID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID)
|
||||
b.repo.SaveMsg(tgChatID, sent.MessageID, chatID, body.Mid)
|
||||
slog.Info("MAX→TG sent", "msgID", sentMsgID, "media", mediaSent, "uid", msgUpd.Message.Sender.UserId, "maxChat", chatID, "tgChat", tgChatID)
|
||||
b.repo.SaveMsg(tgChatID, sentMsgID, chatID, body.Mid)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
13
postgres.go
13
postgres.go
|
|
@ -164,6 +164,19 @@ func (r *pgRepo) Unpair(platform string, chatID int64) bool {
|
|||
return n > 0
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetTgThreadID(tgChatID int64) int {
|
||||
var id int
|
||||
r.db.QueryRow("SELECT COALESCE(tg_thread_id, 0) FROM pairs WHERE tg_chat_id = $1", tgChatID).Scan(&id)
|
||||
return id
|
||||
}
|
||||
|
||||
func (r *pgRepo) SetTgThreadID(tgChatID int64, threadID int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, err := r.db.Exec("UPDATE pairs SET tg_thread_id = $1 WHERE tg_chat_id = $2", threadID, tgChatID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pgRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error {
|
||||
_, err := r.db.Exec(
|
||||
"INSERT INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (tg_chat_id, max_chat_id) DO NOTHING",
|
||||
|
|
|
|||
57
queue.go
57
queue.go
|
|
@ -7,8 +7,6 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -92,8 +90,7 @@ func (b *Bridge) processQueue(ctx context.Context) {
|
|||
slog.Warn("queue item expired", "id", item.ID, "dir", item.Direction, "attempts", item.Attempts, "age", age)
|
||||
b.repo.DeleteFromQueue(item.ID)
|
||||
if item.Direction == "tg2max" {
|
||||
b.tgBot.Send(tgbotapi.NewMessage(item.SrcChatID,
|
||||
fmt.Sprintf("Сообщение не доставлено в MAX после %d попыток.", item.Attempts)))
|
||||
b.tg.SendMessage(ctx, item.SrcChatID, fmt.Sprintf("Сообщение не доставлено в MAX после %d попыток.", item.Attempts), nil)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -102,7 +99,7 @@ func (b *Bridge) processQueue(ctx context.Context) {
|
|||
case "tg2max":
|
||||
b.processQueueTg2Max(ctx, item, now)
|
||||
case "max2tg":
|
||||
b.processQueueMax2Tg(item, now)
|
||||
b.processQueueMax2Tg(ctx, item, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,52 +126,28 @@ func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now tim
|
|||
b.repo.DeleteFromQueue(item.ID)
|
||||
}
|
||||
|
||||
func (b *Bridge) processQueueMax2Tg(item QueueItem, now time.Time) {
|
||||
var sent tgbotapi.Message
|
||||
func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now time.Time) {
|
||||
var sentMsgID int
|
||||
var err error
|
||||
|
||||
threadID := b.repo.GetTgThreadID(item.DstChatID)
|
||||
|
||||
if item.AttType != "" && item.AttURL != "" {
|
||||
opts := &SendOpts{Caption: item.Text, ParseMode: item.ParseMode, ThreadID: threadID}
|
||||
switch item.AttType {
|
||||
case "photo":
|
||||
photo := tgbotapi.NewPhoto(item.DstChatID, tgbotapi.FileURL(item.AttURL))
|
||||
photo.Caption = item.Text
|
||||
if item.ParseMode != "" {
|
||||
photo.ParseMode = item.ParseMode
|
||||
}
|
||||
sent, err = b.tgBot.Send(photo)
|
||||
sentMsgID, err = b.tg.SendPhoto(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts)
|
||||
case "video":
|
||||
video := tgbotapi.NewVideo(item.DstChatID, tgbotapi.FileURL(item.AttURL))
|
||||
video.Caption = item.Text
|
||||
if item.ParseMode != "" {
|
||||
video.ParseMode = item.ParseMode
|
||||
}
|
||||
sent, err = b.tgBot.Send(video)
|
||||
sentMsgID, err = b.tg.SendVideo(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts)
|
||||
case "audio":
|
||||
audio := tgbotapi.NewAudio(item.DstChatID, tgbotapi.FileURL(item.AttURL))
|
||||
audio.Caption = item.Text
|
||||
if item.ParseMode != "" {
|
||||
audio.ParseMode = item.ParseMode
|
||||
}
|
||||
sent, err = b.tgBot.Send(audio)
|
||||
sentMsgID, err = b.tg.SendAudio(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts)
|
||||
case "file":
|
||||
doc := tgbotapi.NewDocument(item.DstChatID, tgbotapi.FileURL(item.AttURL))
|
||||
doc.Caption = item.Text
|
||||
if item.ParseMode != "" {
|
||||
doc.ParseMode = item.ParseMode
|
||||
}
|
||||
sent, err = b.tgBot.Send(doc)
|
||||
sentMsgID, err = b.tg.SendDocument(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts)
|
||||
default:
|
||||
// sticker и прочее — как фото
|
||||
photo := tgbotapi.NewPhoto(item.DstChatID, tgbotapi.FileURL(item.AttURL))
|
||||
photo.Caption = item.Text
|
||||
sent, err = b.tgBot.Send(photo)
|
||||
sentMsgID, err = b.tg.SendPhoto(ctx, item.DstChatID, FileArg{URL: item.AttURL}, opts)
|
||||
}
|
||||
} else {
|
||||
tgMsg := tgbotapi.NewMessage(item.DstChatID, item.Text)
|
||||
if item.ParseMode != "" {
|
||||
tgMsg.ParseMode = item.ParseMode
|
||||
}
|
||||
sent, err = b.tgBot.Send(tgMsg)
|
||||
sentMsgID, err = b.tg.SendMessage(ctx, item.DstChatID, item.Text, &SendOpts{ParseMode: item.ParseMode, ThreadID: threadID})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -188,7 +161,7 @@ func (b *Bridge) processQueueMax2Tg(item QueueItem, now time.Time) {
|
|||
b.repo.IncrementAttempt(item.ID, now.Add(retryDelay(item.Attempts+1)).Unix())
|
||||
return
|
||||
}
|
||||
slog.Info("queue retry ok", "id", item.ID, "dir", "max2tg", "msgID", sent.MessageID)
|
||||
b.repo.SaveMsg(item.DstChatID, sent.MessageID, item.SrcChatID, item.SrcMsgID)
|
||||
slog.Info("queue retry ok", "id", item.ID, "dir", "max2tg", "msgID", sentMsgID)
|
||||
b.repo.SaveMsg(item.DstChatID, sentMsgID, item.SrcChatID, item.SrcMsgID)
|
||||
b.repo.DeleteFromQueue(item.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
13
sqlite.go
13
sqlite.go
|
|
@ -157,6 +157,19 @@ func (r *sqliteRepo) Unpair(platform string, chatID int64) bool {
|
|||
return n > 0
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) GetTgThreadID(tgChatID int64) int {
|
||||
var id int
|
||||
r.db.QueryRow("SELECT COALESCE(tg_thread_id, 0) FROM pairs WHERE tg_chat_id = ?", tgChatID).Scan(&id)
|
||||
return id
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) SetTgThreadID(tgChatID int64, threadID int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, err := r.db.Exec("UPDATE pairs SET tg_thread_id = ? WHERE tg_chat_id = ?", threadID, tgChatID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error {
|
||||
_, err := r.db.Exec("INSERT OR REPLACE INTO crossposts (tg_chat_id, max_chat_id, created_at, owner_id, tg_owner_id) VALUES (?, ?, ?, ?, ?)",
|
||||
tgChatID, maxChatID, time.Now().Unix(), ownerID, tgOwnerID)
|
||||
|
|
|
|||
302
telegram.go
302
telegram.go
|
|
@ -11,33 +11,24 @@ import (
|
|||
|
||||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
func (b *Bridge) listenTelegram(ctx context.Context) {
|
||||
var updates tgbotapi.UpdatesChannel
|
||||
var updates <-chan TGUpdate
|
||||
|
||||
if b.cfg.WebhookURL != "" {
|
||||
whPath := b.tgWebhookPath()
|
||||
whURL := strings.TrimRight(b.cfg.WebhookURL, "/") + whPath
|
||||
wh, err := tgbotapi.NewWebhook(whURL)
|
||||
if err != nil {
|
||||
slog.Error("TG webhook config error", "err", err)
|
||||
return
|
||||
}
|
||||
if _, err := b.tgBot.Request(wh); err != nil {
|
||||
if err := b.tg.SetWebhook(ctx, whURL); err != nil {
|
||||
slog.Error("TG set webhook failed", "err", err)
|
||||
return
|
||||
}
|
||||
updates = b.tgBot.ListenForWebhook(whPath)
|
||||
updates = b.tg.StartWebhook(whPath)
|
||||
slog.Info("TG webhook mode")
|
||||
} else {
|
||||
// Удаляем webhook если был, переключаемся на polling
|
||||
b.tgBot.Request(tgbotapi.DeleteWebhookConfig{})
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates = b.tgBot.GetUpdatesChan(u)
|
||||
b.tg.DeleteWebhook(ctx)
|
||||
updates = b.tg.StartPolling(ctx)
|
||||
slog.Info("TG polling mode")
|
||||
}
|
||||
|
||||
|
|
@ -142,16 +133,16 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
}
|
||||
|
||||
if text == "/whoami" {
|
||||
b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID,
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||
"MaxTelegramBridgeBot — мост между Telegram и MAX.\n"+
|
||||
"Автор: Andrey Lugovskoy (@BEARlogin)\n"+
|
||||
"Исходники: https://github.com/BEARlogin/max-telegram-bridge-bot\n"+
|
||||
"Лицензия: CC BY-NC 4.0"))
|
||||
"Лицензия: CC BY-NC 4.0", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
|
||||
if text == "/start" || text == "/help" {
|
||||
b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID,
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||
"Бот-мост между Telegram и MAX.\n\n"+
|
||||
"Команды (группы):\n"+
|
||||
"/bridge — создать ключ для связки чатов\n"+
|
||||
|
|
@ -178,7 +169,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
"3. В одном из чатов отправьте /bridge\n"+
|
||||
"4. Бот выдаст ключ — отправьте /bridge <ключ> в другом чате\n"+
|
||||
"5. Готово!\n\n"+
|
||||
"Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues"))
|
||||
"Поддержка: https://github.com/BEARlogin/max-telegram-bridge-bot/issues", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +179,7 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
b.clearReplWait(msg.From.ID)
|
||||
rule, valid := parseReplacementInput(text)
|
||||
if !valid {
|
||||
b.tgBot.Send(tgbotapi.NewMessage(msg.Chat.ID, "Неверный формат. Используйте:\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
198
tgsender.go
Normal 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
599
tgsender_impl.go
Normal 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
590
tgsender_impl_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
57
upload.go
57
upload.go
|
|
@ -13,7 +13,6 @@ import (
|
|||
"time"
|
||||
|
||||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// downloadURL скачивает файл по URL и возвращает bytes.
|
||||
|
|
@ -42,60 +41,44 @@ func (b *Bridge) downloadURL(url string) ([]byte, error) {
|
|||
|
||||
// sendTgMediaFromURL скачивает файл с URL и отправляет в TG как upload.
|
||||
// maxBytes=0 means no size limit. fileName overrides name extracted from URL.
|
||||
func (b *Bridge) sendTgMediaFromURL(tgChatID int64, mediaURL, mediaType, caption, parseMode string, replyToID int, maxBytes int64, fileName ...string) (tgbotapi.Message, error) {
|
||||
func (b *Bridge) sendTgMediaFromURL(ctx context.Context, tgChatID int64, mediaURL, mediaType, caption, parseMode string, replyToID, threadID int, maxBytes int64, fileName ...string) (int, error) {
|
||||
slog.Debug("sendTgMediaFromURL start", "url", mediaURL, "type", mediaType, "tgChat", tgChatID)
|
||||
data, nameFromURL, err := b.downloadURLWithLimit(mediaURL, maxBytes)
|
||||
if err == nil {
|
||||
slog.Debug("sendTgMediaFromURL downloaded", "size", len(data), "name", nameFromURL)
|
||||
}
|
||||
if err != nil {
|
||||
return tgbotapi.Message{}, fmt.Errorf("download media: %w", err)
|
||||
return 0, fmt.Errorf("download media: %w", err)
|
||||
}
|
||||
|
||||
name := nameFromURL
|
||||
if len(fileName) > 0 && fileName[0] != "" {
|
||||
name = fileName[0]
|
||||
}
|
||||
fb := tgbotapi.FileBytes{Name: name, Bytes: data}
|
||||
file := FileArg{Name: name, Bytes: data}
|
||||
|
||||
switch mediaType {
|
||||
case "photo":
|
||||
msg := tgbotapi.NewPhoto(tgChatID, fb)
|
||||
msg.Caption = caption
|
||||
if parseMode != "" {
|
||||
msg.ParseMode = parseMode
|
||||
}
|
||||
msg.ReplyToMessageID = replyToID
|
||||
return b.tgBot.Send(msg)
|
||||
return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{
|
||||
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
|
||||
})
|
||||
case "video":
|
||||
msg := tgbotapi.NewVideo(tgChatID, fb)
|
||||
msg.Caption = caption
|
||||
if parseMode != "" {
|
||||
msg.ParseMode = parseMode
|
||||
}
|
||||
msg.ReplyToMessageID = replyToID
|
||||
return b.tgBot.Send(msg)
|
||||
return b.tg.SendVideo(ctx, tgChatID, file, &SendOpts{
|
||||
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
|
||||
})
|
||||
case "audio":
|
||||
msg := tgbotapi.NewAudio(tgChatID, fb)
|
||||
msg.Caption = caption
|
||||
if parseMode != "" {
|
||||
msg.ParseMode = parseMode
|
||||
}
|
||||
msg.ReplyToMessageID = replyToID
|
||||
return b.tgBot.Send(msg)
|
||||
return b.tg.SendAudio(ctx, tgChatID, file, &SendOpts{
|
||||
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
|
||||
})
|
||||
case "file":
|
||||
msg := tgbotapi.NewDocument(tgChatID, fb)
|
||||
msg.Caption = caption
|
||||
if parseMode != "" {
|
||||
msg.ParseMode = parseMode
|
||||
}
|
||||
msg.ReplyToMessageID = replyToID
|
||||
return b.tgBot.Send(msg)
|
||||
return b.tg.SendDocument(ctx, tgChatID, file, &SendOpts{
|
||||
Caption: caption, ParseMode: parseMode, ReplyToID: replyToID, ThreadID: threadID,
|
||||
})
|
||||
default:
|
||||
// sticker и прочее — как фото
|
||||
msg := tgbotapi.NewPhoto(tgChatID, fb)
|
||||
msg.Caption = caption
|
||||
return b.tgBot.Send(msg)
|
||||
return b.tg.SendPhoto(ctx, tgChatID, file, &SendOpts{
|
||||
Caption: caption, ThreadID: threadID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +182,7 @@ func (b *Bridge) customUploadToMax(ctx context.Context, uploadType maxschemes.Up
|
|||
|
||||
// uploadTgPhotoToMax скачивает фото из TG и загружает в MAX через SDK (возвращает PhotoTokens).
|
||||
func (b *Bridge) uploadTgPhotoToMax(ctx context.Context, fileID string) (*maxschemes.PhotoTokens, error) {
|
||||
fileURL, err := b.tgFileURL(fileID)
|
||||
fileURL, err := b.tgFileURL(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tg getFileURL: %w", err)
|
||||
}
|
||||
|
|
@ -220,7 +203,7 @@ func (b *Bridge) uploadTgPhotoToMax(ctx context.Context, fileID string) (*maxsch
|
|||
|
||||
// uploadTgMediaToMax скачивает файл из TG и загружает в MAX
|
||||
func (b *Bridge) uploadTgMediaToMax(ctx context.Context, fileID string, uploadType maxschemes.UploadType, fileName string) (*maxschemes.UploadedInfo, error) {
|
||||
fileURL, err := b.tgFileURL(fileID)
|
||||
fileURL, err := b.tgFileURL(ctx, fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tg getFileURL: %w", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue