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 1be16e2..532f353 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 @@ -403,7 +403,13 @@ async def get_command_variables( webhook = { "status": { "description": "/status webhook provider summary", - "variables": {**common_vars, "provider_name": "Webhook provider name", "last_event": "Last event timestamp"}, + "variables": { + **common_vars, + "trackers_active": "Number of enabled trackers attached to the webhook provider", + "trackers_total": "Total number of trackers attached to the webhook provider", + "provider_name": "Webhook provider name", + "last_event": "Last event timestamp ('YYYY-MM-DD HH:MM' or '-')", + }, }, } diff --git a/packages/server/src/notify_bridge_server/commands/dispatch.py b/packages/server/src/notify_bridge_server/commands/dispatch.py index d573c90..605b12e 100644 --- a/packages/server/src/notify_bridge_server/commands/dispatch.py +++ b/packages/server/src/notify_bridge_server/commands/dispatch.py @@ -33,11 +33,13 @@ def _auto_register() -> None: from .gitea_handler import GiteaCommandHandler from .planka_handler import PlankaCommandHandler from .nut_handler import NutCommandHandler + from .webhook_handler import WebhookCommandHandler register_handler(ImmichCommandHandler()) register_handler(GiteaCommandHandler()) register_handler(PlankaCommandHandler()) register_handler(NutCommandHandler()) + register_handler(WebhookCommandHandler()) # Auto-register on import diff --git a/packages/server/src/notify_bridge_server/commands/webhook_handler.py b/packages/server/src/notify_bridge_server/commands/webhook_handler.py new file mode 100644 index 0000000..321ffe9 --- /dev/null +++ b/packages/server/src/notify_bridge_server/commands/webhook_handler.py @@ -0,0 +1,89 @@ +"""Generic webhook provider bot command handler. + +The generic webhook provider has no upstream API to query — its only +runtime signal is the stream of incoming webhook payloads recorded as +``EventLog`` rows. ``/status`` therefore reports DB-derived stats: + +* ``trackers_active`` — enabled ``NotificationTracker`` rows for the provider +* ``trackers_total`` — all ``NotificationTracker`` rows for the provider +* ``provider_name`` — the provider's display name +* ``last_event`` — formatted timestamp of the most recent received event, + or ``-`` if nothing has been received yet +""" + +from __future__ import annotations + +from typing import Any + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from ..database.engine import get_engine +from ..database.models import ( + CommandConfig, + CommandTracker, + CommandTrackerListener, + NotificationTracker, + ServiceProvider, + TelegramBot, +) +from .base import CommandResponse, ProviderCommandHandler +from .command_utils import get_last_event_str +from .handler import _render_cmd_template + +_WEBHOOK_COMMANDS = {"status"} + + +async def _cmd_status(provider: ServiceProvider) -> dict[str, Any]: + """Build the context for ``/status`` on a webhook provider.""" + engine = get_engine() + async with AsyncSession(engine) as session: + result = await session.exec( + select(NotificationTracker).where( + NotificationTracker.provider_id == provider.id + ) + ) + trackers = list(result.all()) + + active = [t for t in trackers if t.enabled] + last_str = await get_last_event_str([t.id for t in active]) + + return { + "trackers_active": len(active), + "trackers_total": len(trackers), + "provider_name": provider.name or "", + "last_event": last_str, + } + + +class WebhookCommandHandler(ProviderCommandHandler): + """Handles ``/status`` for generic-webhook providers.""" + + provider_type = "webhook" + + def get_provider_commands(self) -> set[str]: + return _WEBHOOK_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 — webhook has no album scope + page: int = 1, + ) -> CommandResponse | None: + if cmd != "status": + return None + ctx = await _cmd_status(provider) + return CommandResponse( + text=_render_cmd_template(cmd_templates, "status", locale, ctx), + ) diff --git a/packages/server/tests/test_webhook_status_handler.py b/packages/server/tests/test_webhook_status_handler.py new file mode 100644 index 0000000..6b2600d --- /dev/null +++ b/packages/server/tests/test_webhook_status_handler.py @@ -0,0 +1,195 @@ +"""Tests for the generic-webhook ``/status`` command handler. + +The webhook provider has no upstream API, so ``/status`` is built from +local DB stats: + +* ``trackers_active`` — count of enabled ``NotificationTracker`` rows +* ``trackers_total`` — count of all ``NotificationTracker`` rows +* ``last_event`` — formatted timestamp of the most recent ``EventLog`` row + tied to one of the provider's trackers, or ``-`` when there are none +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone + +import pytest +from fastapi.testclient import TestClient +from sqlmodel.ext.asyncio.session import AsyncSession + + +_STATUS_TEMPLATE_EN = ( + "Webhook Status\n" + "Trackers active: {{ trackers_active }}/{{ trackers_total }}\n" + "Provider: {{ provider_name }}\n" + "Last event: {{ last_event }}" +) + + +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() -> int: + from notify_bridge_server.database.engine import get_engine + from notify_bridge_server.database.models import User + + engine = get_engine() + async with AsyncSession(engine) as session: + user = User(username=f"u_{datetime.now(timezone.utc).timestamp()}", hashed_password="x") + session.add(user) + await session.commit() + await session.refresh(user) + return user.id + + +async def _seed_provider(user_id: int, name: str = "WH") -> 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 session: + prov = ServiceProvider(user_id=user_id, type="webhook", name=name, config={}) + session.add(prov) + await session.commit() + await session.refresh(prov) + return prov.id + + +async def _seed_tracker(user_id: int, provider_id: int, name: str, *, enabled: bool) -> int: + from notify_bridge_server.database.engine import get_engine + from notify_bridge_server.database.models import NotificationTracker + + engine = get_engine() + async with AsyncSession(engine) as session: + tr = NotificationTracker( + user_id=user_id, provider_id=provider_id, name=name, enabled=enabled, + ) + session.add(tr) + await session.commit() + await session.refresh(tr) + return tr.id + + +async def _seed_event(tracker_id: int, when: datetime) -> None: + from notify_bridge_server.database.engine import get_engine + from notify_bridge_server.database.models import EventLog + + engine = get_engine() + async with AsyncSession(engine) as session: + session.add(EventLog( + tracker_id=tracker_id, + tracker_name="webhook-tr", + event_type="webhook_received", + collection_id="", + collection_name="", + created_at=when, + )) + await session.commit() + + +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 session: + return await session.get(ServiceProvider, provider_id) + + +def test_dispatch_registers_webhook_handler(tmp_data_dir) -> None: # noqa: ARG001 + """Auto-registration must wire the webhook handler to provider type 'webhook'.""" + from notify_bridge_server.commands.dispatch import get_handler + + handler = get_handler("webhook") + assert handler is not None, "WebhookCommandHandler must be registered" + assert handler.provider_type == "webhook" + assert "status" in handler.get_provider_commands() + + +def test_status_renders_active_total_and_last_event(tmp_data_dir) -> None: # noqa: ARG001 + """``/status`` returns active/total counts and formatted last-event timestamp.""" + from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + user_id = await _seed_user() + provider_id = await _seed_provider(user_id, "WH1") + enabled_id = await _seed_tracker(user_id, provider_id, "tr-on", enabled=True) + await _seed_tracker(user_id, provider_id, "tr-off", enabled=False) + event_at = datetime(2026, 5, 1, 12, 34, tzinfo=timezone.utc) + await _seed_event(enabled_id, event_at) + + provider = await _load_provider(provider_id) + handler = WebhookCommandHandler() + templates = {"status": {"en": _STATUS_TEMPLATE_EN}} + response = await handler.handle( + cmd="status", args="", count=5, locale="en", + response_mode="text", provider=provider, + cmd_templates=templates, + bot=None, tracker=None, config=None, + ) + + assert response is not None + assert response.text is not None + text = response.text + assert "Trackers active: 1/2" in text + assert "Provider: WH1" in text + assert "2026-05-01 12:34" in text + + asyncio.run(run()) + + +def test_status_with_no_events_shows_dash(tmp_data_dir) -> None: # noqa: ARG001 + """Zero events → ``last_event`` renders as '-' (the get_last_event_str sentinel).""" + from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + user_id = await _seed_user() + provider_id = await _seed_provider(user_id, "WH-empty") + provider = await _load_provider(provider_id) + + handler = WebhookCommandHandler() + templates = {"status": {"en": _STATUS_TEMPLATE_EN}} + response = await handler.handle( + cmd="status", args="", count=5, locale="en", + response_mode="text", provider=provider, + cmd_templates=templates, + bot=None, tracker=None, config=None, + ) + + assert response is not None + assert "Trackers active: 0/0" in response.text + assert "Last event: -" in response.text + + asyncio.run(run()) + + +def test_status_returns_none_for_unknown_command(tmp_data_dir) -> None: # noqa: ARG001 + """Commands the webhook handler doesn't own must return None (lets dispatch fall through).""" + from notify_bridge_server.commands.webhook_handler import WebhookCommandHandler + + app = _bootstrap_app() + with TestClient(app): + async def run() -> None: + user_id = await _seed_user() + provider_id = await _seed_provider(user_id, "WH-noop") + provider = await _load_provider(provider_id) + + handler = WebhookCommandHandler() + response = await handler.handle( + cmd="albums", args="", count=5, locale="en", + response_mode="text", provider=provider, + cmd_templates={}, + bot=None, tracker=None, config=None, + ) + assert response is None + + asyncio.run(run())