feat: generic webhook provider with JSONPath payload extraction
Add a new "webhook" provider type that accepts arbitrary HTTP POST payloads, extracts template variables via user-defined JSONPath mappings, and dispatches notifications through the existing pipeline. Supports three auth modes (HMAC-SHA256, Bearer token, none), bounded JSONPath cache, and 1MB payload limit. Full stack: core provider + event parser, API endpoint, DB migration, capabilities, seeds, default templates (EN/RU), frontend descriptor, i18n.
This commit is contained in:
@@ -115,6 +115,7 @@
|
|||||||
"typeScheduler": "Scheduler",
|
"typeScheduler": "Scheduler",
|
||||||
"typeNut": "NUT (UPS)",
|
"typeNut": "NUT (UPS)",
|
||||||
"typeGooglePhotos": "Google Photos",
|
"typeGooglePhotos": "Google Photos",
|
||||||
|
"typeWebhook": "Generic Webhook",
|
||||||
"loadError": "Failed to load providers.",
|
"loadError": "Failed to load providers.",
|
||||||
"externalDomain": "External Domain",
|
"externalDomain": "External Domain",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
@@ -126,6 +127,9 @@
|
|||||||
"plankaWebhookSecretHint": "Bearer token for webhook authentication. Set the same token as WEBHOOK_ACCESS_TOKEN in Planka.",
|
"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.",
|
"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).",
|
"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",
|
"webhookSecretRequired": "Webhook secret is required",
|
||||||
"apiToken": "API Token",
|
"apiToken": "API Token",
|
||||||
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
"apiTokenHint": "Optional. Needed for connection testing and repository listing.",
|
||||||
@@ -454,6 +458,7 @@
|
|||||||
"upsReplaceBattery": "Replace battery",
|
"upsReplaceBattery": "Replace battery",
|
||||||
"upsOverload": "UPS overloaded",
|
"upsOverload": "UPS overloaded",
|
||||||
"scheduledMessage": "Scheduled message",
|
"scheduledMessage": "Scheduled message",
|
||||||
|
"webhookReceived": "Webhook received",
|
||||||
"trackImages": "Track images",
|
"trackImages": "Track images",
|
||||||
"trackVideos": "Track videos",
|
"trackVideos": "Track videos",
|
||||||
"favoritesOnly": "Favorites only",
|
"favoritesOnly": "Favorites only",
|
||||||
|
|||||||
@@ -115,6 +115,7 @@
|
|||||||
"typeScheduler": "Планировщик",
|
"typeScheduler": "Планировщик",
|
||||||
"typeNut": "NUT (ИБП)",
|
"typeNut": "NUT (ИБП)",
|
||||||
"typeGooglePhotos": "Google Фото",
|
"typeGooglePhotos": "Google Фото",
|
||||||
|
"typeWebhook": "Универсальный вебхук",
|
||||||
"loadError": "Не удалось загрузить провайдеры.",
|
"loadError": "Не удалось загрузить провайдеры.",
|
||||||
"externalDomain": "Внешний домен",
|
"externalDomain": "Внешний домен",
|
||||||
"optional": "необязательно",
|
"optional": "необязательно",
|
||||||
@@ -126,6 +127,9 @@
|
|||||||
"plankaWebhookSecretHint": "Bearer-токен для аутентификации вебхуков. Укажите тот же токен как WEBHOOK_ACCESS_TOKEN в Planka.",
|
"plankaWebhookSecretHint": "Bearer-токен для аутентификации вебхуков. Укажите тот же токен как WEBHOOK_ACCESS_TOKEN в Planka.",
|
||||||
"plankaApiKeyHint": "Необязательно. Нужен для проверки подключения и получения списка досок.",
|
"plankaApiKeyHint": "Необязательно. Нужен для проверки подключения и получения списка досок.",
|
||||||
"plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).",
|
"plankaWebhookUrlHint": "Укажите этот URL в конфигурации Planka (относительно хоста bridge).",
|
||||||
|
"authMode": "Режим аутентификации",
|
||||||
|
"authModeHint": "Выберите hmac_sha256, bearer_token или none",
|
||||||
|
"genericWebhookSecretHint": "Секрет для HMAC-SHA256 или Bearer token аутентификации. Оставьте пустым для режима без аутентификации.",
|
||||||
"webhookSecretRequired": "Секрет вебхука обязателен",
|
"webhookSecretRequired": "Секрет вебхука обязателен",
|
||||||
"apiToken": "API токен",
|
"apiToken": "API токен",
|
||||||
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
"apiTokenHint": "Необязательно. Нужен для проверки подключения и получения списка репозиториев.",
|
||||||
@@ -454,6 +458,7 @@
|
|||||||
"upsReplaceBattery": "Замена батареи",
|
"upsReplaceBattery": "Замена батареи",
|
||||||
"upsOverload": "Перегрузка ИБП",
|
"upsOverload": "Перегрузка ИБП",
|
||||||
"scheduledMessage": "Запланированное сообщение",
|
"scheduledMessage": "Запланированное сообщение",
|
||||||
|
"webhookReceived": "Вебхук получен",
|
||||||
"trackImages": "Фото",
|
"trackImages": "Фото",
|
||||||
"trackVideos": "Видео",
|
"trackVideos": "Видео",
|
||||||
"favoritesOnly": "Только избранные",
|
"favoritesOnly": "Только избранные",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { plankaDescriptor } from './planka';
|
|||||||
import { schedulerDescriptor } from './scheduler';
|
import { schedulerDescriptor } from './scheduler';
|
||||||
import { nutDescriptor } from './nut';
|
import { nutDescriptor } from './nut';
|
||||||
import { googlePhotosDescriptor } from './google-photos';
|
import { googlePhotosDescriptor } from './google-photos';
|
||||||
|
import { webhookDescriptor } from './webhook';
|
||||||
|
|
||||||
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
||||||
['immich', immichDescriptor],
|
['immich', immichDescriptor],
|
||||||
@@ -20,6 +21,7 @@ const REGISTRY: ReadonlyMap<string, ProviderDescriptor> = new Map([
|
|||||||
['scheduler', schedulerDescriptor],
|
['scheduler', schedulerDescriptor],
|
||||||
['nut', nutDescriptor],
|
['nut', nutDescriptor],
|
||||||
['google_photos', googlePhotosDescriptor],
|
['google_photos', googlePhotosDescriptor],
|
||||||
|
['webhook', webhookDescriptor],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
/** Look up a provider descriptor by type. Returns null for unknown types. */
|
||||||
|
|||||||
@@ -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<string, any> = {
|
||||||
|
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}',
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
"aiohttp>=3.9",
|
"aiohttp>=3.9",
|
||||||
"jinja2>=3.1",
|
"jinja2>=3.1",
|
||||||
"aiosmtplib>=3.0",
|
"aiosmtplib>=3.0",
|
||||||
|
"jsonpath-ng>=1.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class EventType(str, Enum):
|
|||||||
# Scheduler events
|
# Scheduler events
|
||||||
SCHEDULED_MESSAGE = "scheduled_message"
|
SCHEDULED_MESSAGE = "scheduled_message"
|
||||||
|
|
||||||
|
# Generic Webhook events
|
||||||
|
WEBHOOK_RECEIVED = "webhook_received"
|
||||||
|
|
||||||
# NUT (Network UPS Tools) events
|
# NUT (Network UPS Tools) events
|
||||||
UPS_ONLINE = "ups_online"
|
UPS_ONLINE = "ups_online"
|
||||||
UPS_ON_BATTERY = "ups_on_battery"
|
UPS_ON_BATTERY = "ups_on_battery"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class ServiceProviderType(str, Enum):
|
|||||||
SCHEDULER = "scheduler"
|
SCHEDULER = "scheduler"
|
||||||
NUT = "nut"
|
NUT = "nut"
|
||||||
GOOGLE_PHOTOS = "google_photos"
|
GOOGLE_PHOTOS = "google_photos"
|
||||||
|
WEBHOOK = "webhook"
|
||||||
|
|
||||||
|
|
||||||
class ServiceProvider(ABC):
|
class ServiceProvider(ABC):
|
||||||
|
|||||||
@@ -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
|
# Registry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -418,6 +455,7 @@ _REGISTRY: dict[str, ProviderCapabilities] = {
|
|||||||
"scheduler": SCHEDULER_CAPABILITIES,
|
"scheduler": SCHEDULER_CAPABILITIES,
|
||||||
"nut": NUT_CAPABILITIES,
|
"nut": NUT_CAPABILITIES,
|
||||||
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
"google_photos": GOOGLE_PHOTOS_CAPABILITIES,
|
||||||
|
"webhook": WEBHOOK_CAPABILITIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Show available commands
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Show webhook status
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
📋 <b>Available commands:</b>
|
||||||
|
{%- for cmd in commands %}
|
||||||
|
/{{ cmd.name }} — {{ cmd.description }}
|
||||||
|
{%- endfor %}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
No results found.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
⏳ Please wait {{ wait }}s before using this command again.
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
👋 Hi! I'm your Notify Bridge bot for <b>Generic Webhook</b>.
|
||||||
|
Use /help to see available commands.
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
📊 <b>Webhook Status</b>
|
||||||
|
Trackers active: {{ trackers_active }}
|
||||||
|
Last event: {{ last_event }}
|
||||||
@@ -47,6 +47,13 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
|
|||||||
# Description slots
|
# Description slots
|
||||||
"desc_help", "desc_status", "desc_devices", "desc_battery",
|
"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": [
|
"google_photos": [
|
||||||
# Response templates
|
# Response templates
|
||||||
"start", "help", "status", "albums", "latest", "search", "random",
|
"start", "help", "status", "albums", "latest", "search", "random",
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Показать доступные команды
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Показать статус вебхука
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
📋 <b>Доступные команды:</b>
|
||||||
|
{%- for cmd in commands %}
|
||||||
|
/{{ cmd.name }} — {{ cmd.description }}
|
||||||
|
{%- endfor %}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Результатов не найдено.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
⏳ Подождите {{ wait }} сек. перед повторным использованием команды.
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
👋 Привет! Я ваш бот Notify Bridge для <b>вебхуков</b>.
|
||||||
|
Используйте /help для списка команд.
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
📊 <b>Статус вебхука</b>
|
||||||
|
Активных трекеров: {{ trackers_active }}
|
||||||
|
Последнее событие: {{ last_event }}
|
||||||
@@ -149,6 +149,12 @@ def build_template_context(
|
|||||||
if event.new_name:
|
if event.new_name:
|
||||||
ctx.setdefault("new_album_name", 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
|
# Provider-specific defaults for Scheduler
|
||||||
if event.provider_type.value == "scheduler":
|
if event.provider_type.value == "scheduler":
|
||||||
ctx.setdefault("schedule_name", event.collection_name)
|
ctx.setdefault("schedule_name", event.collection_name)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
📩 <b>Webhook received</b> — {{ service_name }}
|
||||||
|
{%- if event_type_raw and event_type_raw != 'webhook_received' %}
|
||||||
|
Event: <code>{{ event_type_raw }}</code>
|
||||||
|
{%- endif %}
|
||||||
|
{%- if collection_name %}
|
||||||
|
Source: {{ collection_name }}
|
||||||
|
{%- endif %}
|
||||||
|
<pre>{{ raw_payload | tojson(indent=2) | truncate(2000) }}</pre>
|
||||||
@@ -60,6 +60,9 @@ PROVIDER_SLOT_FILE_MAP: dict[str, dict[str, str]] = {
|
|||||||
"message_collection_deleted": "gp_collection_deleted.jinja2",
|
"message_collection_deleted": "gp_collection_deleted.jinja2",
|
||||||
"message_sharing_changed": "gp_sharing_changed.jinja2",
|
"message_sharing_changed": "gp_sharing_changed.jinja2",
|
||||||
},
|
},
|
||||||
|
"webhook": {
|
||||||
|
"message_webhook_received": "webhook_received.jinja2",
|
||||||
|
},
|
||||||
"nut": {
|
"nut": {
|
||||||
"message_ups_online": "nut_ups_online.jinja2",
|
"message_ups_online": "nut_ups_online.jinja2",
|
||||||
"message_ups_on_battery": "nut_ups_on_battery.jinja2",
|
"message_ups_on_battery": "nut_ups_on_battery.jinja2",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
📩 <b>Вебхук получен</b> — {{ service_name }}
|
||||||
|
{%- if event_type_raw and event_type_raw != 'webhook_received' %}
|
||||||
|
Событие: <code>{{ event_type_raw }}</code>
|
||||||
|
{%- endif %}
|
||||||
|
{%- if collection_name %}
|
||||||
|
Источник: {{ collection_name }}
|
||||||
|
{%- endif %}
|
||||||
|
<pre>{{ raw_payload | tojson(indent=2) | truncate(2000) }}</pre>
|
||||||
@@ -26,6 +26,7 @@ def validate_template(
|
|||||||
"target_type", "has_videos", "has_photos",
|
"target_type", "has_videos", "has_photos",
|
||||||
"has_oversized_videos", "max_video_size", "max_video_size_mb",
|
"has_oversized_videos", "max_video_size", "max_video_size_mb",
|
||||||
"added_assets", "assets", "albums",
|
"added_assets", "assets", "albums",
|
||||||
|
"raw_payload", "event_type_raw", "source_ip",
|
||||||
}
|
}
|
||||||
allowed = available | runtime_vars
|
allowed = available | runtime_vars
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ async def get_template_variables(
|
|||||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
||||||
"asset_fields": asset_fields,
|
"asset_fields": asset_fields,
|
||||||
},
|
},
|
||||||
|
# --- Generic Webhook slots ---
|
||||||
|
**_webhook_variables(),
|
||||||
# --- Gitea slots ---
|
# --- Gitea slots ---
|
||||||
**_gitea_variables(),
|
**_gitea_variables(),
|
||||||
# --- Planka slots ---
|
# --- 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:
|
def _gitea_variables() -> dict:
|
||||||
common = {
|
common = {
|
||||||
"sender": "Username who triggered the event",
|
"sender": "Username who triggered the event",
|
||||||
|
|||||||
@@ -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.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.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.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.engine import get_engine
|
||||||
from ..database.models import (
|
from ..database.models import (
|
||||||
@@ -289,6 +290,148 @@ async def planka_webhook(provider_id: int, request: Request):
|
|||||||
return {"ok": True, "dispatched": dispatched}
|
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(
|
def _build_target_configs(
|
||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
link_data: list[dict[str, Any]],
|
link_data: list[dict[str, Any]],
|
||||||
|
|||||||
@@ -204,6 +204,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Added %s column to tracking_config table", col_name)
|
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
|
# Drop legacy template content columns from template_config
|
||||||
# (template content moved to template_slot child rows)
|
# (template content moved to template_slot child rows)
|
||||||
if await _has_table(conn, "template_config"):
|
if await _has_table(conn, "template_config"):
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ class TrackingConfig(SQLModel, table=True):
|
|||||||
track_ups_replace_battery: bool = Field(default=True)
|
track_ups_replace_battery: bool = Field(default=True)
|
||||||
track_ups_overload: 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
|
# Immich asset display
|
||||||
track_images: bool = Field(default=True)
|
track_images: bool = Field(default=True)
|
||||||
track_videos: bool = Field(default=True)
|
track_videos: bool = Field(default=True)
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ async def _seed_default_templates() -> None:
|
|||||||
await _seed_provider_template(session, "scheduler", "Scheduler")
|
await _seed_provider_template(session, "scheduler", "Scheduler")
|
||||||
await _seed_provider_template(session, "nut", "NUT")
|
await _seed_provider_template(session, "nut", "NUT")
|
||||||
await _seed_provider_template(session, "google_photos", "Google Photos")
|
await _seed_provider_template(session, "google_photos", "Google Photos")
|
||||||
|
await _seed_provider_template(session, "webhook", "Generic Webhook")
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -179,6 +180,9 @@ async def _seed_default_command_templates() -> None:
|
|||||||
await _seed_provider_command_template(
|
await _seed_provider_command_template(
|
||||||
session, "google_photos", "Default Google Photos Commands", "Default Google Photos command templates",
|
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()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -229,6 +233,11 @@ async def _seed_default_tracking_configs() -> None:
|
|||||||
"name": "Default Scheduler",
|
"name": "Default Scheduler",
|
||||||
"track_scheduled_message": True,
|
"track_scheduled_message": True,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"provider_type": "webhook",
|
||||||
|
"name": "Default Webhook",
|
||||||
|
"track_webhook_received": True,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"provider_type": "nut",
|
"provider_type": "nut",
|
||||||
"name": "Default NUT",
|
"name": "Default NUT",
|
||||||
@@ -309,6 +318,14 @@ async def _seed_default_command_configs() -> None:
|
|||||||
"default_count": 5,
|
"default_count": 5,
|
||||||
"rate_limits": {"api": 15, "default": 10},
|
"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",
|
"provider_type": "google_photos",
|
||||||
"name": "Default Google Photos",
|
"name": "Default Google Photos",
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
|||||||
"task_completed": tc.track_task_completed,
|
"task_completed": tc.track_task_completed,
|
||||||
# Scheduler events
|
# Scheduler events
|
||||||
"scheduled_message": tc.track_scheduled_message,
|
"scheduled_message": tc.track_scheduled_message,
|
||||||
|
# Generic Webhook events
|
||||||
|
"webhook_received": tc.track_webhook_received,
|
||||||
# NUT (UPS) events
|
# NUT (UPS) events
|
||||||
"ups_online": tc.track_ups_online,
|
"ups_online": tc.track_ups_online,
|
||||||
"ups_on_battery": tc.track_ups_on_battery,
|
"ups_on_battery": tc.track_ups_on_battery,
|
||||||
|
|||||||
@@ -189,4 +189,8 @@ _SAMPLE_CONTEXT = {
|
|||||||
"custom_vars": {"team": "Engineering", "message": "Time for standup!"},
|
"custom_vars": {"team": "Engineering", "message": "Time for standup!"},
|
||||||
"team": "Engineering",
|
"team": "Engineering",
|
||||||
"message": "Time for standup!",
|
"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",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user