diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 3ea2079..ef3fc08 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -115,6 +115,7 @@ "typeScheduler": "Scheduler", "typeNut": "NUT (UPS)", "typeGooglePhotos": "Google Photos", + "typeWebhook": "Generic Webhook", "loadError": "Failed to load providers.", "externalDomain": "External Domain", "optional": "optional", @@ -126,6 +127,9 @@ "plankaWebhookSecretHint": "Bearer token for webhook authentication. Set the same token as WEBHOOK_ACCESS_TOKEN in Planka.", "plankaApiKeyHint": "Optional. Needed for connection testing and board listing.", "plankaWebhookUrlHint": "Set this as the Webhook URL in Planka environment config (relative to your bridge host).", + "authMode": "Authentication Mode", + "authModeHint": "Choose hmac_sha256, bearer_token, or none", + "genericWebhookSecretHint": "Secret for HMAC-SHA256 or Bearer token authentication. Leave empty for no authentication.", "webhookSecretRequired": "Webhook secret is required", "apiToken": "API Token", "apiTokenHint": "Optional. Needed for connection testing and repository listing.", @@ -454,6 +458,7 @@ "upsReplaceBattery": "Replace battery", "upsOverload": "UPS overloaded", "scheduledMessage": "Scheduled message", + "webhookReceived": "Webhook received", "trackImages": "Track images", "trackVideos": "Track videos", "favoritesOnly": "Favorites only", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 2604cdb..caf3414 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -115,6 +115,7 @@ "typeScheduler": "Планировщик", "typeNut": "NUT (ИБП)", "typeGooglePhotos": "Google Фото", + "typeWebhook": "Универсальный вебхук", "loadError": "Не удалось загрузить провайдеры.", "externalDomain": "Внешний домен", "optional": "необязательно", @@ -126,6 +127,9 @@ "plankaWebhookSecretHint": "Bearer-токен для аутентификации вебхуков. Укажите тот же токен как WEBHOOK_ACCESS_TOKEN в Planka.", "plankaApiKeyHint": "Необязательно. Нужен для проверки подключения и получения списка досок.", "plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).", + "authMode": "Режим аутентификации", + "authModeHint": "Выберите hmac_sha256, bearer_token или none", + "genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.", "webhookSecretRequired": "Секрет вебхука обязателен", "apiToken": "API токен", "apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.", @@ -454,6 +458,7 @@ "upsReplaceBattery": "Замена батареи", "upsOverload": "Перегрузка ИБП", "scheduledMessage": "Запланированное сообщение", + "webhookReceived": "Вебхук получен", "trackImages": "Фото", "trackVideos": "Видео", "favoritesOnly": "Только избранные", diff --git a/frontend/src/lib/providers/index.ts b/frontend/src/lib/providers/index.ts index 5f691cc..99a4b64 100644 --- a/frontend/src/lib/providers/index.ts +++ b/frontend/src/lib/providers/index.ts @@ -12,6 +12,7 @@ import { plankaDescriptor } from './planka'; import { schedulerDescriptor } from './scheduler'; import { nutDescriptor } from './nut'; import { googlePhotosDescriptor } from './google-photos'; +import { webhookDescriptor } from './webhook'; const REGISTRY: ReadonlyMap = new Map([ ['immich', immichDescriptor], @@ -20,6 +21,7 @@ const REGISTRY: ReadonlyMap = new Map([ ['scheduler', schedulerDescriptor], ['nut', nutDescriptor], ['google_photos', googlePhotosDescriptor], + ['webhook', webhookDescriptor], ]); /** Look up a provider descriptor by type. Returns null for unknown types. */ diff --git a/frontend/src/lib/providers/webhook.ts b/frontend/src/lib/providers/webhook.ts new file mode 100644 index 0000000..91b76a7 --- /dev/null +++ b/frontend/src/lib/providers/webhook.ts @@ -0,0 +1,49 @@ +import type { ProviderDescriptor } from './types'; + +export const webhookDescriptor: ProviderDescriptor = { + type: 'webhook', + defaultName: 'Generic Webhook', + icon: 'mdiWebhook', + hasUrl: false, + + configFields: [ + { + key: 'auth_mode', configKey: 'auth_mode', + label: 'providers.authMode', + type: 'text', + placeholder: 'hmac_sha256 | bearer_token | none', + defaultValue: 'none', + hint: 'providers.authModeHint', + }, + { + key: 'webhook_secret', configKey: 'webhook_secret', + label: 'providers.webhookSecret', editLabel: 'providers.webhookSecretKeep', + type: 'password', optional: true, + hint: 'providers.genericWebhookSecretHint', + }, + ], + + buildConfig(form, editing) { + const config: Record = { + auth_mode: form.auth_mode || 'none', + payload_mappings: form.payload_mappings || [], + }; + if (form.webhook_secret) config.webhook_secret = form.webhook_secret; + if (form.event_type_path) config.event_type_path = form.event_type_path; + if (form.collection_path) config.collection_path = form.collection_path; + return { config }; + }, + + hasConfigChanged(form, existing) { + return form.auth_mode !== (existing.auth_mode || 'none') || + !!form.webhook_secret; + }, + + eventFields: [ + { key: 'track_webhook_received', label: 'trackingConfig.webhookReceived', default: true }, + ], + + collectionMeta: null, + webhookBased: true, + webhookUrlPattern: '/api/webhooks/webhook/{id}', +}; diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index a56135b..ecd7861 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "aiohttp>=3.9", "jinja2>=3.1", "aiosmtplib>=3.0", + "jsonpath-ng>=1.6", ] [project.optional-dependencies] diff --git a/packages/core/src/notify_bridge_core/models/events.py b/packages/core/src/notify_bridge_core/models/events.py index bf8510a..6294177 100644 --- a/packages/core/src/notify_bridge_core/models/events.py +++ b/packages/core/src/notify_bridge_core/models/events.py @@ -52,6 +52,9 @@ class EventType(str, Enum): # Scheduler events SCHEDULED_MESSAGE = "scheduled_message" + # Generic Webhook events + WEBHOOK_RECEIVED = "webhook_received" + # NUT (Network UPS Tools) events UPS_ONLINE = "ups_online" UPS_ON_BATTERY = "ups_on_battery" diff --git a/packages/core/src/notify_bridge_core/providers/base.py b/packages/core/src/notify_bridge_core/providers/base.py index ef99bf9..d6a925c 100644 --- a/packages/core/src/notify_bridge_core/providers/base.py +++ b/packages/core/src/notify_bridge_core/providers/base.py @@ -20,6 +20,7 @@ class ServiceProviderType(str, Enum): SCHEDULER = "scheduler" NUT = "nut" GOOGLE_PHOTOS = "google_photos" + WEBHOOK = "webhook" class ServiceProvider(ABC): diff --git a/packages/core/src/notify_bridge_core/providers/capabilities.py b/packages/core/src/notify_bridge_core/providers/capabilities.py index d402055..2b3da09 100644 --- a/packages/core/src/notify_bridge_core/providers/capabilities.py +++ b/packages/core/src/notify_bridge_core/providers/capabilities.py @@ -407,6 +407,43 @@ GOOGLE_PHOTOS_CAPABILITIES = ProviderCapabilities( ], ) +# --------------------------------------------------------------------------- +# Generic Webhook provider capabilities +# --------------------------------------------------------------------------- + +WEBHOOK_CAPABILITIES = ProviderCapabilities( + provider_type="webhook", + display_name="Generic Webhook", + webhook_based=True, + supported_filters=[ + { + "key": "collections", + "label": "Collections", + "type": "tags", + "placeholder": "value from collection_path", + }, + ], + notification_slots=[ + {"name": "message_webhook_received", "description": "Incoming webhook event"}, + ], + events=[ + {"name": "webhook_received", "description": "Webhook payload received"}, + ], + command_slots=[ + {"name": "start", "description": "/start greeting message"}, + {"name": "help", "description": "/help command listing"}, + {"name": "status", "description": "/status webhook summary"}, + {"name": "rate_limited", "description": "Rate limit warning message"}, + {"name": "no_results", "description": "Empty results fallback"}, + {"name": "desc_help", "description": "Menu description for /help"}, + {"name": "desc_status", "description": "Menu description for /status"}, + ], + commands=[ + {"name": "status", "description": "Show webhook status"}, + {"name": "help", "description": "Show commands"}, + ], +) + # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- @@ -418,6 +455,7 @@ _REGISTRY: dict[str, ProviderCapabilities] = { "scheduler": SCHEDULER_CAPABILITIES, "nut": NUT_CAPABILITIES, "google_photos": GOOGLE_PHOTOS_CAPABILITIES, + "webhook": WEBHOOK_CAPABILITIES, } diff --git a/packages/core/src/notify_bridge_core/providers/webhook/__init__.py b/packages/core/src/notify_bridge_core/providers/webhook/__init__.py new file mode 100644 index 0000000..45223ab --- /dev/null +++ b/packages/core/src/notify_bridge_core/providers/webhook/__init__.py @@ -0,0 +1,16 @@ +"""Generic Webhook service provider implementation.""" + +from notify_bridge_core.providers.base import ServiceProviderType +from notify_bridge_core.templates.variables import registry + +from .event_parser import parse_webhook +from .provider import WEBHOOK_VARIABLES, WebhookServiceProvider + +# Register Webhook variables in the global registry +registry.register_provider_variables(ServiceProviderType.WEBHOOK, WEBHOOK_VARIABLES) + +__all__ = [ + "WebhookServiceProvider", + "WEBHOOK_VARIABLES", + "parse_webhook", +] diff --git a/packages/core/src/notify_bridge_core/providers/webhook/event_parser.py b/packages/core/src/notify_bridge_core/providers/webhook/event_parser.py new file mode 100644 index 0000000..44d6bba --- /dev/null +++ b/packages/core/src/notify_bridge_core/providers/webhook/event_parser.py @@ -0,0 +1,121 @@ +"""Parse generic webhook payloads into ServiceEvent objects using JSONPath mappings.""" + +from __future__ import annotations + +import logging +from collections import OrderedDict +from datetime import datetime, timezone +from typing import Any + +from jsonpath_ng import parse as jsonpath_parse +from jsonpath_ng.exceptions import JsonPathParserError + +from notify_bridge_core.models.events import EventType, ServiceEvent +from notify_bridge_core.providers.base import ServiceProviderType + +_LOGGER = logging.getLogger(__name__) + +# Bounded LRU cache for compiled JSONPath expressions (max 1024 entries). +# Expressions come from provider configs so the set is finite in practice, +# but a bound prevents unbounded growth in long-lived processes. +_JSONPATH_CACHE_MAX = 1024 +_JSONPATH_CACHE: OrderedDict[str, Any] = OrderedDict() + + +def _compile_jsonpath(expression: str) -> Any | None: + """Compile a JSONPath expression, returning cached result or None on error.""" + if expression in _JSONPATH_CACHE: + _JSONPATH_CACHE.move_to_end(expression) + return _JSONPATH_CACHE[expression] + try: + compiled = jsonpath_parse(expression) + except (JsonPathParserError, Exception) as exc: + _LOGGER.warning("Invalid JSONPath expression '%s': %s", expression, exc) + compiled = None + _JSONPATH_CACHE[expression] = compiled + if len(_JSONPATH_CACHE) > _JSONPATH_CACHE_MAX: + _JSONPATH_CACHE.popitem(last=False) + return compiled + + +def _extract_value(compiled_path: Any, data: dict[str, Any], default: Any = "") -> Any: + """Extract a single value from data using a compiled JSONPath expression.""" + if compiled_path is None: + return default + matches = compiled_path.find(data) + if not matches: + return default + return matches[0].value + + +def parse_webhook( + payload: dict[str, Any], + provider_name: str, + config: dict[str, Any], + *, + headers: dict[str, str] | None = None, +) -> ServiceEvent | None: + """Parse a generic webhook payload into a ServiceEvent. + + Uses JSONPath mappings from the provider config to extract template variables. + The full payload is always available as ``raw_payload`` in the extra dict. + + Args: + payload: Parsed JSON body of the webhook. + provider_name: Display name of the ServiceProvider instance. + config: Provider configuration containing payload_mappings, etc. + headers: Optional HTTP headers (made available for event_type_path extraction). + + Returns: + A ServiceEvent, or None if parsing fails critically. + """ + # Build a combined data dict so JSONPath can reference headers too + data: dict[str, Any] = {**payload} + if headers: + data["headers"] = headers + + # Resolve event type from config path or default + event_type_raw = "webhook_received" + event_type_path = config.get("event_type_path", "") + if event_type_path: + compiled = _compile_jsonpath(event_type_path) + extracted = _extract_value(compiled, data, "webhook_received") + if isinstance(extracted, str) and extracted: + event_type_raw = extracted + + # Resolve collection from config path or default + collection_id = "" + collection_name = "" + collection_path = config.get("collection_path", "") + if collection_path: + compiled = _compile_jsonpath(collection_path) + extracted = _extract_value(compiled, data, "") + if isinstance(extracted, str): + collection_id = extracted + collection_name = extracted + + # Apply payload_mappings to extract named variables + extra: dict[str, Any] = {} + mappings = config.get("payload_mappings", []) + for mapping in mappings: + variable = mapping.get("variable", "") + jsonpath_expr = mapping.get("jsonpath", "") + default = mapping.get("default", "") + if not variable or not jsonpath_expr: + continue + compiled = _compile_jsonpath(jsonpath_expr) + extra[variable] = _extract_value(compiled, data, default) + + # Always include raw payload and event type raw + extra["raw_payload"] = payload + extra["event_type_raw"] = event_type_raw + + return ServiceEvent( + event_type=EventType.WEBHOOK_RECEIVED, + provider_type=ServiceProviderType.WEBHOOK, + provider_name=provider_name, + collection_id=collection_id, + collection_name=collection_name, + timestamp=datetime.now(timezone.utc), + extra=extra, + ) diff --git a/packages/core/src/notify_bridge_core/providers/webhook/provider.py b/packages/core/src/notify_bridge_core/providers/webhook/provider.py new file mode 100644 index 0000000..f2a0f8f --- /dev/null +++ b/packages/core/src/notify_bridge_core/providers/webhook/provider.py @@ -0,0 +1,107 @@ +"""Generic Webhook service provider — receives arbitrary HTTP payloads.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType +from notify_bridge_core.templates.variables import TemplateVariableDefinition + +if TYPE_CHECKING: + from notify_bridge_core.models.events import ServiceEvent + +# Provider-specific variables (user-defined variables from payload_mappings +# are dynamic and documented separately in the template editor). +WEBHOOK_VARIABLES: list[TemplateVariableDefinition] = [ + TemplateVariableDefinition( + name="raw_payload", + type="dict", + description="Full incoming JSON payload as a dict (use raw_payload.field or raw_payload | tojson)", + example='{"action": "opened", "issue": {"title": "Bug"}}', + provider_type=ServiceProviderType.WEBHOOK, + ), + TemplateVariableDefinition( + name="event_type_raw", + type="string", + description="Raw event type extracted from payload via event_type_path (or 'webhook_received')", + example="webhook_received", + provider_type=ServiceProviderType.WEBHOOK, + ), + TemplateVariableDefinition( + name="source_ip", + type="string", + description="IP address of the webhook sender", + example="192.168.1.100", + provider_type=ServiceProviderType.WEBHOOK, + ), +] + + +class WebhookServiceProvider(ServiceProvider): + """Generic webhook provider — no polling, receives events via HTTP POST.""" + + provider_type = ServiceProviderType.WEBHOOK + + def __init__(self, name: str = "Webhook") -> None: + self._name = name + + async def connect(self) -> bool: + return True + + async def disconnect(self) -> None: + pass + + async def poll( + self, + collection_ids: list[str], + tracker_state: dict[str, Any], + ) -> tuple[list[ServiceEvent], dict[str, Any]]: + # Webhook-based provider — never polled + return [], tracker_state + + def get_available_variables(self) -> list[TemplateVariableDefinition]: + return WEBHOOK_VARIABLES + + def get_provider_config_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "auth_mode": { + "type": "string", + "enum": ["hmac_sha256", "bearer_token", "none"], + "default": "none", + "description": "Authentication mode for incoming webhooks", + }, + "webhook_secret": { + "type": "string", + "description": "Secret for HMAC-SHA256 or Bearer token authentication", + }, + "payload_mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": {"type": "string"}, + "jsonpath": {"type": "string"}, + "default": {"type": "string"}, + }, + "required": ["variable", "jsonpath"], + }, + "description": "JSONPath rules to extract template variables from payload", + }, + "event_type_path": { + "type": "string", + "description": "JSONPath to extract event type from payload", + }, + "collection_path": { + "type": "string", + "description": "JSONPath to extract collection identifier from payload", + }, + }, + } + + async def list_collections(self) -> list[dict[str, Any]]: + return [] + + async def test_connection(self) -> dict[str, Any]: + return {"ok": True, "message": "Webhook provider is ready to receive events"} diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/desc_help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/desc_help.jinja2 new file mode 100644 index 0000000..11b9c33 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/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/webhook/desc_status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/desc_status.jinja2 new file mode 100644 index 0000000..4288bb6 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/desc_status.jinja2 @@ -0,0 +1 @@ +Show webhook status \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/help.jinja2 new file mode 100644 index 0000000..9ac9a8d --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/help.jinja2 @@ -0,0 +1,4 @@ +📋 Available commands: +{%- for cmd in commands %} +/{{ cmd.name }} — {{ cmd.description }} +{%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/no_results.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/no_results.jinja2 new file mode 100644 index 0000000..bb280ad --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/no_results.jinja2 @@ -0,0 +1 @@ +No results found. \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/rate_limited.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/rate_limited.jinja2 new file mode 100644 index 0000000..b09d00d --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/rate_limited.jinja2 @@ -0,0 +1 @@ +⏳ Please wait {{ wait }}s before using this command again. \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/start.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/start.jinja2 new file mode 100644 index 0000000..d934051 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/start.jinja2 @@ -0,0 +1,2 @@ +👋 Hi! I'm your Notify Bridge bot for Generic Webhook. +Use /help to see available commands. \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/status.jinja2 new file mode 100644 index 0000000..e97c6c2 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/en/webhook/status.jinja2 @@ -0,0 +1,3 @@ +📊 Webhook Status +Trackers active: {{ trackers_active }} +Last event: {{ last_event }} \ 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 70f1d13..06f31b5 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 @@ -47,6 +47,13 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = { # Description slots "desc_help", "desc_status", "desc_devices", "desc_battery", ], + "webhook": [ + # Response templates + "start", "help", "status", + "rate_limited", "no_results", + # Description slots + "desc_help", "desc_status", + ], "google_photos": [ # Response templates "start", "help", "status", "albums", "latest", "search", "random", diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/desc_help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/desc_help.jinja2 new file mode 100644 index 0000000..3e8f915 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/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/webhook/desc_status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/desc_status.jinja2 new file mode 100644 index 0000000..49adca6 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/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/webhook/help.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/help.jinja2 new file mode 100644 index 0000000..26b439e --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/help.jinja2 @@ -0,0 +1,4 @@ +📋 Доступные команды: +{%- for cmd in commands %} +/{{ cmd.name }} — {{ cmd.description }} +{%- endfor %} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/no_results.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/no_results.jinja2 new file mode 100644 index 0000000..fc8f62c --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/no_results.jinja2 @@ -0,0 +1 @@ +Результатов не найдено. \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/rate_limited.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/rate_limited.jinja2 new file mode 100644 index 0000000..992e551 --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/rate_limited.jinja2 @@ -0,0 +1 @@ +⏳ Подождите {{ wait }} сек. перед повторным использованием команды. \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/start.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/start.jinja2 new file mode 100644 index 0000000..9d119cd --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/start.jinja2 @@ -0,0 +1,2 @@ +👋 Привет! Я ваш бот Notify Bridge для вебхуков. +Используйте /help для списка команд. \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/status.jinja2 b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/status.jinja2 new file mode 100644 index 0000000..26a764b --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/command_defaults/ru/webhook/status.jinja2 @@ -0,0 +1,3 @@ +📊 Статус вебхука +Активных трекеров: {{ trackers_active }} +Последнее событие: {{ last_event }} \ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/context.py b/packages/core/src/notify_bridge_core/templates/context.py index 0d4b313..c7baaed 100644 --- a/packages/core/src/notify_bridge_core/templates/context.py +++ b/packages/core/src/notify_bridge_core/templates/context.py @@ -149,6 +149,12 @@ def build_template_context( if event.new_name: ctx.setdefault("new_album_name", event.new_name) + # Provider-specific defaults for Generic Webhook + if event.provider_type.value == "webhook": + ctx.setdefault("raw_payload", event.extra.get("raw_payload", {})) + ctx.setdefault("event_type_raw", event.extra.get("event_type_raw", "webhook_received")) + ctx.setdefault("source_ip", event.extra.get("source_ip", "")) + # Provider-specific defaults for Scheduler if event.provider_type.value == "scheduler": ctx.setdefault("schedule_name", event.collection_name) diff --git a/packages/core/src/notify_bridge_core/templates/defaults/en/webhook_received.jinja2 b/packages/core/src/notify_bridge_core/templates/defaults/en/webhook_received.jinja2 new file mode 100644 index 0000000..cc1555b --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/defaults/en/webhook_received.jinja2 @@ -0,0 +1,8 @@ +📩 Webhook received — {{ service_name }} +{%- if event_type_raw and event_type_raw != 'webhook_received' %} +Event: {{ event_type_raw }} +{%- endif %} +{%- if collection_name %} +Source: {{ collection_name }} +{%- endif %} +
{{ raw_payload | tojson(indent=2) | truncate(2000) }}
\ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/defaults/loader.py b/packages/core/src/notify_bridge_core/templates/defaults/loader.py index 77c78de..0b38913 100644 --- a/packages/core/src/notify_bridge_core/templates/defaults/loader.py +++ b/packages/core/src/notify_bridge_core/templates/defaults/loader.py @@ -60,6 +60,9 @@ PROVIDER_SLOT_FILE_MAP: dict[str, dict[str, str]] = { "message_collection_deleted": "gp_collection_deleted.jinja2", "message_sharing_changed": "gp_sharing_changed.jinja2", }, + "webhook": { + "message_webhook_received": "webhook_received.jinja2", + }, "nut": { "message_ups_online": "nut_ups_online.jinja2", "message_ups_on_battery": "nut_ups_on_battery.jinja2", diff --git a/packages/core/src/notify_bridge_core/templates/defaults/ru/webhook_received.jinja2 b/packages/core/src/notify_bridge_core/templates/defaults/ru/webhook_received.jinja2 new file mode 100644 index 0000000..4e0dcdf --- /dev/null +++ b/packages/core/src/notify_bridge_core/templates/defaults/ru/webhook_received.jinja2 @@ -0,0 +1,8 @@ +📩 Вебхук получен — {{ service_name }} +{%- if event_type_raw and event_type_raw != 'webhook_received' %} +Событие: {{ event_type_raw }} +{%- endif %} +{%- if collection_name %} +Источник: {{ collection_name }} +{%- endif %} +
{{ raw_payload | tojson(indent=2) | truncate(2000) }}
\ No newline at end of file diff --git a/packages/core/src/notify_bridge_core/templates/validator.py b/packages/core/src/notify_bridge_core/templates/validator.py index 5517678..c8e6ea2 100644 --- a/packages/core/src/notify_bridge_core/templates/validator.py +++ b/packages/core/src/notify_bridge_core/templates/validator.py @@ -26,6 +26,7 @@ def validate_template( "target_type", "has_videos", "has_photos", "has_oversized_videos", "max_video_size", "max_video_size_mb", "added_assets", "assets", "albums", + "raw_payload", "event_type_raw", "source_ip", } allowed = available | runtime_vars diff --git a/packages/server/src/notify_bridge_server/api/template_configs.py b/packages/server/src/notify_bridge_server/api/template_configs.py index 357cd60..5b6a8fa 100644 --- a/packages/server/src/notify_bridge_server/api/template_configs.py +++ b/packages/server/src/notify_bridge_server/api/template_configs.py @@ -251,6 +251,8 @@ async def get_template_variables( "variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"}, "asset_fields": asset_fields, }, + # --- Generic Webhook slots --- + **_webhook_variables(), # --- Gitea slots --- **_gitea_variables(), # --- Planka slots --- @@ -271,6 +273,23 @@ async def get_template_variables( } +def _webhook_variables() -> dict: + return { + "message_webhook_received": { + "description": "Incoming webhook event notification", + "variables": { + "service_name": "Provider instance name", + "event_type_raw": "Raw event type from payload (or 'webhook_received')", + "collection_name": "Collection extracted from payload via collection_path (or empty)", + "source_ip": "IP address of the webhook sender", + "raw_payload": "Full JSON payload as dict (use raw_payload.field or raw_payload | tojson)", + "timestamp": "When the webhook was received", + "target_type": "Target type: 'telegram' or 'webhook'", + }, + }, + } + + def _gitea_variables() -> dict: common = { "sender": "Username who triggered the event", diff --git a/packages/server/src/notify_bridge_server/api/webhooks.py b/packages/server/src/notify_bridge_server/api/webhooks.py index f285212..7bbd15e 100644 --- a/packages/server/src/notify_bridge_server/api/webhooks.py +++ b/packages/server/src/notify_bridge_server/api/webhooks.py @@ -15,6 +15,7 @@ from notify_bridge_core.models.events import ServiceEvent from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig from notify_bridge_core.providers.gitea.event_parser import parse_webhook as parse_gitea_webhook from notify_bridge_core.providers.planka.event_parser import parse_webhook as parse_planka_webhook +from notify_bridge_core.providers.webhook.event_parser import parse_webhook as parse_generic_webhook from ..database.engine import get_engine from ..database.models import ( @@ -289,6 +290,148 @@ async def planka_webhook(provider_id: int, request: Request): return {"ok": True, "dispatched": dispatched} +# --------------------------------------------------------------------------- +# Generic Webhook endpoint +# --------------------------------------------------------------------------- + +def _verify_generic_webhook_auth( + config: dict[str, Any], + request: Request, + raw_body: bytes, +) -> bool: + """Verify authentication for a generic webhook based on configured auth_mode.""" + auth_mode = config.get("auth_mode", "none") + + if auth_mode == "none": + return True + + secret = config.get("webhook_secret", "") + if not secret: + return False + + if auth_mode == "hmac_sha256": + # Support common signature headers + signature = ( + request.headers.get("X-Hub-Signature-256", "") + or request.headers.get("X-Webhook-Signature", "") + or request.headers.get("X-Signature-256", "") + ) + # Strip "sha256=" prefix if present (GitHub-style) + if signature.startswith("sha256="): + signature = signature[7:] + if not signature: + return False + expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, signature) + + if auth_mode == "bearer_token": + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + return hmac.compare_digest(token, secret) + return False + + return False + + +@router.post("/webhook/{provider_id}") +async def generic_webhook(provider_id: int, request: Request): + """Receive a generic webhook, extract variables via JSONPath, and dispatch notifications.""" + engine = get_engine() + + # --- Load provider and validate auth --- + async with AsyncSession(engine) as session: + provider = await session.get(ServiceProvider, provider_id) + if not provider or provider.type != "webhook": + raise HTTPException(status_code=404, detail="Provider not found") + + provider_config = provider.config or {} + provider_name = provider.name + + raw_body = await request.body() + + # Enforce payload size limit BEFORE parsing JSON + if len(raw_body) > 1_000_000: + raise HTTPException(status_code=413, detail="Payload too large (max 1 MB)") + + if not _verify_generic_webhook_auth(provider_config, request, raw_body): + raise HTTPException(status_code=403, detail="Authentication failed") + + # Parse JSON payload + try: + payload = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON") + + # Parse via JSONPath mappings + req_headers = dict(request.headers) + event = parse_generic_webhook(payload, provider_name, provider_config, headers=req_headers) + if event is None: + return {"ok": True, "skipped": "parse failed"} + + # Inject source IP + source_ip = request.client.host if request.client else "" + event.extra["source_ip"] = source_ip + + # --- Find trackers for this provider and dispatch --- + dispatched = 0 + async with AsyncSession(engine) as session: + tracker_result = await session.exec( + select(NotificationTracker).where( + NotificationTracker.provider_id == provider_id, + NotificationTracker.enabled == True, + ) + ) + trackers = tracker_result.all() + + for tracker in trackers: + filters = tracker.filters or {} + if not _passes_filters(event, filters): + _LOGGER.debug( + "Event filtered out for tracker %d (%s)", tracker.id, tracker.name + ) + continue + + link_data = await load_link_data(session, tracker.id) + if not link_data: + continue + + # Log event + session.add(EventLog( + tracker_id=tracker.id, + tracker_name=tracker.name, + provider_id=provider_id, + provider_name=provider_name, + event_type=event.event_type.value, + collection_id=event.collection_id, + collection_name=event.collection_name, + assets_count=0, + details={ + "provider_type": "webhook", + "event_type_raw": event.extra.get("event_type_raw", ""), + "source_ip": source_ip, + }, + )) + + # Dispatch to targets + dispatcher = NotificationDispatcher() + target_configs = _build_target_configs(event, link_data, provider_config) + if target_configs: + results = await dispatcher.dispatch(event, target_configs) + for r in results: + if r.get("success"): + dispatched += 1 + else: + _LOGGER.error( + "Notification failed for tracker %d: %s", + tracker.id, r.get("error", "unknown"), + ) + + await session.commit() + + return {"ok": True, "dispatched": dispatched} + + def _build_target_configs( event: ServiceEvent, link_data: list[dict[str, Any]], diff --git a/packages/server/src/notify_bridge_server/database/migrations.py b/packages/server/src/notify_bridge_server/database/migrations.py index e7a6e28..e86adbd 100644 --- a/packages/server/src/notify_bridge_server/database/migrations.py +++ b/packages/server/src/notify_bridge_server/database/migrations.py @@ -204,6 +204,14 @@ async def migrate_schema(engine: AsyncEngine) -> None: ) logger.info("Added %s column to tracking_config table", col_name) + # Add Generic Webhook tracking flag to tracking_config if missing + if await _has_table(conn, "tracking_config"): + if not await _has_column(conn, "tracking_config", "track_webhook_received"): + await conn.execute( + text("ALTER TABLE tracking_config ADD COLUMN track_webhook_received INTEGER DEFAULT 1") + ) + logger.info("Added track_webhook_received column to tracking_config table") + # Drop legacy template content columns from template_config # (template content moved to template_slot child rows) if await _has_table(conn, "template_config"): diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py index bf7ad30..4be0b6b 100644 --- a/packages/server/src/notify_bridge_server/database/models.py +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -160,6 +160,9 @@ class TrackingConfig(SQLModel, table=True): track_ups_replace_battery: bool = Field(default=True) track_ups_overload: bool = Field(default=True) + # Generic Webhook event tracking + track_webhook_received: bool = Field(default=True) + # Immich asset display track_images: bool = Field(default=True) track_videos: bool = Field(default=True) diff --git a/packages/server/src/notify_bridge_server/database/seeds.py b/packages/server/src/notify_bridge_server/database/seeds.py index 9c8313b..e1510b1 100644 --- a/packages/server/src/notify_bridge_server/database/seeds.py +++ b/packages/server/src/notify_bridge_server/database/seeds.py @@ -153,6 +153,7 @@ async def _seed_default_templates() -> None: await _seed_provider_template(session, "scheduler", "Scheduler") await _seed_provider_template(session, "nut", "NUT") await _seed_provider_template(session, "google_photos", "Google Photos") + await _seed_provider_template(session, "webhook", "Generic Webhook") await session.commit() @@ -179,6 +180,9 @@ async def _seed_default_command_templates() -> None: await _seed_provider_command_template( session, "google_photos", "Default Google Photos Commands", "Default Google Photos command templates", ) + await _seed_provider_command_template( + session, "webhook", "Default Webhook Commands", "Default Generic Webhook command templates", + ) await session.commit() @@ -229,6 +233,11 @@ async def _seed_default_tracking_configs() -> None: "name": "Default Scheduler", "track_scheduled_message": True, }, + { + "provider_type": "webhook", + "name": "Default Webhook", + "track_webhook_received": True, + }, { "provider_type": "nut", "name": "Default NUT", @@ -309,6 +318,14 @@ async def _seed_default_command_configs() -> None: "default_count": 5, "rate_limits": {"api": 15, "default": 10}, }, + { + "provider_type": "webhook", + "name": "Default Webhook", + "enabled_commands": ["help", "status"], + "response_mode": "text", + "default_count": 5, + "rate_limits": {"default": 10}, + }, { "provider_type": "google_photos", "name": "Default Google Photos", diff --git a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py index c6a3664..a298f1e 100644 --- a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py +++ b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py @@ -84,6 +84,8 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool: "task_completed": tc.track_task_completed, # Scheduler events "scheduled_message": tc.track_scheduled_message, + # Generic Webhook events + "webhook_received": tc.track_webhook_received, # NUT (UPS) events "ups_online": tc.track_ups_online, "ups_on_battery": tc.track_ups_on_battery, diff --git a/packages/server/src/notify_bridge_server/services/sample_context.py b/packages/server/src/notify_bridge_server/services/sample_context.py index 69d9057..54fed45 100644 --- a/packages/server/src/notify_bridge_server/services/sample_context.py +++ b/packages/server/src/notify_bridge_server/services/sample_context.py @@ -189,4 +189,8 @@ _SAMPLE_CONTEXT = { "custom_vars": {"team": "Engineering", "message": "Time for standup!"}, "team": "Engineering", "message": "Time for standup!", + # Generic Webhook variables (for webhook provider templates) + "raw_payload": {"action": "opened", "issue": {"title": "Bug report", "number": 1}, "sender": {"login": "user1"}}, + "event_type_raw": "webhook_received", + "source_ip": "192.168.1.100", }