mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user