From 86517671121fa492d2305cf7cdfb81d921aca567 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 16 May 2026 03:43:48 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20bridge=5Fself=20bot=20commands=20?= =?UTF-8?q?=E2=80=94=20status,=20thresholds,=20reset,=20health?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bot commands for the bridge_self provider so operators can inspect and manage bridge health from chat: /status, /thresholds, /reset, /health. Includes Jinja2 templates for both locales, seed data, capability slots, and a handler that exposes pending deferred backlog plus per-counter reset. Also adds .claude/skills/ for project-scoped graph-aware skills. --- .claude/skills/debug-issue.md | 27 ++ .claude/skills/explore-codebase.md | 28 ++ .claude/skills/refactor-safely.md | 28 ++ .claude/skills/review-changes.md | 29 ++ frontend/src/lib/i18n/en.json | 12 + frontend/src/lib/i18n/ru.json | 12 + .../src/routes/command-configs/+page.svelte | 2 +- .../command-template-configs/+page.svelte | 14 +- .../routes/notification-trackers/+page.svelte | 18 +- frontend/src/routes/providers/+page.svelte | 8 +- frontend/src/routes/targets/+page.svelte | 30 +- .../src/routes/template-configs/+page.svelte | 6 +- .../src/routes/tracking-configs/+page.svelte | 36 +- .../providers/capabilities.py | 28 +- .../en/bridge_self/desc_health.jinja2 | 1 + .../en/bridge_self/desc_help.jinja2 | 1 + .../en/bridge_self/desc_reset.jinja2 | 1 + .../en/bridge_self/desc_status.jinja2 | 1 + .../en/bridge_self/desc_thresholds.jinja2 | 1 + .../en/bridge_self/health.jinja2 | 5 + .../en/bridge_self/help.jinja2 | 5 + .../en/bridge_self/no_results.jinja2 | 1 + .../en/bridge_self/rate_limited.jinja2 | 1 + .../en/bridge_self/reset.jinja2 | 14 + .../en/bridge_self/start.jinja2 | 2 + .../en/bridge_self/status.jinja2 | 28 ++ .../en/bridge_self/thresholds.jinja2 | 4 + .../en/bridge_self/usage_reset.jinja2 | 1 + .../templates/command_defaults/loader.py | 9 + .../ru/bridge_self/desc_health.jinja2 | 1 + .../ru/bridge_self/desc_help.jinja2 | 1 + .../ru/bridge_self/desc_reset.jinja2 | 1 + .../ru/bridge_self/desc_status.jinja2 | 1 + .../ru/bridge_self/desc_thresholds.jinja2 | 1 + .../ru/bridge_self/health.jinja2 | 5 + .../ru/bridge_self/help.jinja2 | 5 + .../ru/bridge_self/no_results.jinja2 | 1 + .../ru/bridge_self/rate_limited.jinja2 | 1 + .../ru/bridge_self/reset.jinja2 | 14 + .../ru/bridge_self/start.jinja2 | 2 + .../ru/bridge_self/status.jinja2 | 28 ++ .../ru/bridge_self/thresholds.jinja2 | 4 + .../ru/bridge_self/usage_reset.jinja2 | 1 + .../api/command_template_configs.py | 97 +++++ .../src/notify_bridge_server/api/providers.py | 2 +- .../commands/bridge_self_handler.py | 273 +++++++++++++ .../notify_bridge_server/commands/dispatch.py | 2 + .../notify_bridge_server/database/seeds.py | 14 + .../services/bridge_self.py | 181 +++++++++ packages/server/tests/test_bridge_self.py | 383 ++++++++++++++++++ 50 files changed, 1311 insertions(+), 60 deletions(-) create mode 100644 .claude/skills/debug-issue.md create mode 100644 .claude/skills/explore-codebase.md create mode 100644 .claude/skills/refactor-safely.md create mode 100644 .claude/skills/review-changes.md create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_health.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_reset.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_thresholds.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/health.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/no_results.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/rate_limited.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/reset.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/start.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/thresholds.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/usage_reset.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_health.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_reset.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_thresholds.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/health.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/no_results.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/rate_limited.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/reset.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/start.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/thresholds.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/usage_reset.jinja2 create mode 100644 packages/server/src/notify_bridge_server/commands/bridge_self_handler.py diff --git a/.claude/skills/debug-issue.md b/.claude/skills/debug-issue.md new file mode 100644 index 0000000..ef1b38a --- /dev/null +++ b/.claude/skills/debug-issue.md @@ -0,0 +1,27 @@ +--- +name: Debug Issue +description: Systematically debug issues using graph-powered code navigation +--- + +## Debug Issue + +Use the knowledge graph to systematically trace and debug issues. + +### Steps + +1. Use `semantic_search_nodes` to find code related to the issue. +2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains. +3. Use `get_flow` to see full execution paths through suspected areas. +4. Run `detect_changes` to check if recent changes caused the issue. +5. Use `get_impact_radius` on suspected files to see what else is affected. + +### Tips + +- Check both callers and callees to understand the full context. +- Look at affected flows to find the entry point that triggers the bug. +- Recent changes are the most common source of new issues. + +## Token Efficiency Rules +- ALWAYS start with `get_minimal_context(task="")` before any other graph tool. +- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient. +- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens. diff --git a/.claude/skills/explore-codebase.md b/.claude/skills/explore-codebase.md new file mode 100644 index 0000000..dc7ad10 --- /dev/null +++ b/.claude/skills/explore-codebase.md @@ -0,0 +1,28 @@ +--- +name: Explore Codebase +description: Navigate and understand codebase structure using the knowledge graph +--- + +## Explore Codebase + +Use the code-review-graph MCP tools to explore and understand the codebase. + +### Steps + +1. Run `list_graph_stats` to see overall codebase metrics. +2. Run `get_architecture_overview` for high-level community structure. +3. Use `list_communities` to find major modules, then `get_community` for details. +4. Use `semantic_search_nodes` to find specific functions or classes. +5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships. +6. Use `list_flows` and `get_flow` to understand execution paths. + +### Tips + +- Start broad (stats, architecture) then narrow down to specific areas. +- Use `children_of` on a file to see all its functions and classes. +- Use `find_large_functions` to identify complex code. + +## Token Efficiency Rules +- ALWAYS start with `get_minimal_context(task="")` before any other graph tool. +- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient. +- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens. diff --git a/.claude/skills/refactor-safely.md b/.claude/skills/refactor-safely.md new file mode 100644 index 0000000..cf84420 --- /dev/null +++ b/.claude/skills/refactor-safely.md @@ -0,0 +1,28 @@ +--- +name: Refactor Safely +description: Plan and execute safe refactoring using dependency analysis +--- + +## Refactor Safely + +Use the knowledge graph to plan and execute refactoring with confidence. + +### Steps + +1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions. +2. Use `refactor_tool` with mode="dead_code" to find unreferenced code. +3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations. +4. Use `apply_refactor_tool` with the refactor_id to apply renames. +5. After changes, run `detect_changes` to verify the refactoring impact. + +### Safety Checks + +- Always preview before applying (rename mode gives you an edit list). +- Check `get_impact_radius` before major refactors. +- Use `get_affected_flows` to ensure no critical paths are broken. +- Run `find_large_functions` to identify decomposition targets. + +## Token Efficiency Rules +- ALWAYS start with `get_minimal_context(task="")` before any other graph tool. +- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient. +- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens. diff --git a/.claude/skills/review-changes.md b/.claude/skills/review-changes.md new file mode 100644 index 0000000..6bb3558 --- /dev/null +++ b/.claude/skills/review-changes.md @@ -0,0 +1,29 @@ +--- +name: Review Changes +description: Perform a structured code review using change detection and impact +--- + +## Review Changes + +Perform a thorough, risk-aware code review using the knowledge graph. + +### Steps + +1. Run `detect_changes` to get risk-scored change analysis. +2. Run `get_affected_flows` to find impacted execution paths. +3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage. +4. Run `get_impact_radius` to understand the blast radius. +5. For any untested changes, suggest specific test cases. + +### Output Format + +Provide findings grouped by risk level (high/medium/low) with: +- What changed and why it matters +- Test coverage status +- Suggested improvements +- Overall merge recommendation + +## Token Efficiency Rules +- ALWAYS start with `get_minimal_context(task="")` before any other graph tool. +- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient. +- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens. diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index e6219e4..da9b0d1 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -1127,6 +1127,18 @@ "scopeInherit": "Inherit: derive from notification routing", "noCollections": "No albums available." }, + "commands": { + "bridgeSelf": { + "status": "Bridge status", + "statusDesc": "Show current bridge health counters", + "thresholds": "Bridge thresholds", + "thresholdsDesc": "Show configured alert thresholds", + "reset": "Reset counter", + "resetDesc": "Manually reset a failure counter", + "health": "Bridge health", + "healthDesc": "Terse one-line health summary" + } + }, "snackbar": { "showDetails": "Show details", "hideDetails": "Hide details" diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 86be749..7e55fc6 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -1127,6 +1127,18 @@ "scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений", "noCollections": "Нет доступных альбомов." }, + "commands": { + "bridgeSelf": { + "status": "Состояние моста", + "statusDesc": "Показать счётчики состояния моста", + "thresholds": "Пороги моста", + "thresholdsDesc": "Показать настроенные пороги оповещений", + "reset": "Сбросить счётчик", + "resetDesc": "Вручную сбросить счётчик сбоев", + "health": "Здоровье моста", + "healthDesc": "Краткая однострочная сводка состояния" + } + }, "snackbar": { "showDetails": "Показать детали", "hideDetails": "Скрыть детали" diff --git a/frontend/src/routes/command-configs/+page.svelte b/frontend/src/routes/command-configs/+page.svelte index b0bce98..5206eb8 100644 --- a/frontend/src/routes/command-configs/+page.svelte +++ b/frontend/src/routes/command-configs/+page.svelte @@ -51,7 +51,7 @@ let submitting = $state(false); let confirmDelete = $state<{ id: number; onconfirm: () => Promise } | null>(null); - // Immich command icons — used as fallback when capabilities don't specify icons + // Immich command icons — used as fallback when capabilities don't specify icons const commandIcons: Record = { help: 'mdiHelpCircle', status: 'mdiChartBox', albums: 'mdiImageMultiple', events: 'mdiPulse', summary: 'mdiFileDocumentEdit', latest: 'mdiImagePlus', diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index 18e4a34..c12b37b 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -85,7 +85,7 @@ return { value: code, label: m.native, - desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(), + desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(), }; })); $effect(() => { @@ -144,10 +144,10 @@ * Group command slots by purpose so the form mirrors how notification * templates are split (event vs scheduled vs settings). * - * commandResponses — primary reply templates (/start, /help, /status, data slots) - * commandErrors — fallback messages (rate_limited, no_results) - * commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker - * commandUsage — usage_* slots: invocation examples shown by /help + * commandResponses — primary reply templates (/start, /help, /status, data slots) + * commandErrors — fallback messages (rate_limited, no_results) + * commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker + * commandUsage — usage_* slots: invocation examples shown by /help */ let commandSlotGroups = $derived([ { @@ -548,7 +548,7 @@ {#each filteredSlots as slot} toggleSlot(slot.name)} @@ -587,7 +587,7 @@ {#if slotErrors[slot.name]} {#if slotErrorTypes[slot.name] === 'undefined'} -

