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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Show favorites
|
||||||
@@ -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 @@
|
|||||||
|
Показать последние события
|
||||||
+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,33 +29,40 @@ 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."""
|
||||||
|
for slot_name, locale_map in slots.items():
|
||||||
|
for locale, template_text in locale_map.items():
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(CommandTemplateSlot).where(
|
select(CommandTemplateSlot).where(
|
||||||
CommandTemplateSlot.config_id == config_id,
|
CommandTemplateSlot.config_id == config_id,
|
||||||
CommandTemplateSlot.slot_name == slot_name,
|
CommandTemplateSlot.slot_name == slot_name,
|
||||||
|
CommandTemplateSlot.locale == locale,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing = result.first()
|
existing = result.first()
|
||||||
@@ -66,6 +73,7 @@ async def _save_slots(session: AsyncSession, config_id: int, slots: dict[str, st
|
|||||||
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,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
try:
|
||||||
async with http.post(url, json=payload) as resp:
|
async with http.post(url, json=payload) 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)
|
success = True
|
||||||
# Also register for the other locale
|
else:
|
||||||
other_locale = "ru" if locale == "en" else "en"
|
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("description"))
|
||||||
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 commands for locale '%s': %s", locale, err)
|
||||||
return False
|
|
||||||
|
# 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={"commands": en_commands}) as resp:
|
||||||
|
result = await resp.json()
|
||||||
|
if result.get("ok"):
|
||||||
|
_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 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
|
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,39 +210,27 @@ 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()
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
for slot_name, template_text in slots.items():
|
|
||||||
session.add(CommandTemplateSlot(
|
|
||||||
config_id=config.id,
|
|
||||||
slot_name=slot_name,
|
|
||||||
template=template_text,
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
# Update existing system-owned command templates from files
|
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
|
select(CommandTemplateConfig).where(CommandTemplateConfig.user_id == 0)
|
||||||
)
|
)
|
||||||
system_configs = result.all()
|
system_configs = result.all()
|
||||||
for config in system_configs:
|
|
||||||
locale = config.locale if config.locale else ("ru" if "(RU)" in config.name else "en")
|
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)
|
slots = load_default_command_templates(locale)
|
||||||
if not slots:
|
if not slots:
|
||||||
continue
|
continue
|
||||||
@@ -246,6 +239,7 @@ async def _seed_default_command_templates():
|
|||||||
select(CommandTemplateSlot).where(
|
select(CommandTemplateSlot).where(
|
||||||
CommandTemplateSlot.config_id == config.id,
|
CommandTemplateSlot.config_id == config.id,
|
||||||
CommandTemplateSlot.slot_name == slot_name,
|
CommandTemplateSlot.slot_name == slot_name,
|
||||||
|
CommandTemplateSlot.locale == locale,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing = slot_result.first()
|
existing = slot_result.first()
|
||||||
@@ -256,6 +250,7 @@ async def _seed_default_command_templates():
|
|||||||
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,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user