mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-04-26 10:50:57 +00:00
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>
234 lines
7.1 KiB
Go
234 lines
7.1 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
|
||
maxbot "github.com/max-messenger/max-bot-api-client-go"
|
||
maxschemes "github.com/max-messenger/max-bot-api-client-go/schemes"
|
||
)
|
||
|
||
// parseCrosspostReplacements парсит JSON из БД в структуру.
|
||
func parseCrosspostReplacements(raw string) CrosspostReplacements {
|
||
if raw == "" {
|
||
return CrosspostReplacements{}
|
||
}
|
||
var r CrosspostReplacements
|
||
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||
slog.Warn("failed to parse replacements", "err", err)
|
||
return CrosspostReplacements{}
|
||
}
|
||
return r
|
||
}
|
||
|
||
// marshalCrosspostReplacements сериализует структуру в JSON.
|
||
func marshalCrosspostReplacements(r CrosspostReplacements) string {
|
||
if len(r.TgToMax) == 0 && len(r.MaxToTg) == 0 {
|
||
return ""
|
||
}
|
||
data, _ := json.Marshal(r)
|
||
return string(data)
|
||
}
|
||
|
||
// urlRegex матчит URL в тексте.
|
||
var urlRegex = regexp.MustCompile(`https?://[^\s<>"]+`)
|
||
|
||
// applyReplacements применяет список замен к тексту.
|
||
func applyReplacements(text string, rules []Replacement) string {
|
||
for _, r := range rules {
|
||
if r.From == "" {
|
||
continue
|
||
}
|
||
if r.Target == "links" {
|
||
text = applyToLinks(text, r)
|
||
} else {
|
||
text = applyToAll(text, r)
|
||
}
|
||
}
|
||
return text
|
||
}
|
||
|
||
func applyToAll(text string, r Replacement) string {
|
||
if r.Regex {
|
||
re, err := regexp.Compile(r.From)
|
||
if err != nil {
|
||
slog.Warn("invalid replacement regex", "pattern", r.From, "err", err)
|
||
return text
|
||
}
|
||
return re.ReplaceAllString(text, r.To)
|
||
}
|
||
return strings.ReplaceAll(text, r.From, r.To)
|
||
}
|
||
|
||
func applyToLinks(text string, r Replacement) string {
|
||
return urlRegex.ReplaceAllStringFunc(text, func(url string) string {
|
||
if r.Regex {
|
||
re, err := regexp.Compile(r.From)
|
||
if err != nil {
|
||
return url
|
||
}
|
||
return re.ReplaceAllString(url, r.To)
|
||
}
|
||
return strings.ReplaceAll(url, r.From, r.To)
|
||
})
|
||
}
|
||
|
||
// formatReplacementItem форматирует одну замену для отдельного сообщения.
|
||
func formatReplacementItem(r Replacement, dir string) string {
|
||
dirLabel := "TG → MAX"
|
||
if dir == "max>tg" {
|
||
dirLabel = "MAX → TG"
|
||
}
|
||
targetLabel := "весь текст"
|
||
if r.Target == "links" {
|
||
targetLabel = "только ссылки"
|
||
}
|
||
return fmt.Sprintf("%s %s\n<code>%s</code> → <code>%s</code>\nТип: %s", dirLabel, replacementTags(r), r.From, r.To, targetLabel)
|
||
}
|
||
|
||
// formatReplacementsHeader формирует заголовок для списка замен.
|
||
func formatReplacementsHeader(repl CrosspostReplacements) string {
|
||
total := len(repl.TgToMax) + len(repl.MaxToTg)
|
||
if total == 0 {
|
||
return "🔄 Замен нет.\n\nДобавьте замену — текст в пересылаемых постах будет автоматически заменяться."
|
||
}
|
||
return fmt.Sprintf("🔄 Замены (%d):", total)
|
||
}
|
||
|
||
// replacementTags возвращает теги для отображения замены.
|
||
func replacementTags(r Replacement) string {
|
||
var tags []string
|
||
if r.Regex {
|
||
tags = append(tags, "regex")
|
||
}
|
||
if r.Target == "links" {
|
||
tags = append(tags, "ссылки")
|
||
}
|
||
if len(tags) == 0 {
|
||
return ""
|
||
}
|
||
return "[" + strings.Join(tags, ", ") + "] "
|
||
}
|
||
|
||
// tgReplacementsKeyboard строит inline-клавиатуру для управления заменами.
|
||
func tgReplacementsKeyboard(maxChatID int64) *InlineKeyboardMarkup {
|
||
id := fmt.Sprintf("%d", maxChatID)
|
||
return NewInlineKeyboard(
|
||
NewInlineRow(
|
||
NewInlineButton("+ TG→MAX", "cpra:tg>max:"+id),
|
||
NewInlineButton("+ MAX→TG", "cpra:max>tg:"+id),
|
||
),
|
||
NewInlineRow(
|
||
NewInlineButton("🗑 Очистить всё", "cprc:"+id),
|
||
NewInlineButton("◀ Назад", "cprb:"+id),
|
||
),
|
||
)
|
||
}
|
||
|
||
// tgReplItemKeyboard — кнопки для одной замены в TG.
|
||
func tgReplItemKeyboard(dir string, idx int, maxChatID string, currentTarget string) *InlineKeyboardMarkup {
|
||
toggleLabel := "🔗 Только ссылки"
|
||
toggleTarget := "links"
|
||
if currentTarget == "links" {
|
||
toggleLabel = "📝 Весь текст"
|
||
toggleTarget = "all"
|
||
}
|
||
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)),
|
||
),
|
||
)
|
||
}
|
||
|
||
// maxReplacementsKeyboard строит inline-клавиатуру для управления заменами в MAX.
|
||
func maxReplacementsKeyboard(api *maxbot.Api, maxChatID int64) *maxbot.Keyboard {
|
||
id := fmt.Sprintf("%d", maxChatID)
|
||
kb := api.Messages.NewKeyboardBuilder()
|
||
kb.AddRow().
|
||
AddCallback("+ TG→MAX", maxschemes.DEFAULT, "cpra:tg>max:"+id).
|
||
AddCallback("+ MAX→TG", maxschemes.DEFAULT, "cpra:max>tg:"+id)
|
||
kb.AddRow().
|
||
AddCallback("🗑 Очистить всё", maxschemes.NEGATIVE, "cprc:"+id).
|
||
AddCallback("◀ Назад", maxschemes.DEFAULT, "cprb:"+id)
|
||
return kb
|
||
}
|
||
|
||
// maxReplItemKeyboard — кнопки для одной замены в MAX.
|
||
func maxReplItemKeyboard(api *maxbot.Api, dir string, idx int, maxChatID string, currentTarget string) *maxbot.Keyboard {
|
||
toggleLabel := "🔗 Только ссылки"
|
||
toggleTarget := "links"
|
||
if currentTarget == "links" {
|
||
toggleLabel = "📝 Весь текст"
|
||
toggleTarget = "all"
|
||
}
|
||
kb := api.Messages.NewKeyboardBuilder()
|
||
kb.AddRow().
|
||
AddCallback(toggleLabel, maxschemes.DEFAULT, fmt.Sprintf("cprt:%s:%d:%s:%s", dir, idx, toggleTarget, maxChatID)).
|
||
AddCallback("❌ Удалить", maxschemes.NEGATIVE, fmt.Sprintf("cprd:%s:%d:%s", dir, idx, maxChatID))
|
||
return kb
|
||
}
|
||
|
||
// replWait хранит состояние ожидания ввода замены.
|
||
type replWait struct {
|
||
maxChatID int64
|
||
direction string // "tg>max" or "max>tg"
|
||
target string // "all" or "links"
|
||
}
|
||
|
||
// replWaitMap — глобальное хранилище ожиданий (по userID).
|
||
var (
|
||
replWaits = make(map[int64]replWait)
|
||
replWaitsMu sync.Mutex
|
||
)
|
||
|
||
func (b *Bridge) setReplWait(userID, maxChatID int64, direction, target string) {
|
||
replWaitsMu.Lock()
|
||
replWaits[userID] = replWait{maxChatID: maxChatID, direction: direction, target: target}
|
||
replWaitsMu.Unlock()
|
||
}
|
||
|
||
func (b *Bridge) getReplWait(userID int64) (replWait, bool) {
|
||
replWaitsMu.Lock()
|
||
w, ok := replWaits[userID]
|
||
replWaitsMu.Unlock()
|
||
return w, ok
|
||
}
|
||
|
||
func (b *Bridge) clearReplWait(userID int64) {
|
||
replWaitsMu.Lock()
|
||
delete(replWaits, userID)
|
||
replWaitsMu.Unlock()
|
||
}
|
||
|
||
// parseReplacementInput парсит ввод пользователя "from | to" или "/regex/ | to".
|
||
func parseReplacementInput(input string) (Replacement, bool) {
|
||
idx := strings.Index(input, "|")
|
||
if idx < 0 {
|
||
return Replacement{}, false
|
||
}
|
||
|
||
from := strings.TrimSpace(input[:idx])
|
||
to := strings.TrimSpace(input[idx+1:])
|
||
|
||
if from == "" {
|
||
return Replacement{}, false
|
||
}
|
||
|
||
// Regex: /pattern/
|
||
isRegex := false
|
||
if len(from) >= 2 && from[0] == '/' && from[len(from)-1] == '/' {
|
||
from = from[1 : len(from)-1]
|
||
isRegex = true
|
||
// Проверяем что regex валидный
|
||
if _, err := regexp.Compile(from); err != nil {
|
||
return Replacement{}, false
|
||
}
|
||
}
|
||
|
||
return Replacement{From: from, To: to, Regex: isRegex}, true
|
||
}
|