Explorar el Código

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.
maziggy hace 1 mes
padre
commit
6cda236dce

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Telegram notifications can target a forum topic (#1518, reporter @vmhomelab)** — Telegram groups with Topics enabled always received Bambuddy's notifications in the **General** topic, because only Bot Token and Chat ID were configurable. Getting a per-printer split therefore meant creating a separate chat per printer. 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 into that topic, so a single group can carry one topic per printer. Left empty, the behaviour is unchanged. The ID is sent on both the plain-text and the thumbnail code paths, and is validated as a number in the form and again server-side, so a typo is reported instead of silently breaking only text notifications. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Per-file print progress inside a project (#1897, reporter @FedericoPuntelli)** — Projects that consist of many distinct files each needing N prints (e.g. 13 plates × 10 sets = 130 prints) only had aggregate progress; finding out "how many times have I printed plate_7?" meant reading the Activity Timeline line by line. Projects now take an optional **Copies per File** target: each printable file in the project's linked folders shows an **X / N** badge with a mini progress bar (gray not started, amber in progress, green target reached), and the progress card gains a **Complete Sets** bar — the minimum per-file count, i.e. how many finished assemblies you can ship right now. Without the new target, files simply show a printed-count badge (3×). Counting matches the aggregate stats: only completed runs, attributed to a file by a new `library_file_id` stamp on queue-dispatched archives, with content-hash and filename fallbacks covering historical prints. Also fixed along the way: **files queued from a project-linked File Manager folder now attribute their prints to that project** — previously only prints started from the project page counted toward project statistics. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.

+ 20 - 2
backend/app/services/notification_service.py

@@ -438,6 +438,19 @@ class NotificationService:
         if not bot_token or not chat_id:
             return False, "Bot token and chat ID are required"
 
+        # Optional forum topic (#1518).  Telegram expects message_thread_id as an
+        # integer in the JSON sendMessage body — a string 400s there even though
+        # the multipart sendPhoto call below would happily accept one.  Coerce it
+        # once, up front, so both call sites agree and a bad value fails loudly
+        # instead of silently breaking only the text notifications.
+        thread_id_raw = str(config.get("message_thread_id") or "").strip()
+        message_thread_id: int | None = None
+        if thread_id_raw:
+            try:
+                message_thread_id = int(thread_id_raw)
+            except ValueError:
+                return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
+
         # Escape underscores in the message body so Telegram Markdown
         # parsing doesn't break on job names like "A1_plate_8" or error
         # codes like "0300_0001".  The title is already wrapped in *bold*
@@ -452,18 +465,23 @@ class NotificationService:
         if image_data:
             # Use sendPhoto to attach the thumbnail with the caption
             url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
+            form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
+            if message_thread_id is not None:
+                form["message_thread_id"] = message_thread_id
             response = await client.post(
                 url,
-                data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
+                data=form,
                 files={"photo": ("photo.jpg", image_data, "image/jpeg")},
             )
         else:
             url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
-            data = {
+            data: dict[str, Any] = {
                 "chat_id": chat_id,
                 "text": message,
                 "parse_mode": "Markdown",
             }
+            if message_thread_id is not None:
+                data["message_thread_id"] = message_thread_id
             response = await client.post(url, json=data)
 
         if response.status_code == 200:

+ 107 - 0
backend/tests/unit/test_telegram_forum_topic.py

@@ -0,0 +1,107 @@
+"""Tests for optional Telegram forum-topic delivery via message_thread_id (#1518).
+
+Telegram forum groups route messages to a topic by ``message_thread_id``.  The
+field is optional: when it is absent, Telegram posts to the group's General
+topic, which is the behaviour every existing install already relies on.
+
+The subtlety worth pinning is the type.  ``sendMessage`` is posted as JSON, and
+Telegram rejects a *string* thread id there, while the multipart ``sendPhoto``
+call would accept one.  A string passed straight through would therefore work
+for notifications carrying a thumbnail and 400 for plain-text ones — so these
+tests assert an ``int`` reaches both call sites.
+"""
+
+import httpx
+import pytest
+
+from backend.app.services.notification_service import NotificationService
+
+
+class _CaptureClient:
+    """Stand-in for httpx.AsyncClient recording the JSON body and form data."""
+
+    def __init__(self):
+        self.is_closed = False
+        self.calls: list[dict] = []
+
+    async def post(self, url, data=None, files=None, json=None):
+        self.calls.append({"url": url, "data": data, "files": files, "json": json})
+        return httpx.Response(200, json={"ok": True, "result": {}})
+
+
+@pytest.fixture
+def service_with_capture():
+    service = NotificationService()
+    client = _CaptureClient()
+    service._http_client = client  # bypass real HTTP
+    return service, client
+
+
+BASE_CONFIG = {"bot_token": "123456:AAbbCC", "chat_id": "-1002520100736"}
+PNG = b"\x89PNG\r\n\x1a\n"
+
+
+@pytest.mark.asyncio
+async def test_thread_id_omitted_when_unset(service_with_capture):
+    """Default config must produce exactly the pre-#1518 payload."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram(BASE_CONFIG, "*T*\nbody")
+    assert ok
+    assert "message_thread_id" not in client.calls[0]["json"]
+
+
+@pytest.mark.asyncio
+async def test_blank_thread_id_is_treated_as_unset(service_with_capture):
+    """An emptied-out form field must not turn into a bogus topic."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "   "}, "*T*\nbody")
+    assert ok
+    assert "message_thread_id" not in client.calls[0]["json"]
+
+
+@pytest.mark.asyncio
+async def test_sendmessage_carries_thread_id_as_int(service_with_capture):
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody")
+    assert ok
+    body = client.calls[0]["json"]
+    assert body["message_thread_id"] == 25
+    assert isinstance(body["message_thread_id"], int), "Telegram 400s on a string thread id in JSON"
+
+
+@pytest.mark.asyncio
+async def test_sendphoto_carries_thread_id(service_with_capture):
+    """Thumbnail notifications take the multipart path and must route too."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "25"}, "*T*\nbody", image_data=PNG)
+    assert ok
+    call = client.calls[0]
+    assert call["url"].endswith("/sendPhoto")
+    assert call["data"]["message_thread_id"] == 25
+
+
+@pytest.mark.asyncio
+async def test_thread_id_accepts_native_int(service_with_capture):
+    """config is a JSON blob — the value may already deserialise as an int."""
+    service, client = service_with_capture
+    ok, _ = await service._send_telegram({**BASE_CONFIG, "message_thread_id": 25}, "*T*\nbody")
+    assert ok
+    assert client.calls[0]["json"]["message_thread_id"] == 25
+
+
+@pytest.mark.asyncio
+async def test_non_numeric_thread_id_fails_without_sending(service_with_capture):
+    """Reject locally rather than let Telegram answer with an opaque 400."""
+    service, client = service_with_capture
+    ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "General"}, "*T*\nbody")
+    assert not ok
+    assert "not a number" in error
+    assert client.calls == []
+
+
+@pytest.mark.asyncio
+async def test_error_message_does_not_leak_bot_token(service_with_capture):
+    service, _ = service_with_capture
+    ok, error = await service._send_telegram({**BASE_CONFIG, "message_thread_id": "oops"}, "*T*\nbody")
+    assert not ok
+    assert "AAbbCC" not in error

+ 90 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -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();
+  });
+});

+ 22 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -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 - 0
frontend/src/i18n/locales/ko.ts

@@ -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 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/ru.ts

@@ -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 - 0
frontend/src/i18n/locales/tr.ts

@@ -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 - 0
frontend/src/i18n/locales/uk.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5604,6 +5604,9 @@ export default {
     pushoverExpire: '緊急逾時 (秒)',
     botToken: '機器人權杖',
     chatId: '聊天 ID',
+    telegramThreadId: '論壇主題 ID',
+    telegramThreadIdHelp: '選填。傳送到論壇群組中的指定主題,即主題連結結尾的數字 (t.me/c/.../25)。留空則傳送到一般主題。',
+    telegramThreadIdInvalid: '論壇主題 ID 必須是數字。',
     smtpServer: 'SMTP 伺服器',
     smtpPort: 'SMTP 連接埠',
     security: '安全',

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-utEp4da9.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Ceb-MdaC.js"></script>
+    <script type="module" crossorigin src="/assets/index-utEp4da9.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4bpNaiw.css">
   </head>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio