feat(notifications): optional Telegram forum topic via message_thread_id (#1518)

Telegram groups with Topics enabled always received notifications in the
General topic, since only Bot Token and Chat ID were configurable. Splitting
notifications per printer meant running a separate chat for each one.

The Telegram provider now takes an optional Forum Topic ID - the last number
in a topic's link, t.me/c/1234567890/25 - and routes its messages there. Left
empty, nothing changes.

The value is coerced to an int once in _send_telegram and attached to both the
sendMessage JSON body and the sendPhoto form data. That ordering matters:
Telegram rejects a string message_thread_id in the JSON body while accepting
one in the multipart call, so passing the raw form value through would have
worked for thumbnail notifications and 400'd for plain-text ones. A
non-numeric value is rejected in the form and again server-side before any
request goes out.

No migration - provider config is a JSON blob.

Adds Forum Topic ID plus help text to the Telegram section of the provider
dialog, translated in all 13 locales. Backend tests cover omitted / blank /
int-typed / non-numeric values and both send paths; frontend tests cover the
field being optional, absent for other providers, round-tripping on save, and
blocking save on a bad value.
This commit is contained in:
maziggy
2026-07-29 14:10:38 +02:00
parent c11bcecfd8
commit 6cda236dce
20 changed files with 294 additions and 17 deletions
@@ -571,3 +571,93 @@ describe('AddNotificationModal — Bark provider (#1495)', () => {
});
});
});
describe('AddNotificationModal — Telegram forum topic (#1518)', () => {
const telegramProvider = (config: Record<string, unknown> = { bot_token: 'x', chat_id: '-100123' }) =>
buildProvider({ provider_type: 'telegram', config });
it('offers the Forum Topic ID field as optional for telegram', async () => {
render(<AddNotificationModal provider={telegramProvider()} onClose={() => undefined} />);
const label = await screen.findByText(/forum topic id/i);
// Required fields are marked with a trailing asterisk — this one must not be.
expect(label.textContent).not.toContain('*');
expect(screen.getByText(/leave empty for the general topic/i)).toBeInTheDocument();
});
it('does not offer the field for other providers', async () => {
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
await screen.findByDisplayValue('My ntfy');
expect(screen.queryByText(/forum topic id/i)).not.toBeInTheDocument();
});
it('round-trips the topic id into config on save', async () => {
let captured: { config: Record<string, unknown> } | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as { config: Record<string, unknown> };
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
await user.type(await screen.findByPlaceholderText('123'), '25');
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(captured).not.toBeNull();
expect(captured!.config).toMatchObject({ chat_id: '-100123', message_thread_id: '25' });
});
it('keeps the config free of the key when the field is left empty', async () => {
let captured: { config: Record<string, unknown> } | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as { config: Record<string, unknown> };
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
await screen.findByPlaceholderText('123');
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(captured!.config).not.toHaveProperty('message_thread_id');
});
it('blocks save on a non-numeric topic id', async () => {
// Reaches the form via a config written by the API rather than the picker —
// the number input itself already filters most junk out.
let patched = false;
server.use(
http.patch('*/api/v1/notifications/1', async () => {
patched = true;
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(
<AddNotificationModal
provider={telegramProvider({ bot_token: 'x', chat_id: '-100123', message_thread_id: 'General' })}
onClose={onClose}
/>,
);
await screen.findByText(/forum topic id/i);
await user.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByText(/forum topic id must be a number/i)).toBeInTheDocument();
expect(patched).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -160,6 +160,15 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
}
}
// Telegram forum topic must be a plain integer (#1518) — type="number"
// still lets "1e5" and "-" through, and Telegram would 400 on those.
if (providerType === 'telegram' && config.message_thread_id?.trim()) {
if (!/^\d+$/.test(config.message_thread_id.trim())) {
setError(t('notifications.telegramThreadIdInvalid'));
return;
}
}
const finalConfig: Record<string, unknown> =
providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
? { ...config, event_priorities: eventPriorities }
@@ -245,6 +254,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
return [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
{ key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
// Optional forum topic (#1518). Left empty, Telegram posts to the
// group's General topic exactly as before.
{
key: 'message_thread_id',
label: t('notifications.telegramThreadId'),
placeholder: '123',
type: 'number',
required: false,
help: t('notifications.telegramThreadIdHelp'),
},
];
case 'email':
return [
@@ -423,6 +442,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
)}
{'help' in field && (field as { help?: string }).help && (
<p className="text-xs text-bambu-gray mt-1">{(field as { help?: string }).help}</p>
)}
</div>
))}
</div>
+3
View File
@@ -5615,6 +5615,9 @@ export default {
pushoverExpire: 'Notfall-Ablauf (s)',
botToken: 'Bot-Token',
chatId: 'Chat-ID',
telegramThreadId: 'Forum-Themen-ID',
telegramThreadIdHelp: 'Optional. Sendet in ein einzelnes Thema einer Forum-Gruppe — die letzte Zahl im Themen-Link (t.me/c/.../25). Leer lassen für das allgemeine Thema.',
telegramThreadIdInvalid: 'Die Forum-Themen-ID muss eine Zahl sein.',
smtpServer: 'SMTP-Server',
smtpPort: 'SMTP-Port',
security: 'Sicherheit',
+3
View File
@@ -5659,6 +5659,9 @@ export default {
pushoverExpire: 'Emergency Expire (s)',
botToken: 'Bot Token',
chatId: 'Chat ID',
telegramThreadId: 'Forum Topic ID',
telegramThreadIdHelp: 'Optional. Posts into a single topic of a forum group — the last number in the topic link (t.me/c/.../25). Leave empty for the General topic.',
telegramThreadIdInvalid: 'Forum Topic ID must be a number.',
smtpServer: 'SMTP Server',
smtpPort: 'SMTP Port',
security: 'Security',
+3
View File
@@ -5624,6 +5624,9 @@ export default {
pushoverExpire: 'Expiración de emergencia (s)',
botToken: 'Token del bot',
chatId: 'ID del chat',
telegramThreadId: 'ID del tema del foro',
telegramThreadIdHelp: 'Opcional. Envía a un único tema de un grupo de foro: el último número del enlace del tema (t.me/c/.../25). Déjalo vacío para el tema General.',
telegramThreadIdInvalid: 'El ID del tema del foro debe ser un número.',
smtpServer: 'Servidor SMTP',
smtpPort: 'Puerto SMTP',
security: 'Seguridad',
+3
View File
@@ -5605,6 +5605,9 @@ export default {
pushoverExpire: 'Expiration urgence (s)',
botToken: 'Jeton du bot',
chatId: 'ID du chat',
telegramThreadId: 'ID du sujet de forum',
telegramThreadIdHelp: 'Facultatif. Envoie dans un seul sujet d\'un groupe forum : le dernier nombre du lien du sujet (t.me/c/.../25). Laisser vide pour le sujet General.',
telegramThreadIdInvalid: 'L\'ID du sujet de forum doit être un nombre.',
smtpServer: 'Serveur SMTP',
smtpPort: 'Port SMTP',
security: 'Sécurité',
+3
View File
@@ -5604,6 +5604,9 @@ export default {
pushoverExpire: 'Scadenza emergenza (s)',
botToken: 'Token del bot',
chatId: 'ID chat',
telegramThreadId: 'ID argomento forum',
telegramThreadIdHelp: 'Opzionale. Invia in un singolo argomento di un gruppo forum: l\'ultimo numero nel link dell\'argomento (t.me/c/.../25). Lascia vuoto per l\'argomento Generale.',
telegramThreadIdInvalid: 'L\'ID argomento forum deve essere un numero.',
smtpServer: 'Server SMTP',
smtpPort: 'Porta SMTP',
security: 'Sicurezza',
+3
View File
@@ -5616,6 +5616,9 @@ export default {
pushoverExpire: '緊急有効期限 (秒)',
botToken: 'ボットトークン',
chatId: 'チャットID',
telegramThreadId: 'フォーラムトピック ID',
telegramThreadIdHelp: '任意。フォーラムグループ内の特定のトピックに送信します。トピックリンクの末尾の数字です (t.me/c/.../25)。空欄の場合は General トピックに送信されます。',
telegramThreadIdInvalid: 'フォーラムトピック ID は数値で入力してください。',
smtpServer: 'SMTPサーバー',
smtpPort: 'SMTPポート',
security: 'セキュリティ',
+3
View File
@@ -5328,6 +5328,9 @@ export default {
pushoverExpire: '긴급 만료 (초)',
botToken: '봇 토큰',
chatId: '채팅 ID',
telegramThreadId: '포럼 주제 ID',
telegramThreadIdHelp: '선택 사항. 포럼 그룹의 특정 주제로 전송합니다. 주제 링크의 마지막 숫자입니다 (t.me/c/.../25). 비워 두면 General 주제로 전송됩니다.',
telegramThreadIdInvalid: '포럼 주제 ID는 숫자여야 합니다.',
smtpServer: 'SMTP 서버',
smtpPort: 'SMTP 포트',
security: '보안',
+3
View File
@@ -5604,6 +5604,9 @@ export default {
pushoverExpire: 'Expiração de emergência (s)',
botToken: 'Token do Bot',
chatId: 'ID do Chat',
telegramThreadId: 'ID do tópico do fórum',
telegramThreadIdHelp: 'Opcional. Envia para um único tópico de um grupo de fórum: o último número no link do tópico (t.me/c/.../25). Deixe vazio para o tópico Geral.',
telegramThreadIdInvalid: 'O ID do tópico do fórum deve ser um número.',
smtpServer: 'Servidor SMTP',
smtpPort: 'Porta SMTP',
security: 'Segurança',
+3
View File
@@ -5315,6 +5315,9 @@ export default {
pushoverExpire: "Срок действия экстренного уведомления (с)",
botToken: "Токен бота",
chatId: "ID чата",
telegramThreadId: "ID темы форума",
telegramThreadIdHelp: "Необязательно. Отправляет в конкретную тему форум-группы — последнее число в ссылке на тему (t.me/c/.../25). Оставьте пустым для темы General.",
telegramThreadIdInvalid: "ID темы форума должен быть числом.",
smtpServer: "SMTP-сервер",
smtpPort: "Порт SMTP",
security: "Защита соединения",
+3
View File
@@ -5564,6 +5564,9 @@ export default {
pushoverExpire: 'Acil sona erme (sn)',
botToken: 'Bot Belirteci',
chatId: 'Sohbet ID',
telegramThreadId: 'Forum Konu Kimliği',
telegramThreadIdHelp: 'İsteğe bağlı. Forum grubundaki tek bir konuya gönderir: konu bağlantısındaki son sayı (t.me/c/.../25). Genel konu için boş bırakın.',
telegramThreadIdInvalid: 'Forum konu kimliği bir sayı olmalıdır.',
smtpServer: 'SMTP Sunucusu',
smtpPort: 'SMTP Portu',
security: 'Güvenlik',
+3
View File
@@ -5659,6 +5659,9 @@ export default {
pushoverExpire: "Термін дії екстреного сповіщення (с)",
botToken: "Токен бота",
chatId: "Ідентифікатор чату",
telegramThreadId: "ID теми форуму",
telegramThreadIdHelp: "Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.",
telegramThreadIdInvalid: "ID теми форуму має бути числом.",
smtpServer: "Сервер SMTP",
smtpPort: "Порт SMTP",
security: "Безпека",
+3
View File
@@ -5604,6 +5604,9 @@ export default {
pushoverExpire: '紧急过期 (秒)',
botToken: '机器人令牌',
chatId: '聊天 ID',
telegramThreadId: '论坛话题 ID',
telegramThreadIdHelp: '可选。发送到论坛群组中的指定话题,即话题链接末尾的数字 (t.me/c/.../25)。留空则发送到常规话题。',
telegramThreadIdInvalid: '论坛话题 ID 必须是数字。',
smtpServer: 'SMTP 服务器',
smtpPort: 'SMTP 端口',
security: '安全',
+3
View File
@@ -5604,6 +5604,9 @@ export default {
pushoverExpire: '緊急逾時 (秒)',
botToken: '機器人權杖',
chatId: '聊天 ID',
telegramThreadId: '論壇主題 ID',
telegramThreadIdHelp: '選填。傳送到論壇群組中的指定主題,即主題連結結尾的數字 (t.me/c/.../25)。留空則傳送到一般主題。',
telegramThreadIdInvalid: '論壇主題 ID 必須是數字。',
smtpServer: 'SMTP 伺服器',
smtpPort: 'SMTP 連接埠',
security: '安全',