mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-04-28 03:39:46 +00:00
Add /thread_bridge: link a single TG forum thread to a separate MAX chat
Issue #38 part 2. Allows using multiple MAX group chats as per-thread mirrors of a TG forum group, since MAX has no native thread concept. - New table thread_pairs (tg_chat_id, tg_thread_id, max_chat_id) with unique(max_chat_id) — one MAX chat = at most one TG thread. - pending gains thread_id column for thread-bridge key exchange. - Repo methods: StartThreadBridge (TG issues key), CompleteThreadBridge (MAX consumes key), GetThreadMaxChat, GetThreadTgPair, UnpairThread, UnpairThreadByMax. - Commands: * /thread_bridge in a TG forum thread (admin, non-General) -> key * /thread_bridge <key> in a MAX chat -> binds * /thread_unbridge on either side TG's BOT_COMMAND_INVALID rule forbids hyphens, so the commands use underscore. - Routing priority: thread-bridge > regular pair. TG->MAX: if msg.MessageThreadID has a thread_pair, route there. MAX->TG: if MAX chat is in thread_pairs, route to (tg_chat, thread). - For thread-paired MAX chats, the Part 1 reply-to-source-thread override is disabled: thread-paired chats have a fixed target thread, replies stay there. - Safeguard: a MAX chat cannot be in both pairs and thread_pairs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
46d2f4f748
commit
1290a23ad7
10 changed files with 416 additions and 13 deletions
|
|
@ -217,6 +217,8 @@ func (b *Bridge) registerCommands(ctx context.Context) {
|
|||
{Command: "bridge", Description: "Связать чат с MAX-чатом"},
|
||||
{Command: "unbridge", Description: "Удалить связку чатов"},
|
||||
{Command: "thread", Description: "Установить топик для сообщений из MAX"},
|
||||
{Command: "thread_bridge", Description: "Связать тред с отдельным MAX-чатом"},
|
||||
{Command: "thread_unbridge", Description: "Удалить связку треда"},
|
||||
{Command: "crosspost", Description: "Список связок кросспостинга"},
|
||||
{Command: "help", Description: "Инструкция"},
|
||||
}
|
||||
|
|
|
|||
100
max.go
100
max.go
|
|
@ -220,7 +220,9 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
"/bridge — создать ключ для связки чатов\n" +
|
||||
"/bridge <ключ> — связать этот чат с Telegram-чатом по ключу\n" +
|
||||
"/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n" +
|
||||
"/unbridge — удалить связку\n\n" +
|
||||
"/unbridge — удалить связку\n" +
|
||||
"/thread_bridge <ключ> — связать этот MAX-чат с отдельным TG-тредом (форум)\n" +
|
||||
"/thread_unbridge — разорвать связку треда\n\n" +
|
||||
"Кросспостинг каналов (в личке бота):\n" +
|
||||
"/crosspost <TG_ID> — связать MAX-канал с TG-каналом\n" +
|
||||
" (TG ID получить: перешлите пост из TG-канала TG-боту)\n\n" +
|
||||
|
|
@ -354,6 +356,67 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
// /thread_bridge <key> — принять ключ, связать MAX-чат с конкретным TG-тредом
|
||||
if strings.HasPrefix(text, "/thread_bridge") {
|
||||
if isGroup && !isAdmin {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(strings.TrimPrefix(text, "/thread_bridge"))
|
||||
if key == "" {
|
||||
if tgID, tid, ok := b.repo.GetThreadTgPair(chatID); ok {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||||
fmt.Sprintf("Этот чат уже связан с TG-тредом (чат %d, thread %d).\n\n/thread_unbridge — разорвать.", tgID, tid))
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||||
"Нужен ключ: /thread_bridge <ключ>\n\nСначала в Telegram-форум-группе выполните /thread_bridge внутри нужного треда — там выдадут ключ.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
tgChatID, threadID, ok, err := b.repo.CompleteThreadBridge(key, chatID)
|
||||
if err == errThreadMaxBusy {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||||
"Этот MAX-чат уже участвует в другой связке (bridge или thread-bridge). Сначала /unbridge или /thread_unbridge.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("CompleteThreadBridge failed", "err", err)
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ошибка сохранения связки.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText("Ключ не найден или истёк.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||||
fmt.Sprintf("Связано с TG-тредом (чат %d, thread %d). Сообщения из этого MAX-чата будут уходить в указанный тред, и обратно.", tgChatID, threadID))
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
slog.Info("thread-bridge paired", "tgChat", tgChatID, "thread", threadID, "maxChat", chatID)
|
||||
continue
|
||||
}
|
||||
|
||||
if text == "/thread_unbridge" {
|
||||
if isGroup && !isAdmin {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText("Эта команда доступна только админам группы.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
if b.repo.UnpairThreadByMax(chatID) {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText("Связка треда удалена.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
} else {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText("Этот чат не связан с тредом.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Обработка ввода замены (если юзер в режиме ожидания)
|
||||
if isDialog && !strings.HasPrefix(text, "/") && msgUpd.Message.Sender.UserId != 0 {
|
||||
if w, ok := b.getReplWait(msgUpd.Message.Sender.UserId); ok {
|
||||
|
|
@ -506,8 +569,15 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Пересылка (bridge)
|
||||
// Пересылка (bridge). Сначала проверяем thread-bridge (MAX-чат = отдельный TG-тред),
|
||||
// потом обычную пару.
|
||||
tgChatID, linked := b.repo.GetTgChat(chatID)
|
||||
if !linked {
|
||||
if tg, _, ok := b.repo.GetThreadTgPair(chatID); ok {
|
||||
tgChatID = tg
|
||||
linked = true
|
||||
}
|
||||
}
|
||||
if linked && msgUpd.Message.Sender.UserId != b.maxBotUID {
|
||||
// Anti-loop
|
||||
if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") {
|
||||
|
|
@ -930,27 +1000,39 @@ 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)
|
||||
|
||||
// Reply ID + маршрутизация в тред исходного TG-сообщения.
|
||||
// Для reply-ответов всегда используем тред исходника (в т.ч. 0 = General),
|
||||
// а не пара-дефолтный тред — иначе ответ уходит не туда, куда смотрит юзер.
|
||||
// Определяем тред-назначение:
|
||||
// — thread-pair: MAX-чат жёстко привязан к одному TG-треду, все сообщения идут туда,
|
||||
// reply-override не меняет тред.
|
||||
// — обычная пара: дефолтный тред пары + для reply переопределяем на тред исходника
|
||||
// (чтобы ответ попадал в ту же ветку, где лежит исходное сообщение).
|
||||
var threadID int
|
||||
_, threadPairThread, isThreadPair := b.repo.GetThreadTgPair(chatID)
|
||||
if isThreadPair {
|
||||
threadID = threadPairThread
|
||||
} else {
|
||||
threadID = b.repo.GetTgThreadID(tgChatID)
|
||||
}
|
||||
|
||||
var replyToID int
|
||||
if body.ReplyTo != "" {
|
||||
if _, rid, tid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
|
||||
replyToID = rid
|
||||
threadID = tid
|
||||
if !isThreadPair {
|
||||
threadID = tid
|
||||
}
|
||||
}
|
||||
} else if msgUpd.Message.Link != nil && msgUpd.Message.Link.Type == maxschemes.REPLY {
|
||||
mid := msgUpd.Message.Link.Message.Mid
|
||||
if mid != "" {
|
||||
if _, rid, tid, ok := b.repo.LookupTgMsgID(mid); ok {
|
||||
replyToID = rid
|
||||
threadID = tid
|
||||
if !isThreadPair {
|
||||
threadID = tid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
4
migrations/postgres/000015_thread_pairs.down.sql
Normal file
4
migrations/postgres/000015_thread_pairs.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
DROP INDEX IF EXISTS idx_thread_pairs_max;
|
||||
DROP INDEX IF EXISTS idx_thread_pairs_tg;
|
||||
DROP TABLE IF EXISTS thread_pairs;
|
||||
ALTER TABLE pending DROP COLUMN thread_id;
|
||||
12
migrations/postgres/000015_thread_pairs.up.sql
Normal file
12
migrations/postgres/000015_thread_pairs.up.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
ALTER TABLE pending ADD COLUMN thread_id BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE thread_pairs (
|
||||
tg_chat_id BIGINT NOT NULL,
|
||||
tg_thread_id BIGINT NOT NULL,
|
||||
max_chat_id BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (tg_chat_id, tg_thread_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_thread_pairs_max ON thread_pairs(max_chat_id);
|
||||
CREATE INDEX idx_thread_pairs_tg ON thread_pairs(tg_chat_id);
|
||||
4
migrations/sqlite/000015_thread_pairs.down.sql
Normal file
4
migrations/sqlite/000015_thread_pairs.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
DROP INDEX IF EXISTS idx_thread_pairs_max;
|
||||
DROP INDEX IF EXISTS idx_thread_pairs_tg;
|
||||
DROP TABLE IF EXISTS thread_pairs;
|
||||
ALTER TABLE pending DROP COLUMN thread_id;
|
||||
12
migrations/sqlite/000015_thread_pairs.up.sql
Normal file
12
migrations/sqlite/000015_thread_pairs.up.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
ALTER TABLE pending ADD COLUMN thread_id INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE thread_pairs (
|
||||
tg_chat_id INTEGER NOT NULL,
|
||||
tg_thread_id INTEGER NOT NULL,
|
||||
max_chat_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (tg_chat_id, tg_thread_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_thread_pairs_max ON thread_pairs(max_chat_id);
|
||||
CREATE INDEX idx_thread_pairs_tg ON thread_pairs(tg_chat_id);
|
||||
98
postgres.go
98
postgres.go
|
|
@ -177,6 +177,104 @@ func (r *pgRepo) SetTgThreadID(tgChatID int64, threadID int) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (r *pgRepo) StartThreadBridge(tgChatID int64, threadID int) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
var existing string
|
||||
err := r.db.QueryRow(
|
||||
"SELECT key FROM pending WHERE platform = 'tg' AND chat_id = $1 AND thread_id = $2 AND command = 'thread-bridge'",
|
||||
tgChatID, threadID).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
generated := genKey()
|
||||
_, err = r.db.Exec(
|
||||
"INSERT INTO pending (key, platform, chat_id, thread_id, created_at, command) VALUES ($1, 'tg', $2, $3, $4, 'thread-bridge')",
|
||||
generated, tgChatID, threadID, time.Now().Unix())
|
||||
return generated, err
|
||||
}
|
||||
|
||||
func (r *pgRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, int, bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
var tgChatID int64
|
||||
var threadID int
|
||||
err := r.db.QueryRow(
|
||||
"SELECT chat_id, thread_id FROM pending WHERE key = $1 AND platform = 'tg' AND command = 'thread-bridge'",
|
||||
key).Scan(&tgChatID, &threadID)
|
||||
if err != nil {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
var cnt int
|
||||
r.db.QueryRow("SELECT COUNT(*) FROM pairs WHERE max_chat_id = $1", maxChatID).Scan(&cnt)
|
||||
if cnt > 0 {
|
||||
return 0, 0, false, errThreadMaxBusy
|
||||
}
|
||||
cnt = 0
|
||||
r.db.QueryRow("SELECT COUNT(*) FROM thread_pairs WHERE max_chat_id = $1", maxChatID).Scan(&cnt)
|
||||
if cnt > 0 {
|
||||
return 0, 0, false, errThreadMaxBusy
|
||||
}
|
||||
|
||||
r.db.Exec("DELETE FROM pending WHERE key = $1", key)
|
||||
_, err = r.db.Exec(
|
||||
`INSERT INTO thread_pairs (tg_chat_id, tg_thread_id, max_chat_id, created_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (tg_chat_id, tg_thread_id) DO UPDATE
|
||||
SET max_chat_id = EXCLUDED.max_chat_id, created_at = EXCLUDED.created_at`,
|
||||
tgChatID, threadID, maxChatID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
return tgChatID, threadID, true, nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
|
||||
if threadID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
var id int64
|
||||
err := r.db.QueryRow(
|
||||
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2",
|
||||
tgChatID, threadID).Scan(&id)
|
||||
return id, err == nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) GetThreadTgPair(maxChatID int64) (int64, int, bool) {
|
||||
var tgChatID int64
|
||||
var threadID int
|
||||
err := r.db.QueryRow(
|
||||
"SELECT tg_chat_id, tg_thread_id FROM thread_pairs WHERE max_chat_id = $1",
|
||||
maxChatID).Scan(&tgChatID, &threadID)
|
||||
return tgChatID, threadID, err == nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) UnpairThread(tgChatID int64, threadID int) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE tg_chat_id = $1 AND tg_thread_id = $2", tgChatID, threadID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (r *pgRepo) UnpairThreadByMax(maxChatID int64) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE max_chat_id = $1", maxChatID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
// errThreadMaxBusy — попытка thread-bridge на MAX-чат, уже участвующий в какой-то связке.
|
||||
var errThreadMaxBusy = errors.New("max chat is already linked (bridge or thread-bridge)")
|
||||
|
||||
// Replacement — одно правило замены текста.
|
||||
// Target: "" или "all" — весь текст, "links" — только ссылки.
|
||||
type Replacement struct {
|
||||
|
|
@ -46,6 +51,15 @@ type Repository interface {
|
|||
GetTgThreadID(tgChatID int64) int
|
||||
SetTgThreadID(tgChatID int64, threadID int) error
|
||||
|
||||
// Thread-bridge: связка отдельного TG-треда с отдельным MAX-чатом.
|
||||
// Ключ выдаётся в TG-треде (StartThreadBridge), принимается в MAX (CompleteThreadBridge).
|
||||
StartThreadBridge(tgChatID int64, threadID int) (key string, err error)
|
||||
CompleteThreadBridge(key string, maxChatID int64) (tgChatID int64, threadID int, ok bool, err error)
|
||||
GetThreadMaxChat(tgChatID int64, threadID int) (maxChatID int64, ok bool)
|
||||
GetThreadTgPair(maxChatID int64) (tgChatID int64, threadID int, ok bool)
|
||||
UnpairThread(tgChatID int64, threadID int) bool
|
||||
UnpairThreadByMax(maxChatID int64) bool
|
||||
|
||||
// Crosspost methods
|
||||
PairCrosspost(tgChatID, maxChatID, ownerID, tgOwnerID int64) error
|
||||
GetCrosspostOwner(maxChatID int64) (maxOwner, tgOwner int64)
|
||||
|
|
|
|||
97
sqlite.go
97
sqlite.go
|
|
@ -170,6 +170,103 @@ func (r *sqliteRepo) SetTgThreadID(tgChatID int64, threadID int) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) StartThreadBridge(tgChatID int64, threadID int) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Уже есть ожидающий ключ для этого треда — возвращаем его.
|
||||
var existing string
|
||||
err := r.db.QueryRow(
|
||||
"SELECT key FROM pending WHERE platform = 'tg' AND chat_id = ? AND thread_id = ? AND command = 'thread-bridge'",
|
||||
tgChatID, threadID).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
generated := genKey()
|
||||
_, err = r.db.Exec(
|
||||
"INSERT INTO pending (key, platform, chat_id, thread_id, created_at, command) VALUES (?, 'tg', ?, ?, ?, 'thread-bridge')",
|
||||
generated, tgChatID, threadID, time.Now().Unix())
|
||||
return generated, err
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) CompleteThreadBridge(key string, maxChatID int64) (int64, int, bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
var tgChatID int64
|
||||
var threadID int
|
||||
err := r.db.QueryRow(
|
||||
"SELECT chat_id, thread_id FROM pending WHERE key = ? AND platform = 'tg' AND command = 'thread-bridge'",
|
||||
key).Scan(&tgChatID, &threadID)
|
||||
if err != nil {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
// MAX-чат не должен быть в обычной паре или в другом thread-bridge.
|
||||
var cnt int
|
||||
r.db.QueryRow("SELECT COUNT(*) FROM pairs WHERE max_chat_id = ?", maxChatID).Scan(&cnt)
|
||||
if cnt > 0 {
|
||||
return 0, 0, false, errThreadMaxBusy
|
||||
}
|
||||
cnt = 0
|
||||
r.db.QueryRow("SELECT COUNT(*) FROM thread_pairs WHERE max_chat_id = ?", maxChatID).Scan(&cnt)
|
||||
if cnt > 0 {
|
||||
return 0, 0, false, errThreadMaxBusy
|
||||
}
|
||||
|
||||
r.db.Exec("DELETE FROM pending WHERE key = ?", key)
|
||||
_, err = r.db.Exec(
|
||||
"INSERT OR REPLACE INTO thread_pairs (tg_chat_id, tg_thread_id, max_chat_id, created_at) VALUES (?, ?, ?, ?)",
|
||||
tgChatID, threadID, maxChatID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
return tgChatID, threadID, true, nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) GetThreadMaxChat(tgChatID int64, threadID int) (int64, bool) {
|
||||
if threadID == 0 {
|
||||
return 0, false
|
||||
}
|
||||
var id int64
|
||||
err := r.db.QueryRow(
|
||||
"SELECT max_chat_id FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?",
|
||||
tgChatID, threadID).Scan(&id)
|
||||
return id, err == nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) GetThreadTgPair(maxChatID int64) (int64, int, bool) {
|
||||
var tgChatID int64
|
||||
var threadID int
|
||||
err := r.db.QueryRow(
|
||||
"SELECT tg_chat_id, tg_thread_id FROM thread_pairs WHERE max_chat_id = ?",
|
||||
maxChatID).Scan(&tgChatID, &threadID)
|
||||
return tgChatID, threadID, err == nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) UnpairThread(tgChatID int64, threadID int) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE tg_chat_id = ? AND tg_thread_id = ?", tgChatID, threadID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) UnpairThreadByMax(maxChatID int64) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res, err := r.db.Exec("DELETE FROM thread_pairs WHERE max_chat_id = ?", maxChatID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
86
telegram.go
86
telegram.go
|
|
@ -58,7 +58,14 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
if b.isSelfTgBot(edited.From) {
|
||||
continue
|
||||
}
|
||||
maxChatID, linked := b.repo.GetMaxChat(edited.Chat.ID)
|
||||
var maxChatID int64
|
||||
var linked bool
|
||||
if edited.MessageThreadID != 0 {
|
||||
maxChatID, linked = b.repo.GetThreadMaxChat(edited.Chat.ID, edited.MessageThreadID)
|
||||
}
|
||||
if !linked {
|
||||
maxChatID, linked = b.repo.GetMaxChat(edited.Chat.ID)
|
||||
}
|
||||
if !linked {
|
||||
continue
|
||||
}
|
||||
|
|
@ -172,7 +179,9 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
"/bridge <ключ> — связать этот чат с MAX-чатом по ключу\n"+
|
||||
"/bridge prefix on/off — включить/выключить префикс [TG]/[MAX]\n"+
|
||||
"/unbridge — удалить связку\n"+
|
||||
"/thread — направить сообщения из MAX в текущий топик (форум)\n\n"+
|
||||
"/thread — направить сообщения из MAX в текущий топик (форум)\n"+
|
||||
"/thread_bridge — связать текущий тред с отдельной MAX-группой (форум)\n"+
|
||||
"/thread_unbridge — разорвать связку треда\n\n"+
|
||||
"Кросспостинг каналов:\n"+
|
||||
"1. Добавьте бота админом в оба канала (с правом постинга)\n"+
|
||||
"2. Перешлите пост из TG-канала в личку TG-бота\n"+
|
||||
|
|
@ -430,8 +439,77 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Пересылка
|
||||
maxChatID, linked := b.repo.GetMaxChat(msg.Chat.ID)
|
||||
// /thread_bridge — связать конкретный тред с отдельным MAX-чатом
|
||||
if text == "/thread_bridge" {
|
||||
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
|
||||
continue
|
||||
}
|
||||
if !isGroup {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, "Команда работает только в форум-группах.", nil)
|
||||
continue
|
||||
}
|
||||
if !isAdmin {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
if msg.MessageThreadID == 0 {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||
"Отправьте команду <b>внутри конкретного треда</b> (не в General). Тред будет связан с отдельным MAX-чатом.",
|
||||
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
if maxID, ok := b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID); ok {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||
fmt.Sprintf("Этот тред уже связан с MAX-чатом (ID <code>%d</code>).\n\n/thread_unbridge — разорвать связку.", maxID),
|
||||
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
key, err := b.repo.StartThreadBridge(msg.Chat.ID, msg.MessageThreadID)
|
||||
if err != nil {
|
||||
slog.Error("StartThreadBridge failed", "err", err)
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, "Не удалось создать ключ.", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
_, sendErr := b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||
fmt.Sprintf("Ключ для связки этого треда: <code>%s</code>\n\nДобавьте MAX-бота в отдельную MAX-группу (которая будет зеркалом этого треда) и отправьте <b>в ней</b>:\n<code>/thread_bridge %s</code>\n\nСсылка на MAX-бота: %s", key, key, b.cfg.MaxBotURL),
|
||||
&SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||
if sendErr != nil {
|
||||
slog.Error("thread-bridge reply send failed", "err", sendErr, "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID)
|
||||
}
|
||||
slog.Info("thread-bridge pending", "tgChat", msg.Chat.ID, "thread", msg.MessageThreadID, "key", key)
|
||||
continue
|
||||
}
|
||||
|
||||
// /thread_unbridge — удалить связку конкретного треда
|
||||
if text == "/thread_unbridge" {
|
||||
if !b.checkUserAllowed(ctx, msg.Chat.ID, tgUserID(msg), msg.MessageThreadID) {
|
||||
continue
|
||||
}
|
||||
if isGroup && !isAdmin {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, adminDeniedText, &SendOpts{ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
if msg.MessageThreadID == 0 {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, "Отправьте команду внутри треда.", nil)
|
||||
continue
|
||||
}
|
||||
if b.repo.UnpairThread(msg.Chat.ID, msg.MessageThreadID) {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, "Связка треда удалена.", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||
} else {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, "Этот тред не связан.", &SendOpts{ThreadID: msg.MessageThreadID})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Пересылка. Приоритет у thread-bridge: если сообщение в связанном треде — шлём в его MAX-чат.
|
||||
var maxChatID int64
|
||||
var linked bool
|
||||
if msg.MessageThreadID != 0 {
|
||||
maxChatID, linked = b.repo.GetThreadMaxChat(msg.Chat.ID, msg.MessageThreadID)
|
||||
}
|
||||
if !linked {
|
||||
maxChatID, linked = b.repo.GetMaxChat(msg.Chat.ID)
|
||||
}
|
||||
if !linked {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue