mirror of
https://github.com/BEARlogin/max-telegram-bridge-bot.git
synced 2026-04-28 03:39:46 +00:00
Route MAX replies to source TG thread + crosspost/UX fixes
- Issue #38 part 1: MAX reply to a bridged TG message now lands in the same TG forum thread as the original, not in the pair's default thread. messages table gets tg_thread_id column (migration 000014); SaveMsg stores the TG thread, LookupTgMsgID returns it, forwardMaxToTg applies source thread for reply-routing (both body.ReplyTo and Link.Type=reply paths). - Fix crosspost attribution heuristic: forwardMaxToTg used "caption != text" to detect bridge mode, which broke when MaxToTg replacements or whitespace made caption differ from raw body.Text — MAX→TG crossposts then got [MAX] prefix and bold name. Now explicit isCrosspost flag. - /bridge without key in an already-linked chat no longer generates a fresh key; instead shows "already linked" hint with pairing guidance (both TG and MAX sides). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
38e5777798
commit
46d2f4f748
12 changed files with 91 additions and 38 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,3 +21,4 @@ bridge.db.bak
|
|||
.omc/
|
||||
broadcast.sh
|
||||
broadcast_*.html
|
||||
.broadcast_sent/
|
||||
|
|
|
|||
52
max.go
52
max.go
|
|
@ -51,7 +51,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
|
||||
// Обработка удаления (только bridge, не crosspost)
|
||||
if delUpd, isDel := upd.(*maxschemes.MessageRemovedUpdate); isDel {
|
||||
tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(delUpd.MessageId)
|
||||
tgChatID, tgMsgID, _, ok := b.repo.LookupTgMsgID(delUpd.MessageId)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
mid := editUpd.Message.Body.Mid
|
||||
tgChatID, tgMsgID, ok := b.repo.LookupTgMsgID(mid)
|
||||
tgChatID, tgMsgID, _, ok := b.repo.LookupTgMsgID(mid)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
|
@ -295,6 +295,27 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge"))
|
||||
|
||||
// /bridge без ключа — если чат уже связан, не создавать новый ключ
|
||||
if key == "" {
|
||||
if tgID, linked := b.repo.GetTgChat(chatID); linked {
|
||||
var reply string
|
||||
if isGroup {
|
||||
reply = fmt.Sprintf("Эта группа уже связана с Telegram (ID %d).\n\n/unbridge — удалить связку.", tgID)
|
||||
} else {
|
||||
reply = fmt.Sprintf("Этот личный чат уже связан с Telegram (ID %d).\n\nЧтобы связать группу — добавьте бота в неё и отправьте /bridge внутри группы, не здесь.\n\n/unbridge — удалить связку этого личного чата.", tgID)
|
||||
}
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText(reply)
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
if !isGroup {
|
||||
m := maxbot.NewMessage().SetChat(chatID).SetText(
|
||||
"Чтобы связать группу — добавьте бота в неё и отправьте /bridge внутри группы, не здесь.\n\nЕсли хотите связать этот личный чат с Telegram-пользователем — введите ключ от него: /bridge <ключ>.")
|
||||
b.maxApi.Messages.Send(ctx, m)
|
||||
continue
|
||||
}
|
||||
}
|
||||
paired, generatedKey, err := b.repo.Register(key, "max", chatID)
|
||||
if err != nil {
|
||||
slog.Error("register failed", "err", err)
|
||||
|
|
@ -492,7 +513,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
if !strings.HasPrefix(text, "[TG]") && !strings.HasPrefix(text, "[MAX]") {
|
||||
prefix := b.repo.HasPrefix("max", chatID)
|
||||
caption := formatMaxCaption(msgUpd, prefix, b.cfg.MessageNewline)
|
||||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
|
||||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, false)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -522,7 +543,7 @@ func (b *Bridge) listenMax(ctx context.Context) {
|
|||
caption = applyReplacements(caption, repl.MaxToTg)
|
||||
}
|
||||
|
||||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
|
||||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -903,7 +924,8 @@ func maxCrosspostStatusText(tgChatID int64, direction string) string {
|
|||
}
|
||||
|
||||
// forwardMaxToTg пересылает MAX-сообщение (текст/медиа) в TG-чат.
|
||||
func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string) {
|
||||
// Если isCrosspost=true, caption используется как финальный текст (с заменами, без атрибуции).
|
||||
func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageCreatedUpdate, tgChatID int64, caption string, isCrosspost bool) {
|
||||
if b.cbBlocked(tgChatID) {
|
||||
return
|
||||
}
|
||||
|
|
@ -914,17 +936,21 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
chatID := msgUpd.Message.Recipient.ChatId
|
||||
text := strings.TrimSpace(body.Text)
|
||||
|
||||
// Reply ID
|
||||
// Reply ID + маршрутизация в тред исходного TG-сообщения.
|
||||
// Для reply-ответов всегда используем тред исходника (в т.ч. 0 = General),
|
||||
// а не пара-дефолтный тред — иначе ответ уходит не туда, куда смотрит юзер.
|
||||
var replyToID int
|
||||
if body.ReplyTo != "" {
|
||||
if _, rid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
|
||||
if _, rid, tid, ok := b.repo.LookupTgMsgID(body.ReplyTo); ok {
|
||||
replyToID = rid
|
||||
threadID = tid
|
||||
}
|
||||
} else if msgUpd.Message.Link != nil {
|
||||
} else if msgUpd.Message.Link != nil && msgUpd.Message.Link.Type == maxschemes.REPLY {
|
||||
mid := msgUpd.Message.Link.Message.Mid
|
||||
if mid != "" {
|
||||
if _, rid, ok := b.repo.LookupTgMsgID(mid); ok {
|
||||
if _, rid, tid, ok := b.repo.LookupTgMsgID(mid); ok {
|
||||
replyToID = rid
|
||||
threadID = tid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -938,7 +964,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
// Определяем HTML caption: всегда для bridge-режима (жирное имя) и при наличии markups
|
||||
htmlCaption := caption
|
||||
hasMarkups := len(body.Markups) > 0
|
||||
hasAttribution := caption != text // bridge-режим (не кросспостинг)
|
||||
hasAttribution := !isCrosspost // bridge-режим (не кросспостинг)
|
||||
useHTML := hasMarkups || hasAttribution
|
||||
if useHTML {
|
||||
var htmlText string
|
||||
|
|
@ -1135,7 +1161,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
slog.Error("MigrateTgChat failed", "err", err)
|
||||
} else {
|
||||
// Повторяем отправку с новым ID
|
||||
go b.forwardMaxToTg(ctx, msgUpd, newChatID, caption)
|
||||
go b.forwardMaxToTg(ctx, msgUpd, newChatID, caption, isCrosspost)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -1161,7 +1187,7 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
strings.Contains(errStr, "topics are disabled")) {
|
||||
slog.Info("TG forum topics disabled, resetting thread_id", "tgChat", tgChatID, "oldThread", threadID)
|
||||
b.repo.SetTgThreadID(tgChatID, 0)
|
||||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption)
|
||||
go b.forwardMaxToTg(ctx, msgUpd, tgChatID, caption, isCrosspost)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1183,6 +1209,6 @@ func (b *Bridge) forwardMaxToTg(ctx context.Context, msgUpd *maxschemes.MessageC
|
|||
} else {
|
||||
b.cbSuccess(tgChatID)
|
||||
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)
|
||||
b.repo.SaveMsg(tgChatID, sentMsgID, chatID, body.Mid, threadID)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
|
|||
}
|
||||
b.cbSuccess(maxChatID)
|
||||
slog.Info("TG→MAX media group sent", "mid", result.Body.Mid, "photos", photosSent)
|
||||
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, result.Body.Mid)
|
||||
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, result.Body.Mid, items[0].msg.MessageThreadID)
|
||||
}
|
||||
|
||||
// Видео отправляем отдельно через direct API (SDK не поддерживает AddVideo)
|
||||
|
|
@ -224,7 +224,7 @@ func (b *Bridge) flushMediaGroup(ctx context.Context, groupID string) {
|
|||
continue
|
||||
}
|
||||
if i == 0 && photosSent == 0 {
|
||||
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, mid)
|
||||
b.repo.SaveMsg(items[0].msg.Chat.ID, items[0].msg.MessageID, maxChatID, mid, items[0].msg.MessageThreadID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE messages DROP COLUMN tg_thread_id;
|
||||
1
migrations/postgres/000014_messages_tg_thread_id.up.sql
Normal file
1
migrations/postgres/000014_messages_tg_thread_id.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE messages ADD COLUMN tg_thread_id BIGINT NOT NULL DEFAULT 0;
|
||||
1
migrations/sqlite/000014_messages_tg_thread_id.down.sql
Normal file
1
migrations/sqlite/000014_messages_tg_thread_id.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE messages DROP COLUMN tg_thread_id;
|
||||
1
migrations/sqlite/000014_messages_tg_thread_id.up.sql
Normal file
1
migrations/sqlite/000014_messages_tg_thread_id.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE messages ADD COLUMN tg_thread_id INTEGER NOT NULL DEFAULT 0;
|
||||
18
postgres.go
18
postgres.go
|
|
@ -89,13 +89,13 @@ func (r *pgRepo) GetTgChat(maxChatID int64) (int64, bool) {
|
|||
return id, err == nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string) {
|
||||
func (r *pgRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string, tgThreadID int) {
|
||||
r.db.Exec(
|
||||
`INSERT INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, tg_thread_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (tg_chat_id, tg_msg_id) DO UPDATE
|
||||
SET max_chat_id = EXCLUDED.max_chat_id, max_msg_id = EXCLUDED.max_msg_id, created_at = EXCLUDED.created_at`,
|
||||
tgChatID, tgMsgID, maxChatID, maxMsgID, time.Now().Unix())
|
||||
SET max_chat_id = EXCLUDED.max_chat_id, max_msg_id = EXCLUDED.max_msg_id, tg_thread_id = EXCLUDED.tg_thread_id, created_at = EXCLUDED.created_at`,
|
||||
tgChatID, tgMsgID, maxChatID, maxMsgID, tgThreadID, time.Now().Unix())
|
||||
}
|
||||
|
||||
func (r *pgRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) {
|
||||
|
|
@ -104,11 +104,11 @@ func (r *pgRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) {
|
|||
return id, err == nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
|
||||
func (r *pgRepo) LookupTgMsgID(maxMsgID string) (int64, int, int, bool) {
|
||||
var chatID int64
|
||||
var msgID int
|
||||
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id FROM messages WHERE max_msg_id = $1", maxMsgID).Scan(&chatID, &msgID)
|
||||
return chatID, msgID, err == nil
|
||||
var msgID, threadID int
|
||||
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id, COALESCE(tg_thread_id, 0) FROM messages WHERE max_msg_id = $1", maxMsgID).Scan(&chatID, &msgID, &threadID)
|
||||
return chatID, msgID, threadID, err == nil
|
||||
}
|
||||
|
||||
func (r *pgRepo) CleanOldMessages() {
|
||||
|
|
|
|||
6
queue.go
6
queue.go
|
|
@ -127,7 +127,9 @@ func (b *Bridge) processQueueTg2Max(ctx context.Context, item QueueItem, now tim
|
|||
slog.Info("queue retry ok", "id", item.ID, "dir", "tg2max", "mid", mid)
|
||||
tgMsgID, _ := strconv.Atoi(item.SrcMsgID)
|
||||
if tgMsgID > 0 {
|
||||
b.repo.SaveMsg(item.SrcChatID, tgMsgID, item.DstChatID, mid)
|
||||
// Тред исходного TG-сообщения в очереди не сохраняется — реплаи
|
||||
// на такие сообщения из MAX будут уходить в тред по умолчанию.
|
||||
b.repo.SaveMsg(item.SrcChatID, tgMsgID, item.DstChatID, mid, 0)
|
||||
}
|
||||
b.repo.DeleteFromQueue(item.ID)
|
||||
}
|
||||
|
|
@ -177,6 +179,6 @@ func (b *Bridge) processQueueMax2Tg(ctx context.Context, item QueueItem, now tim
|
|||
return
|
||||
}
|
||||
slog.Info("queue retry ok", "id", item.ID, "dir", "max2tg", "msgID", sentMsgID)
|
||||
b.repo.SaveMsg(item.DstChatID, sentMsgID, item.SrcChatID, item.SrcMsgID)
|
||||
b.repo.SaveMsg(item.DstChatID, sentMsgID, item.SrcChatID, item.SrcMsgID, threadID)
|
||||
b.repo.DeleteFromQueue(item.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ type Repository interface {
|
|||
GetTgChat(maxChatID int64) (int64, bool)
|
||||
MigrateTgChat(oldID, newID int64) error
|
||||
|
||||
SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string)
|
||||
SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string, tgThreadID int)
|
||||
LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool)
|
||||
LookupTgMsgID(maxMsgID string) (int64, int, bool)
|
||||
LookupTgMsgID(maxMsgID string) (tgChatID int64, tgMsgID int, tgThreadID int, ok bool)
|
||||
CleanOldMessages()
|
||||
|
||||
HasPrefix(platform string, chatID int64) bool
|
||||
|
|
|
|||
14
sqlite.go
14
sqlite.go
|
|
@ -86,9 +86,9 @@ func (r *sqliteRepo) GetTgChat(maxChatID int64) (int64, bool) {
|
|||
return id, err == nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string) {
|
||||
r.db.Exec("INSERT OR REPLACE INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
tgChatID, tgMsgID, maxChatID, maxMsgID, time.Now().Unix())
|
||||
func (r *sqliteRepo) SaveMsg(tgChatID int64, tgMsgID int, maxChatID int64, maxMsgID string, tgThreadID int) {
|
||||
r.db.Exec("INSERT OR REPLACE INTO messages (tg_chat_id, tg_msg_id, max_chat_id, max_msg_id, tg_thread_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
tgChatID, tgMsgID, maxChatID, maxMsgID, tgThreadID, time.Now().Unix())
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool) {
|
||||
|
|
@ -97,11 +97,11 @@ func (r *sqliteRepo) LookupMaxMsgID(tgChatID int64, tgMsgID int) (string, bool)
|
|||
return id, err == nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) LookupTgMsgID(maxMsgID string) (int64, int, bool) {
|
||||
func (r *sqliteRepo) LookupTgMsgID(maxMsgID string) (int64, int, int, bool) {
|
||||
var chatID int64
|
||||
var msgID int
|
||||
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id FROM messages WHERE max_msg_id = ?", maxMsgID).Scan(&chatID, &msgID)
|
||||
return chatID, msgID, err == nil
|
||||
var msgID, threadID int
|
||||
err := r.db.QueryRow("SELECT tg_chat_id, tg_msg_id, COALESCE(tg_thread_id, 0) FROM messages WHERE max_msg_id = ?", maxMsgID).Scan(&chatID, &msgID, &threadID)
|
||||
return chatID, msgID, threadID, err == nil
|
||||
}
|
||||
|
||||
func (r *sqliteRepo) CleanOldMessages() {
|
||||
|
|
|
|||
26
telegram.go
26
telegram.go
|
|
@ -373,6 +373,26 @@ func (b *Bridge) listenTelegram(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
key := strings.TrimSpace(strings.TrimPrefix(text, "/bridge"))
|
||||
|
||||
// /bridge без ключа — если чат уже связан, не создавать новый ключ
|
||||
if key == "" {
|
||||
if maxID, linked := b.repo.GetMaxChat(msg.Chat.ID); linked {
|
||||
var txt string
|
||||
if isGroup {
|
||||
txt = fmt.Sprintf("Эта группа уже связана с MAX (ID <code>%d</code>).\n\n/unbridge — удалить связку.", maxID)
|
||||
} else {
|
||||
txt = fmt.Sprintf("Этот личный чат уже связан с MAX (ID <code>%d</code>).\n\nЧтобы связать <b>группу</b> — добавьте бота в неё и отправьте <code>/bridge</code> <b>внутри группы</b>, не здесь.\n\n/unbridge — удалить связку этого личного чата.", maxID)
|
||||
}
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID, txt, &SendOpts{ParseMode: "HTML", ThreadID: msg.MessageThreadID})
|
||||
continue
|
||||
}
|
||||
if !isGroup {
|
||||
b.tg.SendMessage(ctx, msg.Chat.ID,
|
||||
"Чтобы связать группу — добавьте бота в неё и отправьте <code>/bridge</code> <b>внутри группы</b>, не здесь.\n\nЕсли хотите связать этот личный чат с MAX-пользователем — введите ключ от него: <code>/bridge <ключ></code>.",
|
||||
&SendOpts{ParseMode: "HTML"})
|
||||
continue
|
||||
}
|
||||
}
|
||||
paired, generatedKey, err := b.repo.Register(key, "tg", msg.Chat.ID)
|
||||
if err != nil {
|
||||
slog.Error("register failed", "err", err)
|
||||
|
|
@ -550,7 +570,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
|||
} else {
|
||||
b.cbSuccess(maxChatID)
|
||||
slog.Info("TG→MAX sent", "mid", result.Body.Mid)
|
||||
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid)
|
||||
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
|
||||
}
|
||||
return
|
||||
} else if msg.Animation != nil {
|
||||
|
|
@ -602,7 +622,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
|||
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)
|
||||
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, result.Body.Mid, msg.MessageThreadID)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
|
|
@ -827,7 +847,7 @@ func (b *Bridge) forwardTgToMax(ctx context.Context, msg *TGMessage, maxChatID i
|
|||
} else {
|
||||
b.cbSuccess(maxChatID)
|
||||
slog.Info("TG→MAX sent", "mid", mid, "uid", uid, "tgChat", msg.Chat.ID, "maxChat", maxChatID)
|
||||
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, mid)
|
||||
b.repo.SaveMsg(msg.Chat.ID, msg.MessageID, maxChatID, mid, msg.MessageThreadID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue