feat: observability, per-receiver Telegram options, oversized-video fallback

Operability:
- Correlation IDs end-to-end: shared dispatch_id between log lines and
  EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths)
  and a new X-Request-Id middleware that normalizes inbound ids and binds
  request_id into log context.
- dispatch_summary block merged into EventLog.details: per-target
  success/failure counts plus Telegram media delivered/skipped/failed and
  truncated error lists, so partial outcomes surface in the UI.
- Diagnostic mode: admin can flip one module to DEBUG for a bounded
  window with auto-revert (in-memory only; setup_logging() resets on
  boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints
  plus DiagnosticsCassette UI on the settings page.

Telegram:
- Per-receiver options: disable_notification (silent send) and
  message_thread_id (forum-topic routing), wired through the dispatcher
  via a ContextVar so all four send sites (sendMessage / sendPhoto-Video-
  Document / sendMediaGroup / cache-hit POST) pick them up.
- send_large_videos_as_documents target setting: bypass the 50 MB
  sendVideo cap by falling back to sendDocument for oversized videos.
- sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES,
  45 MB) with per-item fallback on chunk failure so a stale file_id no
  longer silently drops a cached asset.

Tests:
- New: diagnostic_mode, dispatch_summary, request_correlation,
  telegram_media_group_partial, telegram_per_send_options.

Docs:
- .claude/reviews/: six-axis production-readiness review of v0.8.1.
- .claude/docs/functional-review-2026-05-28.md: focused review of
  Telegram/Immich/logging subsystems.
