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