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
+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}