feat: remove hardcoded command templates, enforce template system exclusively
- Remove ALL hardcoded EN/RU fallback strings from handler.py — every command response now renders through CommandTemplateSlot templates - _render_cmd_template now returns error placeholder instead of None when template is missing, ensuring no silent failures - Fix register_commands_with_telegram tuple unpacking bug (was ignoring cmd_template_slots from _resolve_command_context) - Auto-assign system default template (matching locale) on command config creation when none specified - Add command_template_config_id to CommandConfigCreate model - Remove "no template" option from frontend dropdown — template is now required for command configs - Auto-select first matching template when creating new command config - Fix || vs ?? for command_template_config_id, default_count, and rate_limits in frontend edit function (0 was treated as falsy)
This commit is contained in:
@@ -62,7 +62,14 @@
|
|||||||
finally { loaded = true; }
|
finally { loaded = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
function openNew() {
|
||||||
|
form = defaultForm();
|
||||||
|
// Auto-select first matching template for the default provider_type
|
||||||
|
const match = cmdTemplateConfigs.find((c: any) => c.provider_type === form.provider_type);
|
||||||
|
if (match) form.command_template_config_id = match.id;
|
||||||
|
editing = null;
|
||||||
|
showForm = true;
|
||||||
|
}
|
||||||
function editConfig(cfg: any) {
|
function editConfig(cfg: any) {
|
||||||
form = {
|
form = {
|
||||||
name: cfg.name,
|
name: cfg.name,
|
||||||
@@ -71,9 +78,9 @@
|
|||||||
enabled_commands: [...(cfg.enabled_commands || [])],
|
enabled_commands: [...(cfg.enabled_commands || [])],
|
||||||
locale: cfg.locale || 'en',
|
locale: cfg.locale || 'en',
|
||||||
response_mode: cfg.response_mode || 'media',
|
response_mode: cfg.response_mode || 'media',
|
||||||
default_count: cfg.default_count || 5,
|
default_count: cfg.default_count ?? 5,
|
||||||
rate_limits: { search: cfg.rate_limits?.search || 30, default: cfg.rate_limits?.default || 10 },
|
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
|
||||||
command_template_config_id: cfg.command_template_config_id || null,
|
command_template_config_id: cfg.command_template_config_id ?? null,
|
||||||
};
|
};
|
||||||
editing = cfg.id;
|
editing = cfg.id;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
@@ -167,7 +174,6 @@
|
|||||||
<label for="cc-template" class="block text-sm font-medium mb-1">{t('commandConfig.responseTemplate')}</label>
|
<label for="cc-template" class="block text-sm font-medium mb-1">{t('commandConfig.responseTemplate')}</label>
|
||||||
<select id="cc-template" bind:value={form.command_template_config_id}
|
<select id="cc-template" bind:value={form.command_template_config_id}
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||||
<option value={null}>— {t('commandConfig.noTemplate')} —</option>
|
|
||||||
{#each cmdTemplateConfigs.filter((c: any) => c.provider_type === form.provider_type) as tpl}
|
{#each cmdTemplateConfigs.filter((c: any) => c.provider_type === form.provider_type) as tpl}
|
||||||
<option value={tpl.id}>{tpl.name}{tpl.user_id === 0 ? ' (System)' : ''}</option>
|
<option value={tpl.id}>{tpl.name}{tpl.user_id === 0 ? ' (System)' : ''}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import CommandConfig, CommandTracker, User
|
from ..database.models import CommandConfig, CommandTemplateConfig, CommandTracker, User
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ class CommandConfigCreate(BaseModel):
|
|||||||
response_mode: str = "media"
|
response_mode: str = "media"
|
||||||
default_count: int = 5
|
default_count: int = 5
|
||||||
rate_limits: dict[str, Any] = {}
|
rate_limits: dict[str, Any] = {}
|
||||||
|
command_template_config_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class CommandConfigUpdate(BaseModel):
|
class CommandConfigUpdate(BaseModel):
|
||||||
@@ -36,6 +37,7 @@ class CommandConfigUpdate(BaseModel):
|
|||||||
response_mode: str | None = None
|
response_mode: str | None = None
|
||||||
default_count: int | None = None
|
default_count: int | None = None
|
||||||
rate_limits: dict[str, Any] | None = None
|
rate_limits: dict[str, Any] | None = None
|
||||||
|
command_template_config_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -65,7 +67,18 @@ async def create_command_config(
|
|||||||
detail=f"Invalid provider_type. Must be one of: {', '.join(valid_types)}",
|
detail=f"Invalid provider_type. Must be one of: {', '.join(valid_types)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = CommandConfig(user_id=user.id, **body.model_dump())
|
data = body.model_dump()
|
||||||
|
# Auto-assign system default template if none specified
|
||||||
|
if not data.get("command_template_config_id"):
|
||||||
|
locale = data.get("locale", "en")
|
||||||
|
provider_type = data.get("provider_type", "immich")
|
||||||
|
default_tpl = await _find_system_default_template(
|
||||||
|
session, provider_type, locale
|
||||||
|
)
|
||||||
|
if default_tpl:
|
||||||
|
data["command_template_config_id"] = default_tpl.id
|
||||||
|
|
||||||
|
config = CommandConfig(user_id=user.id, **data)
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
@@ -91,9 +104,13 @@ async def update_command_config(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Update a command config."""
|
"""Update a command config."""
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
config = await _get_user_config(session, config_id, user.id)
|
config = await _get_user_config(session, config_id, user.id)
|
||||||
for field, value in body.model_dump(exclude_unset=True).items():
|
for field, value in body.model_dump(exclude_unset=True).items():
|
||||||
setattr(config, field, value)
|
setattr(config, field, value)
|
||||||
|
# JSON columns need explicit dirty flag for SQLAlchemy change detection
|
||||||
|
if field in ("rate_limits", "enabled_commands"):
|
||||||
|
flag_modified(config, field)
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
@@ -129,6 +146,7 @@ def _config_response(c: CommandConfig) -> dict:
|
|||||||
"response_mode": c.response_mode,
|
"response_mode": c.response_mode,
|
||||||
"default_count": c.default_count,
|
"default_count": c.default_count,
|
||||||
"rate_limits": c.rate_limits or {},
|
"rate_limits": c.rate_limits or {},
|
||||||
|
"command_template_config_id": c.command_template_config_id,
|
||||||
"created_at": c.created_at.isoformat(),
|
"created_at": c.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,3 +158,24 @@ async def _get_user_config(
|
|||||||
if not config or config.user_id != user_id:
|
if not config or config.user_id != user_id:
|
||||||
raise HTTPException(status_code=404, detail="Command config not found")
|
raise HTTPException(status_code=404, detail="Command config not found")
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_system_default_template(
|
||||||
|
session: AsyncSession, provider_type: str, locale: str
|
||||||
|
) -> CommandTemplateConfig | None:
|
||||||
|
"""Find a system default (user_id=0) command template matching provider + locale."""
|
||||||
|
# Try exact locale match first (e.g. "Default Commands (EN)" for locale "en")
|
||||||
|
locale_upper = locale.upper()
|
||||||
|
result = await session.exec(
|
||||||
|
select(CommandTemplateConfig).where(
|
||||||
|
CommandTemplateConfig.user_id == 0,
|
||||||
|
CommandTemplateConfig.provider_type == provider_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
templates = result.all()
|
||||||
|
# Match by locale suffix in name, e.g. "(EN)" or "(RU)"
|
||||||
|
for tpl in templates:
|
||||||
|
if f"({locale_upper})" in tpl.name:
|
||||||
|
return tpl
|
||||||
|
# Fallback: return first system template for this provider
|
||||||
|
return templates[0] if templates else None
|
||||||
|
|||||||
@@ -55,11 +55,12 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int
|
|||||||
|
|
||||||
def _render_cmd_template(
|
def _render_cmd_template(
|
||||||
templates: dict[str, str], slot_name: str, context: dict[str, Any]
|
templates: dict[str, str], slot_name: str, context: dict[str, Any]
|
||||||
) -> str | None:
|
) -> str:
|
||||||
"""Try to render a command template. Returns None if no template or error."""
|
"""Render a command template. Returns rendered string or error placeholder."""
|
||||||
template_str = templates.get(slot_name)
|
template_str = templates.get(slot_name)
|
||||||
if not template_str:
|
if not template_str:
|
||||||
return None
|
_LOGGER.warning("No command template found for slot '%s'", slot_name)
|
||||||
|
return f"[No template: {slot_name}]"
|
||||||
try:
|
try:
|
||||||
from jinja2.sandbox import SandboxedEnvironment
|
from jinja2.sandbox import SandboxedEnvironment
|
||||||
env = SandboxedEnvironment(autoescape=False)
|
env = SandboxedEnvironment(autoescape=False)
|
||||||
@@ -67,17 +68,15 @@ def _render_cmd_template(
|
|||||||
return tmpl.render(**context)
|
return tmpl.render(**context)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
|
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
|
||||||
return None
|
return f"[Template error: {slot_name}]"
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_command_context(
|
async def _resolve_command_context(
|
||||||
bot: TelegramBot,
|
bot: TelegramBot,
|
||||||
) -> list[tuple[CommandTracker, CommandConfig, ServiceProvider]]:
|
) -> tuple[list[tuple[CommandTracker, CommandConfig, ServiceProvider]], dict[str, str]]:
|
||||||
"""Resolve all enabled command trackers, configs, and providers for a bot.
|
"""Resolve all enabled command trackers, configs, and providers for a bot.
|
||||||
|
|
||||||
Finds CommandTrackerListener rows where listener_type="telegram_bot"
|
Returns (context_tuples, cmd_template_slots).
|
||||||
and listener_id=bot.id, then loads the full chain:
|
|
||||||
CommandTrackerListener -> CommandTracker (enabled) -> CommandConfig + ServiceProvider.
|
|
||||||
"""
|
"""
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
async with AsyncSession(engine) as session:
|
async with AsyncSession(engine) as session:
|
||||||
@@ -91,7 +90,7 @@ async def _resolve_command_context(
|
|||||||
listeners = result.all()
|
listeners = result.all()
|
||||||
|
|
||||||
if not listeners:
|
if not listeners:
|
||||||
return []
|
return [], {}
|
||||||
|
|
||||||
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
|
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
|
||||||
for listener in listeners:
|
for listener in listeners:
|
||||||
@@ -161,14 +160,7 @@ async def handle_command(
|
|||||||
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
|
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
|
||||||
|
|
||||||
if cmd == "start":
|
if cmd == "start":
|
||||||
result = _render_cmd_template(cmd_templates, "start", {"locale": locale, "bot_name": bot.name})
|
return _render_cmd_template(cmd_templates, "start", {"locale": locale, "bot_name": bot.name})
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
msgs = {
|
|
||||||
"en": "Hi! I'm your Notify Bridge bot. Use /help to see available commands.",
|
|
||||||
"ru": "Привет! Я бот Notify Bridge. Используйте /help для списка команд.",
|
|
||||||
}
|
|
||||||
return msgs.get(locale, msgs["en"])
|
|
||||||
|
|
||||||
if cmd not in enabled and cmd != "start":
|
if cmd not in enabled and cmd != "start":
|
||||||
return None # Silently ignore disabled commands
|
return None # Silently ignore disabled commands
|
||||||
@@ -176,14 +168,7 @@ async def handle_command(
|
|||||||
# Rate limit check
|
# Rate limit check
|
||||||
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
|
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
|
||||||
if wait is not None:
|
if wait is not None:
|
||||||
result = _render_cmd_template(cmd_templates, "rate_limited", {"wait": wait, "locale": locale})
|
return _render_cmd_template(cmd_templates, "rate_limited", {"wait": wait, "locale": locale})
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
msgs = {
|
|
||||||
"en": f"Please wait {wait}s before using this command again.",
|
|
||||||
"ru": f"Подождите {wait} сек. перед повторным использованием.",
|
|
||||||
}
|
|
||||||
return msgs.get(locale, msgs["en"])
|
|
||||||
|
|
||||||
count = min(count_override or default_count, 20)
|
count = min(count_override or default_count, 20)
|
||||||
|
|
||||||
@@ -192,50 +177,39 @@ async def handle_command(
|
|||||||
for _, _, provider in ctx_tuples:
|
for _, _, provider in ctx_tuples:
|
||||||
providers_map[provider.id] = provider
|
providers_map[provider.id] = provider
|
||||||
|
|
||||||
# Dispatch — each handler returns (fallback_text, template_context)
|
# Dispatch — each handler returns template context dict
|
||||||
# Template is tried first; if no template, fallback is returned.
|
|
||||||
if cmd == "help":
|
if cmd == "help":
|
||||||
fallback, ctx = _cmd_help(enabled, locale)
|
ctx = _cmd_help(enabled, locale)
|
||||||
elif cmd == "status":
|
elif cmd == "status":
|
||||||
fallback, ctx = await _cmd_status(bot, providers_map, locale)
|
ctx = await _cmd_status(bot, providers_map, locale)
|
||||||
elif cmd == "albums":
|
elif cmd == "albums":
|
||||||
fallback, ctx = await _cmd_albums(bot, providers_map, locale)
|
ctx = await _cmd_albums(bot, providers_map, locale)
|
||||||
elif cmd == "events":
|
elif cmd == "events":
|
||||||
fallback, ctx = await _cmd_events(bot, providers_map, count, locale)
|
ctx = await _cmd_events(bot, providers_map, count, locale)
|
||||||
elif cmd == "people":
|
elif cmd == "people":
|
||||||
fallback, ctx = await _cmd_people(providers_map, locale)
|
ctx = await _cmd_people(providers_map, locale)
|
||||||
elif cmd in ("search", "find", "person", "place", "latest", "random",
|
elif cmd in ("search", "find", "person", "place", "latest", "random",
|
||||||
"favorites", "summary", "memory"):
|
"favorites", "summary", "memory"):
|
||||||
return await _cmd_immich(bot, cmd, args, count, locale, response_mode, providers_map, cmd_templates)
|
return await _cmd_immich(bot, cmd, args, count, locale, response_mode, providers_map, cmd_templates)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try template, fall back to hardcoded
|
return _render_cmd_template(cmd_templates, cmd, {**ctx, "locale": locale})
|
||||||
rendered = _render_cmd_template(cmd_templates, cmd, {**ctx, "locale": locale})
|
|
||||||
return rendered if rendered else fallback
|
|
||||||
|
|
||||||
|
|
||||||
def _cmd_help(enabled: list[str], locale: str) -> tuple[str, dict]:
|
def _cmd_help(enabled: list[str], locale: str) -> dict[str, Any]:
|
||||||
commands = []
|
commands = []
|
||||||
lines = []
|
|
||||||
for cmd in enabled:
|
for cmd in enabled:
|
||||||
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
|
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
|
||||||
desc_text = desc.get(locale, desc.get("en", ""))
|
desc_text = desc.get(locale, desc.get("en", ""))
|
||||||
commands.append({"name": cmd, "description": desc_text})
|
commands.append({"name": cmd, "description": desc_text})
|
||||||
lines.append(f"/{cmd} — {desc_text}")
|
return {"commands": commands}
|
||||||
header = {"en": "Available commands:", "ru": "Доступные команды:"}
|
|
||||||
fallback = header.get(locale, header["en"]) + "\n" + "\n".join(lines)
|
|
||||||
return fallback, {"commands": commands}
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_notification_trackers_for_providers(
|
async def _get_notification_trackers_for_providers(
|
||||||
provider_ids: set[int],
|
provider_ids: set[int],
|
||||||
) -> list[NotificationTracker]:
|
) -> list[NotificationTracker]:
|
||||||
"""Get notification trackers for the given provider IDs.
|
"""Get notification trackers for the given provider IDs."""
|
||||||
|
|
||||||
Used by commands like albums, events, status that need notification
|
|
||||||
tracker data (collection_ids, event logs).
|
|
||||||
"""
|
|
||||||
if not provider_ids:
|
if not provider_ids:
|
||||||
return []
|
return []
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
@@ -273,7 +247,7 @@ async def _check_native_memory(bot: TelegramBot) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> tuple[str, dict]:
|
async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
|
||||||
provider_ids = set(providers_map.keys())
|
provider_ids = set(providers_map.keys())
|
||||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||||
active = sum(1 for t in trackers if t.enabled)
|
active = sum(1 for t in trackers if t.enabled)
|
||||||
@@ -288,34 +262,16 @@ async def _cmd_status(bot: TelegramBot, providers_map: dict[int, ServiceProvider
|
|||||||
last_event = result.first()
|
last_event = result.first()
|
||||||
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
||||||
|
|
||||||
ctx = {"trackers_active": active, "trackers_total": total, "total_albums": total_albums, "last_event": last_str}
|
return {"trackers_active": active, "trackers_total": total, "total_albums": total_albums, "last_event": last_str}
|
||||||
|
|
||||||
if locale == "ru":
|
|
||||||
fallback = (
|
|
||||||
f"📊 Статус\n"
|
|
||||||
f"Трекеры: {active}/{total} активных\n"
|
|
||||||
f"Альбомы: {total_albums}\n"
|
|
||||||
f"Последнее событие: {last_str}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
fallback = (
|
|
||||||
f"📊 Status\n"
|
|
||||||
f"Trackers: {active}/{total} active\n"
|
|
||||||
f"Albums: {total_albums}\n"
|
|
||||||
f"Last event: {last_str}"
|
|
||||||
)
|
|
||||||
return fallback, ctx
|
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> tuple[str, dict]:
|
async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
|
||||||
provider_ids = set(providers_map.keys())
|
provider_ids = set(providers_map.keys())
|
||||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||||
if not trackers:
|
if not trackers:
|
||||||
fallback = "No tracked albums." if locale == "en" else "Нет отслеживаемых альбомов."
|
return {"albums": []}
|
||||||
return fallback, {"albums": []}
|
|
||||||
|
|
||||||
albums_data: list[dict] = []
|
albums_data: list[dict] = []
|
||||||
lines = []
|
|
||||||
async with aiohttp.ClientSession() as http:
|
async with aiohttp.ClientSession() as http:
|
||||||
for tracker in trackers:
|
for tracker in trackers:
|
||||||
provider = providers_map.get(tracker.provider_id)
|
provider = providers_map.get(tracker.provider_id)
|
||||||
@@ -327,22 +283,18 @@ async def _cmd_albums(bot: TelegramBot, providers_map: dict[int, ServiceProvider
|
|||||||
album = await immich.client.get_album(album_id)
|
album = await immich.client.get_album(album_id)
|
||||||
if album:
|
if album:
|
||||||
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
|
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
|
||||||
lines.append(f" • {album.name} ({album.asset_count} assets)")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
lines.append(f" • {album_id[:8]}... (error)")
|
albums_data.append({"name": f"{album_id[:8]}...", "asset_count": "?", "id": album_id})
|
||||||
|
|
||||||
header = "📚 Tracked albums:" if locale == "en" else "📚 Отслеживаемые альбомы:"
|
return {"albums": albums_data}
|
||||||
fallback = header + "\n" + "\n".join(lines) if lines else header + "\n (none)"
|
|
||||||
return fallback, {"albums": albums_data}
|
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> tuple[str, dict]:
|
async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider], count: int, locale: str) -> dict[str, Any]:
|
||||||
provider_ids = set(providers_map.keys())
|
provider_ids = set(providers_map.keys())
|
||||||
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
trackers = await _get_notification_trackers_for_providers(provider_ids)
|
||||||
tracker_ids = [t.id for t in trackers]
|
tracker_ids = [t.id for t in trackers]
|
||||||
if not tracker_ids:
|
if not tracker_ids:
|
||||||
fallback = "No events." if locale == "en" else "Нет событий."
|
return {"events": []}
|
||||||
return fallback, {"events": []}
|
|
||||||
|
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
async with AsyncSession(engine) as session:
|
async with AsyncSession(engine) as session:
|
||||||
@@ -354,23 +306,13 @@ async def _cmd_events(bot: TelegramBot, providers_map: dict[int, ServiceProvider
|
|||||||
)
|
)
|
||||||
events = result.all()
|
events = result.all()
|
||||||
|
|
||||||
if not events:
|
|
||||||
fallback = "No events yet." if locale == "en" else "Пока нет событий."
|
|
||||||
return fallback, {"events": []}
|
|
||||||
|
|
||||||
events_data = [{"type": e.event_type, "album": e.collection_name, "count": e.assets_count,
|
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]
|
"date": e.created_at.strftime("%m/%d %H:%M")} for e in events]
|
||||||
|
|
||||||
header = f"📋 Last {len(events)} events:" if locale == "en" else f"📋 Последние {len(events)} событий:"
|
return {"events": events_data}
|
||||||
lines = []
|
|
||||||
for e in events:
|
|
||||||
ts = e.created_at.strftime("%m/%d %H:%M")
|
|
||||||
lines.append(f" {ts} — {e.event_type}: {e.collection_name}")
|
|
||||||
fallback = header + "\n" + "\n".join(lines)
|
|
||||||
return fallback, {"events": events_data}
|
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> tuple[str, dict]:
|
async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) -> dict[str, Any]:
|
||||||
all_people: dict[str, str] = {}
|
all_people: dict[str, str] = {}
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as http:
|
async with aiohttp.ClientSession() as http:
|
||||||
@@ -381,24 +323,18 @@ async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) ->
|
|||||||
people = await immich.client.get_people()
|
people = await immich.client.get_people()
|
||||||
all_people.update(people)
|
all_people.update(people)
|
||||||
|
|
||||||
if not all_people:
|
|
||||||
fallback = "No people detected." if locale == "en" else "Люди не обнаружены."
|
|
||||||
return fallback, {"people": []}
|
|
||||||
|
|
||||||
names = sorted(all_people.values())
|
names = sorted(all_people.values())
|
||||||
header = f"👥 {len(names)} people:" if locale == "en" else f"👥 {len(names)} людей:"
|
return {"people": names}
|
||||||
fallback = header + "\n" + ", ".join(names)
|
|
||||||
return fallback, {"people": names}
|
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_immich(
|
async def _cmd_immich(
|
||||||
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
|
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
|
||||||
response_mode: str, providers_map: dict[int, ServiceProvider],
|
response_mode: str, providers_map: dict[int, ServiceProvider],
|
||||||
cmd_templates: dict[str, str] | None = None,
|
cmd_templates: dict[str, str],
|
||||||
) -> str | list[dict[str, Any]]:
|
) -> str | list[dict[str, Any]]:
|
||||||
"""Handle commands that need Immich API access and may return media."""
|
"""Handle commands that need Immich API access and may return media."""
|
||||||
if not providers_map:
|
if not providers_map:
|
||||||
return "No trackers configured." if locale == "en" else "Трекеры не настроены."
|
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": args, "locale": locale})
|
||||||
|
|
||||||
# Get notification trackers for album data
|
# Get notification trackers for album data
|
||||||
provider_ids = set(providers_map.keys())
|
provider_ids = set(providers_map.keys())
|
||||||
@@ -415,26 +351,27 @@ async def _cmd_immich(
|
|||||||
provider = p
|
provider = p
|
||||||
break
|
break
|
||||||
if not provider:
|
if not provider:
|
||||||
return "Server not found." if locale == "en" else "Сервер не найден."
|
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": args, "locale": locale})
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as http:
|
async with aiohttp.ClientSession() as http:
|
||||||
immich = make_immich_provider(http, provider)
|
immich = make_immich_provider(http, provider)
|
||||||
client = immich.client
|
client = immich.client
|
||||||
|
|
||||||
if cmd == "search":
|
if cmd == "search":
|
||||||
if not args:
|
if not args:
|
||||||
return "Usage: /search <query>" if locale == "en" else "Использование: /search <запрос>"
|
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": "", "locale": locale})
|
||||||
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count)
|
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)
|
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||||
|
|
||||||
if cmd == "find":
|
if cmd == "find":
|
||||||
if not args:
|
if not args:
|
||||||
return "Usage: /find <text>" if locale == "en" else "Использование: /find <текст>"
|
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": "", "locale": locale})
|
||||||
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count)
|
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)
|
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||||
|
|
||||||
if cmd == "person":
|
if cmd == "person":
|
||||||
if not args:
|
if not args:
|
||||||
return "Usage: /person <name>" if locale == "en" else "Использование: /person <имя>"
|
return _render_cmd_template(cmd_templates, "no_results", {"command": "person", "query": "", "locale": locale})
|
||||||
people = await client.get_people()
|
people = await client.get_people()
|
||||||
person_id = None
|
person_id = None
|
||||||
for pid, pname in people.items():
|
for pid, pname in people.items():
|
||||||
@@ -442,13 +379,13 @@ async def _cmd_immich(
|
|||||||
person_id = pid
|
person_id = pid
|
||||||
break
|
break
|
||||||
if not person_id:
|
if not person_id:
|
||||||
return f"Person '{args}' not found." if locale == "en" else f"Человек '{args}' не найден."
|
return _render_cmd_template(cmd_templates, "no_results", {"command": "person", "query": args, "locale": locale})
|
||||||
assets = await client.search_by_person(person_id, limit=count)
|
assets = await client.search_by_person(person_id, limit=count)
|
||||||
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
return _format_assets(assets, cmd, args, locale, response_mode, client, cmd_templates)
|
||||||
|
|
||||||
if cmd == "place":
|
if cmd == "place":
|
||||||
if not args:
|
if not args:
|
||||||
return "Usage: /place <location>" if locale == "en" else "Использование: /place <место>"
|
return _render_cmd_template(cmd_templates, "no_results", {"command": "place", "query": "", "locale": locale})
|
||||||
assets = await client.search_smart(
|
assets = await client.search_smart(
|
||||||
f"photos taken in {args}", album_ids=all_album_ids, limit=count
|
f"photos taken in {args}", album_ids=all_album_ids, limit=count
|
||||||
)
|
)
|
||||||
@@ -508,20 +445,14 @@ async def _cmd_immich(
|
|||||||
|
|
||||||
if cmd == "summary":
|
if cmd == "summary":
|
||||||
albums_data: list[dict] = []
|
albums_data: list[dict] = []
|
||||||
lines = []
|
|
||||||
for album_id in all_album_ids:
|
for album_id in all_album_ids:
|
||||||
try:
|
try:
|
||||||
album = await client.get_album(album_id)
|
album = await client.get_album(album_id)
|
||||||
if album:
|
if album:
|
||||||
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
|
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
|
||||||
lines.append(f" • {album.name}: {album.asset_count} assets")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
rendered = _render_cmd_template(cmd_templates or {}, "summary", {"albums": albums_data, "locale": locale})
|
return _render_cmd_template(cmd_templates, "summary", {"albums": albums_data, "locale": locale})
|
||||||
if rendered:
|
|
||||||
return rendered
|
|
||||||
header = f"📋 Album summary ({len(lines)}):" if locale == "en" else f"📋 Сводка альбомов ({len(lines)}):"
|
|
||||||
return header + "\n" + "\n".join(lines) if lines else header
|
|
||||||
|
|
||||||
if cmd == "memory":
|
if cmd == "memory":
|
||||||
# Check if any linked tracking config uses native memories
|
# Check if any linked tracking config uses native memories
|
||||||
@@ -537,7 +468,6 @@ async def _cmd_immich(
|
|||||||
for mem in memories:
|
for mem in memories:
|
||||||
year = mem.get("data", {}).get("year")
|
year = mem.get("data", {}).get("year")
|
||||||
for raw_asset in mem.get("assets", []):
|
for raw_asset in mem.get("assets", []):
|
||||||
# Optional album filtering
|
|
||||||
if tracked_ids:
|
if tracked_ids:
|
||||||
asset_albums = raw_asset.get("albums", [])
|
asset_albums = raw_asset.get("albums", [])
|
||||||
if not any(a.get("id") in tracked_ids for a in asset_albums):
|
if not any(a.get("id") in tracked_ids for a in asset_albums):
|
||||||
@@ -572,23 +502,20 @@ async def _cmd_immich(
|
|||||||
|
|
||||||
memory_assets = memory_assets[:count]
|
memory_assets = memory_assets[:count]
|
||||||
if not memory_assets:
|
if not memory_assets:
|
||||||
return "No memories for today." if locale == "en" else "Нет воспоминаний за сегодня."
|
return _render_cmd_template(cmd_templates, "no_results", {"command": "memory", "query": "", "locale": locale})
|
||||||
return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
|
return _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
|
||||||
|
|
||||||
return "Unknown command." if locale == "en" else "Неизвестная команда."
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _format_assets(
|
def _format_assets(
|
||||||
assets: list[dict[str, Any]], cmd: str, query: str,
|
assets: list[dict[str, Any]], cmd: str, query: str,
|
||||||
locale: str, response_mode: str, client: Any,
|
locale: str, response_mode: str, client: Any,
|
||||||
cmd_templates: dict[str, str] | None = None,
|
cmd_templates: dict[str, str],
|
||||||
) -> str | list[dict[str, Any]]:
|
) -> str | list[dict[str, Any]]:
|
||||||
"""Format asset results as text or media payload."""
|
"""Format asset results as text or media payload."""
|
||||||
if not assets:
|
if not assets:
|
||||||
rendered = _render_cmd_template(cmd_templates or {}, "no_results", {"command": cmd, "query": query, "locale": locale})
|
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": query, "locale": locale})
|
||||||
if rendered:
|
|
||||||
return rendered
|
|
||||||
return {"en": "No results found.", "ru": "Ничего не найдено."}.get(locale, "No results found.")
|
|
||||||
|
|
||||||
if response_mode == "media":
|
if response_mode == "media":
|
||||||
media_items = []
|
media_items = []
|
||||||
@@ -606,34 +533,12 @@ def _format_assets(
|
|||||||
})
|
})
|
||||||
return media_items
|
return media_items
|
||||||
|
|
||||||
# Text mode — try template first
|
# Text mode — render via template
|
||||||
# Map command names to template slot names (search/find/person/place share "search" slot)
|
|
||||||
slot_map = {"find": "search", "person": "search", "place": "search"}
|
slot_map = {"find": "search", "person": "search", "place": "search"}
|
||||||
slot_name = slot_map.get(cmd, cmd)
|
slot_name = slot_map.get(cmd, cmd)
|
||||||
rendered = _render_cmd_template(cmd_templates or {}, slot_name, {
|
return _render_cmd_template(cmd_templates, slot_name, {
|
||||||
"assets": assets, "query": query, "command": cmd, "count": len(assets), "locale": locale,
|
"assets": assets, "query": query, "command": cmd, "count": len(assets), "locale": locale,
|
||||||
})
|
})
|
||||||
if rendered:
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
# Hardcoded fallback
|
|
||||||
header_map = {
|
|
||||||
"search": {"en": f'🔍 Results for "{query}":', "ru": f'🔍 Результаты для "{query}":'},
|
|
||||||
"find": {"en": f'📄 Files matching "{query}":', "ru": f'📄 Файлы по запросу "{query}":'},
|
|
||||||
"person": {"en": f"👤 Photos of {query}:", "ru": f"👤 Фото {query}:"},
|
|
||||||
"place": {"en": f"📍 Photos from {query}:", "ru": f"📍 Фото из {query}:"},
|
|
||||||
"favorites": {"en": "⭐ Favorites:", "ru": "⭐ Избранное:"},
|
|
||||||
"latest": {"en": "📸 Latest:", "ru": "📸 Последние:"},
|
|
||||||
"random": {"en": "🎲 Random:", "ru": "🎲 Случайные:"},
|
|
||||||
"memory": {"en": "📅 On this day:", "ru": "📅 В этот день:"},
|
|
||||||
}
|
|
||||||
header = header_map.get(cmd, {}).get(locale, f"Results ({len(assets)}):")
|
|
||||||
lines = []
|
|
||||||
for a in assets:
|
|
||||||
name = a.get("originalFileName", a.get("id", "?")[:8])
|
|
||||||
year = a.get("year", "")
|
|
||||||
lines.append(f" • {name} ({year})" if year else f" • {name}")
|
|
||||||
return header + "\n" + "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_media_group(
|
async def send_media_group(
|
||||||
@@ -711,13 +616,9 @@ async def send_media_group(
|
|||||||
|
|
||||||
|
|
||||||
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
|
||||||
"""Register enabled commands with Telegram BotFather API.
|
"""Register enabled commands with Telegram BotFather API."""
|
||||||
|
ctx_tuples, _ = await _resolve_command_context(bot)
|
||||||
Resolves all command trackers and configs for this bot, merges
|
enabled, locale, _, _, _ = _merge_command_context(ctx_tuples)
|
||||||
enabled commands (union), and calls setMyCommands.
|
|
||||||
"""
|
|
||||||
ctx = await _resolve_command_context(bot)
|
|
||||||
enabled, locale, _, _, _ = _merge_command_context(ctx)
|
|
||||||
|
|
||||||
commands = []
|
commands = []
|
||||||
for cmd in enabled:
|
for cmd in enabled:
|
||||||
|
|||||||
Reference in New Issue
Block a user