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