This commit is contained in:
2026-05-28 15:19:31 +03:00
parent 85a8f1e71c
commit 6a8f374678
39 changed files with 7239 additions and 142 deletions
+22
View File
@@ -480,6 +480,7 @@
"videoWarning": "Video size warning",
"disableUrlPreview": "Disable link previews",
"sendLargeAsDocuments": "Send large photos as documents",
"sendLargeVideosAsDocuments": "Send oversized videos as documents (bypass 50 MB limit)",
"chatAction": "Chat action",
"chatActionNone": "None (no action)",
"chatActionTyping": "Typing",
@@ -509,6 +510,11 @@
"confirmDeleteReceiver": "Delete this receiver?",
"receiverEnabled": "Receiver enabled",
"receiverDisabled": "Receiver disabled",
"telegramOptions": "Telegram options",
"telegramOptionsSaved": "Telegram options saved",
"telegramDisableNotification": "Send silently (no sound / vibration)",
"telegramThreadId": "Forum topic ID",
"telegramThreadIdPlaceholder": "Leave empty for general topic",
"groupNoBot": "No bot linked",
"groupDirect": "Direct delivery",
"groupBotMissing": "Unknown bot",
@@ -897,6 +903,22 @@
"identityHeadline": "How this instance presents itself to bots, webhooks, and recipients",
"telegramHeadline": "Webhook authentication and media cache tuning",
"loggingHeadline": "Verbosity, output format, and per-module overrides",
"diagnostics": "Diagnostics",
"diagnosticsHeadline": "Temporary DEBUG for one module, auto-reverted",
"diagnosticsHint": "Use to investigate a specific dispatch failure without flooding stderr. The chosen module flips to DEBUG immediately and reverts to its baseline (your per-module overrides or the noisy-library defaults) when the window ends. Restarts also reset.",
"diagModuleQuick": "Module (quick pick)",
"diagModuleCustom": "Or a custom module name",
"diagModuleCustomPlaceholder": "e.g. notify_bridge_server.services.deferred_dispatch",
"diagModuleRequired": "Pick a module first",
"diagDuration": "Duration",
"diagActivate": "Activate DEBUG",
"diagActivated": "Diagnostic mode activated",
"diagActivateFailed": "Failed to activate diagnostic mode",
"diagActive": "Active overrides",
"diagRevertsIn": "Reverts in",
"diagRevertNow": "Revert now",
"diagReverted": "Diagnostic mode reverted",
"diagRevertFailed": "Failed to revert diagnostic mode",
"heroNoUrl": "External URL not set",
"heroNoLocales": "no locales",
"copy": "Copy",
+22
View File
@@ -480,6 +480,7 @@
"videoWarning": "Предупреждение о размере видео",
"disableUrlPreview": "Отключить превью ссылок",
"sendLargeAsDocuments": "Отправлять большие фото как документы",
"sendLargeVideosAsDocuments": "Отправлять видео сверх лимита как документы (обход 50 МБ)",
"chatAction": "Действие в чате",
"chatActionNone": "Нет (без действия)",
"chatActionTyping": "Печатает",
@@ -509,6 +510,11 @@
"confirmDeleteReceiver": "Удалить этого получателя?",
"receiverEnabled": "Получатель включён",
"receiverDisabled": "Получатель отключён",
"telegramOptions": "Параметры Telegram",
"telegramOptionsSaved": "Параметры Telegram сохранены",
"telegramDisableNotification": "Отправлять без звука и вибрации",
"telegramThreadId": "ID темы форума",
"telegramThreadIdPlaceholder": "Оставьте пустым для общей темы",
"groupNoBot": "Без привязки к боту",
"groupDirect": "Прямая доставка",
"groupBotMissing": "Неизвестный бот",
@@ -897,6 +903,22 @@
"identityHeadline": "Как этот сервер представляется ботам, вебхукам и получателям",
"telegramHeadline": "Аутентификация вебхуков и настройка медиакэша",
"loggingHeadline": "Подробность, формат вывода и переопределения по модулям",
"diagnostics": "Диагностика",
"diagnosticsHeadline": "Временный DEBUG для одного модуля с авто-возвратом",
"diagnosticsHint": "Включите, чтобы разобраться в конкретной ошибке отправки без заливания stderr. Выбранный модуль немедленно переходит в DEBUG и возвращается к базовому уровню (вашим переопределениям или умолчаниям для шумных библиотек) по истечении окна. При перезапуске сервера всё сбрасывается.",
"diagModuleQuick": "Модуль (быстрый выбор)",
"diagModuleCustom": "Или произвольное имя модуля",
"diagModuleCustomPlaceholder": "напр. notify_bridge_server.services.deferred_dispatch",
"diagModuleRequired": "Сначала выберите модуль",
"diagDuration": "Длительность",
"diagActivate": "Включить DEBUG",
"diagActivated": "Режим диагностики включён",
"diagActivateFailed": "Не удалось включить режим диагностики",
"diagActive": "Активные переопределения",
"diagRevertsIn": "Вернётся через",
"diagRevertNow": "Вернуть сейчас",
"diagReverted": "Режим диагностики отменён",
"diagRevertFailed": "Не удалось отменить режим диагностики",
"heroNoUrl": "Внешний URL не задан",
"heroNoLocales": "нет локалей",
"copy": "Копировать",
+32
View File
@@ -235,6 +235,35 @@ export type DispatchStatus =
| 'deferred_then_failed'
| 'suppressed_quiet_hours_nondeferrable';
export interface DispatchSummaryError {
index: number;
error: string;
}
export interface DispatchSummaryMediaError {
target_index: number;
kind?: string;
chunk?: number;
item_index?: number;
error?: string;
code?: number;
}
export interface DispatchSummary {
targets_attempted: number;
targets_succeeded: number;
targets_failed: number;
errors?: DispatchSummaryError[];
errors_truncated?: number;
media?: {
delivered: number;
skipped: number;
failed: number;
};
media_errors?: DispatchSummaryMediaError[];
media_errors_truncated?: number;
}
export interface EventLog {
id: number;
event_type: string;
@@ -256,6 +285,9 @@ export interface EventLog {
deferred_until?: string;
original_event_log_id?: number | null;
deferred_for_seconds?: number;
dispatch_id?: string;
request_id?: string;
dispatch_summary?: DispatchSummary;
};
created_at: string;
}
@@ -14,6 +14,7 @@
import ReleaseCassette from './ReleaseCassette.svelte';
import CacheLedger from './CacheLedger.svelte';
import LoggingCassette from './LoggingCassette.svelte';
import DiagnosticsCassette from './DiagnosticsCassette.svelte';
import SaveBar from './SaveBar.svelte';
interface CacheBucketStats {
@@ -203,6 +204,8 @@
bind:logFormat={settings.log_format}
bind:logLevels={settings.log_levels}
/>
<DiagnosticsCassette />
</div>
<SaveBar
@@ -0,0 +1,424 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
interface ActiveOverride {
module: string;
baseline_level: string;
current_level: string;
activated_at: string;
expires_at: string;
remaining_seconds: number;
}
// Modules ship with shortcuts; users can also type a freeform name
// matching the backend allowlist (notify_bridge_*, sqlalchemy.*, etc.).
// Icons let the IconGridSelect render each entry as a visual chip
// instead of a bare text list — same pattern as the surrounding
// log-level / log-format selectors.
const QUICK_MODULES: { value: string; icon: string; label: string; desc?: string }[] = [
{ value: 'notify_bridge_core.notifications.telegram.client', icon: 'mdiSend', label: 'Telegram client' },
{ value: 'notify_bridge_core.notifications.dispatcher', icon: 'mdiCallSplit', label: 'Dispatcher' },
{ value: 'notify_bridge_core.providers.immich', icon: 'mdiImageMultiple', label: 'Immich provider' },
{ value: 'notify_bridge_server.services.watcher', icon: 'mdiEyeOutline', label: 'Watcher' },
{ value: 'notify_bridge_server.services.deferred_dispatch', icon: 'mdiClockOutline', label: 'Deferred dispatch' },
{ value: 'notify_bridge_server.services.scheduled_dispatch', icon: 'mdiCalendarClock', label: 'Scheduled dispatch' },
{ value: 'sqlalchemy.engine', icon: 'mdiDatabase', label: 'SQLAlchemy engine (SQL)' },
{ value: 'aiohttp.client', icon: 'mdiWeb', label: 'aiohttp client' },
];
const DURATION_PRESETS: { minutes: number; label: string }[] = [
{ minutes: 5, label: '5m' },
{ minutes: 15, label: '15m' },
{ minutes: 30, label: '30m' },
{ minutes: 60, label: '1h' },
{ minutes: 120, label: '2h' },
];
let active = $state<ActiveOverride[]>([]);
let pickedModule = $state(QUICK_MODULES[0].value);
let customModule = $state('');
let pickedMinutes = $state(30);
let submitting = $state(false);
let tickHandle: ReturnType<typeof setInterval> | null = null;
// Resync from the backend every N seconds so a server-side auto-revert
// is reflected even if we missed a tick. Tracked as elapsed-time so the
// 1s ticker can drift without breaking the cadence.
const RESYNC_EVERY_SECONDS = 30;
let lastResyncAt = Date.now();
async function refresh(): Promise<void> {
try {
const data = await api<{ active: ActiveOverride[] }>(
'/settings/diagnostic-mode',
{ method: 'GET' },
);
active = data.active || [];
} catch (err: unknown) {
// Surface non-401 errors only; settings page already shows a banner
// when the API is unreachable.
}
}
function tick(): void {
// Cheap local countdown so the UI doesn't poll the server every second
// to render a clock. The full refresh happens every 30s OR on action.
if (active.length === 0) return;
const now = Date.now();
active = active
.map(a => ({
...a,
remaining_seconds: Math.max(
0,
Math.floor((new Date(a.expires_at).getTime() - now) / 1000),
),
}))
.filter(a => a.remaining_seconds > 0);
}
function startTicker(): void {
if (tickHandle != null) return;
tickHandle = setInterval(() => {
tick();
const now = Date.now();
if (now - lastResyncAt >= RESYNC_EVERY_SECONDS * 1000) {
lastResyncAt = now;
void refresh();
}
}, 1000);
}
function stopTicker(): void {
if (tickHandle != null) {
clearInterval(tickHandle);
tickHandle = null;
}
}
onMount(() => {
lastResyncAt = Date.now();
void refresh();
startTicker();
});
onDestroy(() => {
stopTicker();
});
function effectiveModule(): string {
return (customModule.trim() || pickedModule).trim();
}
async function activate(): Promise<void> {
const mod = effectiveModule();
if (!mod) {
snackError(t('settings.diagModuleRequired'));
return;
}
submitting = true;
try {
const entry = await api<ActiveOverride>('/settings/diagnostic-mode', {
method: 'POST',
body: JSON.stringify({ module: mod, duration_minutes: pickedMinutes }),
});
// Replace any existing row for this module with the new schedule.
active = [
...active.filter(a => a.module !== entry.module),
entry,
];
customModule = '';
snackSuccess(t('settings.diagActivated'));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
snackError(msg || t('settings.diagActivateFailed'));
} finally {
submitting = false;
}
}
async function revert(module: string): Promise<void> {
try {
await api(`/settings/diagnostic-mode/${encodeURIComponent(module)}`, {
method: 'DELETE',
});
active = active.filter(a => a.module !== module);
snackSuccess(t('settings.diagReverted'));
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
snackError(msg || t('settings.diagRevertFailed'));
}
}
function formatRemaining(seconds: number): string {
if (seconds <= 0) return '0s';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins >= 60) {
const hours = Math.floor(mins / 60);
const remMins = mins % 60;
return `${hours}h ${remMins}m`;
}
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
</script>
<section class="diag glass">
<header class="diag-head">
<div class="diag-eyebrow">
<MdiIcon name="mdiBugOutline" size={12} />
<span>{t('settings.diagnostics')}</span>
</div>
<h3 class="diag-title">{t('settings.diagnosticsHeadline')}</h3>
<p class="diag-sub">{t('settings.diagnosticsHint')}</p>
</header>
<!-- Compose new override -->
<div class="diag-compose">
<div class="diag-label">
<span>{t('settings.diagModuleQuick')}</span>
<IconGridSelect items={QUICK_MODULES} bind:value={pickedModule} columns={2} compact />
</div>
<label class="diag-label">
<span>{t('settings.diagModuleCustom')}</span>
<input
bind:value={customModule}
type="text"
autocomplete="off"
spellcheck="false"
placeholder={t('settings.diagModuleCustomPlaceholder')}
class="diag-input"
/>
</label>
<div class="diag-label">
<span>{t('settings.diagDuration')}</span>
<div class="diag-duration-chips">
{#each DURATION_PRESETS as preset (preset.minutes)}
<button
type="button"
class="diag-chip"
class:diag-chip-active={pickedMinutes === preset.minutes}
onclick={() => (pickedMinutes = preset.minutes)}
>
{preset.label}
</button>
{/each}
</div>
</div>
<button
type="button"
onclick={activate}
disabled={submitting}
class="diag-activate"
>
<MdiIcon name="mdiPlay" size={14} />
<span>{submitting ? t('common.loading') : t('settings.diagActivate')}</span>
</button>
</div>
<!-- Active overrides list -->
{#if active.length > 0}
<div class="diag-active" in:slide={{ duration: 180 }}>
<div class="diag-active-head">
<MdiIcon name="mdiTimerSandComplete" size={12} />
<span>{t('settings.diagActive')}</span>
</div>
{#each active as ov (ov.module)}
<div class="diag-row">
<div class="diag-row-info">
<code class="diag-row-module">{ov.module}</code>
<span class="diag-row-meta">
{t('settings.diagRevertsIn')} <strong>{formatRemaining(ov.remaining_seconds)}</strong>
<span class="diag-row-baseline">{ov.baseline_level}</span>
</span>
</div>
<IconButton
icon="mdiUndoVariant"
title={t('settings.diagRevertNow')}
onclick={() => revert(ov.module)}
size={16}
/>
</div>
{/each}
</div>
{/if}
</section>
<style>
.diag {
padding: 1.5rem 1.6rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
}
.diag-head {
position: relative;
z-index: 1;
}
.diag-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-mono);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
margin-bottom: 0.45rem;
}
.diag-title {
margin: 0;
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
font-size: 1.15rem;
line-height: 1.35;
letter-spacing: -0.015em;
color: var(--color-foreground);
max-width: 38ch;
}
.diag-sub {
margin: 0.45rem 0 0 0;
font-size: 0.78rem;
color: var(--color-muted-foreground);
max-width: 56ch;
}
.diag-compose {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.7rem;
padding-top: 0.4rem;
border-top: 1px solid var(--color-border);
}
.diag-label {
display: flex;
flex-direction: column;
gap: 0.32rem;
}
.diag-label > span {
font-size: 0.74rem;
font-weight: 500;
color: var(--color-foreground);
}
.diag-input {
width: 100%;
font-family: var(--font-mono);
font-size: 0.78rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-glass);
color: var(--color-foreground);
}
.diag-duration-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.diag-chip {
padding: 0.32rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-muted-foreground);
font-family: var(--font-mono);
font-size: 0.72rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.diag-chip:hover {
background: var(--color-glass-strong);
color: var(--color-foreground);
}
.diag-chip-active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
}
.diag-activate {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
align-self: flex-start;
padding: 0.55rem 1.1rem;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
font-family: var(--font-display);
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.diag-activate:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 65%, var(--color-border));
}
.diag-activate:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.diag-active {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-top: 0.55rem;
border-top: 1px solid var(--color-border);
}
.diag-active-head {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-mono);
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--color-muted-foreground);
}
.diag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.5rem 0.65rem;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-glass-strong);
}
.diag-row-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.diag-row-module {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--color-foreground);
word-break: break-all;
}
.diag-row-meta {
font-size: 0.72rem;
color: var(--color-muted-foreground);
}
.diag-row-baseline {
font-family: var(--font-mono);
font-size: 0.7rem;
margin-left: 0.4rem;
opacity: 0.7;
}
</style>
+65 -2
View File
@@ -166,7 +166,7 @@
const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
disable_url_preview: true, send_large_photos_as_documents: false, send_large_videos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack shared settings
username: '',
// ntfy shared settings
@@ -407,7 +407,7 @@
bot_id: c.bot_id || 0, bot_token: '',
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false, send_large_videos_as_documents: c.send_large_videos_as_documents ?? false,
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
// discord/slack
username: c.username || '',
@@ -448,6 +448,7 @@
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
send_large_videos_as_documents: form.send_large_videos_as_documents,
ai_captions: form.ai_captions,
};
} else if (formType === 'webhook') {
@@ -603,6 +604,63 @@
} catch (err: unknown) { snackError(errMsg(err)); }
}
// Per-Telegram-receiver options panel: silent send + forum thread id.
// Edits the receiver's config dict in place via PUT.
let editingReceiverId = $state<number | null>(null);
// ``<input type="number">`` binds either a ``number`` or empty string
// when the field is blank — model both so TS strict mode and the save
// path's ``Number(raw)`` coercion agree.
let editingReceiverOptions = $state<{ disable_notification: boolean; message_thread_id: number | '' }>({
disable_notification: false,
message_thread_id: '',
});
function openEditReceiver(_targetId: number, receiver: TargetReceiver) {
editingReceiverId = receiver.id;
// Empty string maps to "no thread" — the form's <input type=number>
// produces '' for an empty field, which we normalize to null on save.
const raw = receiver.config?.message_thread_id;
const parsed = raw == null || raw === '' ? '' : Number(raw);
editingReceiverOptions = {
disable_notification: Boolean(receiver.config?.disable_notification),
message_thread_id: typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : '',
};
}
function cancelEditReceiver() {
editingReceiverId = null;
}
async function saveEditReceiver(targetId: number, receiverId: number) {
const target = allTargets.find(t => t.id === targetId);
const receiver = target?.receivers?.find(r => r.id === receiverId);
if (!receiver) return;
// Merge new options into the existing config so we don't lose the chat_id
// or any other receiver-specific keys (language_code on Telegram).
const newConfig: Record<string, any> = { ...receiver.config };
newConfig.disable_notification = editingReceiverOptions.disable_notification;
const raw = editingReceiverOptions.message_thread_id;
if (raw === '' || raw == null) {
delete newConfig.message_thread_id;
} else {
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) {
newConfig.message_thread_id = Math.trunc(parsed);
} else {
delete newConfig.message_thread_id;
}
}
try {
await api(`/targets/${targetId}/receivers/${receiverId}`, {
method: 'PUT',
body: JSON.stringify({ config: newConfig }),
});
editingReceiverId = null;
await load();
snackSuccess(t('targets.telegramOptionsSaved'));
} catch (err: unknown) { snackError(errMsg(err)); }
}
async function toggleBroadcastChild(targetId: number, childId: number) {
const tgt = allTargets.find(t => t.id === targetId);
if (!tgt) return;
@@ -753,6 +811,8 @@
{receiverBotChats}
{receiverTesting}
{receiverLabel}
{editingReceiverId}
bind:editingReceiverOptions
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
@@ -762,6 +822,9 @@
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
onopenEditReceiver={openEditReceiver}
oncancelEditReceiver={cancelEditReceiver}
onsaveEditReceiver={saveEditReceiver}
/>
</div>
{/if}
@@ -16,6 +16,12 @@
receiverBotChats: Record<number, TelegramChat[]>;
receiverTesting: Record<number, boolean>;
receiverLabel: (target: NotificationTarget, recv: TargetReceiver) => string;
// Telegram-only editing state. Optional so a future caller that
// reuses this component for a non-Telegram target page doesn't have
// to pass dead props; the cog button only renders when both the
// target type matches AND the handlers are wired.
editingReceiverId?: number | null;
editingReceiverOptions?: Record<string, any>;
onopenReceiverForm: (targetId: number, targetType: string) => void;
onsaveReceiver: (targetId: number) => void;
oncancelReceiver: () => void;
@@ -25,6 +31,9 @@
onloadBotChats: (botId: number) => void;
onchangeReceiverForm: (form: Record<string, any>) => void;
ontoggleBroadcastChild?: (targetId: number, childId: number) => void;
onopenEditReceiver?: (targetId: number, receiver: TargetReceiver) => void;
oncancelEditReceiver?: () => void;
onsaveEditReceiver?: (targetId: number, receiverId: number) => void;
}
let {
@@ -37,6 +46,8 @@
receiverBotChats,
receiverTesting,
receiverLabel,
editingReceiverId,
editingReceiverOptions = $bindable(),
onopenReceiverForm,
onsaveReceiver,
oncancelReceiver,
@@ -46,6 +57,9 @@
onloadBotChats,
onchangeReceiverForm,
ontoggleBroadcastChild,
onopenEditReceiver,
oncancelEditReceiver,
onsaveEditReceiver,
}: Props = $props();
</script>
@@ -92,11 +106,25 @@
{#if (recv as any).language_code || recv.config?.language_code}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
{/if}
{#if target.type === 'telegram' && recv.config?.disable_notification}
<MdiIcon name="mdiBellOff" size={12} />
{/if}
{#if target.type === 'telegram' && recv.config?.message_thread_id != null && recv.config?.message_thread_id !== ''}
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]" title={t('targets.telegramThreadId')}>#{recv.config.message_thread_id}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiSend" title={t('targets.test')}
onclick={() => ontestReceiver(target.id, recv.id)}
disabled={receiverTesting[recv.id]} size={16} />
{#if target.type === 'telegram' && onopenEditReceiver != null}
<IconButton
icon="mdiCog"
title={t('targets.telegramOptions')}
onclick={() => onopenEditReceiver!(target.id, recv)}
size={16}
/>
{/if}
<IconButton
icon={recv.enabled ? 'mdiToggleSwitch' : 'mdiToggleSwitchOff'}
title={recv.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled')}
@@ -112,6 +140,31 @@
/>
</div>
</div>
{#if target.type === 'telegram' && editingReceiverId === recv.id && editingReceiverOptions != null && onsaveEditReceiver != null && oncancelEditReceiver != null}
<div in:slide={{ duration: 150 }} class="mb-2 ml-6 mr-2 p-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)]">
<label class="flex items-center gap-2 text-sm mb-2 cursor-pointer">
<input type="checkbox" bind:checked={editingReceiverOptions.disable_notification} />
<span>{t('targets.telegramDisableNotification')}</span>
</label>
<label class="flex flex-col gap-1 text-sm mb-2">
<span>{t('targets.telegramThreadId')}</span>
<input type="number" min="1" inputmode="numeric"
bind:value={editingReceiverOptions.message_thread_id}
placeholder={t('targets.telegramThreadIdPlaceholder')}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</label>
<div class="flex gap-2">
<button type="button" onclick={() => onsaveEditReceiver!(target.id, recv.id)}
class="px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-xs font-medium hover:opacity-90">
{t('common.save')}
</button>
<button type="button" onclick={oncancelEditReceiver}
class="px-3 py-1 border border-[var(--color-border)] rounded-md text-xs hover:bg-[var(--color-muted)]">
{t('targets.cancel')}
</button>
</div>
</div>
{/if}
{/each}
<!-- Telegram: chat picker palette opens directly from the "Add receiver" button — no inline section. -->
@@ -23,6 +23,7 @@
max_asset_size: number;
disable_url_preview: boolean;
send_large_photos_as_documents: boolean;
send_large_videos_as_documents: boolean;
ai_captions: boolean;
chat_action: string;
username: string;
@@ -131,6 +132,7 @@
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_videos_as_documents} /> {t('targets.sendLargeVideosAsDocuments')}</label>
</div>
{/if}
</div>