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, allowNone = false,
noneLabel = '—', noneLabel = '—',
disabled = false, disabled = false,
size = 'default',
onselect,
}: { }: {
items: EntityItem[]; items: EntityItem[];
value: string | number | null; value: string | number | null;
@@ -22,6 +24,8 @@
allowNone?: boolean; allowNone?: boolean;
noneLabel?: string; noneLabel?: string;
disabled?: boolean; disabled?: boolean;
size?: 'sm' | 'default';
onselect?: (value: string | number | null) => void;
} = $props(); } = $props();
let open = $state(false); let open = $state(false);
@@ -59,6 +63,7 @@
function selectItem(item: EntityItem) { function selectItem(item: EntityItem) {
value = item.value || null; value = item.value || null;
onselect?.(value);
closePalette(); closePalette();
} }
@@ -97,7 +102,7 @@
</script> </script>
<!-- Trigger button --> <!-- 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'};"> style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected} {#if selected}
{#if selected.icon} {#if selected.icon}
@@ -174,6 +179,11 @@
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.es-trigger.es-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
gap: 0.375rem;
}
.es-trigger:hover { .es-trigger:hover {
border-color: var(--color-primary); border-color: var(--color-primary);
} }
+1 -1
View File
@@ -201,7 +201,7 @@ export interface CommandTemplateConfig {
name: string; name: string;
description: string; description: string;
icon: string; icon: string;
slots: Record<string, string>; slots: Record<string, Record<string, string>>;
created_at: string; created_at: string;
} }
@@ -13,7 +13,7 @@
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte'; import CrossLink from '$lib/components/CrossLink.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.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 EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight'; import { highlightFromUrl } from '$lib/highlight';
@@ -59,7 +59,6 @@
icon: '', icon: '',
provider_type: 'immich', provider_type: 'immich',
enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[], enabled_commands: ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'] as string[],
locale: 'en',
response_mode: 'media', response_mode: 'media',
default_count: 5, default_count: 5,
rate_limits: { search: 30, default: 10 }, rate_limits: { search: 30, default: 10 },
@@ -92,7 +91,6 @@
icon: cfg.icon || '', icon: cfg.icon || '',
provider_type: cfg.provider_type || 'immich', provider_type: cfg.provider_type || 'immich',
enabled_commands: [...(cfg.enabled_commands || [])], enabled_commands: [...(cfg.enabled_commands || [])],
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 },
@@ -192,11 +190,7 @@
<EntitySelect items={templateItems} bind:value={form.command_template_config_id} placeholder={t('commandConfig.responseTemplate')} /> <EntitySelect items={templateItems} bind:value={form.command_template_config_id} placeholder={t('commandConfig.responseTemplate')} />
</div> </div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div class="grid grid-cols-2 sm:grid-cols-3 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> <div>
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label> <label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label>
<IconGridSelect items={responseModeItems(t)} bind:value={form.response_mode} columns={2} /> <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"> <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')} {(cfg.enabled_commands || []).length} {t('commandConfig.commands')}
</span> </span>
<span class="text-xs text-[var(--color-muted-foreground)]">{cfg.locale?.toUpperCase()}</span>
</div> </div>
<div class="flex items-center gap-2 mt-0.5"> <div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-[var(--color-muted-foreground)]"> <span class="text-xs text-[var(--color-muted-foreground)]">
@@ -26,7 +26,7 @@
name: string; name: string;
description: string; description: string;
icon: string; icon: string;
slots: Record<string, string>; slots: Record<string, Record<string, string>>;
created_at: string; created_at: string;
} }
@@ -35,6 +35,8 @@
description: string; description: string;
} }
const LOCALES = ['en', 'ru'] as const;
let configs = $state<CmdTemplateConfig[]>([]); let configs = $state<CmdTemplateConfig[]>([]);
let loaded = $state(false); let loaded = $state(false);
let showForm = $state(false); let showForm = $state(false);
@@ -48,6 +50,7 @@
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {}; let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
let varsRef = $state<Record<string, any>>({}); let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null); let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en');
// Provider capabilities // Provider capabilities
let allCapabilities = $state<Record<string, any>>({}); let allCapabilities = $state<Record<string, any>>({});
@@ -61,10 +64,21 @@
name: '', name: '',
description: '', description: '',
icon: '', icon: '',
slots: {} as Record<string, string>, slots: {} as Record<string, Record<string, string>>,
}); });
let form = $state(defaultForm()); 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); onMount(load);
async function load() { async function load() {
@@ -122,7 +136,7 @@
function refreshAllPreviews() { function refreshAllPreviews() {
for (const slot of commandSlots) { for (const slot of commandSlots) {
const template = form.slots[slot.name] || ''; const template = getSlotValue(slot.name);
if (template) validateSlot(slot.name, template, true); if (template) validateSlot(slot.name, template, true);
} }
} }
@@ -131,20 +145,27 @@
form = defaultForm(); form = defaultForm();
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en';
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
} }
function edit(c: CmdTemplateConfig) { 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 = { form = {
provider_type: c.provider_type, provider_type: c.provider_type,
name: c.name, name: c.name,
description: c.description || '', description: c.description || '',
icon: c.icon || '', icon: c.icon || '',
slots: { ...c.slots }, slots: slotsCopy,
}; };
editing = c.id; editing = c.id;
showForm = true; showForm = true;
activeLocale = 'en';
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
@@ -170,15 +191,20 @@
} }
function clone(c: CmdTemplateConfig) { function clone(c: CmdTemplateConfig) {
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
slotsCopy[k] = { ...v };
}
form = { form = {
provider_type: c.provider_type, provider_type: c.provider_type,
name: `${c.name} (Copy)`, name: `${c.name} (Copy)`,
description: c.description || '', description: c.description || '',
icon: c.icon || '', icon: c.icon || '',
slots: { ...c.slots }, slots: slotsCopy,
}; };
editing = null; editing = null;
showForm = true; showForm = true;
activeLocale = 'en';
slotPreview = {}; slotPreview = {};
slotErrors = {}; slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100); setTimeout(() => refreshAllPreviews(), 100);
@@ -245,8 +271,20 @@
<fieldset class="border border-[var(--color-border)] rounded-md p-3"> <fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend> <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> <p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<div class="space-y-3 mt-2">
<!-- 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} {#each commandSlots as slot}
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
@@ -257,8 +295,8 @@
{/if} {/if}
</div> </div>
<JinjaEditor <JinjaEditor
value={form.slots[slot.name] || ''} value={getSlotValue(slot.name)}
onchange={(v: string) => { form.slots[slot.name] = v; validateSlot(slot.name, v); }} onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3} rows={3}
errorLine={slotErrorLines[slot.name] || null} errorLine={slotErrorLines[slot.name] || null}
/> />
@@ -3,6 +3,8 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte';
import IconButton from '$lib/components/IconButton.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'; import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
interface Props { interface Props {
@@ -44,6 +46,23 @@
onchangeNewTrackingConfig, onchangeNewTrackingConfig,
onchangeNewTemplateConfig, onchangeNewTemplateConfig,
}: Props = $props(); }: 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> </script>
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide> <div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
@@ -61,18 +80,16 @@
{/if} {/if}
</div> </div>
<div class="flex items-center gap-2 flex-wrap justify-end"> <div class="flex items-center gap-2 flex-wrap justify-end">
<select value={tt.tracking_config_id || 0} <div class="min-w-[140px]">
onchange={(e: Event) => onupdateLink(tt, 'tracking_config_id', Number((e.target as HTMLSelectElement).value) || null)} <EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]"> placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
<option value={0}>— {t('trackingConfig.title')} —</option> onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each} </div>
</select> <div class="min-w-[140px]">
<select value={tt.template_config_id || 0} <EntitySelect items={templateConfigItems} value={tt.template_config_id}
onchange={(e: Event) => onupdateLink(tt, 'template_config_id', Number((e.target as HTMLSelectElement).value) || null)} placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]"> onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
<option value={0}>— {t('templateConfig.title')} —</option> </div>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<div class="relative"> <div class="relative">
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')} <IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)} onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
@@ -91,24 +108,21 @@
<!-- Add target link --> <!-- Add target link -->
{#if unlinkedTargets.length > 0} {#if unlinkedTargets.length > 0}
<div class="flex items-center gap-2 mt-2"> <div class="flex items-center gap-2 mt-2">
<select value={newLinkTargetId} <div class="flex-1 min-w-[140px]">
onchange={(e: Event) => onchangeNewTarget(Number((e.target as HTMLSelectElement).value))} <EntitySelect items={targetItems} value={newLinkTargetId || null}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1"> placeholder={'— ' + t('notificationTracker.addTarget') + ' —'} size="sm"
<option value={0}> {t('notificationTracker.addTarget')} —</option> onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
{#each unlinkedTargets as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each} </div>
</select> <div class="min-w-[140px]">
<select value={newLinkTrackingConfigId} <EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
onchange={(e: Event) => onchangeNewTrackingConfig(Number((e.target as HTMLSelectElement).value))} placeholder={'— ' + t('trackingConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('trackingConfig.title') + ' —'}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]"> onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
<option value={0}> {t('trackingConfig.title')} —</option> </div>
{#each configsForTracker(trackingConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each} <div class="min-w-[140px]">
</select> <EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
<select value={newLinkTemplateConfigId} placeholder={'— ' + t('templateConfig.title') + ' —'} size="sm" allowNone noneLabel={'— ' + t('templateConfig.title') + ' —'}
onchange={(e: Event) => onchangeNewTemplateConfig(Number((e.target as HTMLSelectElement).value))} onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)]"> </div>
<option value={0}> {t('templateConfig.title')} —</option>
{#each configsForTracker(templateConfigs) as tc}<option value={tc.id}>{tc.name}</option>{/each}
</select>
<button onclick={onaddLink} <button onclick={onaddLink}
disabled={!newLinkTargetId || addingTarget} 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"> 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": "memory", "description": "/memory On This Day photos"},
{"name": "rate_limited", "description": "Rate limit warning message"}, {"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"}, {"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=[ events=[
{"name": "assets_added", "description": "New assets detected in album"}, {"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 _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 = [ COMMAND_SLOT_NAMES = [
"start", "help", "status", "albums", "events", "people", "start", "help", "status", "albums", "events", "people",
"search", "latest", "favorites", "random", "summary", "memory", "search", "latest", "favorites", "random", "summary", "memory",
"rate_limited", "no_results", "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]: def load_default_command_templates(locale: str = "en") -> dict[str, str]:
"""Load default command template strings for a locale. """Load default command template strings for a locale.
@@ -26,7 +37,7 @@ def load_default_command_templates(locale: str = "en") -> dict[str, str]:
return {} return {}
templates: dict[str, str] = {} 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" filepath = locale_dir / f"{slot_name}.jinja2"
if filepath.exists(): if filepath.exists():
templates[slot_name] = filepath.read_text(encoding="utf-8").strip() 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 name: str
icon: str = "" icon: str = ""
enabled_commands: list[str] = [] enabled_commands: list[str] = []
locale: str = "en"
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] = {}
@@ -33,7 +32,6 @@ class CommandConfigUpdate(BaseModel):
name: str | None = None name: str | None = None
icon: str | None = None icon: str | None = None
enabled_commands: list[str] | None = None enabled_commands: list[str] | None = None
locale: str | None = None
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
@@ -70,11 +68,8 @@ async def create_command_config(
data = body.model_dump() data = body.model_dump()
# Auto-assign system default template if none specified # Auto-assign system default template if none specified
if not data.get("command_template_config_id"): if not data.get("command_template_config_id"):
locale = data.get("locale", "en")
provider_type = data.get("provider_type", "immich") provider_type = data.get("provider_type", "immich")
default_tpl = await _find_system_default_template( default_tpl = await _find_system_default_template(session, provider_type)
session, provider_type, locale
)
if default_tpl: if default_tpl:
data["command_template_config_id"] = default_tpl.id data["command_template_config_id"] = default_tpl.id
@@ -114,6 +109,11 @@ async def update_command_config(
session.add(config) session.add(config)
await session.commit() await session.commit()
await session.refresh(config) 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) return _config_response(config)
@@ -127,6 +127,11 @@ async def delete_command_config(
from .delete_protection import check_command_config, raise_if_used from .delete_protection import check_command_config, raise_if_used
config = await _get_user_config(session, config_id, user.id) config = await _get_user_config(session, config_id, user.id)
raise_if_used(await check_command_config(session, config.id), config.name) 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.delete(config)
await session.commit() await session.commit()
@@ -142,7 +147,6 @@ def _config_response(c: CommandConfig) -> dict:
"name": c.name, "name": c.name,
"icon": c.icon, "icon": c.icon,
"enabled_commands": c.enabled_commands or [], "enabled_commands": c.enabled_commands or [],
"locale": c.locale,
"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 {},
@@ -161,11 +165,9 @@ async def _get_user_config(
async def _find_system_default_template( async def _find_system_default_template(
session: AsyncSession, provider_type: str, locale: str session: AsyncSession, provider_type: str,
) -> CommandTemplateConfig | None: ) -> CommandTemplateConfig | None:
"""Find a system default (user_id=0) command template matching provider + locale.""" """Find a system default (user_id=0) command template matching provider."""
# Try exact locale match first (e.g. "Default Commands (EN)" for locale "en")
locale_upper = locale.upper()
result = await session.exec( result = await session.exec(
select(CommandTemplateConfig).where( select(CommandTemplateConfig).where(
CommandTemplateConfig.user_id == 0, CommandTemplateConfig.user_id == 0,
@@ -173,13 +175,4 @@ async def _find_system_default_template(
) )
) )
templates = result.all() 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 return templates[0] if templates else None
@@ -29,45 +29,53 @@ class CommandTemplateConfigCreate(BaseModel):
name: str name: str
description: str | None = None description: str | None = None
icon: 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): class CommandTemplateConfigUpdate(BaseModel):
name: str | None = None name: str | None = None
description: str | None = None description: str | None = None
icon: str | None = None icon: str | None = None
slots: dict[str, str] | None = None slots: dict[str, dict[str, str]] | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # 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( result = await session.exec(
select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config_id) 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: async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]) -> None:
for slot_name, template_text in slots.items(): """Save slots from {slot_name: {locale: template}} format."""
result = await session.exec( for slot_name, locale_map in slots.items():
select(CommandTemplateSlot).where( for locale, template_text in locale_map.items():
CommandTemplateSlot.config_id == config_id, result = await session.exec(
CommandTemplateSlot.slot_name == slot_name, select(CommandTemplateSlot).where(
CommandTemplateSlot.config_id == config_id,
CommandTemplateSlot.slot_name == slot_name,
CommandTemplateSlot.locale == locale,
)
) )
) existing = result.first()
existing = result.first() if existing:
if existing: existing.template = template_text
existing.template = template_text session.add(existing)
session.add(existing) else:
else: session.add(CommandTemplateSlot(
session.add(CommandTemplateSlot( config_id=config_id,
config_id=config_id, slot_name=slot_name,
slot_name=slot_name, locale=locale,
template=template_text, template=template_text,
)) ))
async def _response(session: AsyncSession, c: CommandTemplateConfig) -> dict[str, Any]: async def _response(session: AsyncSession, c: CommandTemplateConfig) -> dict[str, Any]:
@@ -87,6 +87,11 @@ async def create_command_tracker(
session.add(tracker) session.add(tracker)
await session.commit() await session.commit()
await session.refresh(tracker) 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) return await _tracker_response(session, tracker)
@@ -130,6 +135,11 @@ async def update_command_tracker(
session.add(tracker) session.add(tracker)
await session.commit() await session.commit()
await session.refresh(tracker) 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) return await _tracker_response(session, tracker)
@@ -142,6 +152,10 @@ async def delete_command_tracker(
"""Delete a command tracker and cascade delete its listeners.""" """Delete a command tracker and cascade delete its listeners."""
tracker = await _get_user_tracker(session, tracker_id, user.id) 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 # Delete associated listeners, collecting bot IDs for polling cleanup
result = await session.exec( result = await session.exec(
select(CommandTrackerListener).where( select(CommandTrackerListener).where(
@@ -177,6 +191,10 @@ async def enable_command_tracker(
await session.commit() await session.commit()
await session.refresh(tracker) 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 # Start polling for any telegram bot listeners
lr = await session.exec( lr = await session.exec(
select(CommandTrackerListener).where( select(CommandTrackerListener).where(
@@ -204,6 +222,10 @@ async def disable_command_tracker(
await session.commit() await session.commit()
await session.refresh(tracker) 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 # Stop polling for any telegram bot listeners that are no longer needed
lr = await session.exec( lr = await session.exec(
select(CommandTrackerListener).where( select(CommandTrackerListener).where(
@@ -286,6 +308,10 @@ async def add_listener(
from ..services.telegram_poller import start_bot_if_needed from ..services.telegram_poller import start_bot_if_needed
await start_bot_if_needed(body.listener_id) 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) return _listener_response(listener)
@@ -313,6 +339,10 @@ async def remove_listener(
from ..services.telegram_poller import stop_bot_if_unused from ..services.telegram_poller import stop_bot_if_unused
await stop_bot_if_unused(removed_id) 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 --- # --- Helpers ---
@@ -30,7 +30,7 @@ from ..database.models import (
TrackingConfig, TrackingConfig,
) )
from .parser import parse_command from .parser import parse_command
from .registry import COMMAND_DESCRIPTIONS, get_rate_category from .registry import get_rate_category
_LOGGER = logging.getLogger(__name__) _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 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( 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: ) -> str:
"""Render a command template. Returns rendered string or error placeholder.""" """Render a locale-aware command template. Falls back to 'en'."""
template_str = templates.get(slot_name) template_str = _resolve_template(templates, slot_name, locale)
if not template_str: 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}]" return f"[No template: {slot_name}]"
try: try:
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
@@ -73,10 +82,11 @@ def _render_cmd_template(
async def _resolve_command_context( async def _resolve_command_context(
bot: TelegramBot, 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. """Resolve all enabled command trackers, configs, and providers for a bot.
Returns (context_tuples, cmd_template_slots). Returns (context_tuples, cmd_template_slots).
cmd_template_slots is {slot_name: {locale: template}}.
""" """
engine = get_engine() engine = get_engine()
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:
@@ -106,7 +116,7 @@ async def _resolve_command_context(
tuples.append((tracker, config, provider)) tuples.append((tracker, config, provider))
# Load command template slots from the first config that has one # 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: for _, config, _ in tuples:
if config.command_template_config_id: if config.command_template_config_id:
slot_result = await session.exec( slot_result = await session.exec(
@@ -114,7 +124,8 @@ async def _resolve_command_context(
CommandTemplateSlot.config_id == config.command_template_config_id 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: if cmd_template_slots:
break break
@@ -123,13 +134,13 @@ async def _resolve_command_context(
def _merge_command_context( def _merge_command_context(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]], 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. """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: if not ctx:
return [], "en", "media", 5, {} return [], "media", 5, {}
# Union of all enabled commands across configs # Union of all enabled commands across configs
enabled: set[str] = set() enabled: set[str] = set()
@@ -138,29 +149,40 @@ def _merge_command_context(
# Use first config's settings as defaults # Use first config's settings as defaults
first_config = ctx[0][1] first_config = ctx[0][1]
locale = first_config.locale or "en"
response_mode = first_config.response_mode or "media" response_mode = first_config.response_mode or "media"
default_count = first_config.default_count or 5 default_count = first_config.default_count or 5
rate_limits = first_config.rate_limits or {} 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( async def handle_command(
bot: TelegramBot, bot: TelegramBot,
chat_id: str, chat_id: str,
text: str, text: str,
language_code: str = "",
) -> str | list[dict[str, Any]] | None: ) -> 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) cmd, args, count_override = parse_command(text)
if not cmd: if not cmd:
return None return None
ctx_tuples, cmd_templates = await _resolve_command_context(bot) 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": 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": if cmd not in enabled and cmd != "start":
return None # Silently ignore disabled commands return None # Silently ignore disabled commands
@@ -168,7 +190,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:
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) count = min(count_override or default_count, 20)
@@ -179,7 +201,7 @@ async def handle_command(
# Dispatch — each handler returns template context dict # Dispatch — each handler returns template context dict
if cmd == "help": if cmd == "help":
ctx = _cmd_help(enabled, locale) ctx = _cmd_help(enabled, locale, cmd_templates)
elif cmd == "status": elif cmd == "status":
ctx = await _cmd_status(bot, providers_map, locale) ctx = await _cmd_status(bot, providers_map, locale)
elif cmd == "albums": elif cmd == "albums":
@@ -194,14 +216,15 @@ async def handle_command(
else: else:
return None 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 = [] commands = []
for cmd in enabled: for cmd in enabled:
desc = COMMAND_DESCRIPTIONS.get(cmd, {}) desc_text = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
desc_text = desc.get(locale, desc.get("en", ""))
commands.append({"name": cmd, "description": desc_text}) commands.append({"name": cmd, "description": desc_text})
return {"commands": commands} return {"commands": commands}
@@ -330,11 +353,11 @@ async def _cmd_people(providers_map: dict[int, ServiceProvider], locale: str) ->
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], cmd_templates: dict[str, 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 _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 # Get notification trackers for album data
provider_ids = set(providers_map.keys()) provider_ids = set(providers_map.keys())
@@ -351,7 +374,7 @@ async def _cmd_immich(
provider = p provider = p
break break
if not provider: 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: async with aiohttp.ClientSession() as http:
immich = make_immich_provider(http, provider) immich = make_immich_provider(http, provider)
@@ -359,19 +382,19 @@ async def _cmd_immich(
if cmd == "search": if cmd == "search":
if not args: 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) 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 _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) 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 _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() people = await client.get_people()
person_id = None person_id = None
for pid, pname in people.items(): for pid, pname in people.items():
@@ -379,13 +402,13 @@ async def _cmd_immich(
person_id = pid person_id = pid
break break
if not person_id: 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) 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 _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( 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
) )
@@ -452,7 +475,7 @@ async def _cmd_immich(
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})
except Exception: except Exception:
pass 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": if cmd == "memory":
# Check if any linked tracking config uses native memories # Check if any linked tracking config uses native memories
@@ -502,7 +525,7 @@ async def _cmd_immich(
memory_assets = memory_assets[:count] memory_assets = memory_assets[:count]
if not memory_assets: 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 _format_assets(memory_assets, cmd, "", locale, response_mode, client, cmd_templates)
return None return None
@@ -511,11 +534,11 @@ async def _cmd_immich(
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], cmd_templates: dict[str, 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:
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": if response_mode == "media":
media_items = [] media_items = []
@@ -536,8 +559,8 @@ def _format_assets(
# Text mode — render via template # Text mode — render via template
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)
return _render_cmd_template(cmd_templates, slot_name, { return _render_cmd_template(cmd_templates, slot_name, locale, {
"assets": assets, "query": query, "command": cmd, "count": len(assets), "locale": 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: 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)
enabled, locale, _, _, _ = _merge_command_context(ctx_tuples)
commands = [] Registers all supported locales explicitly with language_code,
for cmd in enabled: plus English as the default fallback (no language_code).
desc = COMMAND_DESCRIPTIONS.get(cmd, {}) Descriptions are read from desc_* template slots.
commands.append({ """
"command": cmd, ctx_tuples, templates = await _resolve_command_context(bot)
"description": desc.get(locale, desc.get("en", cmd)), enabled, _, _, _ = _merge_command_context(ctx_tuples)
})
async with aiohttp.ClientSession() as http: async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot.token}/setMyCommands" 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: 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() result = await resp.json()
if result.get("ok"): if result.get("ok"):
_LOGGER.info("Registered %d commands for bot @%s", len(commands), bot.bot_username) _LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
# Also register for the other locale success = True
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
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.error("Failed to register commands: %s", err) _LOGGER.error("Failed to register default commands: %s", err)
return False
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 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 # Map commands to rate limit categories
_RATE_CATEGORY: dict[str, str] = { _RATE_CATEGORY: dict[str, str] = {
"search": "search", "find": "search", "person": "search", "search": "search", "find": "search", "person": "search",
@@ -35,7 +15,6 @@ def get_rate_category(cmd: str) -> str:
DEFAULT_COMMANDS_CONFIG = { DEFAULT_COMMANDS_CONFIG = {
"enabled": ["help", "status", "albums", "events", "latest", "random", "favorites", "summary", "memory"], "enabled": ["help", "status", "albums", "events", "latest", "random", "favorites", "summary", "memory"],
"locale": "en",
"response_mode": "media", "response_mode": "media",
"default_count": 5, "default_count": 5,
"rate_limits": {"search": 30, "default": 10}, "rate_limits": {"search": 30, "default": 10},
@@ -76,7 +76,8 @@ async def telegram_webhook(
# Handle commands # Handle commands
if text.startswith("/"): 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 cmd_response is not None:
if isinstance(cmd_response, list): if isinstance(cmd_response, list):
await send_media_group(bot.token, chat_id, cmd_response) 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( await conn.execute(text(
f"UPDATE {table} SET locale = 'en' WHERE user_id = 0 AND locale = ''" 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 name: str
icon: str = Field(default="") icon: str = Field(default="")
enabled_commands: list[str] = Field(default_factory=list, sa_column=Column(JSON)) 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" response_mode: str = Field(default="media") # "media" or "text"
default_count: int = Field(default=5) default_count: int = Field(default=5)
rate_limits: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) 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): 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'). 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" __tablename__ = "command_template_slot"
__table_args__ = ( __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) id: int | None = Field(default=None, primary_key=True)
config_id: int = Field(foreign_key="command_template_config.id", index=True) config_id: int = Field(foreign_key="command_template_config.id", index=True)
slot_name: str slot_name: str
locale: str = Field(default="en")
template: str = Field(default="", sa_column=Column(Text, default="")) template: str = Field(default="", sa_column=Column(Text, default=""))
@@ -39,7 +39,7 @@ async def lifespan(app: FastAPI):
await init_db() await init_db()
# Run data migrations (idempotent) # Run data migrations (idempotent)
from .database.engine import get_engine 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() engine = get_engine()
await migrate_schema(engine) await migrate_schema(engine)
await migrate_tracker_targets(engine) await migrate_tracker_targets(engine)
@@ -48,6 +48,7 @@ async def lifespan(app: FastAPI):
await migrate_target_receivers(engine) await migrate_target_receivers(engine)
await migrate_template_locale(engine) await migrate_template_locale(engine)
await migrate_receivers_from_config(engine) await migrate_receivers_from_config(engine)
await migrate_command_slot_locale(engine)
await _seed_default_templates() await _seed_default_templates()
await _seed_default_command_templates() await _seed_default_command_templates()
# Configure webhook secret from DB setting (falls back to env var) # 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(): 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 import func, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from .database.engine import get_engine from .database.engine import get_engine
@@ -205,59 +210,49 @@ async def _seed_default_command_templates():
engine = get_engine() engine = get_engine()
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:
result = await session.exec(select(func.count()).select_from(CommandTemplateConfig)) # Find or create the system-owned config
count = result.one() result = await session.exec(
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
)
system_configs = result.all()
if count == 0: if not system_configs:
# First startup — seed EN and RU defaults # First startup — create single merged config
for locale in ("en", "ru"): config = CommandTemplateConfig(
slots = load_default_command_templates(locale) user_id=0,
if not slots: provider_type="immich",
continue name="Default Commands",
name = f"Default Commands ({locale.upper()})" description="Default Immich command templates",
config = CommandTemplateConfig( )
user_id=0, session.add(config)
provider_type="immich", await session.flush()
name=name, else:
description=f"Default Immich command templates ({locale.upper()})", config = system_configs[0]
locale=locale,
# 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) existing = slot_result.first()
await session.flush() if existing:
for slot_name, template_text in slots.items(): existing.template = template_text
session.add(existing)
else:
session.add(CommandTemplateSlot( session.add(CommandTemplateSlot(
config_id=config.id, config_id=config.id,
slot_name=slot_name, slot_name=slot_name,
locale=locale,
template=template_text, 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() 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 from .telegram_poller import start_command_listener_polling
await 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: async def _load_tracker_jobs() -> None:
"""Load enabled trackers and schedule polling jobs.""" """Load enabled trackers and schedule polling jobs."""
@@ -205,7 +205,8 @@ async def _poll_bot(bot_id: int) -> None:
# Dispatch commands # Dispatch commands
if text and text.startswith("/"): if text and text.startswith("/"):
try: 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 cmd_response is not None:
if isinstance(cmd_response, list): if isinstance(cmd_response, list):
await send_media_group(bot_token, chat_id, cmd_response) await send_media_group(bot_token, chat_id, cmd_response)