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
@@ -192,31 +192,40 @@ async def handle_command(
for _, _, provider in ctx_tuples:
providers_map[provider.id] = provider
# Dispatch
# Dispatch — each handler returns (fallback_text, template_context)
# Template is tried first; if no template, fallback is returned.
if cmd == "help":
return _cmd_help(enabled, locale)
if cmd == "status":
return await _cmd_status(bot, providers_map, locale)
if cmd == "albums":
return await _cmd_albums(bot, providers_map, locale)
if cmd == "events":
return await _cmd_events(bot, providers_map, count, locale)
if cmd == "people":
return await _cmd_people(providers_map, locale)
if cmd in ("search", "find", "person", "place", "latest", "random",
"favorites", "summary", "memory"):
return await _cmd_immich(bot, cmd, args, count, locale, response_mode, providers_map)
fallback, ctx = _cmd_help(enabled, locale)
elif cmd == "status":
fallback, ctx = await _cmd_status(bot, providers_map, locale)
elif cmd == "albums":
fallback, ctx = await _cmd_albums(bot, providers_map, locale)
elif cmd == "events":
fallback, ctx = await _cmd_events(bot, providers_map, count, locale)
elif cmd == "people":
fallback, ctx = await _cmd_people(providers_map, locale)
elif cmd in ("search", "find", "person", "place", "latest", "random",
"favorites", "summary", "memory"):
return await _cmd_immich(bot, cmd, args, count, locale, response_mode, providers_map, cmd_templates)
else:
return None
return None
# Try template, fall back to hardcoded
rendered = _render_cmd_template(cmd_templates, cmd, {**ctx, "locale": locale})
return rendered if rendered else fallback
def _cmd_help(enabled: list[str], locale: str) -> str:
def _cmd_help(enabled: list[str], locale: str) -> tuple[str, dict]:
commands = []
lines = []
for cmd in enabled:
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
lines.append(f"/{cmd}{desc.get(locale, desc.get('en', ''))}")
desc_text = desc.get(locale, desc.get("en", ""))
commands.append({"name": cmd, "description": desc_text})
lines.append(f"/{cmd}{desc_text}")
header = {"en": "Available commands:", "ru": "Доступные команды:"}
return header.get(locale, header["en"]) + "\n" + "\n".join(lines)
fallback = header.get(locale, header["en"]) + "\n" + "\n".join(lines)
return fallback, {"commands": commands}
async def _get_notification_trackers_for_providers(
@@ -264,7 +273,7 @@ async def _check_native_memory(bot: TelegramBot) -> bool:
return False
async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> str:
async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> tuple[str, dict]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
active = sum(1 for t in trackers if t.enabled)
@@ -279,27 +288,33 @@ async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider
last_event = result.first()
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
ctx = {"trackers_active": active, "trackers_total": total, "total_albums": total_albums, "last_event": last_str}
if locale == "ru":
return (
fallback = (
f"📊 Статус\n"
f"Трекеры: {active}/{total} активных\n"
f"Альбомы: {total_albums}\n"
f"Последнее событие: {last_str}"
)
return (
f"📊 Status\n"
f"Trackers: {active}/{total} active\n"
f"Albums: {total_albums}\n"
f"Last event: {last_str}"
)
else:
fallback = (
f"📊 Status\n"
f"Trackers: {active}/{total} active\n"
f"Albums: {total_albums}\n"
f"Last event: {last_str}"
)
return fallback, ctx
async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> str:
async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> tuple[str, dict]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
if not trackers:
return "No tracked albums." if locale == "en" else "Нет отслеживаемых альбомов."
fallback = "No tracked albums." if locale == "en" else "Нет отслеживаемых альбомов."
return fallback, {"albums": []}
albums_data: list[dict] = []
lines = []
async with aiohttp.ClientSession() as http:
for tracker in trackers:
@@ -311,20 +326,23 @@ async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider
try:
album = await immich.client.get_album(album_id)
if album:
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
lines.append(f"{album.name} ({album.asset_count} assets)")
except Exception:
lines.append(f"{album_id[:8]}... (error)")
header = "📚 Tracked albums:" if locale == "en" else "📚 Отслеживаемые альбомы:"
return header + "\n" + "\n".join(lines) if lines else header + "\n (none)"
fallback = header + "\n" + "\n".join(lines) if lines else header + "\n (none)"
return fallback, {"albums": albums_data}
async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> str:
async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> tuple[str, dict]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracker_ids = [t.id for t in trackers]
if not tracker_ids:
return "No events." if locale == "en" else "Нет событий."
fallback = "No events." if locale == "en" else "Нет событий."
return fallback, {"events": []}
engine = get_engine()
async with AsyncSession(engine) as session:
@@ -337,17 +355,22 @@ async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider
events = result.all()
if not events:
return "No events yet." if locale == "en" else "Пока нет событий."
fallback = "No events yet." if locale == "en" else "Пока нет событий."
return fallback, {"events": []}
events_data = [{"type": e.event_type, "album": e.collection_name, "count": e.assets_count,
"date": e.created_at.strftime("%m/%d %H:%M")} for e in events]
header = f"📋 Last {len(events)} events:" if locale == "en" else f"📋 Последние {len(events)} событий:"
lines = []
for e in events:
ts = e.created_at.strftime("%m/%d %H:%M")
lines.append(f" {ts}{e.event_type}: {e.collection_name}")
return header + "\n" + "\n".join(lines)
fallback = header + "\n" + "\n".join(lines)
return fallback, {"events": events_data}
async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> str:
async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> tuple[str, dict]:
all_people: dict[str, str] = {}
async with aiohttp.ClientSession() as http:
@@ -359,16 +382,19 @@ async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) ->
all_people.update(people)
if not all_people:
return "No people detected." if locale == "en" else "Люди не обнаружены."
fallback = "No people detected." if locale == "en" else "Люди не обнаружены."
return fallback, {"people": []}
names = sorted(all_people.values())
header = f"👥 {len(names)} people:" if locale == "en" else f"👥 {len(names)} людей:"
return header + "\n" + ", ".join(names)
fallback = header + "\n" + ", ".join(names)
return fallback, {"people": names}
async def _cmd_immich(
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
response_mode: str, providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, str] | None = None,
) -> str | list[dict[str, Any]]:
"""Handle commands that need Immich API access and may return media."""
if not providers_map:
@@ -398,13 +424,13 @@ async def _cmd_immich(
if not args:
return "Usage: /search <query>" if locale == "en" else "Использование: /search <запрос>"
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "find":
if not args:
return "Usage: /find <text>" if locale == "en" else "Использование: /find <текст>"
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "person":
if not args:
@@ -418,7 +444,7 @@ async def _cmd_immich(
if not person_id:
return f"Person '{args}' not found." if locale == "en" else f"Человек '{args}' не найден."
assets = await client.search_by_person(person_id, limit=count)
return _format_assets(assets, cmd, args, locale, response_mode, client)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "place":
if not args:
@@ -426,7 +452,7 @@ async def _cmd_immich(
assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count
)
return _format_assets(assets, cmd, args, locale, response_mode, client)
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
if cmd == "favorites":
fav_assets: list[dict[str, Any]] = []
@@ -444,7 +470,7 @@ async def _cmd_immich(
pass
if len(fav_assets) >= count:
break
return _format_assets(fav_assets, cmd, "", locale, response_mode, client)
return _format_assets(fav_assets, cmd, "", locale, response_mode, client, cmd_templates)
if cmd == "latest":
latest_assets: list[dict[str, Any]] = []
@@ -460,7 +486,7 @@ async def _cmd_immich(
except Exception:
pass
latest_assets.sort(key=lambda a: a.get("createdAt", ""), reverse=True)
return _format_assets(latest_assets[:count], cmd, "", locale, response_mode, client)
return _format_assets(latest_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
if cmd == "random":
random_assets: list[dict[str, Any]] = []
@@ -478,17 +504,22 @@ async def _cmd_immich(
except Exception:
pass
rng.shuffle(random_assets)
return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client)
return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
if cmd == "summary":
albums_data: list[dict] = []
lines = []
for album_id in all_album_ids:
try:
album = await client.get_album(album_id)
if album:
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
lines.append(f"{album.name}: {album.asset_count} assets")
except Exception:
pass
rendered = _render_cmd_template(cmd_templates or {}, "summary", {"albums": albums_data, "locale": locale})
if rendered:
return rendered
header = f"📋 Album summary ({len(lines)}):" if locale == "en" else f"📋 Сводка альбомов ({len(lines)}):"
return header + "\n" + "\n".join(lines) if lines else header
@@ -542,7 +573,7 @@ async def _cmd_immich(
memory_assets = memory_assets[:count]
if not memory_assets:
return "No memories for today." if locale == "en" else "Нет воспоминаний за сегодня."
return _format_assets(memory_assets, cmd, "", locale, response_mode, client)
return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
return "Unknown command." if locale == "en" else "Неизвестная команда."
@@ -550,9 +581,13 @@ async def _cmd_immich(
def _format_assets(
assets: list[dict[str, Any]], cmd: str, query: str,
locale: str, response_mode: str, client: Any,
cmd_templates: dict[str, str] | None = None,
) -> str | list[dict[str, Any]]:
"""Format asset results as text or media payload."""
if not assets:
rendered = _render_cmd_template(cmd_templates or {}, "no_results", {"command": cmd, "query": query, "locale": locale})
if rendered:
return rendered
return {"en": "No results found.", "ru": "Ничего не найдено."}.get(locale, "No results found.")
if response_mode == "media":
@@ -571,7 +606,17 @@ def _format_assets(
})
return media_items
# Text mode
# Text mode — try template first
# Map command names to template slot names (search/find/person/place share "search" slot)
slot_map = {"find": "search", "person": "search", "place": "search"}
slot_name = slot_map.get(cmd, cmd)
rendered = _render_cmd_template(cmd_templates or {}, slot_name, {
"assets": assets, "query": query, "command": cmd, "count": len(assets), "locale": locale,
})
if rendered:
return rendered
# Hardcoded fallback
header_map = {
"search": {"en": f'🔍 Results for "{query}":', "ru": f'🔍 Результаты для "{query}":'},
"find": {"en": f'📄 Files matching "{query}":', "ru": f'📄 Файлы по запросу "{query}":'},