mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'セキュリティ',
|
||||
|
||||
@@ -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: '보안',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "Защита соединения",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5659,6 +5659,9 @@ export default {
|
||||
pushoverExpire: "Термін дії екстреного сповіщення (с)",
|
||||
botToken: "Токен бота",
|
||||
chatId: "Ідентифікатор чату",
|
||||
telegramThreadId: "ID теми форуму",
|
||||
telegramThreadIdHelp: "Необов'язково. Надсилає в конкретну тему форум-групи — останнє число у посиланні на тему (t.me/c/.../25). Залиште порожнім для теми General.",
|
||||
telegramThreadIdInvalid: "ID теми форуму має бути числом.",
|
||||
smtpServer: "Сервер SMTP",
|
||||
smtpPort: "Порт SMTP",
|
||||
security: "Безпека",
|
||||
|
||||
@@ -5604,6 +5604,9 @@ export default {
|
||||
pushoverExpire: '紧急过期 (秒)',
|
||||
botToken: '机器人令牌',
|
||||
chatId: '聊天 ID',
|
||||
telegramThreadId: '论坛话题 ID',
|
||||
telegramThreadIdHelp: '可选。发送到论坛群组中的指定话题,即话题链接末尾的数字 (t.me/c/.../25)。留空则发送到常规话题。',
|
||||
telegramThreadIdInvalid: '论坛话题 ID 必须是数字。',
|
||||
smtpServer: 'SMTP 服务器',
|
||||
smtpPort: 'SMTP 端口',
|
||||
security: '安全',
|
||||
|
||||
@@ -5604,6 +5604,9 @@ export default {
|
||||
pushoverExpire: '緊急逾時 (秒)',
|
||||
botToken: '機器人權杖',
|
||||
chatId: '聊天 ID',
|
||||
telegramThreadId: '論壇主題 ID',
|
||||
telegramThreadIdHelp: '選填。傳送到論壇群組中的指定主題,即主題連結結尾的數字 (t.me/c/.../25)。留空則傳送到一般主題。',
|
||||
telegramThreadIdInvalid: '論壇主題 ID 必須是數字。',
|
||||
smtpServer: 'SMTP 伺服器',
|
||||
smtpPort: 'SMTP 連接埠',
|
||||
security: '安全',
|
||||
|
||||
Reference in New Issue
Block a user