feat: locale-aware command templates, debounced auto-sync, entity pickers

- Locale-aware templates: CommandTemplateSlot now has a locale column,
  allowing each slot to have per-language variants (EN/RU). Templates
  are resolved at runtime from the Telegram user's language_code.

- Merged system configs: "Default Commands (EN)" and "(RU)" merged
  into a single "Default Commands" config with locale-aware slots.
  Migration handles existing data automatically.

- Configurable command descriptions: hardcoded COMMAND_DESCRIPTIONS
  replaced with desc_* template slots (desc_status, desc_help, etc.)
  that users can customize per locale. setMyCommands registers all
  locales explicitly.

- Removed locale from CommandConfig: no longer needed since locale
  is derived from the Telegram user's language at runtime.

- Debounced command auto-sync: after command config/tracker changes,
  affected bots are marked dirty and synced after a 30s debounce
  window. Manual "Sync with Telegram" button still works.

- Entity pickers in LinkedTargetsSection: replaced 6 plain <select>
  elements with EntitySelect components (search, icons, keyboard nav).
  Added onselect callback and size="sm" props to EntitySelect.
This commit is contained in:
2026-03-22 03:14:51 +03:00
parent 751097b347
commit 1167d138a3
47 changed files with 604 additions and 230 deletions
@@ -15,6 +15,8 @@
allowNone = false,
noneLabel = '—',
disabled = false,
size = 'default',
onselect,
}: {
items: EntityItem[];
value: string | number | null;
@@ -22,6 +24,8 @@
allowNone?: boolean;
noneLabel?: string;
disabled?: boolean;
size?: 'sm' | 'default';
onselect?: (value: string | number | null) => void;
} = $props();
let open = $state(false);
@@ -59,6 +63,7 @@
function selectItem(item: EntityItem) {
value = item.value || null;
onselect?.(value);
closePalette();
}
@@ -97,7 +102,7 @@
</script>
<!-- Trigger button -->
<button type="button" class="es-trigger" onclick={openPalette}
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
@@ -174,6 +179,11 @@
text-align: left;
cursor: pointer;
}
.es-trigger.es-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
gap: 0.375rem;
}
.es-trigger:hover {
border-color: var(--color-primary);
}
+1 -1
View File
@@ -201,7 +201,7 @@ export interface CommandTemplateConfig {
name: string;
description: string;
icon: string;
slots: Record<string, string>;
slots: Record<string, Record<string, string>>;
created_at: string;
}
@@ -13,7 +13,7 @@
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems, localeItems, responseModeItems } from '$lib/grid-items';
import { providerTypeItems, responseModeItems } from '$lib/grid-items';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
@@ -59,7 +59,6 @@
icon: '',
provider_type: 'immich',
enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[],
locale: 'en',
response_mode: 'media',
default_count: 5,
rate_limits: { search: 30, default: 10 },
@@ -92,7 +91,6 @@
icon: cfg.icon || '',
provider_type: cfg.provider_type || 'immich',
enabled_commands: [...(cfg.enabled_commands || [])],
locale: cfg.locale || 'en',
response_mode: cfg.response_mode || 'media',
default_count: cfg.default_count ?? 5,
rate_limits: { search: cfg.rate_limits?.search ?? 30, default: cfg.rate_limits?.default ?? 10 },
@@ -192,11 +190,7 @@
<EntitySelect items={templateItems} bind:value={form.command_template_config_id} placeholder={t('commandConfig.responseTemplate')} />
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label class="block text-xs mb-1">{t('commandConfig.locale')}</label>
<IconGridSelect items={localeItems()} bind:value={form.locale} columns={2} />
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label>
<IconGridSelect items={responseModeItems(t)} bind:value={form.response_mode} columns={2} />
@@ -244,7 +238,6 @@
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500 font-mono">
{(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
</span>
<span class="text-xs text-[var(--color-muted-foreground)]">{cfg.locale?.toUpperCase()}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-[var(--color-muted-foreground)]">
@@ -26,7 +26,7 @@
name: string;
description: string;
icon: string;
slots: Record<string, string>;
slots: Record<string, Record<string, string>>;
created_at: string;
}
@@ -35,6 +35,8 @@
description: string;
}
const LOCALES = ['en', 'ru'] as const;
let configs = $state<CmdTemplateConfig[]>([]);
let loaded = $state(false);
let showForm = $state(false);
@@ -48,6 +50,7 @@
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en');
// Provider capabilities
let allCapabilities = $state<Record<string, any>>({});
@@ -61,10 +64,21 @@
name: '',
description: '',
icon: '',
slots: {} as Record<string, string>,
slots: {} as Record<string, Record<string, string>>,
});
let form = $state(defaultForm());
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
return form.slots[slotName]?.[activeLocale] || '';
}
/** Set slot template for current locale. */
function setSlotValue(slotName: string, value: string) {
if (!form.slots[slotName]) form.slots[slotName] = {};
form.slots[slotName][activeLocale] = value;
}
onMount(load);
async function load() {
@@ -122,7 +136,7 @@
function refreshAllPreviews() {
for (const slot of commandSlots) {
const template = form.slots[slot.name] || '';
const template = getSlotValue(slot.name);
if (template) validateSlot(slot.name, template, true);
}
}
@@ -131,20 +145,27 @@
form = defaultForm();
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
}
function edit(c: CmdTemplateConfig) {
// Deep copy nested slots
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
slotsCopy[k] = { ...v };
}
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: slotsCopy,
};
editing = c.id;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
@@ -170,15 +191,20 @@
}
function clone(c: CmdTemplateConfig) {
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
slotsCopy[k] = { ...v };
}
form = {
provider_type: c.provider_type,
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: slotsCopy,
};
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
@@ -245,8 +271,20 @@
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<div class="space-y-3 mt-2">
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
</div>
<div class="space-y-3">
{#each commandSlots as slot}
<div>
<div class="flex items-center justify-between mb-1">
@@ -257,8 +295,8 @@
{/if}
</div>
<JinjaEditor
value={form.slots[slot.name] || ''}
onchange={(v: string) => { form.slots[slot.name] = v; validateSlot(slot.name, v); }}
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
/>
@@ -3,6 +3,8 @@
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
interface Props {
@@ -44,6 +46,23 @@
onchangeNewTrackingConfig,
onchangeNewTemplateConfig,
}: Props = $props();
function toItems(configs: any[]): EntityItem[] {
return configsForTracker(configs).map(c => ({
value: c.id,
label: c.name,
icon: c.icon || '',
}));
}
const trackingConfigItems = $derived(toItems(trackingConfigs));
const templateConfigItems = $derived(toItems(templateConfigs));
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
value: tgt.id,
label: tgt.name,
icon: tgt.icon || (tgt.type === 'telegram' ? 'mdiSend' : 'mdiWebhook'),
desc: tgt.type,
})));
</script>
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
@@ -61,18 +80,16 @@
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<select value={tt.tracking_config_id || 0}
onchange={(e: Event) => onupdateLink(tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}>— {t('trackingConfig.title')} —</option>
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={tt.template_config_id || 0}
onchange={(e: Event) => onupdateLink(tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}>— {t('templateConfig.title')} —</option>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
</div>
<div class="relative">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
@@ -91,24 +108,21 @@
<!-- Add target link -->
{#if unlinkedTargets.length > 0}
<div class="flex items-center gap-2 mt-2">
<select value={newLinkTargetId}
onchange={(e: Event) => onchangeNewTarget(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
<option value={0}> {t('notificationTracker.addTarget')} —</option>
{#each unlinkedTargets as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
</select>
<select value={newLinkTrackingConfigId}
onchange={(e: Event) => onchangeNewTrackingConfig(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('trackingConfig.title')} —</option>
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<select value={newLinkTemplateConfigId}
onchange={(e: Event) => onchangeNewTemplateConfig(Number((e.target as HTMLSelectElement).value))}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="flex-1 min-w-[140px]">
<EntitySelect items={targetItems} value={newLinkTargetId || null}
placeholder={'— ' + t('notificationTracker.addTarget') + ' —'} size="sm"
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
</div>
<div class="min-w-[140px]">
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
</div>
<button onclick={onaddLink}
disabled={!newLinkTargetId || addingTarget}
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
@@ -62,6 +62,20 @@ IMMICH_CAPABILITIES = ProviderCapabilities(
{"name": "memory", "description": "/memory On This Day photos"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_albums", "description": "Menu description for /albums"},
{"name": "desc_events", "description": "Menu description for /events"},
{"name": "desc_summary", "description": "Menu description for /summary"},
{"name": "desc_latest", "description": "Menu description for /latest"},
{"name": "desc_memory", "description": "Menu description for /memory"},
{"name": "desc_random", "description": "Menu description for /random"},
{"name": "desc_search", "description": "Menu description for /search"},
{"name": "desc_find", "description": "Menu description for /find"},
{"name": "desc_person", "description": "Menu description for /person"},
{"name": "desc_place", "description": "Menu description for /place"},
{"name": "desc_favorites", "description": "Menu description for /favorites"},
{"name": "desc_people", "description": "Menu description for /people"},
{"name": "desc_help", "description": "Menu description for /help"},
],
events=[
{"name": "assets_added", "description": "New assets detected in album"},
@@ -0,0 +1 @@
List tracked albums
@@ -0,0 +1 @@
Show recent events
@@ -0,0 +1 @@
Search by filename
@@ -0,0 +1 @@
Show available commands
@@ -0,0 +1 @@
Show latest photos
@@ -0,0 +1 @@
On This Day memories
@@ -0,0 +1 @@
List detected people
@@ -0,0 +1 @@
Find photos of person
@@ -0,0 +1 @@
Find photos by location
@@ -0,0 +1 @@
Send random photo
@@ -0,0 +1 @@
Smart search (AI)
@@ -0,0 +1 @@
Show tracker status
@@ -0,0 +1 @@
Send album summary now
@@ -7,13 +7,24 @@ _LOGGER = logging.getLogger(__name__)
_DEFAULTS_DIR = Path(__file__).parent
# All command template slot names (file stem = slot name)
# Response template slot names (file stem = slot name)
COMMAND_SLOT_NAMES = [
"start", "help", "status", "albums", "events", "people",
"search", "latest", "favorites", "random", "summary", "memory",
"rate_limited", "no_results",
]
# Description slots for Telegram command menu (desc_{cmd} -> short text)
COMMAND_DESC_SLOT_NAMES = [
"desc_status", "desc_albums", "desc_events", "desc_summary",
"desc_latest", "desc_memory", "desc_random", "desc_search",
"desc_find", "desc_person", "desc_place", "desc_favorites",
"desc_people", "desc_help",
]
# All slot names (response + description)
ALL_SLOT_NAMES = COMMAND_SLOT_NAMES + COMMAND_DESC_SLOT_NAMES
def load_default_command_templates(locale: str = "en") -> dict[str, str]:
"""Load default command template strings for a locale.
@@ -26,7 +37,7 @@ def load_default_command_templates(locale: str = "en") -> dict[str, str]:
return {}
templates: dict[str, str] = {}
for slot_name in COMMAND_SLOT_NAMES:
for slot_name in ALL_SLOT_NAMES:
filepath = locale_dir / f"{slot_name}.jinja2"
if filepath.exists():
templates[slot_name] = filepath.read_text(encoding="utf-8").strip()
@@ -0,0 +1 @@
Список отслеживаемых альбомов
@@ -0,0 +1 @@
Показать последние события
@@ -0,0 +1 @@
Показать избранное
@@ -0,0 +1 @@
Поиск по имени файла
@@ -0,0 +1 @@
Показать доступные команды
@@ -0,0 +1 @@
Показать последние фото
@@ -0,0 +1 @@
Воспоминания за этот день
@@ -0,0 +1 @@
Список людей
@@ -0,0 +1 @@
Найти фото человека
@@ -0,0 +1 @@
Найти фото по месту
@@ -0,0 +1 @@
Отправить случайное фото
@@ -0,0 +1 @@
Умный поиск (AI)
@@ -0,0 +1 @@
Показать статус трекеров
@@ -0,0 +1 @@
Отправить сводку альбомов
@@ -22,7 +22,6 @@ class CommandConfigCreate(BaseModel):
name: str
icon: str = ""
enabled_commands: list[str] = []
locale: str = "en"
response_mode: str = "media"
default_count: int = 5
rate_limits: dict[str, Any] = {}
@@ -33,7 +32,6 @@ class CommandConfigUpdate(BaseModel):
name: str | None = None
icon: str | None = None
enabled_commands: list[str] | None = None
locale: str | None = None
response_mode: str | None = None
default_count: int | None = None
rate_limits: dict[str, Any] | None = None
@@ -70,11 +68,8 @@ async def create_command_config(
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
)
default_tpl = await _find_system_default_template(session, provider_type)
if default_tpl:
data["command_template_config_id"] = default_tpl.id
@@ -114,6 +109,11 @@ async def update_command_config(
session.add(config)
await session.commit()
await session.refresh(config)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_config
await mark_dirty_for_config(config.id)
return _config_response(config)
@@ -127,6 +127,11 @@ async def delete_command_config(
from .delete_protection import check_command_config, raise_if_used
config = await _get_user_config(session, config_id, user.id)
raise_if_used(await check_command_config(session, config.id), config.name)
# Mark affected bots dirty before deleting
from ..services.command_sync import mark_dirty_for_config
await mark_dirty_for_config(config.id)
await session.delete(config)
await session.commit()
@@ -142,7 +147,6 @@ def _config_response(c: CommandConfig) -> dict:
"name": c.name,
"icon": c.icon,
"enabled_commands": c.enabled_commands or [],
"locale": c.locale,
"response_mode": c.response_mode,
"default_count": c.default_count,
"rate_limits": c.rate_limits or {},
@@ -161,11 +165,9 @@ async def _get_user_config(
async def _find_system_default_template(
session: AsyncSession, provider_type: str, locale: str
session: AsyncSession, provider_type: 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()
"""Find a system default (user_id=0) command template matching provider."""
result = await session.exec(
select(CommandTemplateConfig).where(
CommandTemplateConfig.user_id == 0,
@@ -173,13 +175,4 @@ async def _find_system_default_template(
)
)
templates = result.all()
# Match by locale column first, fall back to name suffix
locale_lower = locale_upper.lower()
for tpl in templates:
if tpl.locale == locale_lower:
return tpl
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
@@ -29,45 +29,53 @@ class CommandTemplateConfigCreate(BaseModel):
name: str
description: str | None = None
icon: str | None = None
slots: dict[str, str] = {} # slot_name -> template text
slots: dict[str, dict[str, str]] = {} # slot_name -> {locale -> template}
class CommandTemplateConfigUpdate(BaseModel):
name: str | None = None
description: str | None = None
icon: str | None = None
slots: dict[str, str] | None = None
slots: dict[str, dict[str, str]] | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, str]:
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, dict[str, str]]:
"""Load slots as {slot_name: {locale: template}}."""
result = await session.exec(
select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config_id)
)
return {s.slot_name: s.template for s in result.all()}
nested: dict[str, dict[str, str]] = {}
for s in result.all():
nested.setdefault(s.slot_name, {})[s.locale] = s.template
return nested
async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, str]) -> None:
for slot_name, template_text in slots.items():
result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config_id,
CommandTemplateSlot.slot_name == slot_name,
async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]) -> None:
"""Save slots from {slot_name: {locale: template}} format."""
for slot_name, locale_map in slots.items():
for locale, template_text in locale_map.items():
result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config_id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
)
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config_id,
slot_name=slot_name,
template=template_text,
))
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config_id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
async def _response(session: AsyncSession, c: CommandTemplateConfig) -> dict[str, Any]:
@@ -87,6 +87,11 @@ async def create_command_tracker(
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
return await _tracker_response(session, tracker)
@@ -130,6 +135,11 @@ async def update_command_tracker(
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
return await _tracker_response(session, tracker)
@@ -142,6 +152,10 @@ async def delete_command_tracker(
"""Delete a command tracker and cascade delete its listeners."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
# Mark affected bots dirty before deleting (chain breaks after deletion)
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Delete associated listeners, collecting bot IDs for polling cleanup
result = await session.exec(
select(CommandTrackerListener).where(
@@ -177,6 +191,10 @@ async def enable_command_tracker(
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Start polling for any telegram bot listeners
lr = await session.exec(
select(CommandTrackerListener).where(
@@ -204,6 +222,10 @@ async def disable_command_tracker(
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Stop polling for any telegram bot listeners that are no longer needed
lr = await session.exec(
select(CommandTrackerListener).where(
@@ -286,6 +308,10 @@ async def add_listener(
from ..services.telegram_poller import start_bot_if_needed
await start_bot_if_needed(body.listener_id)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(body.listener_id)
return _listener_response(listener)
@@ -313,6 +339,10 @@ async def remove_listener(
from ..services.telegram_poller import stop_bot_if_unused
await stop_bot_if_unused(removed_id)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(removed_id)
# --- Helpers ---
@@ -30,7 +30,7 @@ from ..database.models import (
TrackingConfig,
)
from .parser import parse_command
from .registry import COMMAND_DESCRIPTIONS, get_rate_category
from .registry import get_rate_category
_LOGGER = logging.getLogger(__name__)
@@ -53,13 +53,22 @@ def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int
return None
def _resolve_template(
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
) -> str | None:
"""Pick a template string for slot+locale, falling back to 'en'."""
locale_map = templates.get(slot_name, {})
return locale_map.get(locale) or locale_map.get("en")
def _render_cmd_template(
templates: dict[str, str], slot_name: str, context: dict[str, Any]
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
context: dict[str, Any],
) -> str:
"""Render a command template. Returns rendered string or error placeholder."""
template_str = templates.get(slot_name)
"""Render a locale-aware command template. Falls back to 'en'."""
template_str = _resolve_template(templates, slot_name, locale)
if not template_str:
_LOGGER.warning("No command template found for slot '%s'", slot_name)
_LOGGER.warning("No command template found for slot '%s' locale '%s'", slot_name, locale)
return f"[No template: {slot_name}]"
try:
from jinja2.sandbox import SandboxedEnvironment
@@ -73,10 +82,11 @@ def _render_cmd_template(
async def _resolve_command_context(
bot: TelegramBot,
) -> tuple[list[tuple[CommandTracker, CommandConfig, ServiceProvider]], dict[str, str]]:
) -> tuple[list[tuple[CommandTracker, CommandConfig, ServiceProvider]], dict[str, dict[str, str]]]:
"""Resolve all enabled command trackers, configs, and providers for a bot.
Returns (context_tuples, cmd_template_slots).
cmd_template_slots is {slot_name: {locale: template}}.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
@@ -106,7 +116,7 @@ async def _resolve_command_context(
tuples.append((tracker, config, provider))
# Load command template slots from the first config that has one
cmd_template_slots: dict[str, str] = {}
cmd_template_slots: dict[str, dict[str, str]] = {}
for _, config, _ in tuples:
if config.command_template_config_id:
slot_result = await session.exec(
@@ -114,7 +124,8 @@ async def _resolve_command_context(
CommandTemplateSlot.config_id == config.command_template_config_id
)
)
cmd_template_slots = {s.slot_name: s.template for s in slot_result.all()}
for s in slot_result.all():
cmd_template_slots.setdefault(s.slot_name, {})[s.locale] = s.template
if cmd_template_slots:
break
@@ -123,13 +134,13 @@ async def _resolve_command_context(
def _merge_command_context(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> tuple[list[str], str, str, int, dict[str, Any]]:
) -> tuple[list[str], str, int, dict[str, Any]]:
"""Merge enabled_commands from all configs and pick defaults from first config.
Returns (enabled_commands, locale, response_mode, default_count, rate_limits).
Returns (enabled_commands, response_mode, default_count, rate_limits).
"""
if not ctx:
return [], "en", "media", 5, {}
return [], "media", 5, {}
# Union of all enabled commands across configs
enabled: set[str] = set()
@@ -138,29 +149,40 @@ def _merge_command_context(
# Use first config's settings as defaults
first_config = ctx[0][1]
locale = first_config.locale or "en"
response_mode = first_config.response_mode or "media"
default_count = first_config.default_count or 5
rate_limits = first_config.rate_limits or {}
return sorted(enabled), locale, response_mode, default_count, rate_limits
return sorted(enabled), response_mode, default_count, rate_limits
async def handle_command(
bot: TelegramBot,
chat_id: str,
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. Returns text response, media list, or None.
language_code is the Telegram user's language (from message.from.language_code).
Used to pick the right locale for template rendering.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
return None
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
enabled, locale, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
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"
if cmd == "start":
return _render_cmd_template(cmd_templates, "start", {"locale": locale, "bot_name": bot.name})
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
@@ -168,7 +190,7 @@ async def handle_command(
# Rate limit check
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
if wait is not None:
return _render_cmd_template(cmd_templates, "rate_limited", {"wait": wait, "locale": locale})
return _render_cmd_template(cmd_templates, "rate_limited", locale, {"wait": wait})
count = min(count_override or default_count, 20)
@@ -179,7 +201,7 @@ async def handle_command(
# Dispatch — each handler returns template context dict
if cmd == "help":
ctx = _cmd_help(enabled, locale)
ctx = _cmd_help(enabled, locale, cmd_templates)
elif cmd == "status":
ctx = await _cmd_status(bot, providers_map, locale)
elif cmd == "albums":
@@ -194,14 +216,15 @@ async def handle_command(
else:
return None
return _render_cmd_template(cmd_templates, cmd, {**ctx, "locale": locale})
return _render_cmd_template(cmd_templates, cmd, locale, {**ctx})
def _cmd_help(enabled: list[str], locale: str) -> dict[str, Any]:
def _cmd_help(
enabled: list[str], locale: str, templates: dict[str, dict[str, str]],
) -> dict[str, Any]:
commands = []
for cmd in enabled:
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
desc_text = desc.get(locale, desc.get("en", ""))
desc_text = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"name": cmd, "description": desc_text})
return {"commands": commands}
@@ -330,11 +353,11 @@ async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) ->
async def _cmd_immich(
bot: TelegramBot, cmd: str, args: str, count: int, locale: str,
response_mode: str, providers_map: dict[int, ServiceProvider],
cmd_templates: dict[str, str],
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", {"command": cmd, "query": args, "locale": locale})
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())
@@ -351,7 +374,7 @@ async def _cmd_immich(
provider = p
break
if not provider:
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": args, "locale": locale})
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)
@@ -359,19 +382,19 @@ async def _cmd_immich(
if cmd == "search":
if not args:
return _render_cmd_template(cmd_templates, "no_results", {"command": cmd, "query": "", "locale": locale})
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", {"command": cmd, "query": "", "locale": locale})
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", {"command": "person", "query": "", "locale": locale})
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():
@@ -379,13 +402,13 @@ async def _cmd_immich(
person_id = pid
break
if not person_id:
return _render_cmd_template(cmd_templates, "no_results", {"command": "person", "query": args, "locale": locale})
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", {"command": "place", "query": "", "locale": locale})
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
)
@@ -452,7 +475,7 @@ async def _cmd_immich(
albums_data.append({"name": album.name, "asset_count": album.asset_count, "id": album_id})
except Exception:
pass
return _render_cmd_template(cmd_templates, "summary", {"albums": albums_data, "locale": locale})
return _render_cmd_template(cmd_templates, "summary", locale, {"albums": albums_data})
if cmd == "memory":
# Check if any linked tracking config uses native memories
@@ -502,7 +525,7 @@ async def _cmd_immich(
memory_assets = memory_assets[:count]
if not memory_assets:
return _render_cmd_template(cmd_templates, "no_results", {"command": "memory", "query": "", "locale": locale})
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
@@ -511,11 +534,11 @@ async def _cmd_immich(
def _format_assets(
assets: list[dict[str, Any]], cmd: str, query: str,
locale: str, response_mode: str, client: Any,
cmd_templates: dict[str, str],
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", {"command": cmd, "query": query, "locale": locale})
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": cmd, "query": query})
if response_mode == "media":
media_items = []
@@ -536,8 +559,8 @@ def _format_assets(
# 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, {
"assets": assets, "query": query, "command": cmd, "count": len(assets), "locale": locale,
return _render_cmd_template(cmd_templates, slot_name, locale, {
"assets": assets, "query": query, "command": cmd, "count": len(assets),
})
@@ -635,37 +658,49 @@ async def send_media_group(
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
"""Register enabled commands with Telegram BotFather API."""
ctx_tuples, _ = await _resolve_command_context(bot)
enabled, locale, _, _, _ = _merge_command_context(ctx_tuples)
"""Register enabled commands with Telegram BotFather API.
commands = []
for cmd in enabled:
desc = COMMAND_DESCRIPTIONS.get(cmd, {})
commands.append({
"command": cmd,
"description": desc.get(locale, desc.get("en", cmd)),
})
Registers all supported locales explicitly with language_code,
plus English as the default fallback (no language_code).
Descriptions are read from desc_* template slots.
"""
ctx_tuples, templates = await _resolve_command_context(bot)
enabled, _, _, _ = _merge_command_context(ctx_tuples)
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot.token}/setMyCommands"
payload: dict[str, Any] = {"commands": commands}
success = False
for locale in ("en", "ru"):
commands = []
for cmd in enabled:
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:
result = await resp.json()
if result.get("ok"):
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("description"))
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
en_commands.append({"command": cmd, "description": desc})
try:
async with http.post(url, json=payload) as resp:
async with http.post(url, json={"commands": en_commands}) as resp:
result = await resp.json()
if result.get("ok"):
_LOGGER.info("Registered %d commands for bot @%s", len(commands), bot.bot_username)
# Also register for the other locale
other_locale = "ru" if locale == "en" else "en"
other_commands = [
{"command": c, "description": COMMAND_DESCRIPTIONS.get(c, {}).get(other_locale, c)}
for c in enabled
]
async with http.post(url, json={"commands": other_commands, "language_code": other_locale}) as r2:
pass
return True
_LOGGER.warning("Failed to register commands: %s", result.get("description"))
return False
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
success = True
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register commands: %s", err)
return False
_LOGGER.error("Failed to register default commands: %s", err)
return success
@@ -1,27 +1,7 @@
"""Command definitions — descriptions, categories, and rate limit grouping."""
"""Command definitions — categories, rate limit grouping, and defaults."""
from __future__ import annotations
# Command descriptions for Telegram menu (EN / RU)
COMMAND_DESCRIPTIONS: dict[str, dict[str, str]] = {
"status": {"en": "Show tracker status", "ru": "Показать статус трекеров"},
"albums": {"en": "List tracked albums", "ru": "Список отслеживаемых альбомов"},
"events": {"en": "Show recent events", "ru": "Показать последние события"},
"summary": {"en": "Send album summary now", "ru": "Отправить сводку альбомов"},
"latest": {"en": "Show latest photos", "ru": "Показать последние фото"},
"memory": {"en": "On This Day memories", "ru": "Воспоминания за этот день"},
"random": {"en": "Send random photo", "ru": "Отправить случайное фото"},
"search": {"en": "Smart search (AI)", "ru": "Умный поиск (AI)"},
"find": {"en": "Search by filename", "ru": "Поиск по имени файла"},
"person": {"en": "Find photos of person", "ru": "Найти фото человека"},
"place": {"en": "Find photos by location", "ru": "Найти фото по месту"},
"favorites": {"en": "Show favorites", "ru": "Показать избранное"},
"people": {"en": "List detected people", "ru": "Список людей"},
"help": {"en": "Show available commands", "ru": "Показать доступные команды"},
}
ALL_COMMANDS = list(COMMAND_DESCRIPTIONS.keys())
# Map commands to rate limit categories
_RATE_CATEGORY: dict[str, str] = {
"search": "search", "find": "search", "person": "search",
@@ -35,7 +15,6 @@ def get_rate_category(cmd: str) -> str:
DEFAULT_COMMANDS_CONFIG = {
"enabled": ["help", "status", "albums", "events", "latest", "random", "favorites", "summary", "memory"],
"locale": "en",
"response_mode": "media",
"default_count": 5,
"rate_limits": {"search": 30, "default": 10},
@@ -76,7 +76,8 @@ async def telegram_webhook(
# Handle commands
if text.startswith("/"):
cmd_response = await handle_command(bot, chat_id, text)
language_code = message.get("from", {}).get("language_code", "")
cmd_response = await handle_command(bot, chat_id, text, language_code=language_code)
if cmd_response is not None:
if isinstance(cmd_response, list):
await send_media_group(bot.token, chat_id, cmd_response)
@@ -850,3 +850,95 @@ async def migrate_template_locale(engine: AsyncEngine) -> None:
await conn.execute(text(
f"UPDATE {table} SET locale = 'en' WHERE user_id = 0 AND locale = ''"
))
async def migrate_command_slot_locale(engine: AsyncEngine) -> None:
"""Add locale column to command_template_slot and merge system EN/RU configs.
1. Recreate command_template_slot with locale column and new unique constraint
2. Backfill locale from parent config's locale (or 'en')
3. Merge "Default Commands (RU)" slots into "Default Commands (EN)" with locale='ru'
4. Rename merged config, update references, delete orphan RU config
"""
async with engine.begin() as conn:
if not await _has_table(conn, "command_template_slot"):
return
# Skip if locale column already exists (idempotent)
if await _has_column(conn, "command_template_slot", "locale"):
return
logger.info("Adding locale column to command_template_slot and merging system configs")
# Step 1: Recreate table with locale column and new unique constraint
await conn.execute(text(
"CREATE TABLE command_template_slot_new ("
" id INTEGER PRIMARY KEY,"
" config_id INTEGER NOT NULL REFERENCES command_template_config(id),"
" slot_name TEXT NOT NULL,"
" locale TEXT NOT NULL DEFAULT 'en',"
" template TEXT DEFAULT '',"
" UNIQUE(config_id, slot_name, locale)"
")"
))
# Step 2: Copy existing data, deriving locale from parent config
await conn.execute(text(
"INSERT INTO command_template_slot_new (id, config_id, slot_name, locale, template) "
"SELECT s.id, s.config_id, s.slot_name, "
" CASE WHEN c.locale != '' THEN c.locale ELSE 'en' END, "
" s.template "
"FROM command_template_slot s "
"LEFT JOIN command_template_config c ON s.config_id = c.id"
))
await conn.execute(text("DROP TABLE command_template_slot"))
await conn.execute(text(
"ALTER TABLE command_template_slot_new RENAME TO command_template_slot"
))
# Step 3: Merge system EN/RU configs into one
# Find the system EN and RU config IDs
en_row = (await conn.execute(text(
"SELECT id FROM command_template_config "
"WHERE user_id = 0 AND (locale = 'en' OR name LIKE '%(EN)%') "
"LIMIT 1"
))).fetchone()
ru_row = (await conn.execute(text(
"SELECT id FROM command_template_config "
"WHERE user_id = 0 AND (locale = 'ru' OR name LIKE '%(RU)%') "
"LIMIT 1"
))).fetchone()
if en_row and ru_row and en_row[0] != ru_row[0]:
en_id, ru_id = en_row[0], ru_row[0]
# Move RU slots to the EN config (they already have locale='ru')
await conn.execute(text(
"UPDATE command_template_slot SET config_id = :en_id "
"WHERE config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Update any command_config references from RU to EN
if await _has_table(conn, "command_config"):
await conn.execute(text(
"UPDATE command_config SET command_template_config_id = :en_id "
"WHERE command_template_config_id = :ru_id"
), {"en_id": en_id, "ru_id": ru_id})
# Delete the orphan RU config
await conn.execute(text(
"DELETE FROM command_template_config WHERE id = :ru_id"
), {"ru_id": ru_id})
# Rename the merged config
await conn.execute(text(
"UPDATE command_template_config SET name = 'Default Commands', "
"description = 'Default Immich command templates', locale = '' "
"WHERE id = :en_id"
), {"en_id": en_id})
logger.info(
"Merged system command template configs (EN=%d, RU=%d) into single config %d",
en_id, ru_id, en_id,
)
@@ -310,7 +310,6 @@ class CommandConfig(SQLModel, table=True):
name: str
icon: str = Field(default="")
enabled_commands: list[str] = Field(default_factory=list, sa_column=Column(JSON))
locale: str = Field(default="en")
response_mode: str = Field(default="media") # "media" or "text"
default_count: int = Field(default=5)
rate_limits: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
@@ -336,19 +335,22 @@ class CommandTemplateConfig(SQLModel, table=True):
class CommandTemplateSlot(SQLModel, table=True):
"""One Jinja2 template for a specific command response slot.
"""One Jinja2 template for a specific command response slot and locale.
Slot names match command names (e.g. 'status', 'help', 'albums').
Description slots use 'desc_' prefix (e.g. 'desc_status', 'desc_help').
Each (config, slot, locale) triple holds a separate template.
"""
__tablename__ = "command_template_slot"
__table_args__ = (
UniqueConstraint("config_id", "slot_name", name="uq_command_template_slot"),
UniqueConstraint("config_id", "slot_name", "locale", name="uq_cmd_slot_locale"),
)
id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="command_template_config.id", index=True)
slot_name: str
locale: str = Field(default="en")
template: str = Field(default="", sa_column=Column(Text, default=""))
@@ -39,7 +39,7 @@ async def lifespan(app: FastAPI):
await init_db()
# Run data migrations (idempotent)
from .database.engine import get_engine
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config, migrate_command_slot_locale
engine = get_engine()
await migrate_schema(engine)
await migrate_tracker_targets(engine)
@@ -48,6 +48,7 @@ async def lifespan(app: FastAPI):
await migrate_target_receivers(engine)
await migrate_template_locale(engine)
await migrate_receivers_from_config(engine)
await migrate_command_slot_locale(engine)
await _seed_default_templates()
await _seed_default_command_templates()
# Configure webhook secret from DB setting (falls back to env var)
@@ -196,7 +197,11 @@ async def _seed_default_templates():
async def _seed_default_command_templates():
"""Seed or update default command response templates on startup."""
"""Seed or update default command response templates on startup.
Creates a single 'Default Commands' config with locale-aware slots
(each slot has an EN and RU version stored as separate rows).
"""
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine
@@ -205,59 +210,49 @@ async def _seed_default_command_templates():
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(select(func.count()).select_from(CommandTemplateConfig))
count = result.one()
# Find or create the system-owned config
result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
system_configs = result.all()
if count == 0:
# First startup — seed EN and RU defaults
for locale in ("en", "ru"):
slots = load_default_command_templates(locale)
if not slots:
continue
name = f"Default Commands ({locale.upper()})"
config = CommandTemplateConfig(
user_id=0,
provider_type="immich",
name=name,
description=f"Default Immich command templates ({locale.upper()})",
locale=locale,
if not system_configs:
# First startup — create single merged config
config = CommandTemplateConfig(
user_id=0,
provider_type="immich",
name="Default Commands",
description="Default Immich command templates",
)
session.add(config)
await session.flush()
else:
config = system_configs[0]
# Upsert slots for each locale
for locale in ("en", "ru"):
slots = load_default_command_templates(locale)
if not slots:
continue
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config.id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
)
session.add(config)
await session.flush()
for slot_name, template_text in slots.items():
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config.id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
else:
# Update existing system-owned command templates from files
result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
system_configs = result.all()
for config in system_configs:
locale = config.locale if config.locale else ("ru" if "(RU)" in config.name else "en")
slots = load_default_command_templates(locale)
if not slots:
continue
for slot_name, template_text in slots.items():
slot_result = await session.exec(
select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config.id,
CommandTemplateSlot.slot_name == slot_name,
)
)
existing = slot_result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot(
config_id=config.id,
slot_name=slot_name,
template=template_text,
))
await session.commit()
@@ -0,0 +1,126 @@
"""Debounced auto-sync of Telegram bot commands.
When a CommandConfig, CommandTracker, or CommandTrackerListener changes,
the affected bot(s) are marked dirty. A periodic scheduler job checks
for bots that have been dirty longer than the debounce window and syncs
their commands with the Telegram API.
The manual "Sync with Telegram" button remains available and is unaffected.
"""
from __future__ import annotations
import logging
import time
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import CommandConfig, CommandTracker, CommandTrackerListener, TelegramBot
_LOGGER = logging.getLogger(__name__)
# bot_id -> timestamp when it was first marked dirty
_dirty_bots: dict[int, float] = {}
# Seconds to wait after the last dirty mark before syncing
DEBOUNCE_SECONDS = 30
def mark_bot_dirty(bot_id: int) -> None:
"""Mark a bot as needing a command sync with Telegram."""
if bot_id not in _dirty_bots:
_dirty_bots[bot_id] = time.time()
_LOGGER.debug("Marked bot %d dirty for command sync", bot_id)
else:
# Reset the debounce timer on each new change
_dirty_bots[bot_id] = time.time()
async def mark_dirty_for_config(config_id: int) -> None:
"""Find all bots linked to a CommandConfig and mark them dirty.
Chain: CommandConfig -> CommandTracker -> CommandTrackerListener -> TelegramBot
"""
engine = get_engine()
async with AsyncSession(engine) as session:
# Find trackers using this config
result = await session.exec(
select(CommandTracker.id).where(CommandTracker.command_config_id == config_id)
)
tracker_ids = list(result.all())
if not tracker_ids:
return
# Find telegram bot listeners for those trackers
result = await session.exec(
select(CommandTrackerListener.listener_id).where(
CommandTrackerListener.command_tracker_id.in_(tracker_ids),
CommandTrackerListener.listener_type == "telegram_bot",
)
)
for bot_id in result.all():
mark_bot_dirty(bot_id)
async def mark_dirty_for_tracker(tracker_id: int) -> None:
"""Find all bots linked to a CommandTracker and mark them dirty."""
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
select(CommandTrackerListener.listener_id).where(
CommandTrackerListener.command_tracker_id == tracker_id,
CommandTrackerListener.listener_type == "telegram_bot",
)
)
for bot_id in result.all():
mark_bot_dirty(bot_id)
async def _flush_dirty_bots() -> None:
"""Check for bots that have been dirty past the debounce window and sync them."""
if not _dirty_bots:
return
now = time.time()
ready = [bid for bid, ts in _dirty_bots.items() if now - ts >= DEBOUNCE_SECONDS]
if not ready:
return
from ..commands.handler import register_commands_with_telegram
engine = get_engine()
for bot_id in ready:
_dirty_bots.pop(bot_id, None)
try:
async with AsyncSession(engine) as session:
bot = await session.get(TelegramBot, bot_id)
if not bot:
continue
success = await register_commands_with_telegram(bot)
if success:
_LOGGER.info("Auto-synced commands for bot %d (@%s)", bot_id, bot.bot_username)
else:
_LOGGER.warning("Auto-sync failed for bot %d", bot_id)
except Exception:
_LOGGER.error("Error auto-syncing commands for bot %d", bot_id, exc_info=True)
def start_sync_scheduler() -> None:
"""Register the periodic flush job with APScheduler."""
from .scheduler import get_scheduler
scheduler = get_scheduler()
job_id = "command_sync_flush"
if scheduler.get_job(job_id):
return
scheduler.add_job(
_flush_dirty_bots,
"interval",
seconds=10,
id=job_id,
replace_existing=True,
max_instances=1,
)
_LOGGER.info("Command auto-sync scheduler started (debounce=%ds)", DEBOUNCE_SECONDS)
@@ -30,6 +30,10 @@ async def start_scheduler() -> None:
from .telegram_poller import start_command_listener_polling
await start_command_listener_polling()
# Start debounced command auto-sync scheduler
from .command_sync import start_sync_scheduler
start_sync_scheduler()
async def _load_tracker_jobs() -> None:
"""Load enabled trackers and schedule polling jobs."""
@@ -205,7 +205,8 @@ async def _poll_bot(bot_id: int) -> None:
# Dispatch commands
if text and text.startswith("/"):
try:
cmd_response = await handle_command(bot_obj, chat_id, text)
language_code = message.get("from", {}).get("language_code", "")
cmd_response = await handle_command(bot_obj, chat_id, text, language_code=language_code)
if cmd_response is not None:
if isinstance(cmd_response, list):
await send_media_group(bot_token, chat_id, cmd_response)