mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
feat(tgbot): route admin notifications to a forum topic (#6879)
Admins who run the bot in a forum supergroup got every notification in the General topic. An admin chat ID may now carry a topic, chatId:topicId, and the bot's admin messages and reports go to that topic. Repeated chats collapse into one entry and a second, different topic for the same chat is rejected, in the panel and in the docs setup helper alike. The chat ID hint in every locale and the English, Persian, Russian and Chinese guides describe the form.
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -40,6 +41,7 @@ var (
|
||||
|
||||
botHandler *th.BotHandler
|
||||
adminIds []int64
|
||||
adminTopics map[int64]int // admin chat -> forum topic (message_thread_id)
|
||||
isRunning bool
|
||||
hostname string
|
||||
hashStorage *global.HashStorage
|
||||
@@ -321,20 +323,14 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAdminIds := make([]int64, 0)
|
||||
// Parse admin IDs from comma-separated string
|
||||
if tgBotID != "" {
|
||||
for adminID := range strings.SplitSeq(tgBotID, ",") {
|
||||
id, err := strconv.ParseInt(adminID, 10, 64)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
|
||||
return err
|
||||
}
|
||||
parsedAdminIds = append(parsedAdminIds, id)
|
||||
}
|
||||
parsedAdminIds, parsedAdminTopics, err := parseAdminChats(tgBotID)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
|
||||
return err
|
||||
}
|
||||
tgBotMutex.Lock()
|
||||
adminIds = parsedAdminIds
|
||||
adminTopics = parsedAdminTopics
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
// Get Telegram bot proxy URL
|
||||
@@ -500,6 +496,61 @@ func adminSnapshot() []int64 {
|
||||
return slices.Clone(adminIds)
|
||||
}
|
||||
|
||||
// parseAdminChats reads "chatId[:topicId],..."; the optional topic targets a
|
||||
// forum topic in a supergroup, e.g. "123456789,-1001234567890:42".
|
||||
func parseAdminChats(raw string) ([]int64, map[int64]int, error) {
|
||||
ids := make([]int64, 0)
|
||||
topics := make(map[int64]int)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return ids, topics, nil
|
||||
}
|
||||
for entry := range strings.SplitSeq(raw, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
chatPart, topicPart, hasTopic := strings.Cut(entry, ":")
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(chatPart), 10, 64)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
topic := 0
|
||||
if hasTopic {
|
||||
topic, err = strconv.Atoi(strings.TrimSpace(topicPart))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if topic <= 0 {
|
||||
return nil, nil, fmt.Errorf("invalid topic ID %q for chat %d", topicPart, id)
|
||||
}
|
||||
}
|
||||
// General is thread 1 internally, but the Bot API only reaches it with no thread id.
|
||||
if hasTopic && topic != generalTopicID {
|
||||
if prev, ok := topics[id]; ok && prev != topic {
|
||||
return nil, nil, fmt.Errorf("chat %d has more than one topic configured", id)
|
||||
}
|
||||
topics[id] = topic
|
||||
}
|
||||
if !slices.Contains(ids, id) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, topics, nil
|
||||
}
|
||||
|
||||
const generalTopicID = 1
|
||||
|
||||
// adminTopic returns the forum topic configured for chatId, or 0 when the
|
||||
// chat has none and messages should go to the chat itself.
|
||||
func adminTopic(chatId int64) int {
|
||||
tgBotMutex.Lock()
|
||||
defer tgBotMutex.Unlock()
|
||||
return adminTopics[chatId]
|
||||
}
|
||||
|
||||
// inAdminTopic routes a document to the configured forum topic of its chat.
|
||||
func inAdminTopic(document *telego.SendDocumentParams) *telego.SendDocumentParams {
|
||||
document.MessageThreadID = adminTopic(document.ChatID.ID)
|
||||
return document
|
||||
}
|
||||
|
||||
// SetHostname sets the hostname for the bot.
|
||||
func (t *Tgbot) SetHostname() {
|
||||
host, err := os.Hostname()
|
||||
@@ -518,6 +569,7 @@ func (t *Tgbot) Stop() {
|
||||
logger.Info("Stop Telegram receiver ...")
|
||||
tgBotMutex.Lock()
|
||||
adminIds = nil
|
||||
adminTopics = nil
|
||||
tgBotMutex.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "sub.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "subjson.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
@@ -349,7 +349,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, filename),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
_, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
// Reduced delay for better performance
|
||||
if i < max-1 { // Only delay between documents, not after the last one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
@@ -432,7 +432,7 @@ func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(dbData, dbFilename),
|
||||
)
|
||||
_, err := bot.SendDocument(ctx, document)
|
||||
_, err := bot.SendDocument(ctx, inAdminTopic(document))
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading backup: ", err)
|
||||
@@ -452,7 +452,7 @@ func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
_, err = bot.SendDocument(ctx, inAdminTopic(document))
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading config.json: ", err)
|
||||
}
|
||||
@@ -478,7 +478,7 @@ func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), document)
|
||||
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
|
||||
}
|
||||
@@ -499,7 +499,7 @@ func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), document)
|
||||
_, err = bot.SendDocument(context.Background(), inAdminTopic(document))
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedLog: ", err)
|
||||
}
|
||||
|
||||
@@ -133,9 +133,10 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
|
||||
allMessages := pageMessage(msg, telegramPageLimit)
|
||||
for n, message := range allMessages {
|
||||
params := telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
Text: message,
|
||||
ParseMode: "HTML",
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageThreadID: adminTopic(chatId),
|
||||
Text: message,
|
||||
ParseMode: "HTML",
|
||||
}
|
||||
// only add replyMarkup to last message
|
||||
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
|
||||
@@ -260,9 +261,10 @@ func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSecon
|
||||
|
||||
// Send the message
|
||||
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
Text: msg,
|
||||
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageThreadID: adminTopic(chatId),
|
||||
Text: msg,
|
||||
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send message:", err)
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
|
||||
@@ -154,3 +158,67 @@ func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T)
|
||||
t.Fatal("commands must remain accepted when the current bot username is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminChats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
ids []int64
|
||||
topics map[int64]int
|
||||
}{
|
||||
{name: "empty", raw: "", ids: []int64{}, topics: map[int64]int{}},
|
||||
{name: "plain ids", raw: "123,-1001", ids: []int64{123, -1001}, topics: map[int64]int{}},
|
||||
{name: "topic", raw: "-1003855200872:42", ids: []int64{-1003855200872}, topics: map[int64]int{-1003855200872: 42}},
|
||||
{name: "mixed with spaces", raw: " 123 , -1001 : 7 ", ids: []int64{123, -1001}, topics: map[int64]int{-1001: 7}},
|
||||
{name: "general topic means no thread", raw: "-1001:1,123", ids: []int64{-1001, 123}, topics: map[int64]int{}},
|
||||
{name: "duplicate chat collapses", raw: "-1001:7,-1001:7,-1001", ids: []int64{-1001}, topics: map[int64]int{-1001: 7}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ids, topics, err := parseAdminChats(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAdminChats(%q) error: %v", tc.raw, err)
|
||||
}
|
||||
if !slices.Equal(ids, tc.ids) {
|
||||
t.Fatalf("ids = %v, want %v", ids, tc.ids)
|
||||
}
|
||||
if len(topics) != len(tc.topics) {
|
||||
t.Fatalf("topics = %v, want %v", topics, tc.topics)
|
||||
}
|
||||
for chat, topic := range tc.topics {
|
||||
if topics[chat] != topic {
|
||||
t.Fatalf("topics[%d] = %d, want %d", chat, topics[chat], topic)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAdminChatsRejectsInvalid(t *testing.T) {
|
||||
for _, raw := range []string{"abc", "123:", "123:abc", "123:0", "123:-5", "-1001:7,-1001:8", "123,"} {
|
||||
if _, _, err := parseAdminChats(raw); err == nil {
|
||||
t.Errorf("parseAdminChats(%q) accepted invalid input", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInAdminTopicRoutesConfiguredChat(t *testing.T) {
|
||||
tgBotMutex.Lock()
|
||||
orig := adminTopics
|
||||
adminTopics = map[int64]int{-1001: 42}
|
||||
tgBotMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
tgBotMutex.Lock()
|
||||
adminTopics = orig
|
||||
tgBotMutex.Unlock()
|
||||
})
|
||||
|
||||
doc := inAdminTopic(tu.Document(tu.ID(-1001), tu.FileFromBytes([]byte("x"), "x.db")))
|
||||
if doc.MessageThreadID != 42 {
|
||||
t.Fatalf("MessageThreadID = %d, want 42", doc.MessageThreadID)
|
||||
}
|
||||
doc = inAdminTopic(&telego.SendDocumentParams{ChatID: tu.ID(123)})
|
||||
if doc.MessageThreadID != 0 {
|
||||
t.Fatalf("MessageThreadID = %d for chat without topic, want 0", doc.MessageThreadID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user