max-telegram-bridge-bot/replacements.go
Andrey Lugovskoy f8e4ff0ebd 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>
2026-04-02 17:48:33 +03:00

234 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}