вљ  {t('common.undefinedVar')}: {slotErrors[slot.name]}

+

⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}

{:else}

вњ• {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}

{/if} diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index 0c2dca5..32cbf42 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -98,7 +98,7 @@ // Test types: basic is always available; periodic/scheduled/memory only for providers // that have those notification slots in their capabilities AND have the feature // enabled on the tracker's default TrackingConfig. A disabled feature on the - // default config means cron dispatch won't fire it in production either — so + // default config means cron dispatch won't fire it in production either — so // the test button would just surface a silent skip. const allTestTypes: Record 0 || !trkDesc?.webhookBased) { tiles.push({ @@ -403,7 +403,7 @@ tone: 'sky', }); } - // Scan interval — only meaningful for polling trackers + // Scan interval — only meaningful for polling trackers if (!trkDesc?.webhookBased) { tiles.push({ icon: 'mdiTimerOutline', @@ -593,8 +593,8 @@

- {(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} Р’В· - {#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s Р’В·{/if} + {(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} · + {#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if} {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}

@@ -605,7 +605,7 @@ toggle(tracker)} disabled={toggling[tracker.id]} /> startDelete(tracker)} variant="danger" /> diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index 36c0239..7224420 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -61,7 +61,7 @@ const tiles: MetaTile[] = []; const h = health[provider.id]; const provDesc = getDescriptor(provider.type); - // Status — first tile, color-coded + // Status — first tile, color-coded if (h === true) { tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' }); } else if (h === false) { @@ -107,10 +107,10 @@ try { const u = new URL(url); const segments = u.pathname.split('/').filter(Boolean); - const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : ''; + const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : ''; return `${u.host}${tail}`; } catch { - return url.length > 32 ? `${url.slice(0, 30)}…` : url; + return url.length > 32 ? `${url.slice(0, 30)}…` : url; } } @@ -142,7 +142,7 @@ let health = $state>({}); - // Status pill row for the page header — derived from health probes. + // Status pill row for the page header — derived from health probes. const headerPills = $derived.by(() => { const onlineCount = Object.values(health).filter(v => v === true).length; const offlineCount = Object.values(health).filter(v => v === false).length; diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index 949c592..4c9b3d1 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -26,7 +26,7 @@ import ReceiverSection from './ReceiverSection.svelte'; import BotGroupHeader from './BotGroupHeader.svelte'; - // в”Ђв”Ђ Helpers в”Ђв”Ђ + // ──── Helpers ──── function getBotName(target: NotificationTarget): string | null { if (target.type === 'telegram' && target.config?.bot_id) { @@ -74,7 +74,7 @@ return recv.receiver_key || '?'; } - // в”Ђв”Ђ Constants в”Ђв”Ђ + // ──── Constants ──── const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const; type TargetType = typeof ALL_TYPES[number]; @@ -97,7 +97,7 @@ function targetTiles(target: NotificationTarget): MetaTile[] { const tiles: MetaTile[] = []; - // Type tile — useful when the "all types" filter is active and rows + // Type tile — useful when the "all types" filter is active and rows // from multiple types appear side-by-side. The receivers count is // already shown inside the `target-summary` button, so we don't repeat // it as a tile. @@ -115,8 +115,8 @@ tone: 'sky', }); } - // Telegram targets expose a chat label in config — surface it so the - // row reads "Telegram Р’В· @bot Р’В· Family chat" without expanding. + // Telegram targets expose a chat label in config — surface it so the + // row reads "Telegram · @bot · Family chat" without expanding. const cfg = (target.config || {}) as Record; if (target.type === 'telegram' && cfg.chat_id) { tiles.push({ @@ -126,7 +126,7 @@ mono: true, }); } - // Webhook target — show host + // Webhook target — show host if (target.type === 'webhook' && cfg.url) { let host = String(cfg.url); try { host = new URL(host).host; } catch { /* keep raw */ } @@ -142,7 +142,7 @@ return tiles; } - // в”Ђв”Ђ Derived state в”Ђв”Ђ + // ──── Derived state ──── let allTargets = $derived(targetsCache.items); let activeType = $derived(page.url.searchParams.get('type') as TargetType | null); @@ -158,7 +158,7 @@ const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email }))); const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url }))); - // в”Ђв”Ђ Target form state в”Ђв”Ђ + // ──── Target form state ──── let showForm = $state(false); let editing = $state(null); @@ -204,7 +204,7 @@ formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } - // в”Ђв”Ђ Receiver inline form state в”Ђв”Ђ + // ──── Receiver inline form state ──── let addingReceiverForTarget = $state(null); let receiverForm = $state>({}); @@ -228,7 +228,7 @@ if (!expandedTargets.has(id)) expandedTargets.add(id); } - // в”Ђв”Ђ Effects в”Ђв”Ђ + // ──── Effects ──── // Reset form when switching target type tabs $effect(() => { @@ -239,11 +239,11 @@ addingReceiverForTarget = null; }); - // в”Ђв”Ђ Data loading в”Ђв”Ђ + // ──── Data loading ──── onMount(load); - // в”Ђв”Ђ Bot grouping в”Ђв”Ђ + // ──── Bot grouping ──── type TargetGroup = { key: string; @@ -372,7 +372,7 @@ } catch (e) { console.warn('Failed to load bot chats:', e); } } - // Active discovery — actually polls Telegram getUpdates and persists any new chats. + // Active discovery — actually polls Telegram getUpdates and persists any new chats. // Fired when the chat picker opens so the user sees the freshest list without a manual click. async function discoverReceiverBotChats(botId: number) { if (!botId) return; @@ -382,7 +382,7 @@ } catch (e) { console.warn('Failed to discover bot chats:', e); } } - // в”Ђв”Ђ Target CRUD в”Ђв”Ђ + // ──── Target CRUD ──── function openNew() { form = defaultForm(); @@ -507,7 +507,7 @@ } } - // в”Ђв”Ђ Receiver CRUD в”Ђв”Ђ + // ──── Receiver CRUD ──── async function openReceiverForm(targetId: number, targetType: string) { // Force a remount of any picker palette when the same target is reopened diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index 977a8e7..e4dfd7a 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -82,7 +82,7 @@ return { value: code, label: m.native, - desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(), + desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(), }; })); /** @@ -442,7 +442,7 @@ label: t('templateConfig.slots'), tone: slotCount > 0 ? 'sky' : 'default', }); - // Locale coverage — count unique locales present across all slots + // Locale coverage — count unique locales present across all slots const locales = new Set(); for (const s of Object.values(config.slots || {})) { for (const loc of Object.keys(s || {})) locales.add(loc); @@ -624,7 +624,7 @@ {#if slotErrors[slot.key]} {#if slotErrorTypes[slot.key] === 'undefined'} -

