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:
2026-03-22 17:44:47 +03:00
parent 0562f78b35
commit 63437c1841
45 changed files with 1175 additions and 397 deletions
@@ -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