refactor: provider-agnostic bot command system + Gitea commands
Refactored the monolithic command handler (707 lines) into a pluggable provider-handler architecture: - Abstract ProviderCommandHandler interface (base.py) - Handler dispatch registry routes commands by provider type - Extracted all Immich logic into ImmichCommandHandler - New GiteaCommandHandler with /status, /repos, /issues, /prs, /commits - Multi-provider routing: groups context by provider type, finds handler - handler.py reduced to ~280 line thin orchestrator Gitea commands: - Extended GiteaClient with get_repo_issues, get_repo_pulls, get_repo_commits - 30 Jinja2 command templates (15 EN + 15 RU) - Gitea capabilities updated with 6 commands + 15 command_slots - Default command config + command template config seeded on startup - Rate limiting: Gitea API commands share "api" category (15s cooldown) Also: - Command configs API accepts "gitea" provider type - System command configs (user_id=0) visible to all users - Webhook URL shown on Gitea provider card and edit form - Scan interval hidden for webhook-based providers
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""Abstract provider command handler interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from ..database.models import CommandTracker, CommandConfig, ServiceProvider, TelegramBot
|
||||
|
||||
|
||||
class ProviderCommandHandler(ABC):
|
||||
"""Base class for provider-specific bot command handlers.
|
||||
|
||||
Each provider (Immich, Gitea, etc.) implements this interface to handle
|
||||
its own set of commands. The dispatch layer routes commands to the
|
||||
correct handler based on the provider type.
|
||||
"""
|
||||
|
||||
provider_type: str
|
||||
|
||||
@abstractmethod
|
||||
def get_provider_commands(self) -> set[str]:
|
||||
"""Return the set of command names this handler owns.
|
||||
|
||||
These are provider-specific commands (e.g., 'albums' for Immich,
|
||||
'repos' for Gitea). Universal commands like 'help' and 'start'
|
||||
are handled by the main dispatcher.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def handle(
|
||||
self,
|
||||
cmd: str,
|
||||
args: str,
|
||||
count: int,
|
||||
locale: str,
|
||||
response_mode: str,
|
||||
providers_map: dict[int, ServiceProvider],
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
bot: TelegramBot,
|
||||
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
) -> str | list[dict[str, Any]] | None:
|
||||
"""Handle a provider-specific command.
|
||||
|
||||
Args:
|
||||
cmd: The command name (without '/').
|
||||
args: Arguments after the command.
|
||||
count: Number of results to return.
|
||||
locale: User's locale ('en', 'ru').
|
||||
response_mode: 'media' or 'text'.
|
||||
providers_map: Provider instances keyed by ID.
|
||||
cmd_templates: Template slots {slot_name: {locale: template}}.
|
||||
bot: The Telegram bot instance.
|
||||
ctx_tuples: Command context tuples for this provider type.
|
||||
|
||||
Returns:
|
||||
Text response, media list, or None if unhandled.
|
||||
"""
|
||||
|
||||
def get_rate_categories(self) -> dict[str, str]:
|
||||
"""Return rate limit category mapping for this provider's commands.
|
||||
|
||||
Keys are command names, values are category strings.
|
||||
Commands not listed default to 'default' category.
|
||||
"""
|
||||
return {}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Provider command handler registry and routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .base import ProviderCommandHandler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REGISTRY: dict[str, ProviderCommandHandler] = {}
|
||||
|
||||
|
||||
def register_handler(handler: ProviderCommandHandler) -> None:
|
||||
"""Register a provider command handler."""
|
||||
_REGISTRY[handler.provider_type] = handler
|
||||
_LOGGER.debug("Registered command handler for provider type: %s", handler.provider_type)
|
||||
|
||||
|
||||
def get_handler(provider_type: str) -> ProviderCommandHandler | None:
|
||||
"""Get the command handler for a provider type."""
|
||||
return _REGISTRY.get(provider_type)
|
||||
|
||||
|
||||
def get_all_handlers() -> dict[str, ProviderCommandHandler]:
|
||||
"""Get all registered handlers."""
|
||||
return dict(_REGISTRY)
|
||||
|
||||
|
||||
def _auto_register() -> None:
|
||||
"""Auto-register all built-in handlers."""
|
||||
from .immich_handler import ImmichCommandHandler
|
||||
from .gitea_handler import GiteaCommandHandler
|
||||
|
||||
register_handler(ImmichCommandHandler())
|
||||
register_handler(GiteaCommandHandler())
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
_auto_register()
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Gitea-specific bot command handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
CommandConfig, CommandTracker, EventLog,
|
||||
NotificationTracker, ServiceProvider, TelegramBot,
|
||||
)
|
||||
from ..services import make_gitea_provider
|
||||
from .base import ProviderCommandHandler
|
||||
from .handler import _render_cmd_template, _get_notification_trackers_for_providers
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_GITEA_COMMANDS = {"status", "repos", "issues", "prs", "commits"}
|
||||
|
||||
|
||||
class GiteaCommandHandler(ProviderCommandHandler):
|
||||
"""Handles Gitea-specific bot commands."""
|
||||
|
||||
provider_type = "gitea"
|
||||
|
||||
def get_provider_commands(self) -> set[str]:
|
||||
return _GITEA_COMMANDS
|
||||
|
||||
def get_rate_categories(self) -> dict[str, str]:
|
||||
return {
|
||||
"repos": "api", "issues": "api",
|
||||
"prs": "api", "commits": "api",
|
||||
}
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
cmd: str,
|
||||
args: str,
|
||||
count: int,
|
||||
locale: str,
|
||||
response_mode: str,
|
||||
providers_map: dict[int, ServiceProvider],
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
bot: TelegramBot,
|
||||
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
) -> str | list[dict[str, Any]] | None:
|
||||
if cmd == "status":
|
||||
ctx = await _cmd_status(providers_map)
|
||||
return _render_cmd_template(cmd_templates, "status", locale, ctx)
|
||||
if cmd == "repos":
|
||||
ctx = await _cmd_repos(providers_map)
|
||||
return _render_cmd_template(cmd_templates, "repos", locale, ctx)
|
||||
if cmd == "issues":
|
||||
ctx = await _cmd_issues(providers_map, count)
|
||||
return _render_cmd_template(cmd_templates, "issues", locale, ctx)
|
||||
if cmd == "prs":
|
||||
ctx = await _cmd_prs(providers_map, count)
|
||||
return _render_cmd_template(cmd_templates, "prs", locale, ctx)
|
||||
if cmd == "commits":
|
||||
ctx = await _cmd_commits(providers_map, count)
|
||||
return _render_cmd_template(cmd_templates, "commits", locale, ctx)
|
||||
return None
|
||||
|
||||
|
||||
def _get_tracked_repos(
|
||||
providers_map: dict[int, ServiceProvider],
|
||||
trackers: list[NotificationTracker],
|
||||
) -> list[tuple[ServiceProvider, str, str]]:
|
||||
"""Get (provider, owner, repo) tuples from tracked collection_ids."""
|
||||
repos: list[tuple[ServiceProvider, str, str]] = []
|
||||
for tracker in trackers:
|
||||
provider = providers_map.get(tracker.provider_id)
|
||||
if not provider or provider.type != "gitea":
|
||||
continue
|
||||
if not provider.config.get("api_token"):
|
||||
continue
|
||||
for full_name in (tracker.collection_ids or []):
|
||||
parts = full_name.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
repos.append((provider, parts[0], parts[1]))
|
||||
# Also check filters.collections
|
||||
for tracker in trackers:
|
||||
provider = providers_map.get(tracker.provider_id)
|
||||
if not provider or provider.type != "gitea":
|
||||
continue
|
||||
if not provider.config.get("api_token"):
|
||||
continue
|
||||
for full_name in (tracker.filters or {}).get("collections", []):
|
||||
parts = full_name.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
entry = (provider, parts[0], parts[1])
|
||||
if entry not in repos:
|
||||
repos.append(entry)
|
||||
return repos[:20] # Cap to prevent API hammering
|
||||
|
||||
|
||||
async def _cmd_status(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
tracked_repos = _get_tracked_repos(providers_map, trackers)
|
||||
|
||||
# Get server version from first Gitea provider with token
|
||||
server_version = "unknown"
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider in providers_map.values():
|
||||
if provider.type == "gitea" and provider.config.get("api_token"):
|
||||
gitea = make_gitea_provider(http, provider)
|
||||
version = await gitea.client.get_server_version()
|
||||
if version:
|
||||
server_version = version
|
||||
break
|
||||
|
||||
# Last event
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
tracker_ids = [t.id for t in trackers]
|
||||
if tracker_ids:
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc()).limit(1)
|
||||
)
|
||||
last_event = result.first()
|
||||
else:
|
||||
last_event = None
|
||||
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
||||
|
||||
return {
|
||||
"repos_count": len(tracked_repos),
|
||||
"server_version": server_version,
|
||||
"last_event": last_str,
|
||||
}
|
||||
|
||||
|
||||
async def _cmd_repos(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
tracked_repos = _get_tracked_repos(providers_map, trackers)
|
||||
|
||||
repos_data: list[dict[str, Any]] = []
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider, owner, repo in tracked_repos:
|
||||
gitea = make_gitea_provider(http, provider)
|
||||
try:
|
||||
all_repos = await gitea.client.get_repos(limit=50)
|
||||
for r in all_repos:
|
||||
if r.get("full_name") == f"{owner}/{repo}":
|
||||
repos_data.append({
|
||||
"full_name": r.get("full_name", ""),
|
||||
"description": r.get("description", ""),
|
||||
"stars": r.get("stars_count", 0),
|
||||
"url": r.get("html_url", ""),
|
||||
})
|
||||
break
|
||||
else:
|
||||
repos_data.append({
|
||||
"full_name": f"{owner}/{repo}",
|
||||
"description": "",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
})
|
||||
except Exception:
|
||||
repos_data.append({
|
||||
"full_name": f"{owner}/{repo}",
|
||||
"description": "?",
|
||||
"stars": 0,
|
||||
"url": "",
|
||||
})
|
||||
|
||||
return {"repos": repos_data}
|
||||
|
||||
|
||||
async def _cmd_issues(
|
||||
providers_map: dict[int, ServiceProvider], count: int,
|
||||
) -> dict[str, Any]:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
tracked_repos = _get_tracked_repos(providers_map, trackers)
|
||||
|
||||
all_issues: list[dict[str, Any]] = []
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider, owner, repo in tracked_repos:
|
||||
gitea = make_gitea_provider(http, provider)
|
||||
issues = await gitea.client.get_repo_issues(owner, repo, limit=count)
|
||||
for issue in issues:
|
||||
all_issues.append({
|
||||
"repo": f"{owner}/{repo}",
|
||||
"number": issue.get("number", 0),
|
||||
"title": issue.get("title", ""),
|
||||
"url": issue.get("html_url", ""),
|
||||
"user": issue.get("user", {}).get("login", ""),
|
||||
"state": issue.get("state", ""),
|
||||
})
|
||||
|
||||
all_issues.sort(key=lambda i: i.get("number", 0), reverse=True)
|
||||
return {"issues": all_issues[:count]}
|
||||
|
||||
|
||||
async def _cmd_prs(
|
||||
providers_map: dict[int, ServiceProvider], count: int,
|
||||
) -> dict[str, Any]:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
tracked_repos = _get_tracked_repos(providers_map, trackers)
|
||||
|
||||
all_prs: list[dict[str, Any]] = []
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider, owner, repo in tracked_repos:
|
||||
gitea = make_gitea_provider(http, provider)
|
||||
prs = await gitea.client.get_repo_pulls(owner, repo, limit=count)
|
||||
for pr in prs:
|
||||
all_prs.append({
|
||||
"repo": f"{owner}/{repo}",
|
||||
"number": pr.get("number", 0),
|
||||
"title": pr.get("title", ""),
|
||||
"url": pr.get("html_url", ""),
|
||||
"user": pr.get("user", {}).get("login", ""),
|
||||
"state": pr.get("state", ""),
|
||||
})
|
||||
|
||||
all_prs.sort(key=lambda p: p.get("number", 0), reverse=True)
|
||||
return {"prs": all_prs[:count]}
|
||||
|
||||
|
||||
async def _cmd_commits(
|
||||
providers_map: dict[int, ServiceProvider], count: int,
|
||||
) -> dict[str, Any]:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
tracked_repos = _get_tracked_repos(providers_map, trackers)
|
||||
|
||||
all_commits: list[dict[str, Any]] = []
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider, owner, repo in tracked_repos:
|
||||
gitea = make_gitea_provider(http, provider)
|
||||
commits = await gitea.client.get_repo_commits(owner, repo, limit=count)
|
||||
for c in commits:
|
||||
commit_data = c.get("commit", {})
|
||||
all_commits.append({
|
||||
"repo": f"{owner}/{repo}",
|
||||
"short_id": c.get("sha", "")[:7],
|
||||
"message": commit_data.get("message", "").split("\n")[0][:80],
|
||||
"author": commit_data.get("author", {}).get("name", ""),
|
||||
"url": c.get("html_url", ""),
|
||||
})
|
||||
|
||||
return {"commits": all_commits[:count]}
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Telegram bot command handler — implements all /commands."""
|
||||
"""Telegram bot command handler — provider-agnostic dispatcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random as rng
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -14,7 +12,6 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
|
||||
from ..database.engine import get_engine
|
||||
from ..services import make_immich_provider
|
||||
from ..database.models import (
|
||||
CommandConfig,
|
||||
CommandTemplateConfig,
|
||||
@@ -22,12 +19,9 @@ from ..database.models import (
|
||||
CommandTracker,
|
||||
CommandTrackerListener,
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
TrackingConfig,
|
||||
)
|
||||
from .parser import parse_command
|
||||
from .registry import get_rate_category
|
||||
@@ -90,7 +84,6 @@ async def _resolve_command_context(
|
||||
"""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
# Find all listeners for this bot
|
||||
result = await session.exec(
|
||||
select(CommandTrackerListener).where(
|
||||
CommandTrackerListener.listener_type == "telegram_bot",
|
||||
@@ -115,19 +108,20 @@ async def _resolve_command_context(
|
||||
continue
|
||||
tuples.append((tracker, config, provider))
|
||||
|
||||
# Load command template slots from the first config that has one
|
||||
# Load command template slots — merge from all configs
|
||||
cmd_template_slots: dict[str, dict[str, str]] = {}
|
||||
seen_config_ids: set[int] = set()
|
||||
for _, config, _ in tuples:
|
||||
if config.command_template_config_id:
|
||||
cfg_id = config.command_template_config_id
|
||||
if cfg_id and cfg_id not in seen_config_ids:
|
||||
seen_config_ids.add(cfg_id)
|
||||
slot_result = await session.exec(
|
||||
select(CommandTemplateSlot).where(
|
||||
CommandTemplateSlot.config_id == config.command_template_config_id
|
||||
CommandTemplateSlot.config_id == cfg_id
|
||||
)
|
||||
)
|
||||
for s in slot_result.all():
|
||||
cmd_template_slots.setdefault(s.slot_name, {})[s.locale] = s.template
|
||||
if cmd_template_slots:
|
||||
break
|
||||
|
||||
return tuples, cmd_template_slots
|
||||
|
||||
@@ -135,19 +129,14 @@ async def _resolve_command_context(
|
||||
def _merge_command_context(
|
||||
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
) -> tuple[list[str], str, int, dict[str, Any]]:
|
||||
"""Merge enabled_commands from all configs and pick defaults from first config.
|
||||
|
||||
Returns (enabled_commands, response_mode, default_count, rate_limits).
|
||||
"""
|
||||
"""Merge enabled_commands from all configs and pick defaults from first config."""
|
||||
if not ctx:
|
||||
return [], "media", 5, {}
|
||||
|
||||
# Union of all enabled commands across configs
|
||||
enabled: set[str] = set()
|
||||
for _, config, _ in ctx:
|
||||
enabled.update(config.enabled_commands or [])
|
||||
|
||||
# Use first config's settings as defaults
|
||||
first_config = ctx[0][1]
|
||||
response_mode = first_config.response_mode or "media"
|
||||
default_count = first_config.default_count or 5
|
||||
@@ -162,10 +151,9 @@ async def handle_command(
|
||||
text: str,
|
||||
language_code: str = "",
|
||||
) -> str | list[dict[str, Any]] | None:
|
||||
"""Handle a bot command. Returns text response, media list, or None.
|
||||
"""Handle a bot command. Routes to provider-specific handlers.
|
||||
|
||||
language_code is the Telegram user's language (from message.from.language_code).
|
||||
Used to pick the right locale for template rendering.
|
||||
Returns text response, media list, or None.
|
||||
"""
|
||||
cmd, args, count_override = parse_command(text)
|
||||
if not cmd:
|
||||
@@ -174,10 +162,7 @@ async def handle_command(
|
||||
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
|
||||
enabled, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
|
||||
|
||||
# Derive locale from Telegram user language, falling back to "en"
|
||||
locale = language_code[:2].lower() if language_code else "en"
|
||||
# Only use locale if we actually have templates for it, otherwise fall back
|
||||
# (_render_cmd_template handles per-slot fallback, but let's normalize)
|
||||
if locale not in ("en", "ru"):
|
||||
locale = "en"
|
||||
|
||||
@@ -185,7 +170,7 @@ async def handle_command(
|
||||
return _render_cmd_template(cmd_templates, "start", locale, {"bot_name": bot.name})
|
||||
|
||||
if cmd not in enabled and cmd != "start":
|
||||
return None # Silently ignore disabled commands
|
||||
return None
|
||||
|
||||
# Rate limit check
|
||||
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
|
||||
@@ -199,24 +184,34 @@ async def handle_command(
|
||||
for _, _, provider in ctx_tuples:
|
||||
providers_map[provider.id] = provider
|
||||
|
||||
# Dispatch — each handler returns template context dict
|
||||
# Universal commands
|
||||
if cmd == "help":
|
||||
ctx = _cmd_help(enabled, locale, cmd_templates)
|
||||
elif cmd == "status":
|
||||
ctx = await _cmd_status(bot, providers_map, locale)
|
||||
elif cmd == "albums":
|
||||
ctx = await _cmd_albums(bot, providers_map, locale)
|
||||
elif cmd == "events":
|
||||
ctx = await _cmd_events(bot, providers_map, count, locale)
|
||||
elif cmd == "people":
|
||||
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 _render_cmd_template(cmd_templates, "help", locale, ctx)
|
||||
|
||||
return _render_cmd_template(cmd_templates, cmd, locale, {**ctx})
|
||||
# Provider-specific dispatch
|
||||
from .dispatch import get_handler
|
||||
|
||||
# Group ctx_tuples by provider type
|
||||
by_type: dict[str, list[tuple[CommandTracker, CommandConfig, ServiceProvider]]] = {}
|
||||
for t in ctx_tuples:
|
||||
ptype = t[2].type
|
||||
by_type.setdefault(ptype, []).append(t)
|
||||
|
||||
# Find which handler claims this command
|
||||
for ptype, ptuples in by_type.items():
|
||||
handler = get_handler(ptype)
|
||||
if handler and cmd in handler.get_provider_commands():
|
||||
# Build provider map filtered to this provider type
|
||||
pmap = {p.id: p for _, _, p in ptuples}
|
||||
result = await handler.handle(
|
||||
cmd, args, count, locale, response_mode,
|
||||
pmap, cmd_templates, bot, ptuples,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _cmd_help(
|
||||
@@ -245,325 +240,6 @@ async def _get_notification_trackers_for_providers(
|
||||
return list(result.all())
|
||||
|
||||
|
||||
async def _check_native_memory(bot: TelegramBot) -> bool:
|
||||
"""Check if any tracker-target linked to this bot uses native memory source."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(
|
||||
NotificationTarget.type == "telegram",
|
||||
NotificationTarget.user_id == bot.user_id,
|
||||
)
|
||||
)
|
||||
targets = result.all()
|
||||
bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
|
||||
if not bot_target_ids:
|
||||
return False
|
||||
tt_result = await session.exec(
|
||||
select(NotificationTrackerTarget).where(NotificationTrackerTarget.target_id.in_(bot_target_ids))
|
||||
)
|
||||
for tt in tt_result.all():
|
||||
if tt.tracking_config_id:
|
||||
tc = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
if tc and tc.memory_source == "native":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
|
||||
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)
|
||||
total = len(trackers)
|
||||
total_albums = sum(len(t.collection_ids or []) for t in trackers)
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog).order_by(EventLog.created_at.desc()).limit(1)
|
||||
)
|
||||
last_event = result.first()
|
||||
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
||||
|
||||
return {"trackers_active": active, "trackers_total": total, "total_albums": total_albums, "last_event": last_str}
|
||||
|
||||
|
||||
async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
if not trackers:
|
||||
return {"albums": []}
|
||||
|
||||
albums_data: list[dict] = []
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for tracker in trackers:
|
||||
provider = providers_map.get(tracker.provider_id)
|
||||
if not provider or provider.type != "immich":
|
||||
continue
|
||||
immich = make_immich_provider(http, provider)
|
||||
for album_id in (tracker.collection_ids or []):
|
||||
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})
|
||||
except Exception:
|
||||
albums_data.append({"name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id})
|
||||
|
||||
return {"albums": albums_data}
|
||||
|
||||
|
||||
async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> dict[str, Any]:
|
||||
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 {"events": []}
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(count)
|
||||
)
|
||||
events = result.all()
|
||||
|
||||
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]
|
||||
|
||||
return {"events": events_data}
|
||||
|
||||
|
||||
async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
|
||||
all_people: dict[str, str] = {}
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider in providers_map.values():
|
||||
if provider.type != "immich":
|
||||
continue
|
||||
immich = make_immich_provider(http, provider)
|
||||
people = await immich.client.get_people()
|
||||
all_people.update(people)
|
||||
|
||||
names = sorted(all_people.values())
|
||||
return {"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, dict[str, str]],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Handle commands that need Immich API access and may return media."""
|
||||
if not providers_map:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
|
||||
|
||||
# Get notification trackers for album data
|
||||
provider_ids = set(providers_map.keys())
|
||||
notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
|
||||
all_album_ids: list[str] = []
|
||||
for t in notification_trackers:
|
||||
all_album_ids.extend(t.collection_ids or [])
|
||||
|
||||
# Pick the first immich provider
|
||||
provider: ServiceProvider | None = None
|
||||
for p in providers_map.values():
|
||||
if p.type == "immich":
|
||||
provider = p
|
||||
break
|
||||
if not provider:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
immich = make_immich_provider(http, provider)
|
||||
client = immich.client
|
||||
|
||||
if cmd == "search":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
|
||||
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "find":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
|
||||
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "person":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": ""})
|
||||
people = await client.get_people()
|
||||
person_id = None
|
||||
for pid, pname in people.items():
|
||||
if args.lower() in pname.lower():
|
||||
person_id = pid
|
||||
break
|
||||
if not person_id:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
|
||||
assets = await client.search_by_person(person_id, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "place":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "place", "query": ""})
|
||||
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, cmd_templates)
|
||||
|
||||
if cmd == "favorites":
|
||||
fav_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in list(album.assets.items())[:50]:
|
||||
if asset.is_favorite and len(fav_assets) < count:
|
||||
fav_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
if len(fav_assets) >= count:
|
||||
break
|
||||
return _format_assets(fav_assets, cmd, "", locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "latest":
|
||||
latest_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in list(album.assets.items())[:count]:
|
||||
latest_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type, "createdAt": asset.created_at,
|
||||
})
|
||||
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, cmd_templates)
|
||||
|
||||
if cmd == "random":
|
||||
random_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
asset_list = list(album.assets.values())
|
||||
sampled = rng.sample(asset_list, min(count, len(asset_list)))
|
||||
for asset in sampled:
|
||||
random_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
rng.shuffle(random_assets)
|
||||
return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "summary":
|
||||
albums_data: list[dict] = []
|
||||
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})
|
||||
except Exception:
|
||||
pass
|
||||
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
|
||||
|
||||
if cmd == "memory":
|
||||
# Check if any linked tracking config uses native memories
|
||||
use_native = await _check_native_memory(bot)
|
||||
|
||||
today = datetime.now(timezone.utc)
|
||||
memory_assets: list[dict[str, Any]] = []
|
||||
|
||||
if use_native:
|
||||
# Use Immich native memories API
|
||||
memories = await client.get_memories()
|
||||
tracked_ids = set(all_album_ids) if all_album_ids else None
|
||||
for mem in memories:
|
||||
year = mem.get("data", {}).get("year")
|
||||
for raw_asset in mem.get("assets", []):
|
||||
if tracked_ids:
|
||||
asset_albums = raw_asset.get("albums", [])
|
||||
if not any(a.get("id") in tracked_ids for a in asset_albums):
|
||||
continue
|
||||
memory_assets.append({
|
||||
"id": raw_asset.get("id", ""),
|
||||
"originalFileName": raw_asset.get("originalFileName", ""),
|
||||
"type": raw_asset.get("type", "IMAGE"),
|
||||
"createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
|
||||
"year": year,
|
||||
})
|
||||
else:
|
||||
# Album-scanning fallback
|
||||
month_day = (today.month, today.day)
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in album.assets.items():
|
||||
try:
|
||||
dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00"))
|
||||
if (dt.month, dt.day) == month_day and dt.year != today.year:
|
||||
memory_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type, "createdAt": asset.created_at,
|
||||
"year": dt.year,
|
||||
})
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory_assets = memory_assets[:count]
|
||||
if not memory_assets:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "memory", "query": ""})
|
||||
return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_assets(
|
||||
assets: list[dict[str, Any]], cmd: str, query: str,
|
||||
locale: str, response_mode: str, client: Any,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Format asset results as text or media payload."""
|
||||
if not assets:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
|
||||
|
||||
if response_mode == "media":
|
||||
media_items = []
|
||||
for asset in assets:
|
||||
asset_id = asset.get("id", "")
|
||||
filename = asset.get("originalFileName", "")
|
||||
year = asset.get("year", "")
|
||||
caption = f"{filename} ({year})" if year else filename
|
||||
media_items.append({
|
||||
"type": "photo",
|
||||
"asset_id": asset_id,
|
||||
"caption": caption,
|
||||
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
|
||||
"api_key": client.api_key,
|
||||
})
|
||||
return media_items
|
||||
|
||||
# Text mode — render via template
|
||||
slot_map = {"find": "search", "person": "search", "place": "search"}
|
||||
slot_name = slot_map.get(cmd, cmd)
|
||||
return _render_cmd_template(cmd_templates, slot_name, locale, {
|
||||
"assets": assets, "query": query, "command": cmd, "count": len(assets),
|
||||
})
|
||||
|
||||
|
||||
async def send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a text reply via Telegram Bot API, retrying without HTML on parse failure."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
@@ -586,17 +262,12 @@ async def send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
async def send_media_group(
|
||||
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Send media items as a Telegram media group (album).
|
||||
|
||||
Falls back to individual sendPhoto calls if sendMediaGroup fails.
|
||||
Telegram allows max 10 items per media group.
|
||||
"""
|
||||
"""Send media items as a Telegram media group (album)."""
|
||||
if not media_items:
|
||||
return
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
# Download all thumbnails first
|
||||
downloaded: list[tuple[bytes, str, str]] = [] # (photo_bytes, asset_id, caption)
|
||||
downloaded: list[tuple[bytes, str, str]] = []
|
||||
for item in media_items:
|
||||
asset_id = item.get("asset_id", "")
|
||||
caption = item.get("caption", "")
|
||||
@@ -615,12 +286,10 @@ async def send_media_group(
|
||||
if not downloaded:
|
||||
return
|
||||
|
||||
# Send in groups of 10 (Telegram limit)
|
||||
for i in range(0, len(downloaded), 10):
|
||||
chunk = downloaded[i:i + 10]
|
||||
|
||||
if len(chunk) == 1:
|
||||
# Single photo — use sendPhoto
|
||||
photo_bytes, asset_id, caption = chunk[0]
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("chat_id", chat_id)
|
||||
@@ -635,7 +304,6 @@ async def send_media_group(
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to send photo: %s", err)
|
||||
else:
|
||||
# Multiple photos — use sendMediaGroup
|
||||
import json as _json
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("chat_id", chat_id)
|
||||
@@ -658,12 +326,7 @@ async def send_media_group(
|
||||
|
||||
|
||||
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||
"""Register enabled commands with Telegram BotFather API.
|
||||
|
||||
Registers all supported locales explicitly with language_code,
|
||||
plus English as the default fallback (no language_code).
|
||||
Descriptions are read from desc_* template slots.
|
||||
"""
|
||||
"""Register enabled commands with Telegram BotFather API."""
|
||||
ctx_tuples, templates = await _resolve_command_context(bot)
|
||||
enabled, _, _, _ = _merge_command_context(ctx_tuples)
|
||||
|
||||
@@ -677,7 +340,6 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
|
||||
commands.append({"command": cmd, "description": desc})
|
||||
|
||||
# Register with explicit language_code
|
||||
payload: dict[str, Any] = {"commands": commands, "language_code": locale}
|
||||
try:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
@@ -689,7 +351,6 @@ async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to register commands for locale '%s': %s", locale, err)
|
||||
|
||||
# Also register English as the default (no language_code) for unsupported langs
|
||||
en_commands = []
|
||||
for cmd in enabled:
|
||||
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Immich-specific bot command handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random as rng
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
CommandConfig, CommandTracker, NotificationTarget,
|
||||
NotificationTracker, NotificationTrackerTarget,
|
||||
ServiceProvider, TelegramBot, TrackingConfig,
|
||||
)
|
||||
from ..services import make_immich_provider
|
||||
from .base import ProviderCommandHandler
|
||||
from .handler import _render_cmd_template, _get_notification_trackers_for_providers
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_IMMICH_COMMANDS = {
|
||||
"status", "albums", "events", "people",
|
||||
"search", "find", "person", "place",
|
||||
"latest", "random", "favorites", "summary", "memory",
|
||||
}
|
||||
|
||||
|
||||
class ImmichCommandHandler(ProviderCommandHandler):
|
||||
"""Handles all Immich-specific bot commands."""
|
||||
|
||||
provider_type = "immich"
|
||||
|
||||
def get_provider_commands(self) -> set[str]:
|
||||
return _IMMICH_COMMANDS
|
||||
|
||||
def get_rate_categories(self) -> dict[str, str]:
|
||||
return {
|
||||
"search": "search", "find": "search", "person": "search",
|
||||
"place": "search", "favorites": "search", "people": "search",
|
||||
}
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
cmd: str,
|
||||
args: str,
|
||||
count: int,
|
||||
locale: str,
|
||||
response_mode: str,
|
||||
providers_map: dict[int, ServiceProvider],
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
bot: TelegramBot,
|
||||
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
|
||||
) -> str | list[dict[str, Any]] | None:
|
||||
if cmd == "status":
|
||||
ctx = await _cmd_status(bot, providers_map, locale)
|
||||
return _render_cmd_template(cmd_templates, "status", locale, ctx)
|
||||
if cmd == "albums":
|
||||
ctx = await _cmd_albums(bot, providers_map, locale)
|
||||
return _render_cmd_template(cmd_templates, "albums", locale, ctx)
|
||||
if cmd == "events":
|
||||
ctx = await _cmd_events(bot, providers_map, count, locale)
|
||||
return _render_cmd_template(cmd_templates, "events", locale, ctx)
|
||||
if cmd == "people":
|
||||
ctx = await _cmd_people(providers_map, locale)
|
||||
return _render_cmd_template(cmd_templates, "people", locale, ctx)
|
||||
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, cmd_templates,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# --- Immich command implementations (moved from handler.py) ---
|
||||
|
||||
|
||||
async def _cmd_status(
|
||||
bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
|
||||
) -> dict[str, Any]:
|
||||
from ..database.models import EventLog
|
||||
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)
|
||||
total = len(trackers)
|
||||
total_albums = sum(len(t.collection_ids or []) for t in trackers)
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog).order_by(EventLog.created_at.desc()).limit(1)
|
||||
)
|
||||
last_event = result.first()
|
||||
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
||||
|
||||
return {
|
||||
"trackers_active": active, "trackers_total": total,
|
||||
"total_albums": total_albums, "last_event": last_str,
|
||||
}
|
||||
|
||||
|
||||
async def _cmd_albums(
|
||||
bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str,
|
||||
) -> dict[str, Any]:
|
||||
provider_ids = set(providers_map.keys())
|
||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
if not trackers:
|
||||
return {"albums": []}
|
||||
|
||||
albums_data: list[dict] = []
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for tracker in trackers:
|
||||
provider = providers_map.get(tracker.provider_id)
|
||||
if not provider or provider.type != "immich":
|
||||
continue
|
||||
immich = make_immich_provider(http, provider)
|
||||
for album_id in (tracker.collection_ids or []):
|
||||
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,
|
||||
})
|
||||
except Exception:
|
||||
albums_data.append({
|
||||
"name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id,
|
||||
})
|
||||
|
||||
return {"albums": albums_data}
|
||||
|
||||
|
||||
async def _cmd_events(
|
||||
bot: TelegramBot, providers_map: dict[int, ServiceProvider],
|
||||
count: int, locale: str,
|
||||
) -> dict[str, Any]:
|
||||
from ..database.models import EventLog
|
||||
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 {"events": []}
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(count)
|
||||
)
|
||||
events = result.all()
|
||||
|
||||
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
|
||||
]
|
||||
return {"events": events_data}
|
||||
|
||||
|
||||
async def _cmd_people(
|
||||
providers_map: dict[int, ServiceProvider], locale: str,
|
||||
) -> dict[str, Any]:
|
||||
all_people: dict[str, str] = {}
|
||||
async with aiohttp.ClientSession() as http:
|
||||
for provider in providers_map.values():
|
||||
if provider.type != "immich":
|
||||
continue
|
||||
immich = make_immich_provider(http, provider)
|
||||
people = await immich.client.get_people()
|
||||
all_people.update(people)
|
||||
names = sorted(all_people.values())
|
||||
return {"people": names}
|
||||
|
||||
|
||||
async def _check_native_memory(bot: TelegramBot) -> bool:
|
||||
"""Check if any tracker-target linked to this bot uses native memory source."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(NotificationTarget).where(
|
||||
NotificationTarget.type == "telegram",
|
||||
NotificationTarget.user_id == bot.user_id,
|
||||
)
|
||||
)
|
||||
targets = result.all()
|
||||
bot_target_ids = {t.id for t in targets if t.config.get("bot_token") == bot.token}
|
||||
if not bot_target_ids:
|
||||
return False
|
||||
tt_result = await session.exec(
|
||||
select(NotificationTrackerTarget).where(
|
||||
NotificationTrackerTarget.target_id.in_(bot_target_ids)
|
||||
)
|
||||
)
|
||||
for tt in tt_result.all():
|
||||
if tt.tracking_config_id:
|
||||
tc = await session.get(TrackingConfig, tt.tracking_config_id)
|
||||
if tc and tc.memory_source == "native":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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, dict[str, str]],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Handle commands that need Immich API access and may return media."""
|
||||
if not providers_map:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
|
||||
|
||||
provider_ids = set(providers_map.keys())
|
||||
notification_trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||
|
||||
all_album_ids: list[str] = []
|
||||
for t in notification_trackers:
|
||||
all_album_ids.extend(t.collection_ids or [])
|
||||
|
||||
provider: ServiceProvider | None = None
|
||||
for p in providers_map.values():
|
||||
if p.type == "immich":
|
||||
provider = p
|
||||
break
|
||||
if not provider:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": args})
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
immich = make_immich_provider(http, provider)
|
||||
client = immich.client
|
||||
|
||||
if cmd == "search":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
|
||||
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "find":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": ""})
|
||||
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "person":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": ""})
|
||||
people = await client.get_people()
|
||||
person_id = None
|
||||
for pid, pname in people.items():
|
||||
if args.lower() in pname.lower():
|
||||
person_id = pid
|
||||
break
|
||||
if not person_id:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
|
||||
assets = await client.search_by_person(person_id, limit=count)
|
||||
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "place":
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "place", "query": ""})
|
||||
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, cmd_templates)
|
||||
|
||||
if cmd == "favorites":
|
||||
fav_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in list(album.assets.items())[:50]:
|
||||
if asset.is_favorite and len(fav_assets) < count:
|
||||
fav_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
if len(fav_assets) >= count:
|
||||
break
|
||||
return _format_assets(fav_assets, cmd, "", locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "latest":
|
||||
latest_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in list(album.assets.items())[:count]:
|
||||
latest_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type, "createdAt": asset.created_at,
|
||||
})
|
||||
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, cmd_templates)
|
||||
|
||||
if cmd == "random":
|
||||
random_assets: list[dict[str, Any]] = []
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
asset_list = list(album.assets.values())
|
||||
sampled = rng.sample(asset_list, min(count, len(asset_list)))
|
||||
for asset in sampled:
|
||||
random_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
rng.shuffle(random_assets)
|
||||
return _format_assets(random_assets[:count], cmd, "", locale, response_mode, client, cmd_templates)
|
||||
|
||||
if cmd == "summary":
|
||||
albums_data: list[dict] = []
|
||||
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,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
|
||||
|
||||
if cmd == "memory":
|
||||
use_native = await _check_native_memory(bot)
|
||||
today = datetime.now(timezone.utc)
|
||||
memory_assets: list[dict[str, Any]] = []
|
||||
|
||||
if use_native:
|
||||
memories = await client.get_memories()
|
||||
tracked_ids = set(all_album_ids) if all_album_ids else None
|
||||
for mem in memories:
|
||||
year = mem.get("data", {}).get("year")
|
||||
for raw_asset in mem.get("assets", []):
|
||||
if tracked_ids:
|
||||
asset_albums = raw_asset.get("albums", [])
|
||||
if not any(a.get("id") in tracked_ids for a in asset_albums):
|
||||
continue
|
||||
memory_assets.append({
|
||||
"id": raw_asset.get("id", ""),
|
||||
"originalFileName": raw_asset.get("originalFileName", ""),
|
||||
"type": raw_asset.get("type", "IMAGE"),
|
||||
"createdAt": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
|
||||
"year": year,
|
||||
})
|
||||
else:
|
||||
month_day = (today.month, today.day)
|
||||
for album_id in all_album_ids[:10]:
|
||||
try:
|
||||
album = await client.get_album(album_id)
|
||||
if album:
|
||||
for aid, asset in album.assets.items():
|
||||
try:
|
||||
dt = datetime.fromisoformat(asset.created_at.replace("Z", "+00:00"))
|
||||
if (dt.month, dt.day) == month_day and dt.year != today.year:
|
||||
memory_assets.append({
|
||||
"id": asset.id, "originalFileName": asset.filename,
|
||||
"type": asset.type, "createdAt": asset.created_at,
|
||||
"year": dt.year,
|
||||
})
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory_assets = memory_assets[:count]
|
||||
if not memory_assets:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "memory", "query": ""})
|
||||
return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_assets(
|
||||
assets: list[dict[str, Any]], cmd: str, query: str,
|
||||
locale: str, response_mode: str, client: Any,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Format asset results as text or media payload."""
|
||||
if not assets:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
|
||||
|
||||
if response_mode == "media":
|
||||
media_items = []
|
||||
for asset in assets:
|
||||
asset_id = asset.get("id", "")
|
||||
filename = asset.get("originalFileName", "")
|
||||
year = asset.get("year", "")
|
||||
caption = f"{filename} ({year})" if year else filename
|
||||
media_items.append({
|
||||
"type": "photo",
|
||||
"asset_id": asset_id,
|
||||
"caption": caption,
|
||||
"thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
|
||||
"api_key": client.api_key,
|
||||
})
|
||||
return media_items
|
||||
|
||||
slot_map = {"find": "search", "person": "search", "place": "search"}
|
||||
slot_name = slot_map.get(cmd, cmd)
|
||||
return _render_cmd_template(cmd_templates, slot_name, locale, {
|
||||
"assets": assets, "query": query, "command": cmd, "count": len(assets),
|
||||
})
|
||||
@@ -4,8 +4,11 @@ from __future__ import annotations
|
||||
|
||||
# Map commands to rate limit categories
|
||||
_RATE_CATEGORY: dict[str, str] = {
|
||||
# Immich
|
||||
"search": "search", "find": "search", "person": "search",
|
||||
"place": "search", "favorites": "search", "people": "search",
|
||||
# Gitea (API calls share a category)
|
||||
"repos": "api", "issues": "api", "prs": "api", "commits": "api",
|
||||
}
|
||||
|
||||
|
||||
@@ -17,5 +20,5 @@ DEFAULT_COMMANDS_CONFIG = {
|
||||
"enabled": ["help", "status", "albums", "events", "latest", "random", "favorites", "summary", "memory"],
|
||||
"response_mode": "media",
|
||||
"default_count": 5,
|
||||
"rate_limits": {"search": 30, "default": 10},
|
||||
"rate_limits": {"search": 30, "api": 15, "default": 10},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user