вљ  {t('common.undefinedVar')}: {slotErrors[slot.key]}

+

⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}

{:else}

вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}

{/if} diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte index 0390d07..3ef934d 100644 --- a/frontend/src/routes/tracking-configs/+page.svelte +++ b/frontend/src/routes/tracking-configs/+page.svelte @@ -28,13 +28,13 @@ import Button from '$lib/components/Button.svelte'; import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte'; - /** Grid-select item source lookup — maps descriptor string name to actual function. */ + /** Grid-select item source lookup — maps descriptor string name to actual function. */ const gridItemSources: Record any[]> = { sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems, }; /** - * HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron + * HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron * dispatch accepts. Matched on blur for time-list fields; invalid values * are surfaced inline next to the input. */ @@ -43,7 +43,7 @@ /** Per-field error messages surfaced inline under time-list inputs. */ let timeListErrors = $state>({}); - /** Normalize "9:0 , 18:30" в†’ "09:00,18:30" on blur, clear error when valid. */ + /** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */ function normalizeTimeList(key: string) { const raw = String(form[key] ?? '').trim(); if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; } @@ -74,8 +74,8 @@ } /** - * Quiet-hours preview: "22:00 в†’ 07:00 next day (9h)" or "Quiet period is 0 - * minutes — adjust times" when start equals end. Handles overnight ranges + * Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0 + * minutes — adjust times" when start equals end. Handles overnight ranges * (start > end) correctly. */ function quietHoursPreview(start: string, end: string): string { @@ -92,8 +92,8 @@ const m = span % 60; const dur = m === 0 ? `${h}h` : `${h}h ${m}m`; const arrow = overnight - ? `${start} в†’ ${end} ${t('trackingConfig.nextDay')}` - : `${start} в†’ ${end}`; + ? `${start} → ${end} ${t('trackingConfig.nextDay')}` + : `${start} → ${end}`; return `${arrow} (${dur})`; } @@ -112,12 +112,12 @@ /** * Inline preview of the shipped default template for a scheduled/periodic/ * memory slot. Using the shipped default (not a tracker's current template) - * keeps this scoped to the tracking-config page — which has no concept of + * keeps this scoped to the tracking-config page — which has no concept of * which TemplateConfig a given tracker uses. Users who want to edit the * actual config can click "Edit template" in the modal footer. * * ``previewLocale`` is modal-scoped so switching tabs only refetches for - * this preview — the user's UI locale (and other previews) are untouched. + * this preview — the user's UI locale (and other previews) are untouched. */ let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null); let previewLoading = $state(false); @@ -262,7 +262,7 @@ if (config.quiet_hours_start && config.quiet_hours_end) { tiles.push({ icon: 'mdiWeatherNight', - label: `${config.quiet_hours_start}–${config.quiet_hours_end}`, + label: `${config.quiet_hours_start}–${config.quiet_hours_end}`, hint: t('trackingConfig.quietHoursStart'), tone: 'citrus', mono: true, @@ -344,7 +344,7 @@ {/if} - + {#if descriptor}
{t('trackingConfig.eventTracking')} @@ -377,7 +377,7 @@ {/if}
- + {#each descriptor.featureSections ?? [] as section (section.key)}
@@ -494,9 +494,9 @@

{(desc?.eventFields ?? []).filter(f => (config as Record)[f.key]).map(f => t(f.label)).join(', ')} - {config.periodic_enabled ? ` Р’В· ${t('trackingConfig.periodic')}` : ''} - {config.scheduled_enabled ? ` Р’В· ${t('trackingConfig.scheduled')}` : ''} - {config.memory_enabled ? ` Р’В· ${t('trackingConfig.memory')}` : ''} + {config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''} + {config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''} + {config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}

@@ -518,7 +518,7 @@ blockedBy = null} /> previewModal = null}> {#if previewModal} {#if previewLocales.length > 1} @@ -537,7 +537,7 @@ {t('trackingConfig.previewSampleNote')}

@@ -550,7 +550,7 @@
{@html sanitizePreview(previewModal.rendered)}
{:else} -
…
+
{/if}
diff --git a/packages/core/src/notify_bridge_core/providers/capabilities.py b/packages/core/src/notify_bridge_core/providers/capabilities.py index b27c94c..e9af5aa 100644 --- a/packages/core/src/notify_bridge_core/providers/capabilities.py +++ b/packages/core/src/notify_bridge_core/providers/capabilities.py @@ -542,8 +542,32 @@ BRIDGE_SELF_CAPABILITIES = ProviderCapabilities( {"name": "bridge_self_deferred_backlog", "description": "Deferred backlog high"}, {"name": "bridge_self_target_failures", "description": "Target send failures"}, ], - command_slots=[], - commands=[], + command_slots=[ + # Response templates + {"name": "start", "description": "/start greeting message"}, + {"name": "help", "description": "/help command listing"}, + {"name": "status", "description": "/status full counter snapshot"}, + {"name": "thresholds", "description": "/thresholds configured alert thresholds"}, + {"name": "reset", "description": "/reset manual counter reset"}, + {"name": "health", "description": "/health terse one-line summary"}, + {"name": "rate_limited", "description": "Rate limit warning message"}, + {"name": "no_results", "description": "Empty results fallback"}, + # Description slots + {"name": "desc_help", "description": "Menu description for /help"}, + {"name": "desc_status", "description": "Menu description for /status"}, + {"name": "desc_thresholds", "description": "Menu description for /thresholds"}, + {"name": "desc_reset", "description": "Menu description for /reset"}, + {"name": "desc_health", "description": "Menu description for /health"}, + # Usage examples + {"name": "usage_reset", "description": "Usage example for /reset"}, + ], + commands=[ + {"name": "status", "description": "Show current bridge health counters"}, + {"name": "thresholds", "description": "Show configured alert thresholds"}, + {"name": "reset", "description": "Manually reset a failure counter"}, + {"name": "health", "description": "Terse one-line health summary"}, + {"name": "help", "description": "Show commands"}, + ], ) diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_health.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_health.jinja2 new file mode 100644 index 0000000..bde9ecc --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_health.jinja2 @@ -0,0 +1 @@ +Terse one-line health summary \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_help.jinja2 new file mode 100644 index 0000000..11b9c33 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_help.jinja2 @@ -0,0 +1 @@ +Show available commands \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_reset.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_reset.jinja2 new file mode 100644 index 0000000..2bed080 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_reset.jinja2 @@ -0,0 +1 @@ +Reset a failure counter (tracker:<id>, target:<id>, or all) \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_status.jinja2 new file mode 100644 index 0000000..17b9d24 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_status.jinja2 @@ -0,0 +1 @@ +Show current bridge health counters \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_thresholds.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_thresholds.jinja2 new file mode 100644 index 0000000..98bcaa7 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/desc_thresholds.jinja2 @@ -0,0 +1 @@ +Show configured alert thresholds \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/health.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/health.jinja2 new file mode 100644 index 0000000..47c4303 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/health.jinja2 @@ -0,0 +1,5 @@ +{%- if healthy -%} +✅ {{ summary }} +{%- else -%} +🚨 {{ summary }} +{%- endif %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/help.jinja2 new file mode 100644 index 0000000..4eba2fd --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/help.jinja2 @@ -0,0 +1,5 @@ +🩺 Available commands: +{%- for cmd in commands %} +/{{ cmd.name }} — {{ cmd.description }} +{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %} +{%- endfor %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/no_results.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/no_results.jinja2 new file mode 100644 index 0000000..cc0cb70 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/no_results.jinja2 @@ -0,0 +1 @@ +No results. diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/rate_limited.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/rate_limited.jinja2 new file mode 100644 index 0000000..afc388c --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/rate_limited.jinja2 @@ -0,0 +1 @@ +⏳ Too many requests. Please wait {{ wait }}s before trying again. diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/reset.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/reset.jinja2 new file mode 100644 index 0000000..ad96b43 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/reset.jinja2 @@ -0,0 +1,14 @@ +{%- if success %} +✅ Counter reset +{%- if subject_type == 'all' %} +Cleared {{ previous_count }} of your failure counters (trackers + targets). +{%- else %} +{{ subject_type|capitalize }} {{ subject_name }}{% if subject_id %} (id {{ subject_id }}){% endif %} +Previous count: {{ previous_count }} → 0 +{%- endif %} +{%- else %} +❌ Reset failed +{%- if error_message %} +{{ error_message }} +{%- endif %} +{%- endif %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/start.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/start.jinja2 new file mode 100644 index 0000000..6a767b7 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/start.jinja2 @@ -0,0 +1,2 @@ +👋 Hi! I'm your Notify Bridge bot for Bridge Self-Monitoring. +Use /help to see available commands. diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/status.jinja2 new file mode 100644 index 0000000..aa50a23 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/status.jinja2 @@ -0,0 +1,28 @@ +🩺 Bridge Status +{%- if poll_failures %} + +🚨 Tracker poll failures +{%- for f in poll_failures %} +• {{ f.tracker_name }} (id {{ f.tracker_id }}) — {{ f.count }} consecutive +{%- endfor %} +{%- endif %} +{%- if deferred_pending is none %} + +⏳ Deferred backlog +Pending: unknown (DB unavailable) · Threshold: {{ deferred_threshold }} +{%- elif deferred_pending %} + +⏳ Deferred backlog +Pending: {{ deferred_pending }} · Threshold: {{ deferred_threshold }} +{%- endif %} +{%- if target_failures %} + +📡 Target send failures +{%- for f in target_failures %} +• {{ f.target_name }} (id {{ f.target_id }}) — {{ f.count }} consecutive +{%- endfor %} +{%- endif %} +{%- if not poll_failures and not target_failures and deferred_pending == 0 %} + +✅ All counters at zero. Nothing to report. +{%- endif %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/thresholds.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/thresholds.jinja2 new file mode 100644 index 0000000..fc53beb --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/thresholds.jinja2 @@ -0,0 +1,4 @@ +⚙️ Bridge Thresholds +Tracker poll failures: {{ poll_failure_threshold }} +Deferred backlog: {{ deferred_backlog_threshold }} +Target send failures: {{ target_failure_threshold }} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/usage_reset.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/usage_reset.jinja2 new file mode 100644 index 0000000..3445bbb --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/bridge_self/usage_reset.jinja2 @@ -0,0 +1 @@ +/reset tracker:42 (or target:<id>, or all) \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py b/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py index 7f412aa..b89bf00 100644 --- a/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/loader.py @@ -73,6 +73,15 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = { # Usage examples "usage_entities", "usage_state", ], + "bridge_self": [ + # Response templates + "start", "help", "status", "thresholds", "reset", "health", + "rate_limited", "no_results", + # Description slots + "desc_help", "desc_status", "desc_thresholds", "desc_reset", "desc_health", + # Usage examples + "usage_reset", + ], } # Backward-compatible aliases diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_health.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_health.jinja2 new file mode 100644 index 0000000..af8d494 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_health.jinja2 @@ -0,0 +1 @@ +Краткая однострочная сводка состояния \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_help.jinja2 new file mode 100644 index 0000000..3e8f915 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_help.jinja2 @@ -0,0 +1 @@ +Показать доступные команды \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_reset.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_reset.jinja2 new file mode 100644 index 0000000..11e521a --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_reset.jinja2 @@ -0,0 +1 @@ +Сбросить счётчик сбоев (tracker:<id>, target:<id> или all) \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_status.jinja2 new file mode 100644 index 0000000..19004e3 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_status.jinja2 @@ -0,0 +1 @@ +Показать счётчики состояния моста \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_thresholds.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_thresholds.jinja2 new file mode 100644 index 0000000..883351f --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/desc_thresholds.jinja2 @@ -0,0 +1 @@ +Показать настроенные пороги оповещений \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/health.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/health.jinja2 new file mode 100644 index 0000000..47c4303 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/health.jinja2 @@ -0,0 +1,5 @@ +{%- if healthy -%} +✅ {{ summary }} +{%- else -%} +🚨 {{ summary }} +{%- endif %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/help.jinja2 new file mode 100644 index 0000000..e97b49f --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/help.jinja2 @@ -0,0 +1,5 @@ +🩺 Доступные команды: +{%- for cmd in commands %} +/{{ cmd.name }} — {{ cmd.description }} +{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %} +{%- endfor %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/no_results.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/no_results.jinja2 new file mode 100644 index 0000000..5bbf7f8 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/no_results.jinja2 @@ -0,0 +1 @@ +Нет результатов. diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/rate_limited.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/rate_limited.jinja2 new file mode 100644 index 0000000..1a5745d --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/rate_limited.jinja2 @@ -0,0 +1 @@ +⏳ Слишком много запросов. Подождите {{ wait }}с и попробуйте снова. diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/reset.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/reset.jinja2 new file mode 100644 index 0000000..9becebd --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/reset.jinja2 @@ -0,0 +1,14 @@ +{%- if success %} +✅ Счётчик сброшен +{%- if subject_type == 'all' %} +Очищено {{ previous_count }} ваших счётчиков (трекеры + цели). +{%- else %} +{{ subject_type|capitalize }} {{ subject_name }}{% if subject_id %} (id {{ subject_id }}){% endif %} +Было: {{ previous_count }} → 0 +{%- endif %} +{%- else %} +❌ Не удалось сбросить +{%- if error_message %} +{{ error_message }} +{%- endif %} +{%- endif %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/start.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/start.jinja2 new file mode 100644 index 0000000..0e4b1a1 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/start.jinja2 @@ -0,0 +1,2 @@ +👋 Привет! Я бот Notify Bridge для Самомониторинга моста. +Используйте /help, чтобы посмотреть доступные команды. diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/status.jinja2 new file mode 100644 index 0000000..0099513 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/status.jinja2 @@ -0,0 +1,28 @@ +🩺 Состояние моста +{%- if poll_failures %} + +🚨 Сбои опроса трекеров +{%- for f in poll_failures %} +• {{ f.tracker_name }} (id {{ f.tracker_id }}) — {{ f.count }} подряд +{%- endfor %} +{%- endif %} +{%- if deferred_pending is none %} + +⏳ Очередь отложенной отправки +В ожидании: неизвестно (БД недоступна) · Порог: {{ deferred_threshold }} +{%- elif deferred_pending %} + +⏳ Очередь отложенной отправки +В ожидании: {{ deferred_pending }} · Порог: {{ deferred_threshold }} +{%- endif %} +{%- if target_failures %} + +📡 Сбои отправки в адресаты +{%- for f in target_failures %} +• {{ f.target_name }} (id {{ f.target_id }}) — {{ f.count }} подряд +{%- endfor %} +{%- endif %} +{%- if not poll_failures and not target_failures and deferred_pending == 0 %} + +✅ Все счётчики в нуле. Всё хорошо. +{%- endif %} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/thresholds.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/thresholds.jinja2 new file mode 100644 index 0000000..fe66548 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/thresholds.jinja2 @@ -0,0 +1,4 @@ +⚙️ Пороги моста +Сбои опроса трекеров: {{ poll_failure_threshold }} +Очередь отложенной отправки: {{ deferred_backlog_threshold }} +Сбои отправки в адресаты: {{ target_failure_threshold }} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/usage_reset.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/usage_reset.jinja2 new file mode 100644 index 0000000..07f9df2 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/bridge_self/usage_reset.jinja2 @@ -0,0 +1 @@ +/reset tracker:42 (или target:<id>, или all) \ No newline at end of file diff --git a/packages/server/src/notify_bridge_server/api/command_template_configs.py b/packages/server/src/notify_bridge_server/api/command_template_configs.py index 5114347..62186ff 100644 --- a/packages/server/src/notify_bridge_server/api/command_template_configs.py +++ b/packages/server/src/notify_bridge_server/api/command_template_configs.py @@ -413,6 +413,69 @@ async def get_command_variables( }, } + # --- Bridge self-monitoring --- + bridge_self_poll_failure_fields = { + "tracker_id": "Tracker id (int)", + "tracker_name": "Tracker display name", + "count": "Consecutive poll failures", + } + bridge_self_target_failure_fields = { + "target_id": "Target id (int)", + "target_name": "Target display name", + "count": "Consecutive send failures", + } + bridge_self = { + "status": { + "description": "/status snapshot of all bridge_self counters", + "variables": { + **common_vars, + "poll_failures": "List of {tracker_id, tracker_name, count} dicts (use {% for f in poll_failures %})", + "deferred_pending": "Pending deferred-dispatch row count for this user", + "deferred_threshold": "Deferred backlog threshold from provider config", + "target_failures": "List of {target_id, target_name, count} dicts (use {% for f in target_failures %})", + }, + "poll_failure_fields": bridge_self_poll_failure_fields, + "target_failure_fields": bridge_self_target_failure_fields, + }, + "thresholds": { + "description": "/thresholds configured alert thresholds", + "variables": { + **common_vars, + "poll_failure_threshold": "Tracker poll failure threshold (int)", + "deferred_backlog_threshold": "Deferred backlog threshold (int)", + "target_failure_threshold": "Target send failure threshold (int)", + }, + }, + "reset": { + "description": "/reset result of a manual counter reset", + "variables": { + **common_vars, + "subject_type": "'tracker', 'target', or 'all' (empty on parse error)", + "subject_id": "Subject id (int) or None for 'all' / errors", + "subject_name": "Display name of the subject (empty on error)", + "previous_count": "Counter value before reset", + "success": "Whether the reset succeeded (boolean)", + "error_message": "Error message when success=False (None on success)", + }, + }, + "health": { + "description": "/health terse one-line summary", + "variables": { + **common_vars, + "healthy": "Whether everything is at zero (boolean)", + "summary": "Human-readable one-line summary", + "tracker_count": "Total enabled tracker count for this user", + "failing_tracker_count": "Number of trackers with non-zero poll failures", + "deferred_pending": "Pending deferred-dispatch row count for this user", + "failing_target_count": "Number of targets with non-zero send failures", + }, + }, + "desc_thresholds": {"description": "Description for /thresholds command", "variables": common_vars}, + "desc_reset": {"description": "Description for /reset command", "variables": common_vars}, + "desc_health": {"description": "Description for /health command", "variables": common_vars}, + "usage_reset": {"description": "Usage example for /reset", "variables": common_vars}, + } + return { **shared, "immich": immich, @@ -421,6 +484,7 @@ async def get_command_variables( "nut": nut, "google_photos": google_photos, "webhook": webhook, + "bridge_self": bridge_self, } @@ -625,6 +689,39 @@ async def preview_raw( {"area_id": "kitchen", "name": "Kitchen", "entity_count": 14}, {"area_id": "entrance", "name": "Entrance", "entity_count": 4}, ], + # --- Bridge self-monitoring --- + # /status — three categories of failure (preview shows non-empty data + # so operators can see how the template renders failures, not the + # all-zero case) + "poll_failures": [ + {"tracker_id": 12, "tracker_name": "Family Photos poller", "count": 3}, + {"tracker_id": 18, "tracker_name": "Vacation 2025 poller", "count": 5}, + ], + "deferred_pending": 47, + "deferred_threshold": 100, + "target_failures": [ + {"target_id": 4, "target_name": "Telegram - Family chat", "count": 7}, + ], + # /thresholds — user's configured thresholds + "poll_failure_threshold": 3, + "deferred_backlog_threshold": 100, + "target_failure_threshold": 5, + # /reset — sample success result + "subject_type": "tracker", + "subject_id": 12, + "subject_name": "Family Photos poller", + "previous_count": 3, + "success": True, + "error_message": None, + # /health — sample "unhealthy" line that matches the failure data + # above. Templates that only branch on ``healthy`` still preview + # cleanly — operators wanting the healthy case can flip the flag + # in their template editor. + "healthy": False, + "summary": "2 trackers failing, 47 deferred, 1 target failing", + "tracker_count": 3, + "failing_tracker_count": 2, + "failing_target_count": 1, } return render_template_preview(body.template, sample_ctx) diff --git a/packages/server/src/notify_bridge_server/api/providers.py b/packages/server/src/notify_bridge_server/api/providers.py index 9d0aff0..6aa6d55 100644 --- a/packages/server/src/notify_bridge_server/api/providers.py +++ b/packages/server/src/notify_bridge_server/api/providers.py @@ -244,7 +244,7 @@ async def _test_provider_connection(provider: ServiceProvider) -> dict[str, Any] ) return await ha.test_connection() - if provider.type in ("scheduler", "webhook"): + if provider.type in ("scheduler", "webhook", "bridge_self"): return {"ok": True, "message": "Virtual provider — always available"} return {"ok": False, "message": f"Unknown provider type: {provider.type}"} diff --git a/packages/server/src/notify_bridge_server/commands/bridge_self_handler.py b/packages/server/src/notify_bridge_server/commands/bridge_self_handler.py new file mode 100644 index 0000000..bb189d7 --- /dev/null +++ b/packages/server/src/notify_bridge_server/commands/bridge_self_handler.py @@ -0,0 +1,273 @@ +"""Bridge self-monitoring bot command handler. + +Read-only commands plus a ``/reset`` operator action that clears in-memory +failure counters once the underlying issue has been fixed. + +Counters are kept in module-level dicts inside +``services.bridge_self`` — see that module for the increment / reset +helpers used by the watcher / scheduler / dispatcher hot path. We only +read snapshots from those dicts here so we never block the emission +side or hold a lock across DB calls. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..database.models import ( + CommandConfig, + CommandTracker, + CommandTrackerListener, + ServiceProvider, + TelegramBot, +) +from ..services import bridge_self as bs +from .base import CommandResponse, ProviderCommandHandler +from .handler import _render_cmd_template + +_LOGGER = logging.getLogger(__name__) + +_BRIDGE_SELF_COMMANDS = {"status", "thresholds", "reset", "health"} + + +# --------------------------------------------------------------------------- +# Context builders — one per command. Each returns the dict passed to the +# template renderer. +# --------------------------------------------------------------------------- + + +async def _build_status_context(provider: ServiceProvider) -> dict[str, Any]: + """Snapshot the calling user's counters + pending deferred backlog. + + User-scoped: ``get_user_*_failures`` filter the global counter dicts by + ownership in a single batched query so one user cannot see another + user's failing trackers/targets. + """ + thresholds = await bs.get_user_thresholds(provider.user_id) + + poll_rows = await bs.get_user_poll_failures(provider.user_id) + poll_failures = sorted( + ({"tracker_id": r["id"], "tracker_name": r["name"], "count": r["count"]} for r in poll_rows), + key=lambda r: r["tracker_id"], + ) + + target_rows = await bs.get_user_target_failures(provider.user_id) + target_failures = sorted( + ({"target_id": r["id"], "target_name": r["name"], "count": r["count"]} for r in target_rows), + key=lambda r: r["target_id"], + ) + + deferred_pending = await bs.get_pending_deferred_count(provider.user_id) + + return { + "poll_failures": poll_failures, + "deferred_pending": deferred_pending, + "deferred_threshold": thresholds["deferred_backlog_threshold"], + "target_failures": target_failures, + } + + +async def _build_thresholds_context(provider: ServiceProvider) -> dict[str, Any]: + """Render the user's configured alert thresholds.""" + thresholds = await bs.get_user_thresholds(provider.user_id) + return { + "poll_failure_threshold": thresholds["poll_failure_threshold"], + "deferred_backlog_threshold": thresholds["deferred_backlog_threshold"], + "target_failure_threshold": thresholds["target_failure_threshold"], + } + + +def _parse_reset_subject(args: str) -> tuple[str, int | None, str | None]: + """Parse a ``/reset`` argument into ``(subject_type, subject_id, error)``. + + Accepted forms: + * ``tracker:`` — clear that tracker's poll-failure counter + * ``target:`` — clear that target's send-failure counter + * ``all`` — clear every in-memory counter + + On a parse error we return ``("", None, "")`` so the handler can + render a templated error reply instead of propagating an exception. + """ + raw = (args or "").strip() + if not raw: + return "", None, "Missing subject. Use tracker:, target:, or all." + if raw.lower() == "all": + return "all", None, None + if ":" not in raw: + return "", None, ( + f"Invalid subject '{raw}'. Use tracker:, target:, or all." + ) + prefix, _, value = raw.partition(":") + prefix = prefix.strip().lower() + value = value.strip() + if prefix not in ("tracker", "target"): + return "", None, ( + f"Unknown subject type '{prefix}'. Use tracker, target, or all." + ) + try: + subject_id = int(value) + except ValueError: + return "", None, f"Invalid id '{value}'. Must be an integer." + if subject_id <= 0: + return "", None, f"Invalid id '{value}'. Must be a positive integer." + return prefix, subject_id, None + + +async def _build_reset_context(args: str, provider: ServiceProvider) -> dict[str, Any]: + """Reset a counter, enforcing that the subject belongs to ``provider.user_id``. + + Without this ownership check, any authenticated user could reset another + user's counters (or wipe global state via ``all``). For ``all`` we only + clear counters tied to trackers/targets owned by the calling user. + """ + subject_type, subject_id, error = _parse_reset_subject(args) + if error is not None: + return { + "subject_type": "", + "subject_id": None, + "subject_name": "", + "previous_count": 0, + "success": False, + "error_message": error, + } + + if subject_type == "all": + cleared = await bs.reset_user_counters(provider.user_id) + return { + "subject_type": "all", + "subject_id": None, + "subject_name": "your counters", + "previous_count": cleared, + "success": True, + "error_message": None, + } + + # Verify the subject belongs to the calling user before touching the counter. + owner_lookup = bs.find_tracker_owner if subject_type == "tracker" else bs.find_target_owner + owner = await owner_lookup(subject_id) if subject_id is not None else None + if owner is None or owner != provider.user_id: + return { + "subject_type": subject_type, + "subject_id": subject_id, + "subject_name": "", + "previous_count": 0, + "success": False, + "error_message": f"{subject_type} {subject_id} not found", + } + + failure_type = "poll_failures" if subject_type == "tracker" else "target_failures" + name_lookup = bs.get_tracker_name if subject_type == "tracker" else bs.get_target_name + subject_name = await name_lookup(subject_id) if subject_id is not None else "" + previous = bs.reset_counter(failure_type, subject_id) + return { + "subject_type": subject_type, + "subject_id": subject_id, + "subject_name": subject_name, + "previous_count": previous, + "success": True, + "error_message": None, + } + + +async def _build_health_context(provider: ServiceProvider) -> dict[str, Any]: + """Build the terse one-line health summary context.""" + from ..database.models import NotificationTracker + from sqlmodel import select + from sqlmodel.ext.asyncio.session import AsyncSession + + from ..database.engine import get_engine + + engine = get_engine() + try: + async with AsyncSession(engine) as session: + result = await session.exec( + select(NotificationTracker).where( + NotificationTracker.user_id == provider.user_id, + NotificationTracker.enabled == True, # noqa: E712 — SQLModel needs == + ) + ) + tracker_count = len(list(result.all())) + except Exception: # noqa: BLE001 + _LOGGER.exception("health: failed to count trackers for user=%s", provider.user_id) + tracker_count = 0 + + # User-scoped: only count failures that belong to THIS user's trackers/targets. + failing_tracker_count = len(await bs.get_user_poll_failures(provider.user_id)) + failing_target_count = len(await bs.get_user_target_failures(provider.user_id)) + deferred_pending = await bs.get_pending_deferred_count(provider.user_id) + + # Treat unknown deferred count (DB error) as not-healthy so operators + # don't get a misleading "all clear" when the bridge can't introspect itself. + healthy = ( + failing_tracker_count == 0 + and failing_target_count == 0 + and deferred_pending == 0 + ) + if healthy: + summary = f"Bridge healthy: {tracker_count} trackers polling, 0 failures" + else: + parts: list[str] = [] + if failing_tracker_count: + parts.append(f"{failing_tracker_count} trackers failing") + if deferred_pending is None: + parts.append("deferred backlog unknown (DB unavailable)") + elif deferred_pending: + parts.append(f"{deferred_pending} deferred") + if failing_target_count: + parts.append(f"{failing_target_count} targets failing") + summary = ", ".join(parts) or "bridge state unknown" + + return { + "healthy": healthy, + "summary": summary, + "tracker_count": tracker_count, + "failing_tracker_count": failing_tracker_count, + "deferred_pending": deferred_pending, + "failing_target_count": failing_target_count, + } + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + + +class BridgeSelfCommandHandler(ProviderCommandHandler): + """Read-only commands plus /reset for the bridge_self provider.""" + + provider_type = "bridge_self" + + def get_provider_commands(self) -> set[str]: + return _BRIDGE_SELF_COMMANDS + + async def handle( + self, + cmd: str, + args: str, + count: int, + locale: str, + response_mode: str, + provider: ServiceProvider, + cmd_templates: dict[str, dict[str, str]], + bot: TelegramBot, + tracker: CommandTracker, + config: CommandConfig, + *, + listener: CommandTrackerListener | None = None, + allowed_album_ids: set[str] | None = None, # noqa: ARG002 — bridge_self has no album scope + page: int = 1, + ) -> CommandResponse | None: + if cmd == "status": + ctx = await _build_status_context(provider) + return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx)) + if cmd == "thresholds": + ctx = await _build_thresholds_context(provider) + return CommandResponse(text=_render_cmd_template(cmd_templates, "thresholds", locale, ctx)) + if cmd == "reset": + ctx = await _build_reset_context(args, provider) + return CommandResponse(text=_render_cmd_template(cmd_templates, "reset", locale, ctx)) + if cmd == "health": + ctx = await _build_health_context(provider) + return CommandResponse(text=_render_cmd_template(cmd_templates, "health", locale, ctx)) + return None diff --git a/packages/server/src/notify_bridge_server/commands/dispatch.py b/packages/server/src/notify_bridge_server/commands/dispatch.py index d867a3b..0e04987 100644 --- a/packages/server/src/notify_bridge_server/commands/dispatch.py +++ b/packages/server/src/notify_bridge_server/commands/dispatch.py @@ -35,6 +35,7 @@ def _auto_register() -> None: from .nut_handler import NutCommandHandler from .webhook_handler import WebhookCommandHandler from .home_assistant_handler import HomeAssistantCommandHandler + from .bridge_self_handler import BridgeSelfCommandHandler register_handler(ImmichCommandHandler()) register_handler(GiteaCommandHandler()) @@ -42,6 +43,7 @@ def _auto_register() -> None: register_handler(NutCommandHandler()) register_handler(WebhookCommandHandler()) register_handler(HomeAssistantCommandHandler()) + register_handler(BridgeSelfCommandHandler()) # Auto-register on import diff --git a/packages/server/src/notify_bridge_server/database/seeds.py b/packages/server/src/notify_bridge_server/database/seeds.py index 6e721b4..c1934c7 100644 --- a/packages/server/src/notify_bridge_server/database/seeds.py +++ b/packages/server/src/notify_bridge_server/database/seeds.py @@ -195,6 +195,10 @@ async def _seed_default_command_templates() -> None: session, "home_assistant", "Default Home Assistant Commands", "Default Home Assistant command templates", ) + await _seed_provider_command_template( + session, "bridge_self", "Default Bridge Self-Monitoring Commands", + "Default Bridge Self-Monitoring command templates", + ) await session.commit() @@ -381,6 +385,16 @@ async def _seed_default_command_configs() -> None: "default_count": 5, "rate_limits": {"search": 30, "default": 10}, }, + { + "provider_type": "bridge_self", + "name": "Default Bridge Self-Monitoring", + "enabled_commands": [ + "help", "status", "thresholds", "reset", "health", + ], + "response_mode": "text", + "default_count": 5, + "rate_limits": {"default": 10}, + }, ] for cfg in defaults: diff --git a/packages/server/src/notify_bridge_server/services/bridge_self.py b/packages/server/src/notify_bridge_server/services/bridge_self.py index 27219ca..b93fdf2 100644 --- a/packages/server/src/notify_bridge_server/services/bridge_self.py +++ b/packages/server/src/notify_bridge_server/services/bridge_self.py @@ -263,6 +263,117 @@ def get_target_last_error(target_id: int) -> str: return _target_failure_last_error.get(target_id, "") +def get_all_poll_failures() -> dict[int, int]: + """Return a snapshot of all current poll failure counters (tracker_id -> count). + + Only includes non-zero counters. The returned dict is a copy and can be + iterated safely without holding a reference to the live module-level state. + """ + return {tid: count for tid, count in _poll_failure_counts.items() if count > 0} + + +def get_all_target_failures() -> dict[int, int]: + """Return a snapshot of all current target failure counters (target_id -> count). + + Only includes non-zero counters. + """ + return {tid: count for tid, count in _target_failure_counts.items() if count > 0} + + +def reset_counter(failure_type: str, subject_id: int | None = None) -> int: + """Reset bridge_self failure counters. + + Args: + failure_type: One of ``"poll_failures"``, ``"target_failures"``, or + ``"all"``. Anything else is treated as a no-op. + subject_id: When ``failure_type`` is ``"poll_failures"`` or + ``"target_failures"``, the tracker_id / target_id whose counter + to clear. Ignored when ``failure_type == "all"``. + + Returns: + The previous count for the reset entry. For ``"all"``, the total + number of entries cleared across both counter dicts. Idempotent — + clearing an absent entry returns 0. + """ + if failure_type == "all": + cleared = ( + len(_poll_failure_counts) + + len(_target_failure_counts) + ) + _poll_failure_counts.clear() + _poll_failure_last_error.clear() + _target_failure_counts.clear() + _target_failure_last_error.clear() + return cleared + if failure_type == "poll_failures" and subject_id is not None: + previous = _poll_failure_counts.get(subject_id, 0) + reset_poll_counter(subject_id) + return previous + if failure_type == "target_failures" and subject_id is not None: + previous = _target_failure_counts.get(subject_id, 0) + reset_target_counter(subject_id) + return previous + return 0 + + +async def get_pending_deferred_count(user_id: int) -> int | None: + """Return the count of pending DeferredDispatch rows for a user. + + Used by command handlers to render the current backlog in /status and + /health responses. Returns ``None`` on any error so command templates + can render "unknown" instead of a misleading "0" — operators looking + at bridge health when the bridge is unhealthy must not be told + everything is fine. + """ + from sqlalchemy import func + + from ..database.models import DeferredDispatch + + engine = get_engine() + try: + async with AsyncSession(engine) as session: + result = await session.exec( + select(func.count(DeferredDispatch.id)) + .where(DeferredDispatch.status == "pending") + .where(DeferredDispatch.user_id == user_id) + ) + count = result.first() + return int(count or 0) + except Exception: # noqa: BLE001 — never block a command reply + _LOGGER.exception("get_pending_deferred_count failed for user_id=%s", user_id) + return None + + +async def get_tracker_name(tracker_id: int) -> str: + """Return the display name of a NotificationTracker, or ``"tracker {id}"``.""" + from ..database.models import NotificationTracker + + engine = get_engine() + try: + async with AsyncSession(engine) as session: + tracker = await session.get(NotificationTracker, tracker_id) + if tracker is not None: + return tracker.name or f"tracker {tracker_id}" + except Exception: # noqa: BLE001 + _LOGGER.exception("get_tracker_name failed for tracker_id=%s", tracker_id) + return f"tracker {tracker_id}" + + +async def get_target_name(target_id: int) -> str: + """Return the display name of a NotificationTarget, or ``"target {id}"``.""" + from ..database.models import NotificationTarget + + engine = get_engine() + try: + async with AsyncSession(engine) as session: + target = await session.get(NotificationTarget, target_id) + if target is not None: + return target.name or f"target {target_id}" + except Exception: # noqa: BLE001 + _LOGGER.exception("get_target_name failed for target_id=%s", target_id) + return f"target {target_id}" + + # --------------------------------------------------------------------------- # User-level helpers # --------------------------------------------------------------------------- @@ -300,6 +411,76 @@ async def find_target_owner(target_id: int) -> int | None: return int(target.user_id) +async def get_user_poll_failures(user_id: int) -> list[dict[str, Any]]: + """Return ``[{"id", "name", "count"}]`` for trackers owned by ``user_id``. + + Single batched query — replaces the N+1 pattern of calling + ``get_tracker_name`` per failing tracker, and enforces ownership so + one user cannot see another user's failure list. + """ + from ..database.models import NotificationTracker + + snapshot = {tid: c for tid, c in _poll_failure_counts.items() if c > 0} + if not snapshot: + return [] + engine = get_engine() + async with AsyncSession(engine) as session: + result = await session.exec( + select(NotificationTracker.id, NotificationTracker.name).where( + NotificationTracker.id.in_(list(snapshot.keys())), + NotificationTracker.user_id == user_id, + ) + ) + rows = list(result.all()) + return [ + {"id": int(tid), "name": name or f"tracker {tid}", "count": snapshot[int(tid)]} + for tid, name in rows + ] + + +async def get_user_target_failures(user_id: int) -> list[dict[str, Any]]: + """Return ``[{"id", "name", "count"}]`` for targets owned by ``user_id``.""" + from ..database.models import NotificationTarget + + snapshot = {tid: c for tid, c in _target_failure_counts.items() if c > 0} + if not snapshot: + return [] + engine = get_engine() + async with AsyncSession(engine) as session: + result = await session.exec( + select(NotificationTarget.id, NotificationTarget.name).where( + NotificationTarget.id.in_(list(snapshot.keys())), + NotificationTarget.user_id == user_id, + ) + ) + rows = list(result.all()) + return [ + {"id": int(tid), "name": name or f"target {tid}", "count": snapshot[int(tid)]} + for tid, name in rows + ] + + +async def reset_user_counters(user_id: int) -> int: + """Clear all poll/target counters for trackers/targets owned by ``user_id``. + + Returns the number of distinct (tracker + target) entries cleared. + Cross-user counters are left untouched — addresses the multi-tenant + safety hole where ``reset_counter("all")`` wiped every user's state. + """ + polls = await get_user_poll_failures(user_id) + tgts = await get_user_target_failures(user_id) + cleared = 0 + for entry in polls: + if _poll_failure_counts.pop(entry["id"], None) is not None: + cleared += 1 + _poll_failure_last_error.pop(entry["id"], None) + for entry in tgts: + if _target_failure_counts.pop(entry["id"], None) is not None: + cleared += 1 + _target_failure_last_error.pop(entry["id"], None) + return cleared + + # --------------------------------------------------------------------------- # Backlog scan # --------------------------------------------------------------------------- diff --git a/packages/server/tests/test_bridge_self.py b/packages/server/tests/test_bridge_self.py index d779b0c..d6005ba 100644 --- a/packages/server/tests/test_bridge_self.py +++ b/packages/server/tests/test_bridge_self.py @@ -263,3 +263,386 @@ def test_default_template_loader_returns_bridge_self_slots() -> None: # Sanity: each template references at least one of the bridge_self vars. for tpl in list(en.values()) + list(ru.values()): assert "{{" in tpl + + +# --------------------------------------------------------------------------- +# Bot commands — context builders +# +# These tests run against the real (temp-dir) DB via the FastAPI lifespan so +# that ``services.bridge_self`` helpers using ``get_engine()`` resolve to the +# same DB the test seeds rows into. We follow the pattern used by +# test_webhook_status_handler.py — bootstrap once, seed under TestClient. +# --------------------------------------------------------------------------- + + +def _bootstrap_app(): + """Bring up the app once so migrations run against the temp DB.""" + from notify_bridge_server.main import app + + return app + + +async def _seed_user_and_provider( + *, username: str, config: dict[str, int], +) -> tuple[int, int]: + """Create a fresh ``(user_id, provider_id)`` against the live engine. + + Uses two short-lived sessions to avoid SQLAlchemy auto-expiring the + first-committed object once a second commit fires on the same session. + """ + from notify_bridge_server.database.engine import get_engine + from notify_bridge_server.database.models import ServiceProvider, User + + engine = get_engine() + async with AsyncSession(engine) as db: + user = User( + username=f"{username}_{datetime.now(timezone.utc).timestamp()}", + hashed_password="x", role="user", + ) + db.add(user) + await db.commit() + await db.refresh(user) + user_id = int(user.id) + async with AsyncSession(engine) as db: + provider = ServiceProvider( + user_id=user_id, type="bridge_self", name="Bridge", + config=dict(config), + ) + db.add(provider) + await db.commit() + await db.refresh(provider) + provider_id = int(provider.id) + return user_id, provider_id + + +async def _load_provider(provider_id: int): + from notify_bridge_server.database.engine import get_engine + from notify_bridge_server.database.models import ServiceProvider + + engine = get_engine() + async with AsyncSession(engine) as db: + return await db.get(ServiceProvider, provider_id) + + +def test_command_status_returns_empty_lists_when_no_failures(tmp_data_dir) -> None: # noqa: ARG001 + import asyncio + from fastapi.testclient import TestClient + + from notify_bridge_server.commands.bridge_self_handler import ( + _build_status_context, + ) + from notify_bridge_server.services import bridge_self as bs + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + _user_id, provider_id = await _seed_user_and_provider( + username="status_user", + config={ + "poll_failure_threshold": 3, + "deferred_backlog_threshold": 100, + "target_failure_threshold": 5, + }, + ) + provider = await _load_provider(provider_id) + + # Make sure the in-memory dicts contain nothing. + bs._poll_failure_counts.clear() + bs._target_failure_counts.clear() + + ctx = await _build_status_context(provider) + + assert ctx["poll_failures"] == [] + assert ctx["target_failures"] == [] + assert ctx["deferred_pending"] == 0 + assert ctx["deferred_threshold"] == 100 + + asyncio.run(run()) + + +def test_command_thresholds_returns_user_config(tmp_data_dir) -> None: # noqa: ARG001 + import asyncio + from fastapi.testclient import TestClient + + from notify_bridge_server.commands.bridge_self_handler import ( + _build_thresholds_context, + ) + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + _user_id, provider_id = await _seed_user_and_provider( + username="thresholds_user", + config={ + "poll_failure_threshold": 7, + "deferred_backlog_threshold": 250, + "target_failure_threshold": 11, + }, + ) + provider = await _load_provider(provider_id) + + ctx = await _build_thresholds_context(provider) + + assert ctx == { + "poll_failure_threshold": 7, + "deferred_backlog_threshold": 250, + "target_failure_threshold": 11, + } + + asyncio.run(run()) + + +def test_command_reset_clears_named_counter_and_is_idempotent(tmp_data_dir) -> None: # noqa: ARG001 + """``/reset`` clears the in-memory counter and is idempotent. + + Now ownership-aware: we seed a real NotificationTracker owned by the + test user so ``find_tracker_owner`` returns a matching user_id and the + reset proceeds. The cross-user-rejection case is covered by + :func:`test_command_reset_rejects_cross_user_subject` below. + """ + import asyncio + from fastapi.testclient import TestClient + + from notify_bridge_server.commands.bridge_self_handler import ( + _build_reset_context, _parse_reset_subject, + ) + from notify_bridge_server.database.engine import get_engine + from notify_bridge_server.database.models import NotificationTracker + from notify_bridge_server.services import bridge_self as bs + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + user_id, provider_id = await _seed_user_and_provider( + username="reset_user", + config={ + "poll_failure_threshold": 3, + "deferred_backlog_threshold": 100, + "target_failure_threshold": 5, + }, + ) + provider = await _load_provider(provider_id) + + # Seed an owned NotificationTracker so the ownership check + # in _build_reset_context can match it back to user_id. + engine = get_engine() + async with AsyncSession(engine) as db: + tracker = NotificationTracker( + user_id=user_id, provider_id=provider_id, + name="reset-test", enabled=True, + ) + db.add(tracker) + await db.commit() + await db.refresh(tracker) + tid = int(tracker.id) + + bs.reset_poll_counter(tid) + bs.record_poll_failure(tid, "boom") + bs.record_poll_failure(tid, "boom") + assert bs.get_poll_failure_count(tid) == 2 + + ctx = await _build_reset_context(f"tracker:{tid}", provider) + assert ctx["success"] is True + assert ctx["subject_type"] == "tracker" + assert ctx["subject_id"] == tid + assert ctx["previous_count"] == 2 + assert ctx["error_message"] is None + assert bs.get_poll_failure_count(tid) == 0 + + # Idempotent — second call still succeeds with previous=0. + ctx2 = await _build_reset_context(f"tracker:{tid}", provider) + assert ctx2["success"] is True + assert ctx2["previous_count"] == 0 + + # Parse error → templated error, no exception. + bad_ctx = await _build_reset_context("not a subject", provider) + assert bad_ctx["success"] is False + assert bad_ctx["error_message"] + + asyncio.run(run()) + + # Parser direct sanity-checks (pure function, no DB needed). + assert _parse_reset_subject("all") == ("all", None, None) + assert _parse_reset_subject("tracker:42") == ("tracker", 42, None) + assert _parse_reset_subject("target:7") == ("target", 7, None) + _, _, err = _parse_reset_subject("rocket:1") + assert err is not None + + +def test_command_reset_rejects_cross_user_subject(tmp_data_dir) -> None: # noqa: ARG001 + """User A cannot reset a counter belonging to user B's tracker. + + Regression guard for the multi-tenant data-leak hole the original + handler had — ``reset_counter`` was called without verifying the + subject's ``user_id`` matched ``provider.user_id``. + """ + import asyncio + from fastapi.testclient import TestClient + + from notify_bridge_server.commands.bridge_self_handler import _build_reset_context + from notify_bridge_server.database.engine import get_engine + from notify_bridge_server.database.models import NotificationTracker + from notify_bridge_server.services import bridge_self as bs + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + user_a_id, provider_a_id = await _seed_user_and_provider( + username="user_a", config={ + "poll_failure_threshold": 3, + "deferred_backlog_threshold": 100, + "target_failure_threshold": 5, + }, + ) + user_b_id, provider_b_id = await _seed_user_and_provider( + username="user_b", config={ + "poll_failure_threshold": 3, + "deferred_backlog_threshold": 100, + "target_failure_threshold": 5, + }, + ) + provider_a = await _load_provider(provider_a_id) + + # Seed a tracker owned by user B and increment its counter. + engine = get_engine() + async with AsyncSession(engine) as db: + tracker_b = NotificationTracker( + user_id=user_b_id, provider_id=provider_b_id, + name="b-only", enabled=True, + ) + db.add(tracker_b) + await db.commit() + await db.refresh(tracker_b) + tid_b = int(tracker_b.id) + + bs.reset_poll_counter(tid_b) + bs.record_poll_failure(tid_b, "boom") + assert bs.get_poll_failure_count(tid_b) == 1 + + # User A tries to reset user B's tracker — must fail. + ctx = await _build_reset_context(f"tracker:{tid_b}", provider_a) + assert ctx["success"] is False + assert "not found" in (ctx["error_message"] or "").lower() + # Counter must remain untouched. + assert bs.get_poll_failure_count(tid_b) == 1 + + asyncio.run(run()) + + +def test_command_health_is_healthy_when_counters_zero(tmp_data_dir) -> None: # noqa: ARG001 + import asyncio + from fastapi.testclient import TestClient + + from notify_bridge_server.commands.bridge_self_handler import ( + _build_health_context, + ) + from notify_bridge_server.services import bridge_self as bs + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + _user_id, provider_id = await _seed_user_and_provider( + username="health_user", + config={ + "poll_failure_threshold": 3, + "deferred_backlog_threshold": 100, + "target_failure_threshold": 5, + }, + ) + provider = await _load_provider(provider_id) + + # Empty counters and no deferred rows for this user. + bs._poll_failure_counts.clear() + bs._target_failure_counts.clear() + + ctx = await _build_health_context(provider) + + assert ctx["healthy"] is True + assert ctx["failing_tracker_count"] == 0 + assert ctx["failing_target_count"] == 0 + assert ctx["deferred_pending"] == 0 + assert "healthy" in ctx["summary"].lower() + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# reset_counter direct unit test (covers the "all" branch) +# --------------------------------------------------------------------------- + + +def test_reset_counter_all_clears_every_dict() -> None: + from notify_bridge_server.services import bridge_self as bs + + # Seed both dicts with a couple of entries. + bs._poll_failure_counts.clear() + bs._target_failure_counts.clear() + bs.record_poll_failure(9_401, "boom") + bs.record_poll_failure(9_402, "boom") + bs.record_target_failure(9_501, "503") + + cleared = bs.reset_counter("all") + + # 2 poll + 1 target = 3 entries cleared. + assert cleared == 3 + assert bs._poll_failure_counts == {} + assert bs._target_failure_counts == {} + + +def test_reset_counter_unknown_failure_type_is_noop() -> None: + from notify_bridge_server.services import bridge_self as bs + + bs._poll_failure_counts.clear() + bs.record_poll_failure(9_601, "boom") + + # Unknown type returns 0 and leaves dicts intact. + assert bs.reset_counter("rocket_failures", 9_601) == 0 + assert bs.get_poll_failure_count(9_601) == 1 + + +# --------------------------------------------------------------------------- +# Capability / handler registration +# --------------------------------------------------------------------------- + + +def test_capability_registry_lists_bridge_self_commands() -> None: + from notify_bridge_core.providers.capabilities import get_capabilities + + caps = get_capabilities("bridge_self") + assert caps is not None + + cmd_names = {c["name"] for c in caps.commands} + assert {"status", "thresholds", "reset", "health", "help"}.issubset(cmd_names) + + slot_names = {s["name"] for s in caps.command_slots} + # Response slots + assert {"status", "thresholds", "reset", "health"}.issubset(slot_names) + # Description slots — needed so the menu sync registers a description + # for every operator-facing command. + assert { + "desc_status", "desc_thresholds", "desc_reset", "desc_health", + }.issubset(slot_names) + + +def test_command_handler_registered_for_bridge_self() -> None: + """Auto-registration wires the bridge_self handler into dispatch.""" + from notify_bridge_server.commands.dispatch import get_handler + + handler = get_handler("bridge_self") + assert handler is not None + assert handler.provider_type == "bridge_self" + assert {"status", "thresholds", "reset", "health"} == handler.get_provider_commands() + + +def test_default_command_template_loader_returns_bridge_self_slots() -> None: + """All shipped command-template slots load for both locales.""" + from notify_bridge_core.templates.command_defaults.loader import ( + load_default_command_templates, + ) + + en = load_default_command_templates("en", "bridge_self") + ru = load_default_command_templates("ru", "bridge_self") + + required = {"status", "thresholds", "reset", "health", "help", "start"} + assert required.issubset(en.keys()) + assert required.issubset(ru.keys())