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:
Tuyến Trần
2026-10-24 10:55:55 +07:00
committed by GitHub
parent 7d45456feb
commit a403faee23
25 changed files with 245 additions and 53 deletions
@@ -77,7 +77,7 @@ export function TelegramSetupHelper() {
/> />
{adminIds ? ( {adminIds ? (
idsV.invalid.length > 0 ? ( idsV.invalid.length > 0 ? (
<Status ok={false}>Not numeric: {idsV.invalid.join(', ')}</Status> <Status ok={false}>Invalid: {idsV.invalid.join(', ')}</Status>
) : ( ) : (
<Status ok> <Status ok>
{idsV.ids.length} admin id{idsV.ids.length === 1 ? '' : 's'} {idsV.ids.length} admin id{idsV.ids.length === 1 ? '' : 's'}
@@ -37,6 +37,11 @@ connected). This is your **admin** ID.
In Panel Settings, enable the Telegram bot and set the **token** and **admin chat In Panel Settings, enable the Telegram bot and set the **token** and **admin chat
ID(s)** (comma-separated). Save, then message your bot. ID(s)** (comma-separated). Save, then message your bot.
To send notifications into a specific topic of a forum supergroup, append the
topic ID after a colon, e.g. `-1001234567890:42`. The topic ID is the number
after the chat in a topic message link (`https://t.me/c/1234567890/42/…`). Leave
the topic off (or use `1`) for the General topic.
</Step> </Step>
</Steps> </Steps>
@@ -101,7 +106,7 @@ The warning lead times come from `expireDiff` (days before expiry) and
| -------------- | -------------------------- | ---------------------------------------------- | | -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | Master on/off. | | `tgBotEnable` | `false` | Master on/off. |
| `tgBotToken` | _(secret)_ | Bot API token. | | `tgBotToken` | _(secret)_ | Bot API token. |
| `tgBotChatId` | _(none)_ | Comma-separated **admin** Telegram IDs. | | `tgBotChatId` | _(none)_ | Comma-separated **admin** Telegram IDs; `chatId:topicId` targets a forum topic. |
| `tgBotProxy` | _(none)_ | `socks5://`, `http://`, or `https://` proxy. | | `tgBotProxy` | _(none)_ | `socks5://`, `http://`, or `https://` proxy. |
| `tgBotAPIServer` | _(default)_ | Custom Telegram Bot API server. | | `tgBotAPIServer` | _(default)_ | Custom Telegram Bot API server. |
| `tgRunTime` | `@daily` | Report schedule (cron / `@daily` / `@every …`).| | `tgRunTime` | `@daily` | Report schedule (cron / `@daily` / `@every …`).|
@@ -37,6 +37,11 @@ icon: Send
در تنظیمات پنل، ربات Telegram را فعال کنید و **توکن** و **شناسه‌(های) چت ادمین** را در تنظیمات پنل، ربات Telegram را فعال کنید و **توکن** و **شناسه‌(های) چت ادمین** را
(با کاما جداشده) تنظیم نمایید. ذخیره کنید، سپس به ربات خود پیام بدهید. (با کاما جداشده) تنظیم نمایید. ذخیره کنید، سپس به ربات خود پیام بدهید.
برای ارسال اعلان‌ها به یک تاپیک مشخص از یک سوپرگروه فروم، شناسه‌ی تاپیک را پس از
دونقطه اضافه کنید، مثلاً `-1001234567890:42`. شناسه‌ی تاپیک عدد بعد از چت در لینک یک
پیام تاپیک است (`https://t.me/c/1234567890/42/…`). برای تاپیک General آن را ننویسید
(یا `1` بگذارید).
</Step> </Step>
</Steps> </Steps>
@@ -101,7 +106,7 @@ icon: Send
| -------------- | -------------------------- | ---------------------------------------------- | | -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | روشن/خاموش اصلی. | | `tgBotEnable` | `false` | روشن/خاموش اصلی. |
| `tgBotToken` | _(محرمانه)_ | توکن API ربات. | | `tgBotToken` | _(محرمانه)_ | توکن API ربات. |
| `tgBotChatId` | _(هیچ‌کدام)_ | شناسه‌های Telegram **ادمین**، جداشده با کاما. | | `tgBotChatId` | _(هیچ‌کدام)_ | شناسه‌های Telegram **ادمین**، جداشده با کاما؛ `chatId:topicId` به یک تاپیک فروم می‌فرستد. |
| `tgBotProxy` | _(هیچ‌کدام)_ | پروکسی `socks5://`، `http://` یا `https://`. | | `tgBotProxy` | _(هیچ‌کدام)_ | پروکسی `socks5://`، `http://` یا `https://`. |
| `tgBotAPIServer` | _(پیش‌فرض)_ | سرور سفارشی Telegram Bot API. | | `tgBotAPIServer` | _(پیش‌فرض)_ | سرور سفارشی Telegram Bot API. |
| `tgRunTime` | `@daily` | زمان‌بندی گزارش (cron / `@daily` / `@every …`). | | `tgRunTime` | `@daily` | زمان‌بندی گزارش (cron / `@daily` / `@every …`). |
@@ -39,6 +39,11 @@ icon: Send
В настройках панели включите Telegram-бота и задайте **токен** и **админские В настройках панели включите Telegram-бота и задайте **токен** и **админские
chat ID** (через запятую). Сохраните, затем напишите своему боту. chat ID** (через запятую). Сохраните, затем напишите своему боту.
Чтобы отправлять уведомления в определённую тему форумной супергруппы, добавьте
ID темы после двоеточия, например `-1001234567890:42`. ID темы — число после чата
в ссылке на сообщение темы (`https://t.me/c/1234567890/42/…`). Для темы General
не указывайте его (или укажите `1`).
</Step> </Step>
</Steps> </Steps>
@@ -108,7 +113,7 @@ chat ID** (через запятую). Сохраните, затем напиш
| -------------- | -------------------------- | ---------------------------------------------- | | -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | Главный переключатель вкл./выкл. | | `tgBotEnable` | `false` | Главный переключатель вкл./выкл. |
| `tgBotToken` | _(секрет)_ | Токен Bot API. | | `tgBotToken` | _(секрет)_ | Токен Bot API. |
| `tgBotChatId` | _(нет)_ | **Админские** Telegram ID через запятую. | | `tgBotChatId` | _(нет)_ | **Админские** Telegram ID через запятую; `chatId:topicId` — в тему форума. |
| `tgBotProxy` | _(нет)_ | Прокси `socks5://`, `http://` или `https://`. | | `tgBotProxy` | _(нет)_ | Прокси `socks5://`, `http://` или `https://`. |
| `tgBotAPIServer` | _(по умолчанию)_ | Пользовательский сервер Telegram Bot API. | | `tgBotAPIServer` | _(по умолчанию)_ | Пользовательский сервер Telegram Bot API. |
| `tgRunTime` | `@daily` | Расписание отчётов (cron / `@daily` / `@every …`).| | `tgRunTime` | `@daily` | Расписание отчётов (cron / `@daily` / `@every …`).|
@@ -36,6 +36,10 @@ icon: Send
在面板设置中启用 Telegram 机器人,并填写**令牌**和**管理员聊天 ID** 在面板设置中启用 Telegram 机器人,并填写**令牌**和**管理员聊天 ID**
(多个时以逗号分隔)。保存后,再向你的机器人发送消息。 (多个时以逗号分隔)。保存后,再向你的机器人发送消息。
要把通知发送到论坛超级群组的某个话题,在聊天 ID 后加冒号和话题 ID,例如
`-1001234567890:42`。话题 ID 是话题消息链接中聊天之后的数字
(`https://t.me/c/1234567890/42/…`)。General 话题不填(或填 `1`)。
</Step> </Step>
</Steps> </Steps>
@@ -97,7 +101,7 @@ ID 的所有客户端。订阅 ID 就是邀请码,因此请保持其足够长
| -------------- | -------------------------- | ---------------------------------------------- | | -------------- | -------------------------- | ---------------------------------------------- |
| `tgBotEnable` | `false` | 总开关。 | | `tgBotEnable` | `false` | 总开关。 |
| `tgBotToken` | _(机密)_ | 机器人 API 令牌。 | | `tgBotToken` | _(机密)_ | 机器人 API 令牌。 |
| `tgBotChatId` | _(无)_ | 以逗号分隔的**管理员** Telegram ID。 | | `tgBotChatId` | _(无)_ | 以逗号分隔的**管理员** Telegram ID;`chatId:topicId` 发送到论坛话题。 |
| `tgBotProxy` | _(无)_ | `socks5://`、`http://` 或 `https://` 代理。 | | `tgBotProxy` | _(无)_ | `socks5://`、`http://` 或 `https://` 代理。 |
| `tgBotAPIServer` | _(默认)_ | 自定义 Telegram Bot API 服务器。 | | `tgBotAPIServer` | _(默认)_ | 自定义 Telegram Bot API 服务器。 |
| `tgRunTime` | `@daily` | 报告计划(cron / `@daily` / `@every …`)。 | | `tgRunTime` | `@daily` | 报告计划(cron / `@daily` / `@every …`)。 |
+40 -2
View File
@@ -32,18 +32,47 @@ describe('validateBotToken', () => {
describe('parseAdminIds', () => { describe('parseAdminIds', () => {
it('splits a comma list into integer ids', () => { it('splits a comma list into integer ids', () => {
expect(parseAdminIds('111, 222')).toEqual({ ids: [111, 222], invalid: [] }); expect(parseAdminIds('111, 222')).toEqual({
ids: [111, 222],
entries: ['111', '222'],
invalid: [],
});
}); });
it('accepts negative group ids and captures invalid entries', () => { it('accepts negative group ids and captures invalid entries', () => {
expect(parseAdminIds('-1001234567, abc, 42')).toEqual({ expect(parseAdminIds('-1001234567, abc, 42')).toEqual({
ids: [-1001234567, 42], ids: [-1001234567, 42],
entries: ['-1001234567', '42'],
invalid: ['abc'], invalid: ['abc'],
}); });
}); });
it('drops the General topic suffix, since the Bot API rejects thread id 1', () => {
expect(parseAdminIds('-1001234567:1')).toEqual({
ids: [-1001234567],
entries: ['-1001234567'],
invalid: [],
});
});
it('rejects a second topic for the same chat, as the panel does', () => {
expect(parseAdminIds('-1001:7, -1001:1, -1001:8')).toEqual({
ids: [-1001],
entries: ['-1001:7'],
invalid: ['-1001:8'],
});
});
it('keeps a forum topic suffix and rejects a malformed one', () => {
expect(parseAdminIds('-1001234567:42, 111:0, 222:x')).toEqual({
ids: [-1001234567],
entries: ['-1001234567:42'],
invalid: ['111:0', '222:x'],
});
});
it('returns empty for blank input', () => { it('returns empty for blank input', () => {
expect(parseAdminIds(' ')).toEqual({ ids: [], invalid: [] }); expect(parseAdminIds(' ')).toEqual({ ids: [], entries: [], invalid: [] });
}); });
}); });
@@ -103,4 +132,13 @@ describe('buildBotConfigSummary', () => {
expect(s.tgBotChatId).toBe('111,222'); expect(s.tgBotChatId).toBe('111,222');
expect(s.tgRunTime).toBe('@daily'); expect(s.tgRunTime).toBe('@daily');
}); });
it('keeps forum topic suffixes in the chat id setting', () => {
const s = buildBotConfigSummary({
token: VALID_TOKEN,
adminIds: '111, -1001234567 : 42',
runTime: '@daily',
});
expect(s.tgBotChatId).toBe('111,-1001234567:42');
});
}); });
+22 -9
View File
@@ -1,6 +1,6 @@
// Pure validation + templating helpers for 3x-ui's Telegram bot settings. // Pure validation + templating helpers for 3x-ui's Telegram bot settings.
// Grounded in internal/web/service/tgbot/tgbot.go (admin ids parsed with // Grounded in internal/web/service/tgbot/tgbot.go (admin ids parsed by
// strconv.ParseInt(_,10,64); token handed to telego.NewBot → api.telegram.org) // parseAdminChats as "chatId[:topicId]"; token handed to telego.NewBot → api.telegram.org)
// and the panel's tg* settings (tgRunTime uses robfig/cron). No React/DOM. // and the panel's tg* settings (tgRunTime uses robfig/cron). No React/DOM.
export interface BotConfig { export interface BotConfig {
@@ -17,6 +17,7 @@ export interface TokenValidation {
export interface AdminIdsResult { export interface AdminIdsResult {
ids: number[]; ids: number[];
entries: string[]; // normalized "chatId" or "chatId:topicId", as the panel stores them
invalid: string[]; invalid: string[];
} }
@@ -41,17 +42,29 @@ export function validateBotToken(token: string): TokenValidation {
} }
export function parseAdminIds(raw: string): AdminIdsResult { export function parseAdminIds(raw: string): AdminIdsResult {
const ids: number[] = []; // Mirrors parseAdminChats in tgbot.go: one topic per chat, repeats collapse.
const topics = new Map<string, number>();
const invalid: string[] = []; const invalid: string[] = [];
for (const part of raw for (const part of raw
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean)) { .filter(Boolean)) {
// Telegram chat ids are integers; group/channel ids are negative. // Telegram chat ids are integers; group/channel ids are negative. An
if (/^-?\d+$/.test(part)) ids.push(Number(part)); // optional ":<topicId>" targets a forum topic inside a supergroup.
else invalid.push(part); const m = /^(-?\d+)\s*(?::\s*(\d+))?$/.exec(part);
if (!m || (m[2] !== undefined && Number(m[2]) <= 0)) {
invalid.push(part);
continue;
}
// General (thread 1) is reached by omitting the topic; the Bot API rejects 1.
const topic = m[2] === undefined || Number(m[2]) === 1 ? 0 : Number(m[2]);
const prev = topics.get(m[1]) ?? 0;
if (topic && prev && prev !== topic) invalid.push(part);
else topics.set(m[1], topic || prev);
} }
return { ids, invalid }; const ids = [...topics.keys()].map(Number);
const entries = [...topics].map(([chat, topic]) => (topic ? `${chat}:${topic}` : chat));
return { ids, entries, invalid };
} }
// robfig/cron predefined macros (note: @reboot is NOT supported). // robfig/cron predefined macros (note: @reboot is NOT supported).
@@ -108,11 +121,11 @@ export function buildBotConfigSummary(c: BotConfig): {
tgBotChatId: string; tgBotChatId: string;
tgRunTime: string; tgRunTime: string;
} { } {
const { ids } = parseAdminIds(c.adminIds); const { entries } = parseAdminIds(c.adminIds);
return { return {
tgBotEnable: true, tgBotEnable: true,
tgBotToken: c.token.trim(), tgBotToken: c.token.trim(),
tgBotChatId: ids.join(','), tgBotChatId: entries.join(','),
tgRunTime: c.runTime.trim(), tgRunTime: c.runTime.trim(),
}; };
} }
+63 -11
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"embed" "embed"
"fmt"
"math/big" "math/big"
"net" "net"
"net/url" "net/url"
@@ -40,6 +41,7 @@ var (
botHandler *th.BotHandler botHandler *th.BotHandler
adminIds []int64 adminIds []int64
adminTopics map[int64]int // admin chat -> forum topic (message_thread_id)
isRunning bool isRunning bool
hostname string hostname string
hashStorage *global.HashStorage hashStorage *global.HashStorage
@@ -321,20 +323,14 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
return err return err
} }
parsedAdminIds := make([]int64, 0) parsedAdminIds, parsedAdminTopics, err := parseAdminChats(tgBotID)
// Parse admin IDs from comma-separated string if err != nil {
if tgBotID != "" { logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
for adminID := range strings.SplitSeq(tgBotID, ",") { return err
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)
}
} }
tgBotMutex.Lock() tgBotMutex.Lock()
adminIds = parsedAdminIds adminIds = parsedAdminIds
adminTopics = parsedAdminTopics
tgBotMutex.Unlock() tgBotMutex.Unlock()
// Get Telegram bot proxy URL // Get Telegram bot proxy URL
@@ -500,6 +496,61 @@ func adminSnapshot() []int64 {
return slices.Clone(adminIds) 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. // SetHostname sets the hostname for the bot.
func (t *Tgbot) SetHostname() { func (t *Tgbot) SetHostname() {
host, err := os.Hostname() host, err := os.Hostname()
@@ -518,6 +569,7 @@ func (t *Tgbot) Stop() {
logger.Info("Stop Telegram receiver ...") logger.Info("Stop Telegram receiver ...")
tgBotMutex.Lock() tgBotMutex.Lock()
adminIds = nil adminIds = nil
adminTopics = nil
tgBotMutex.Unlock() tgBotMutex.Unlock()
} }
+3 -3
View File
@@ -320,7 +320,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
tu.ID(chatId), tu.ID(chatId),
tu.FileFromBytes(png, "sub.png"), tu.FileFromBytes(png, "sub.png"),
) )
_, _ = bot.SendDocument(context.Background(), document) _, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
} else { } else {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) 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.ID(chatId),
tu.FileFromBytes(png, "subjson.png"), tu.FileFromBytes(png, "subjson.png"),
) )
_, _ = bot.SendDocument(context.Background(), document) _, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
} else { } else {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) 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.ID(chatId),
tu.FileFromBytes(png, filename), tu.FileFromBytes(png, filename),
) )
_, _ = bot.SendDocument(context.Background(), document) _, _ = bot.SendDocument(context.Background(), inAdminTopic(document))
// Reduced delay for better performance // Reduced delay for better performance
if i < max-1 { // Only delay between documents, not after the last one if i < max-1 { // Only delay between documents, not after the last one
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
+4 -4
View File
@@ -432,7 +432,7 @@ func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
tu.ID(chatId), tu.ID(chatId),
tu.FileFromBytes(dbData, dbFilename), tu.FileFromBytes(dbData, dbFilename),
) )
_, err := bot.SendDocument(ctx, document) _, err := bot.SendDocument(ctx, inAdminTopic(document))
cancel() cancel()
if err != nil { if err != nil {
logger.Error("Error in uploading backup: ", err) 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.ID(chatId),
tu.File(file), tu.File(file),
) )
_, err = bot.SendDocument(ctx, document) _, err = bot.SendDocument(ctx, inAdminTopic(document))
if err != nil { if err != nil {
logger.Error("Error in uploading config.json: ", err) logger.Error("Error in uploading config.json: ", err)
} }
@@ -478,7 +478,7 @@ func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
tu.ID(chatId), tu.ID(chatId),
tu.File(file), tu.File(file),
) )
_, err = bot.SendDocument(context.Background(), document) _, err = bot.SendDocument(context.Background(), inAdminTopic(document))
if err != nil { if err != nil {
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err) logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
} }
@@ -499,7 +499,7 @@ func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
tu.ID(chatId), tu.ID(chatId),
tu.File(file), tu.File(file),
) )
_, err = bot.SendDocument(context.Background(), document) _, err = bot.SendDocument(context.Background(), inAdminTopic(document))
if err != nil { if err != nil {
logger.Error("Error in uploading IPLimitBannedLog: ", err) logger.Error("Error in uploading IPLimitBannedLog: ", err)
} }
+8 -6
View File
@@ -133,9 +133,10 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
allMessages := pageMessage(msg, telegramPageLimit) allMessages := pageMessage(msg, telegramPageLimit)
for n, message := range allMessages { for n, message := range allMessages {
params := telego.SendMessageParams{ params := telego.SendMessageParams{
ChatID: tu.ID(chatId), ChatID: tu.ID(chatId),
Text: message, MessageThreadID: adminTopic(chatId),
ParseMode: "HTML", Text: message,
ParseMode: "HTML",
} }
// only add replyMarkup to last message // only add replyMarkup to last message
if len(replyMarkup) > 0 && n == (len(allMessages)-1) { if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
@@ -260,9 +261,10 @@ func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSecon
// Send the message // Send the message
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{ sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
ChatID: tu.ID(chatId), ChatID: tu.ID(chatId),
Text: msg, MessageThreadID: adminTopic(chatId),
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value Text: msg,
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
}) })
if err != nil { if err != nil {
logger.Warning("Failed to send message:", err) logger.Warning("Failed to send message:", err)
+68
View File
@@ -4,8 +4,12 @@ import (
"io" "io"
"net" "net"
"reflect" "reflect"
"slices"
"testing" "testing"
"time" "time"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
) )
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) { 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") 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)
}
}
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "خادم API لتيليجرام", "telegramAPIServer": "خادم API لتيليجرام",
"telegramAPIServerDesc": "سيرفر Telegram API المستخدم. سيبه فاضي لاستخدام الافتراضي.", "telegramAPIServerDesc": "سيرفر Telegram API المستخدم. سيبه فاضي لاستخدام الافتراضي.",
"telegramChatId": "ID شات الأدمن", "telegramChatId": "ID شات الأدمن",
"telegramChatIdDesc": "ID شات الأدمن في Telegram. (مفصول بفواصل)(تقدر تجيبه من {'@'}userinfobot) أو (استخدم '/id' في البوت)", "telegramChatIdDesc": "ID شات الأدمن في Telegram. (مفصول بفواصل)(تقدر تجيبه من {'@'}userinfobot) أو (استخدم '/id' في البوت) للنشر في موضوع منتدى، أضف ':topicId'، مثل -1001234567890:42",
"telegramNotifyTime": "وقت الإشعار", "telegramNotifyTime": "وقت الإشعار",
"telegramNotifyTimeDesc": "عدد مرات إرسال البوت للتقارير الدورية. اختر فترة جاهزة، أو اختر «مخصص» لإدخال تعبير crontab.", "telegramNotifyTimeDesc": "عدد مرات إرسال البوت للتقارير الدورية. اختر فترة جاهزة، أو اختر «مخصص» لإدخال تعبير crontab.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1343,7 +1343,7 @@
"telegramAPIServer": "Telegram API Server", "telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "The Telegram API server to use. Leave blank to use the default server.", "telegramAPIServerDesc": "The Telegram API server to use. Leave blank to use the default server.",
"telegramChatId": "Admin Chat ID", "telegramChatId": "Admin Chat ID",
"telegramChatIdDesc": "The Telegram Admin Chat ID(s). (comma-separated)(get it here {'@'}userinfobot) or (use '/id' command in the bot)", "telegramChatIdDesc": "The Telegram Admin Chat ID(s). (comma-separated)(get it here {'@'}userinfobot) or (use '/id' command in the bot). To post into a forum topic, append ':topicId', e.g. -1001234567890:42",
"telegramNotifyTime": "Notification Time", "telegramNotifyTime": "Notification Time",
"telegramNotifyTimeDesc": "How often the Telegram bot sends periodic reports. Pick a preset interval, or choose Custom to enter a raw crontab expression.", "telegramNotifyTimeDesc": "How often the Telegram bot sends periodic reports. Pick a preset interval, or choose Custom to enter a raw crontab expression.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Servidor API de Telegram", "telegramAPIServer": "Servidor API de Telegram",
"telegramAPIServerDesc": "El servidor API de Telegram a utilizar. Déjelo en blanco para utilizar el servidor predeterminado.", "telegramAPIServerDesc": "El servidor API de Telegram a utilizar. Déjelo en blanco para utilizar el servidor predeterminado.",
"telegramChatId": "IDs de Chat de Telegram para Administradores", "telegramChatId": "IDs de Chat de Telegram para Administradores",
"telegramChatIdDesc": "IDs de Chat múltiples separados por comas. Use {'@'}userinfobot o use el comando '/id' en el bot para obtener sus IDs de Chat.", "telegramChatIdDesc": "IDs de Chat múltiples separados por comas. Use {'@'}userinfobot o use el comando '/id' en el bot para obtener sus IDs de Chat. Para publicar en un tema de foro, añada ':topicId', p. ej. -1001234567890:42",
"telegramNotifyTime": "Hora de Notificación del Bot de Telegram", "telegramNotifyTime": "Hora de Notificación del Bot de Telegram",
"telegramNotifyTimeDesc": "Con qué frecuencia el bot de Telegram envía informes periódicos. Elige un intervalo predefinido o selecciona Personalizado para introducir una expresión crontab.", "telegramNotifyTimeDesc": "Con qué frecuencia el bot de Telegram envía informes periódicos. Elige un intervalo predefinido o selecciona Personalizado para introducir una expresión crontab.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1224,7 +1224,7 @@
"telegramAPIServer": "سرور API تلگرام", "telegramAPIServer": "سرور API تلگرام",
"telegramAPIServerDesc": "API سرور تلگرام برای اتصال را تغییر میدهد. برای استفاده از سرور پیش فرض خالی بگذارید", "telegramAPIServerDesc": "API سرور تلگرام برای اتصال را تغییر میدهد. برای استفاده از سرور پیش فرض خالی بگذارید",
"telegramChatId": "آی‌دی چت مدیر", "telegramChatId": "آی‌دی چت مدیر",
"telegramChatIdDesc": "دریافت ‌کنید ('/id'یا (دستور ({'@'}userinfobot) آی‌دی(های) چت تلگرام مدیر، از", "telegramChatIdDesc": "آی‌دی(های) چت تلگرام مدیر، جدا شده با کاما. از {'@'}userinfobot یا دستور '/id' در ربات دریافت کنید. برای ارسال در یک تاپیک فروم، ':topicId' را اضافه کنید، مثلاً -1001234567890:42",
"telegramNotifyTime": "زمان نوتیفیکیشن", "telegramNotifyTime": "زمان نوتیفیکیشن",
"telegramNotifyTimeDesc": "هر چند وقت یک‌بار ربات تلگرام گزارش دوره‌ای بفرستد. یک بازهٔ آماده انتخاب کنید یا گزینهٔ سفارشی را بزنید تا عبارت crontab وارد کنید.", "telegramNotifyTimeDesc": "هر چند وقت یک‌بار ربات تلگرام گزارش دوره‌ای بفرستد. یک بازهٔ آماده انتخاب کنید یا گزینهٔ سفارشی را بزنید تا عبارت crontab وارد کنید.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Server API Telegram", "telegramAPIServer": "Server API Telegram",
"telegramAPIServerDesc": "Server API Telegram yang akan digunakan. Biarkan kosong untuk menggunakan server default.", "telegramAPIServerDesc": "Server API Telegram yang akan digunakan. Biarkan kosong untuk menggunakan server default.",
"telegramChatId": "ID Obrolan Admin", "telegramChatId": "ID Obrolan Admin",
"telegramChatIdDesc": "ID Obrolan Admin Telegram. (dipisahkan koma)(dapatkan di sini {'@'}userinfobot) atau (gunakan perintah '/id' di bot)", "telegramChatIdDesc": "ID Obrolan Admin Telegram. (dipisahkan koma)(dapatkan di sini {'@'}userinfobot) atau (gunakan perintah '/id' di bot). Untuk mengirim ke topik forum, tambahkan ':topicId', mis. -1001234567890:42",
"telegramNotifyTime": "Waktu Notifikasi", "telegramNotifyTime": "Waktu Notifikasi",
"telegramNotifyTimeDesc": "Seberapa sering bot Telegram mengirim laporan berkala. Pilih interval siap pakai, atau pilih Kustom untuk memasukkan ekspresi crontab.", "telegramNotifyTimeDesc": "Seberapa sering bot Telegram mengirim laporan berkala. Pilih interval siap pakai, atau pilih Kustom untuk memasukkan ekspresi crontab.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Telegram API サーバー", "telegramAPIServer": "Telegram API サーバー",
"telegramAPIServerDesc": "使用するTelegram APIサーバー。空白の場合はデフォルトサーバーを使用する", "telegramAPIServerDesc": "使用するTelegram APIサーバー。空白の場合はデフォルトサーバーを使用する",
"telegramChatId": "管理者チャットID", "telegramChatId": "管理者チャットID",
"telegramChatIdDesc": "Telegram管理者チャットID(複数の場合はカンマで区切る){'@'}userinfobotで取得するか、ボットで'/id'コマンドを使用して取得する", "telegramChatIdDesc": "Telegram管理者チャットID(複数の場合はカンマで区切る){'@'}userinfobotで取得するか、ボットで'/id'コマンドを使用して取得する フォーラムのトピックに投稿するには ':topicId' を付けます(例: -1001234567890:42)",
"telegramNotifyTime": "通知時間", "telegramNotifyTime": "通知時間",
"telegramNotifyTimeDesc": "Telegram ボットが定期レポートを送信する頻度です。プリセットの間隔を選ぶか、「カスタム」を選んで crontab 式を入力します。", "telegramNotifyTimeDesc": "Telegram ボットが定期レポートを送信する頻度です。プリセットの間隔を選ぶか、「カスタム」を選んで crontab 式を入力します。",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Servidor API do Telegram", "telegramAPIServer": "Servidor API do Telegram",
"telegramAPIServerDesc": "O servidor API do Telegram a ser usado. Deixe em branco para usar o servidor padrão.", "telegramAPIServerDesc": "O servidor API do Telegram a ser usado. Deixe em branco para usar o servidor padrão.",
"telegramChatId": "ID de Chat do Administrador", "telegramChatId": "ID de Chat do Administrador",
"telegramChatIdDesc": "O(s) ID(s) de Chat do Administrador no Telegram. (separado por vírgulas)(obtenha aqui {'@'}userinfobot) ou (use o comando '/id' no bot)", "telegramChatIdDesc": "O(s) ID(s) de Chat do Administrador no Telegram. (separado por vírgulas)(obtenha aqui {'@'}userinfobot) ou (use o comando '/id' no bot). Para publicar em um tópico de fórum, acrescente ':topicId', ex.: -1001234567890:42",
"telegramNotifyTime": "Hora da Notificação", "telegramNotifyTime": "Hora da Notificação",
"telegramNotifyTimeDesc": "Com que frequência o bot do Telegram envia relatórios periódicos. Escolha um intervalo predefinido ou selecione Personalizado para inserir uma expressão crontab.", "telegramNotifyTimeDesc": "Com que frequência o bot do Telegram envia relatórios periódicos. Escolha um intervalo predefinido ou selecione Personalizado para inserir uma expressão crontab.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Telegram API Server", "telegramAPIServer": "Telegram API Server",
"telegramAPIServerDesc": "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию.", "telegramAPIServerDesc": "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию.",
"telegramChatId": "User ID администратора бота", "telegramChatId": "User ID администратора бота",
"telegramChatIdDesc": "Один или несколько User ID администратора(-ов) Telegram-бота. Для получения User ID используйте {'@'}userinfobot или команду '/id' в боте.", "telegramChatIdDesc": "Один или несколько User ID администратора(-ов) Telegram-бота. Для получения User ID используйте {'@'}userinfobot или команду '/id' в боте. Чтобы отправлять в тему форума, добавьте ':topicId', например -1001234567890:42",
"telegramNotifyTime": "Частота уведомлений для администраторов от бота", "telegramNotifyTime": "Частота уведомлений для администраторов от бота",
"telegramNotifyTimeDesc": "Как часто бот Telegram отправляет периодические отчёты. Выберите готовый интервал или «Произвольный», чтобы ввести выражение crontab.", "telegramNotifyTimeDesc": "Как часто бот Telegram отправляет периодические отчёты. Выберите готовый интервал или «Произвольный», чтобы ввести выражение crontab.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Telegram API Sunucusu", "telegramAPIServer": "Telegram API Sunucusu",
"telegramAPIServerDesc": "Kullanılacak Telegram API sunucusu. Varsayılan sunucuyu kullanmak için boş bırakın.", "telegramAPIServerDesc": "Kullanılacak Telegram API sunucusu. Varsayılan sunucuyu kullanmak için boş bırakın.",
"telegramChatId": "Yönetici Sohbet Kimliği", "telegramChatId": "Yönetici Sohbet Kimliği",
"telegramChatIdDesc": "Telegram Yönetici Sohbet Kimliği (Chat ID). Birden fazla ise virgülle ayırın. ({'@'}userinfobot'tan alabilirsiniz veya botta '/id' komutunu kullanabilirsiniz.)", "telegramChatIdDesc": "Telegram Yönetici Sohbet Kimliği (Chat ID). Birden fazla ise virgülle ayırın. ({'@'}userinfobot'tan alabilirsiniz veya botta '/id' komutunu kullanabilirsiniz.) Bir forum konusuna göndermek için ':topicId' ekleyin, ör. -1001234567890:42",
"telegramNotifyTime": "Bildirim Zamanı", "telegramNotifyTime": "Bildirim Zamanı",
"telegramNotifyTimeDesc": "Telegram botunun periyodik raporları gönderme sıklığı. Hazır bir aralık seçin veya bir crontab ifadesi girmek için Özel'i seçin.", "telegramNotifyTimeDesc": "Telegram botunun periyodik raporları gönderme sıklığı. Hazır bir aralık seçin veya bir crontab ifadesi girmek için Özel'i seçin.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Telegram API сервер", "telegramAPIServer": "Telegram API сервер",
"telegramAPIServerDesc": "Сервер Telegram API для використання. Залиште поле порожнім, щоб використовувати сервер за умовчанням.", "telegramAPIServerDesc": "Сервер Telegram API для використання. Залиште поле порожнім, щоб використовувати сервер за умовчанням.",
"telegramChatId": "Ідентифікатор чату адміністратора", "telegramChatId": "Ідентифікатор чату адміністратора",
"telegramChatIdDesc": "Ідентифікатори чату адміністратора Telegram. (розділені комами) (отримайте тут {'@'}userinfobot) або (використовуйте команду '/id' у боті)", "telegramChatIdDesc": "Ідентифікатори чату адміністратора Telegram. (розділені комами) (отримайте тут {'@'}userinfobot) або (використовуйте команду '/id' у боті) Щоб надсилати в тему форуму, додайте ':topicId', наприклад -1001234567890:42",
"telegramNotifyTime": "Час сповіщення", "telegramNotifyTime": "Час сповіщення",
"telegramNotifyTimeDesc": "Як часто бот Telegram надсилає періодичні звіти. Виберіть готовий інтервал або «Власний», щоб ввести вираз crontab.", "telegramNotifyTimeDesc": "Як часто бот Telegram надсилає періодичні звіти. Виберіть готовий інтервал або «Власний», щоб ввести вираз crontab.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Máy chủ API Telegram", "telegramAPIServer": "Máy chủ API Telegram",
"telegramAPIServerDesc": "Máy chủ API Telegram để sử dụng. Để trống để sử dụng máy chủ mặc định.", "telegramAPIServerDesc": "Máy chủ API Telegram để sử dụng. Để trống để sử dụng máy chủ mặc định.",
"telegramChatId": "Chat ID Telegram của quản trị viên", "telegramChatId": "Chat ID Telegram của quản trị viên",
"telegramChatIdDesc": "Nhiều Chat ID phân tách bằng dấu phẩy. Sử dụng {'@'}userinfobot hoặc sử dụng lệnh '/id' trong bot để lấy Chat ID của bạn.", "telegramChatIdDesc": "Nhiều Chat ID phân tách bằng dấu phẩy. Sử dụng {'@'}userinfobot hoặc sử dụng lệnh '/id' trong bot để lấy Chat ID của bạn. Để gửi vào một chủ đề diễn đàn, thêm ':topicId', ví dụ -1001234567890:42",
"telegramNotifyTime": "Thời gian thông báo của bot Telegram", "telegramNotifyTime": "Thời gian thông báo của bot Telegram",
"telegramNotifyTimeDesc": "Tần suất bot Telegram gửi báo cáo định kỳ. Chọn một khoảng thời gian có sẵn, hoặc chọn Tùy chỉnh để nhập biểu thức crontab.", "telegramNotifyTimeDesc": "Tần suất bot Telegram gửi báo cáo định kỳ. Chọn một khoảng thời gian có sẵn, hoặc chọn Tùy chỉnh để nhập biểu thức crontab.",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Telegram API 服务器", "telegramAPIServer": "Telegram API 服务器",
"telegramAPIServerDesc": "要使用的 Telegram API 服务器。留空以使用默认服务器。", "telegramAPIServerDesc": "要使用的 Telegram API 服务器。留空以使用默认服务器。",
"telegramChatId": "管理员聊天 ID", "telegramChatId": "管理员聊天 ID",
"telegramChatIdDesc": "Telegram 管理员聊天 ID (多个以逗号分隔)(可通过 {'@'}userinfobot 获取,或在机器人中使用 '/id' 命令获取)", "telegramChatIdDesc": "Telegram 管理员聊天 ID (多个以逗号分隔)(可通过 {'@'}userinfobot 获取,或在机器人中使用 '/id' 命令获取) 如需发送到论坛话题,请追加 ':topicId',例如 -1001234567890:42",
"telegramNotifyTime": "通知时间", "telegramNotifyTime": "通知时间",
"telegramNotifyTimeDesc": "Telegram 机器人发送周期性报告的频率。选择预设间隔,或选择“自定义”以输入 crontab 表达式。", "telegramNotifyTimeDesc": "Telegram 机器人发送周期性报告的频率。选择预设间隔,或选择“自定义”以输入 crontab 表达式。",
"notifyTime": { "notifyTime": {
+1 -1
View File
@@ -1220,7 +1220,7 @@
"telegramAPIServer": "Telegram API 伺服器", "telegramAPIServer": "Telegram API 伺服器",
"telegramAPIServerDesc": "要使用的 Telegram API 伺服器。留空以使用預設伺服器。", "telegramAPIServerDesc": "要使用的 Telegram API 伺服器。留空以使用預設伺服器。",
"telegramChatId": "管理員聊天 ID", "telegramChatId": "管理員聊天 ID",
"telegramChatIdDesc": "Telegram 管理員聊天 ID (多個以逗號分隔)(可通過 {'@'}userinfobot 獲取,或在機器人中使用 '/id' 命令獲取)", "telegramChatIdDesc": "Telegram 管理員聊天 ID (多個以逗號分隔)(可通過 {'@'}userinfobot 獲取,或在機器人中使用 '/id' 命令獲取) 如需發送到論壇話題,請追加 ':topicId',例如 -1001234567890:42",
"telegramNotifyTime": "通知時間", "telegramNotifyTime": "通知時間",
"telegramNotifyTimeDesc": "Telegram 機器人傳送週期性報告的頻率。選擇預設間隔,或選擇「自訂」以輸入 crontab 運算式。", "telegramNotifyTimeDesc": "Telegram 機器人傳送週期性報告的頻率。選擇預設間隔,或選擇「自訂」以輸入 crontab 運算式。",
"notifyTime": { "notifyTime": {