feat: Discord/Slack/ntfy/Matrix targets, command templates, delete protection, email/matrix bots

- Discord, Slack, ntfy, Matrix notification target types with clients and dispatch
- MatrixBot model + API + frontend in Bots tab
- Command template system fully wired into all handler commands
- Default command templates seeded (EN/RU, 14 slots each)
- Command template editor with variables reference including child fields
- Delete protection on all 10 entity types (409 with consumer details)
- Provider type selector on template config forms
- Target type selector as dropdown with all 7 types
- Response template selector on command config form
- CLAUDE.md: mandatory server restart rule, child properties rule
This commit is contained in:
2026-03-21 20:36:12 +03:00
parent 846d480d38
commit 3e3a6f0777
64 changed files with 1861 additions and 180 deletions
@@ -0,0 +1,5 @@
"""Discord webhook notification client."""
from .client import DiscordClient
__all__ = ["DiscordClient"]
@@ -0,0 +1,88 @@
"""Discord webhook client."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
_LOGGER = logging.getLogger(__name__)
# Discord webhook content limit
MAX_CONTENT_LENGTH = 2000
class DiscordClient:
"""Sends messages via Discord webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
async def send(
self,
webhook_url: str,
message: str,
username: str | None = None,
avatar_url: str | None = None,
) -> dict[str, Any]:
"""Send a message to a Discord webhook.
If message exceeds 2000 chars, it's split into chunks.
"""
if not webhook_url:
return {"success": False, "error": "Missing webhook_url"}
chunks = _split_message(message, MAX_CONTENT_LENGTH)
for chunk in chunks:
payload: dict[str, Any] = {"content": chunk}
if username:
payload["username"] = username
if avatar_url:
payload["avatar_url"] = avatar_url
result = await self._post(webhook_url, payload)
if not result["success"]:
return result
# Small delay between chunks to respect rate limits
if len(chunks) > 1:
await asyncio.sleep(0.5)
return {"success": True}
async def _post(self, url: str, payload: dict) -> dict[str, Any]:
try:
async with self._session.post(
url, json=payload, headers={"Content-Type": "application/json"}
) as resp:
if resp.status == 429:
retry_after = float(resp.headers.get("Retry-After", "2"))
_LOGGER.warning("Discord rate limited, retrying after %.1fs", retry_after)
await asyncio.sleep(retry_after)
return await self._post(url, payload)
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
def _split_message(text: str, limit: int) -> list[str]:
"""Split message into chunks respecting the character limit."""
if len(text) <= limit:
return [text]
chunks = []
while text:
if len(text) <= limit:
chunks.append(text)
break
# Try to split at newline
split_at = text.rfind("\n", 0, limit)
if split_at <= 0:
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip("\n")
return chunks
@@ -25,7 +25,7 @@ DEFAULT_TEMPLATE = '{{ event_type }}: "{{ collection_name }}"'
class TargetConfig:
"""Configuration for a notification target."""
type: str # "telegram", "webhook", or "email"
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
template_slots: dict[str, str] | None = None # event_type -> template string
date_format: str = "%d.%m.%Y, %H:%M UTC"
@@ -87,12 +87,17 @@ class NotificationDispatcher:
)
message = render_template(template_str, ctx)
if target.type == "telegram":
return await self._send_telegram(target, message, event)
elif target.type == "webhook":
return await self._send_webhook(target, message, event)
elif target.type == "email":
return await self._send_email(target, message, event)
send_method = {
"telegram": self._send_telegram,
"webhook": self._send_webhook,
"email": self._send_email,
"discord": self._send_discord,
"slack": self._send_slack,
"ntfy": self._send_ntfy,
"matrix": self._send_matrix,
}.get(target.type)
if send_method:
return await send_method(target, message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"}
async def _send_telegram(
@@ -172,15 +177,7 @@ class NotificationDispatcher:
results.append(text_result)
# Return aggregate result
successes = sum(1 for r in results if r.get("success"))
if successes == len(results):
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0] # All failed — return first error
return {"success": False, "error": "No receivers configured"}
return self._aggregate_results(results)
async def _send_webhook(
self, target: TargetConfig, message: str, event: ServiceEvent
@@ -208,14 +205,7 @@ class NotificationDispatcher:
client = WebhookClient(session, url, headers)
results.append(await client.send(payload))
successes = sum(1 for r in results if r.get("success"))
if successes == len(results):
return {"success": True, "receivers": len(results)}
elif successes > 0:
return {"success": True, "receivers": len(results), "partial_failures": len(results) - successes}
elif results:
return results[0]
return {"success": False, "error": "No receivers configured"}
return self._aggregate_results(results)
async def _send_email(
self, target: TargetConfig, message: str, event: ServiceEvent
@@ -254,6 +244,104 @@ class NotificationDispatcher:
)
results.append(result)
return self._aggregate_results(results)
async def _send_discord(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
from .discord.client import DiscordClient
receivers = target.receivers or [{"webhook_url": target.config.get("webhook_url", "")}]
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = DiscordClient(session)
for receiver in receivers:
webhook_url = receiver.get("webhook_url")
if not webhook_url:
results.append({"success": False, "error": "Missing webhook_url"})
continue
results.append(await client.send(webhook_url, message, username=username))
return self._aggregate_results(results)
async def _send_slack(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
from .slack.client import SlackClient
receivers = target.receivers or [{"webhook_url": target.config.get("webhook_url", "")}]
username = target.config.get("username")
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = SlackClient(session)
for receiver in receivers:
webhook_url = receiver.get("webhook_url")
if not webhook_url:
results.append({"success": False, "error": "Missing webhook_url"})
continue
results.append(await client.send(webhook_url, message, username=username))
return self._aggregate_results(results)
async def _send_ntfy(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
from .ntfy.client import NtfyClient
server_url = target.config.get("server_url", "https://ntfy.sh")
auth_token = target.config.get("auth_token")
receivers = target.receivers or [{"topic": target.config.get("topic", "")}]
title = f"{event.event_type.value}: {event.collection_name}"
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = NtfyClient(session)
for receiver in receivers:
topic = receiver.get("topic")
if not topic:
results.append({"success": False, "error": "Missing topic"})
continue
priority = receiver.get("priority", 3)
results.append(await client.send(
server_url, topic, message,
title=title, priority=priority, auth_token=auth_token,
))
return self._aggregate_results(results)
async def _send_matrix(
self, target: TargetConfig, message: str, event: ServiceEvent
) -> dict[str, Any]:
from .matrix.client import MatrixClient
homeserver = target.config.get("homeserver_url")
access_token = target.config.get("access_token")
if not homeserver or not access_token:
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
receivers = target.receivers or [{"room_id": target.config.get("room_id", "")}]
results: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as session:
client = MatrixClient(session, homeserver, access_token)
for receiver in receivers:
room_id = receiver.get("room_id")
if not room_id:
results.append({"success": False, "error": "Missing room_id"})
continue
results.append(await client.send_message(
room_id, message, html_message=message,
))
return self._aggregate_results(results)
@staticmethod
def _aggregate_results(results: list[dict[str, Any]]) -> dict[str, Any]:
"""Aggregate broadcast results into a single result dict."""
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and results:
return {"success": True, "receivers": len(results)}
@@ -0,0 +1,5 @@
"""Matrix notification client."""
from .client import MatrixClient
__all__ = ["MatrixClient"]
@@ -0,0 +1,79 @@
"""Matrix client-server API client for sending messages."""
from __future__ import annotations
import logging
import time
from typing import Any
import aiohttp
_LOGGER = logging.getLogger(__name__)
# Monotonically increasing transaction counter for idempotent sends
_txn_counter = int(time.time() * 1000)
def _next_txn_id() -> str:
global _txn_counter
_txn_counter += 1
return str(_txn_counter)
class MatrixClient:
"""Sends messages to Matrix rooms via the client-server API."""
def __init__(
self,
session: aiohttp.ClientSession,
homeserver_url: str,
access_token: str,
) -> None:
self._session = session
self._homeserver = homeserver_url.rstrip("/")
self._token = access_token
async def send_message(
self,
room_id: str,
message: str,
html_message: str | None = None,
) -> dict[str, Any]:
"""Send a text message to a Matrix room.
Args:
room_id: Internal room ID (e.g. !abc:matrix.org)
message: Plain text body
html_message: Optional HTML-formatted body
"""
if not room_id:
return {"success": False, "error": "Missing room_id"}
txn_id = _next_txn_id()
# URL-encode the room_id (! and : need encoding)
encoded_room = room_id.replace("!", "%21").replace(":", "%3A")
url = f"{self._homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
body: dict[str, Any] = {
"msgtype": "m.text",
"body": message,
}
if html_message:
body["format"] = "org.matrix.custom.html"
body["formatted_body"] = html_message
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
try:
async with self._session.put(url, json=body, headers=headers) as resp:
if 200 <= resp.status < 300:
return {"success": True}
resp_body = await resp.text()
if resp.status == 429:
_LOGGER.warning("Matrix rate limited: %s", resp_body[:200])
return {"success": False, "error": f"HTTP {resp.status}: {resp_body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
@@ -0,0 +1,5 @@
"""ntfy push notification client."""
from .client import NtfyClient
__all__ = ["NtfyClient"]
@@ -0,0 +1,60 @@
"""ntfy push notification client."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
_LOGGER = logging.getLogger(__name__)
class NtfyClient:
"""Sends push notifications via ntfy server."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
async def send(
self,
server_url: str,
topic: str,
message: str,
title: str | None = None,
priority: int = 3,
tags: list[str] | None = None,
click_url: str | None = None,
auth_token: str | None = None,
) -> dict[str, Any]:
"""Send a push notification to an ntfy topic."""
if not server_url or not topic:
return {"success": False, "error": "Missing server_url or topic"}
url = f"{server_url.rstrip('/')}"
payload: dict[str, Any] = {
"topic": topic,
"message": message,
"markdown": True,
}
if title:
payload["title"] = title
if priority != 3:
payload["priority"] = priority
if tags:
payload["tags"] = tags
if click_url:
payload["click"] = click_url
headers: dict[str, str] = {"Content-Type": "application/json"}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
try:
async with self._session.post(url, json=payload, headers=headers) as resp:
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
@@ -0,0 +1,5 @@
"""Slack webhook notification client."""
from .client import SlackClient
__all__ = ["SlackClient"]
@@ -0,0 +1,50 @@
"""Slack incoming webhook client."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
_LOGGER = logging.getLogger(__name__)
class SlackClient:
"""Sends messages via Slack incoming webhook URLs."""
def __init__(self, session: aiohttp.ClientSession) -> None:
self._session = session
async def send(
self,
webhook_url: str,
message: str,
username: str | None = None,
icon_emoji: str | None = None,
) -> dict[str, Any]:
"""Send a message to a Slack incoming webhook."""
if not webhook_url:
return {"success": False, "error": "Missing webhook_url"}
payload: dict[str, Any] = {"text": message}
if username:
payload["username"] = username
if icon_emoji:
payload["icon_emoji"] = icon_emoji
try:
async with self._session.post(
webhook_url,
json=payload,
headers={"Content-Type": "application/json"},
) as resp:
if resp.status == 429:
_LOGGER.warning("Slack rate limited")
return {"success": False, "error": "Rate limited by Slack"}
if 200 <= resp.status < 300:
return {"success": True}
body = await resp.text()
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
except aiohttp.ClientError as e:
return {"success": False, "error": str(e)}
@@ -0,0 +1,5 @@
"""Default command template loader."""
from .loader import load_default_command_templates
__all__ = ["load_default_command_templates"]
@@ -0,0 +1,8 @@
📚 Tracked albums:
{%- if albums %}
{%- for album in albums %}
• {{ album.name }} ({{ album.asset_count }} assets)
{%- endfor %}
{%- else %}
(none)
{%- endif %}
@@ -0,0 +1,7 @@
📋 Last {{ events | length }} events:
{%- for event in events %}
{{ event.date }} — {{ event.type }}: {{ event.album }}
{%- endfor %}
{%- if not events %}
No events yet.
{%- endif %}
@@ -0,0 +1,4 @@
⭐ Favorites:
{%- for asset in assets %}
• {{ asset.originalFileName }}
{%- endfor %}
@@ -0,0 +1,4 @@
Available commands:
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- endfor %}
@@ -0,0 +1,4 @@
📸 Latest:
{%- for asset in assets %}
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- endfor %}
@@ -0,0 +1,4 @@
📅 On this day:
{%- for asset in assets %}
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- endfor %}
@@ -0,0 +1 @@
No results found.
@@ -0,0 +1,5 @@
👥 {{ people | length }} people:
{{ people | join(", ") }}
{%- if not people %}
No people detected.
{%- endif %}
@@ -0,0 +1,4 @@
🎲 Random:
{%- for asset in assets %}
• {{ asset.originalFileName }}
{%- endfor %}
@@ -0,0 +1 @@
Please wait {{ wait }}s before using this command again.
@@ -0,0 +1,8 @@
{%- if command == "find" %}📄 Files matching "{{ query }}":
{%- elif command == "person" %}👤 Photos of {{ query }}:
{%- elif command == "place" %}📍 Photos from {{ query }}:
{%- else %}🔍 Results for "{{ query }}":
{%- endif %}
{%- for asset in assets %}
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- endfor %}
@@ -0,0 +1 @@
Hi! I'm your Notify Bridge bot. Use /help to see available commands.
@@ -0,0 +1,4 @@
📊 Status
Trackers: {{ trackers_active }}/{{ trackers_total }} active
Albums: {{ total_albums }}
Last event: {{ last_event }}
@@ -0,0 +1,4 @@
📋 Album summary ({{ albums | length }}):
{%- for album in albums %}
• {{ album.name }}: {{ album.asset_count }} assets
{%- endfor %}
@@ -0,0 +1,36 @@
"""Load default command templates from .jinja2 files."""
import logging
from pathlib import Path
_LOGGER = logging.getLogger(__name__)
_DEFAULTS_DIR = Path(__file__).parent
# All command template slot names (file stem = slot name)
COMMAND_SLOT_NAMES = [
"start", "help", "status", "albums", "events", "people",
"search", "latest", "favorites", "random", "summary", "memory",
"rate_limited", "no_results",
]
def load_default_command_templates(locale: str = "en") -> dict[str, str]:
"""Load default command template strings for a locale.
Returns dict mapping slot_name -> template string.
"""
locale_dir = _DEFAULTS_DIR / locale
if not locale_dir.is_dir():
_LOGGER.warning("No default command templates for locale '%s'", locale)
return {}
templates: dict[str, str] = {}
for slot_name in COMMAND_SLOT_NAMES:
filepath = locale_dir / f"{slot_name}.jinja2"
if filepath.exists():
templates[slot_name] = filepath.read_text(encoding="utf-8").strip()
else:
_LOGGER.debug("Missing default command template: %s/%s.jinja2", locale, slot_name)
return templates
@@ -0,0 +1,8 @@
📚 Отслеживаемые альбомы:
{%- if albums %}
{%- for album in albums %}
• {{ album.name }} ({{ album.asset_count }} файлов)
{%- endfor %}
{%- else %}
(нет)
{%- endif %}
@@ -0,0 +1,7 @@
📋 Последние {{ events | length }} событий:
{%- for event in events %}
{{ event.date }} — {{ event.type }}: {{ event.album }}
{%- endfor %}
{%- if not events %}
Пока нет событий.
{%- endif %}
@@ -0,0 +1,4 @@
⭐ Избранное:
{%- for asset in assets %}
• {{ asset.originalFileName }}
{%- endfor %}
@@ -0,0 +1,4 @@
Доступные команды:
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- endfor %}
@@ -0,0 +1,4 @@
📸 Последние:
{%- for asset in assets %}
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- endfor %}
@@ -0,0 +1,4 @@
📅 В этот день:
{%- for asset in assets %}
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- endfor %}
@@ -0,0 +1 @@
Ничего не найдено.
@@ -0,0 +1,5 @@
👥 {{ people | length }} людей:
{{ people | join(", ") }}
{%- if not people %}
Люди не обнаружены.
{%- endif %}
@@ -0,0 +1,4 @@
🎲 Случайные:
{%- for asset in assets %}
• {{ asset.originalFileName }}
{%- endfor %}
@@ -0,0 +1 @@
Подождите {{ wait }} сек. перед повторным использованием.
@@ -0,0 +1,8 @@
{%- if command == "find" %}📄 Файлы по запросу "{{ query }}":
{%- elif command == "person" %}👤 Фото {{ query }}:
{%- elif command == "place" %}📍 Фото из {{ query }}:
{%- else %}🔍 Результаты для "{{ query }}":
{%- endif %}
{%- for asset in assets %}
• {{ asset.originalFileName }}{% if asset.year %} ({{ asset.year }}){% endif %}
{%- endfor %}
@@ -0,0 +1 @@
Привет! Я бот Notify Bridge. Используйте /help для списка команд.
@@ -0,0 +1,4 @@
📊 Статус
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
Альбомы: {{ total_albums }}
Последнее событие: {{ last_event }}
@@ -0,0 +1,4 @@
📋 Сводка альбомов ({{ albums | length }}):
{%- for album in albums %}
• {{ album.name }}: {{ album.asset_count }} файлов
{%- endfor %}