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
@@ -17,10 +17,20 @@ _TEST_MESSAGES: dict[str, dict[str, str]] = {
"en": {
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
"webhook": "Test notification from Notify Bridge",
"email": "Test email from Notify Bridge",
"discord": "Test message from **Notify Bridge**",
"slack": "Test message from *Notify Bridge*",
"ntfy": "Test notification from Notify Bridge",
"matrix": "Test message from Notify Bridge",
},
"ru": {
"telegram": "\u2705 Тестовое сообщение от <b>Notify Bridge</b>",
"webhook": "Тестовое уведомление от Notify Bridge",
"email": "Тестовое письмо от Notify Bridge",
"discord": "Тестовое сообщение от **Notify Bridge**",
"slack": "Тестовое сообщение от *Notify Bridge*",
"ntfy": "Тестовое уведомление от Notify Bridge",
"matrix": "Тестовое сообщение от Notify Bridge",
},
}
@@ -50,12 +60,17 @@ async def send_to_target(target: NotificationTarget, message: str) -> dict:
"""
try:
receivers = await _load_receivers(target.id)
if target.type == "telegram":
return await _send_telegram_broadcast(target, message, receivers)
elif target.type == "webhook":
return await _send_webhook_broadcast(target, message, receivers)
elif target.type == "email":
return await _send_email_broadcast(target, message, receivers)
send_fn = {
"telegram": _send_telegram_broadcast,
"webhook": _send_webhook_broadcast,
"email": _send_email_broadcast,
"discord": _send_webhook_like_broadcast,
"slack": _send_webhook_like_broadcast,
"ntfy": _send_ntfy_broadcast,
"matrix": _send_matrix_broadcast,
}.get(target.type)
if send_fn:
return await send_fn(target, message, receivers)
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Send failed: %s", e)
@@ -188,6 +203,107 @@ async def _send_email_broadcast(target: NotificationTarget, message: str, receiv
return {"success": False, "error": "No valid email receivers"}
async def _send_webhook_like_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
"""Broadcast for Discord and Slack — both use webhook URLs as receivers."""
if not receivers:
webhook_url = target.config.get("webhook_url")
if webhook_url:
receivers = [{"webhook_url": webhook_url}]
else:
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
if target.type == "discord":
from notify_bridge_core.notifications.discord.client import DiscordClient
client = DiscordClient(session)
for recv in receivers:
url = recv.get("webhook_url")
if url:
results.append(await client.send(url, message, username=target.config.get("username")))
elif target.type == "slack":
from notify_bridge_core.notifications.slack.client import SlackClient
client = SlackClient(session)
for recv in receivers:
url = recv.get("webhook_url")
if url:
results.append(await client.send(url, message, username=target.config.get("username")))
return _aggregate(results)
async def _send_ntfy_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
"""Broadcast to ntfy topics."""
server_url = target.config.get("server_url", "https://ntfy.sh")
auth_token = target.config.get("auth_token")
if not receivers:
topic = target.config.get("topic")
if topic:
receivers = [{"topic": topic}]
else:
return {"success": False, "error": "No receivers configured"}
from notify_bridge_core.notifications.ntfy.client import NtfyClient
results: list[dict] = []
async with aiohttp.ClientSession() as session:
client = NtfyClient(session)
for recv in receivers:
topic = recv.get("topic")
if topic:
results.append(await client.send(
server_url, topic, message,
title="Notify Bridge",
priority=recv.get("priority", 3),
auth_token=auth_token,
))
return _aggregate(results)
async def _send_matrix_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
"""Broadcast to Matrix rooms."""
from notify_bridge_core.notifications.matrix.client import MatrixClient
from ..database.models import MatrixBot
matrix_bot_id = target.config.get("matrix_bot_id")
if not matrix_bot_id:
return {"success": False, "error": "No Matrix bot configured for this target"}
engine = get_engine()
async with AsyncSession(engine) as session:
bot = await session.get(MatrixBot, matrix_bot_id)
if not bot:
return {"success": False, "error": "Matrix bot not found"}
homeserver = bot.homeserver_url
access_token = bot.access_token
if not receivers:
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as http:
client = MatrixClient(http, homeserver, access_token)
for recv in receivers:
room_id = recv.get("room_id")
if room_id:
results.append(await client.send_message(room_id, message, html_message=message))
return _aggregate(results)
def _aggregate(results: list[dict]) -> dict:
"""Aggregate broadcast results."""
successes = sum(1 for r in results if r.get("success"))
if successes == len(results) and 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 valid receivers"}
# --- Public API used by routes ---
@@ -19,6 +19,7 @@ from ..database.engine import get_engine
from ..database.models import (
EmailBot,
EventLog,
MatrixBot,
NotificationTarget,
NotificationTracker,
NotificationTrackerState,
@@ -162,7 +163,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
template_slots[event_key] = tmpl_text
target_config = dict(target.config)
# Inject SMTP config for email targets from EmailBot
# Inject bot credentials for bot-backed target types
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
@@ -177,6 +178,13 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
link_data.append({
"target_type": target.type,