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:
@@ -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",
|
||||
|
||||
@@ -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": "Копировать",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user