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:
@@ -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}":'},
|
||||
|
||||
Reference in New Issue
Block a user