feat: entity relationship refactor — notification trackers, command system, chat actions

Rework entity schema: rename Tracker→NotificationTracker, add CommandConfig/
CommandTracker/CommandTrackerListener entities for decoupled command handling.
Commands now resolve through CommandTracker→CommandConfig instead of
TelegramBot.commands_config. Smart ref-counted bot polling based on active
listeners. Add chat_action to telegram targets. Full frontend CRUD pages
for command configs and command trackers. Idempotent SQLite migrations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 01:27:20 +03:00
parent 0dcca2fbe6
commit 1d445f3980
34 changed files with 2777 additions and 582 deletions
+3 -1
View File
@@ -39,11 +39,13 @@
const baseNavItems = [
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
{ href: '/notification-trackers', key: 'nav.notificationTrackers', icon: 'mdiRadar' },
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
{ href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' },
{ href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' },
];
const navItems = $derived(auth.isAdmin
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
+6
View File
@@ -127,6 +127,9 @@
animateCount(0, status.trackers.active, (v) => displayActive = v);
animateCount(0, status.trackers.total, (v) => displayTotal = v);
animateCount(0, status.targets, (v) => displayTargets = v);
if (status.command_trackers !== undefined) {
animateCount(0, status.command_trackers, (v) => displayCommandTrackers = v);
}
}, 200);
} catch (err: any) {
error = err.message || t('common.error');
@@ -135,10 +138,13 @@
}
}
let displayCommandTrackers = $state(0);
const statCards = $derived(status ? [
{ icon: 'mdiServer', label: 'dashboard.providers', value: displayProviders, color: '#0d9488' },
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []),
] : []);
function timeAgo(dateStr: string): string {
@@ -0,0 +1,240 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
const allCommands = [
{ key: 'help', icon: 'mdiHelpCircle' },
{ key: 'status', icon: 'mdiChartBox' },
{ key: 'albums', icon: 'mdiImageMultiple' },
{ key: 'events', icon: 'mdiPulse' },
{ key: 'summary', icon: 'mdiFileDocumentEdit' },
{ key: 'latest', icon: 'mdiImagePlus' },
{ key: 'memory', icon: 'mdiHistory' },
{ key: 'random', icon: 'mdiDice3' },
{ key: 'search', icon: 'mdiMagnify' },
{ key: 'find', icon: 'mdiFileSearch' },
{ key: 'person', icon: 'mdiAccount' },
{ key: 'place', icon: 'mdiMapMarker' },
{ key: 'favorites', icon: 'mdiStar' },
{ key: 'people', icon: 'mdiAccountGroup' },
];
const defaultForm = () => ({
name: '',
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 },
});
let form = $state(defaultForm());
onMount(load);
async function load() {
try {
configs = await api('/command-configs');
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function editConfig(cfg: any) {
form = {
name: cfg.name,
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 },
};
editing = cfg.id;
showForm = true;
}
function toggleCmd(cmd: string) {
const enabled = [...form.enabled_commands];
const idx = enabled.indexOf(cmd);
if (idx >= 0) enabled.splice(idx, 1);
else enabled.push(cmd);
form.enabled_commands = enabled;
}
async function saveConfig(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
const body = JSON.stringify(form);
if (editing) {
await api(`/command-configs/${editing}`, { method: 'PUT', body });
snackSuccess(t('snack.commandConfigSaved'));
} else {
await api('/command-configs', { method: 'POST', body });
snackSuccess(t('snack.commandConfigSaved'));
}
form = defaultForm(); showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
}
function remove(cfg: any) {
confirmDelete = {
id: cfg.id,
onconfirm: async () => {
try {
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandConfigDeleted'));
} catch (err: any) { snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
</script>
<PageHeader title={t('commandConfig.title')} description={t('commandConfig.description')}>
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? t('common.cancel') : t('commandConfig.newConfig')}
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={saveConfig} class="space-y-4">
<div>
<label for="cfg-name" class="block text-sm font-medium mb-1">{t('commandConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="cfg-name" bind:value={form.name} required placeholder={t('commandConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="cfg-provider-type" class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</label>
<select id="cfg-provider-type" bind:value={form.provider_type}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="immich">Immich</option>
</select>
</div>
<!-- Enabled commands -->
<div>
<p class="text-sm font-medium mb-2">{t('commandConfig.enabledCommands')}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1">
{#each allCommands as cmd}
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<input type="checkbox" checked={form.enabled_commands.includes(cmd.key)}
onchange={() => toggleCmd(cmd.key)} />
<MdiIcon name={cmd.icon} size={14} />
/{cmd.key}
</label>
{/each}
</div>
</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>
<select bind:value={form.locale}
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<div>
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label>
<select bind:value={form.response_mode}
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="media">{t('commandConfig.modeMedia')}</option>
<option value="text">{t('commandConfig.modeText')}</option>
</select>
</div>
<div>
<label class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
<input type="number" bind:value={form.default_count} min="1" max="20"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
<input type="number" bind:value={form.rate_limits.search} min="0" max="300"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="w-1/2 sm:w-1/4">
<label class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
<input type="number" bind:value={form.rate_limits.default} min="0" max="300"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<button type="submit" disabled={submitting}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
</button>
</form>
</Card>
{/if}
{#if configs.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiConsoleLine" message={t('commandConfig.noConfigs')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each configs as cfg}
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={cfg.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{cfg.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{cfg.provider_type}</span>
<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>
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{t('commandConfig.responseMode')}: {cfg.response_mode === 'media' ? t('commandConfig.modeMedia') : t('commandConfig.modeText')}
&middot; {t('commandConfig.defaultCount')}: {cfg.default_count}
</p>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editConfig(cfg)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(cfg)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
@@ -0,0 +1,314 @@
<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let trackers = $state<any[]>([]);
let providers = $state<ServiceProvider[]>([]);
let commandConfigs = $state<any[]>([]);
let telegramBots = $state<TelegramBot[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let submitting = $state(false);
let confirmDelete = $state<any>(null);
let toggling = $state<Record<number, boolean>>({});
// Listeners per tracker
let listeners = $state<Record<number, any[]>>({});
let listenersLoading = $state<Record<number, boolean>>({});
let expandedTracker = $state<number | null>(null);
let addingListener = $state<Record<number, boolean>>({});
let newListenerBotId = $state<Record<number, number>>({});
const defaultForm = () => ({
name: '',
icon: '',
provider_id: 0,
command_config_id: 0,
enabled: true,
});
let form = $state(defaultForm());
// Filter command configs by selected provider's type
let filteredConfigs = $derived(() => {
if (!form.provider_id) return commandConfigs;
const provider = providers.find(p => p.id === form.provider_id);
if (!provider) return commandConfigs;
return commandConfigs.filter(c => c.provider_type === provider.type);
});
onMount(load);
async function load() {
try {
[trackers, providers, commandConfigs, telegramBots] = await Promise.all([
api('/command-trackers'),
api('/providers'),
api('/command-configs'),
api('/telegram-bots'),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; }
function editTracker(trk: any) {
form = {
name: trk.name,
icon: trk.icon || '',
provider_id: trk.provider_id,
command_config_id: trk.command_config_id,
enabled: trk.enabled,
};
editing = trk.id;
showForm = true;
}
async function saveTracker(e: SubmitEvent) {
e.preventDefault(); error = ''; submitting = true;
try {
const body = JSON.stringify(form);
if (editing) {
await api(`/command-trackers/${editing}`, { method: 'PUT', body });
snackSuccess(t('snack.commandTrackerUpdated'));
} else {
await api('/command-trackers', { method: 'POST', body });
snackSuccess(t('snack.commandTrackerCreated'));
}
form = defaultForm(); showForm = false; editing = null; await load();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { submitting = false; }
}
function remove(trk: any) {
confirmDelete = {
id: trk.id,
onconfirm: async () => {
try {
await api(`/command-trackers/${trk.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.commandTrackerDeleted'));
} catch (err: any) { snackError(err.message); }
finally { confirmDelete = null; }
}
};
}
async function toggleEnabled(trk: any) {
toggling = { ...toggling, [trk.id]: true };
try {
const endpoint = trk.enabled ? 'disable' : 'enable';
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
snackSuccess(trk.enabled ? t('snack.commandTrackerDisabled') : t('snack.commandTrackerEnabled'));
await load();
} catch (err: any) { snackError(err.message); }
toggling = { ...toggling, [trk.id]: false };
}
function toggleListeners(trkId: number) {
if (expandedTracker === trkId) {
expandedTracker = null;
return;
}
expandedTracker = trkId;
loadListeners(trkId);
}
async function loadListeners(trkId: number) {
listenersLoading = { ...listenersLoading, [trkId]: true };
try {
listeners = { ...listeners, [trkId]: await api(`/command-trackers/${trkId}/listeners`) };
} catch { listeners = { ...listeners, [trkId]: [] }; }
listenersLoading = { ...listenersLoading, [trkId]: false };
}
async function addListener(trkId: number) {
const botId = newListenerBotId[trkId];
if (!botId) return;
addingListener = { ...addingListener, [trkId]: true };
try {
await api(`/command-trackers/${trkId}/listeners`, {
method: 'POST',
body: JSON.stringify({ listener_type: 'telegram_bot', listener_id: botId }),
});
snackSuccess(t('snack.listenerAdded'));
await loadListeners(trkId);
newListenerBotId = { ...newListenerBotId, [trkId]: 0 };
} catch (err: any) { snackError(err.message); }
addingListener = { ...addingListener, [trkId]: false };
}
async function removeListener(trkId: number, listenerId: number) {
try {
await api(`/command-trackers/${trkId}/listeners/${listenerId}`, { method: 'DELETE' });
snackSuccess(t('snack.listenerRemoved'));
await loadListeners(trkId);
} catch (err: any) { snackError(err.message); }
}
function providerName(id: number): string {
return providers.find(p => p.id === id)?.name || '?';
}
function configName(id: number): string {
return commandConfigs.find(c => c.id === id)?.name || '?';
}
</script>
<PageHeader title={t('commandTracker.title')} description={t('commandTracker.description')}>
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? t('common.cancel') : t('commandTracker.newTracker')}
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={saveTracker} class="space-y-3">
<div>
<label for="trk-name" class="block text-sm font-medium mb-1">{t('commandTracker.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('commandTracker.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</label>
<select id="trk-provider" bind:value={form.provider_id} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0} disabled>{t('commandTracker.selectProvider')}</option>
{#each providers as p}
<option value={p.id}>{p.name} ({p.type})</option>
{/each}
</select>
</div>
<div>
<label for="trk-config" class="block text-sm font-medium mb-1">{t('commandTracker.commandConfig')}</label>
<select id="trk-config" bind:value={form.command_config_id} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0} disabled>{t('commandTracker.selectCommandConfig')}</option>
{#each filteredConfigs() as c}
<option value={c.id}>{c.name}</option>
{/each}
</select>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={form.enabled} />
{t('commandTracker.enabled')}
</label>
<button type="submit" disabled={submitting}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{submitting ? t('common.loading') : (editing ? t('common.save') : t('common.create'))}
</button>
</form>
</Card>
{/if}
{#if trackers.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiConsoleLine" message={t('commandTracker.noTrackers')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each trackers as trk}
<Card hover>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={trk.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{trk.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{providerName(trk.provider_id)}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{configName(trk.command_config_id)}</span>
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-red-500/10 text-red-500'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
{#if trk.listener_count !== undefined}
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">
{trk.listener_count} {t('commandTracker.listeners').toLowerCase()}
</p>
{/if}
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editTracker(trk)} />
<button onclick={() => toggleEnabled(trk)} disabled={toggling[trk.id]}
class="text-xs px-2 py-1 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50">
{trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
</button>
<button onclick={() => toggleListeners(trk.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('commandTracker.listeners')} {expandedTracker === trk.id ? '▲' : '▼'}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(trk)} variant="danger" />
</div>
</div>
<!-- Listeners section -->
{#if expandedTracker === trk.id}
<div class="mt-3 border-t border-[var(--color-border)] pt-3" transition:slide>
{#if listenersLoading[trk.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (listeners[trk.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
{:else}
<div class="space-y-1">
{#each listeners[trk.id] as listener}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
<div class="flex items-center gap-2">
<MdiIcon name="mdiRobot" size={14} />
<span class="font-medium">{listener.name || listener.listener_type}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-500 font-mono">{listener.listener_type}</span>
</div>
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
</div>
{/each}
</div>
{/if}
<!-- Add listener -->
<div class="flex items-center gap-2 mt-2">
<select bind:value={newListenerBotId[trk.id]}
class="flex-1 px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value={0} disabled selected>{t('commandTracker.selectBot')}</option>
{#each telegramBots as bot}
<option value={bot.id}>{bot.name} {bot.bot_username ? `(@${bot.bot_username})` : ''}</option>
{/each}
</select>
<button onclick={() => addListener(trk.id)} disabled={!newListenerBotId[trk.id] || addingListener[trk.id]}
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{addingListener[trk.id] ? t('common.loading') : t('commandTracker.addListener')}
</button>
</div>
</div>
{/if}
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
@@ -18,7 +18,7 @@
let loaded = $state(false);
let loadError = $state('');
let trackers = $state<Tracker[]>([]);
let notificationTrackers = $state<Tracker[]>([]);
let providers = $state<ServiceProvider[]>([]);
let targets = $state<NotificationTarget[]>([]);
let trackingConfigs = $state<TrackingConfig[]>([]);
@@ -59,8 +59,8 @@
async function load() {
loadError = '';
try {
[trackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
api('/trackers'), api('/providers'), api('/targets'),
[notificationTrackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
api('/notification-trackers'), api('/providers'), api('/targets'),
api('/tracking-configs'), api('/template-configs'),
]);
} catch (err: any) {
@@ -126,10 +126,10 @@
submitting = true;
try {
if (editing) {
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
snackSuccess(t('snack.trackerUpdated'));
} else {
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(form) });
snackSuccess(t('snack.trackerCreated'));
}
showForm = false; editing = null; linkWarning = null; await load();
@@ -164,7 +164,7 @@
if (toggling[tracker.id]) return;
toggling = { ...toggling, [tracker.id]: true };
try {
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
await load();
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
@@ -173,7 +173,7 @@
async function doDelete() {
if (!confirmDelete) return;
try {
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.trackerDeleted'));
} catch (err: any) { error = err.message; snackError(err.message); }
@@ -183,10 +183,10 @@
let testMenuStyle = $state('');
const testTypes = [
{ key: 'basic', icon: 'mdiSend', labelKey: 'trackers.testBasic' },
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'trackers.testPeriodic' },
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'trackers.testScheduled' },
{ key: 'memory', icon: 'mdiHistory', labelKey: 'trackers.testMemory' },
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
];
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
@@ -195,7 +195,7 @@
if (ttTesting[key]) return;
ttTesting = { ...ttTesting, [key]: testType };
try {
await api(`/trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
snackSuccess(t('snack.targetTestSent'));
} catch (err: any) {
snackError(err.message);
@@ -237,7 +237,7 @@
if (!targetId) return;
addingTarget = { ...addingTarget, [trackerId]: true };
try {
await api(`/trackers/${trackerId}/targets`, {
await api(`/notification-trackers/${trackerId}/targets`, {
method: 'POST',
body: JSON.stringify({
target_id: targetId,
@@ -256,7 +256,7 @@
async function removeTargetLink(trackerId: number, ttId: number) {
try {
await api(`/trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetUnlinked'));
} catch (err: any) { snackError(err.message); }
@@ -264,7 +264,7 @@
async function updateTargetLink(trackerId: number, tt: any, field: string, value: any) {
try {
await api(`/trackers/${trackerId}/targets/${tt.id}`, {
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
method: 'PUT',
body: JSON.stringify({ [field]: value }),
});
@@ -273,10 +273,10 @@
}
</script>
<PageHeader title={t('trackers.title')} description={t('trackers.description')}>
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}>
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? t('trackers.cancel') : t('trackers.newTracker')}
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
</button>
</PageHeader>
@@ -292,22 +292,22 @@
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-4">
<div>
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
<label for="trk-name" class="block text-sm font-medium mb-1">{t('notificationTracker.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<input id="trk-name" bind:value={form.name} required placeholder={t('notificationTracker.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
<label for="trk-provider" class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
<select id="trk-provider" bind:value={form.provider_id} onchange={loadCollections} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value={0} disabled>{t('trackers.selectServer')}</option>
<option value={0} disabled>{t('notificationTracker.selectServer')}</option>
{#each providers as p}<option value={p.id}>{p.name}</option>{/each}
</select>
</div>
{#if collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({collections.length})</label>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
@@ -327,17 +327,17 @@
{/if}
<div class="grid grid-cols-2 gap-3">
<div>
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('trackers.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('trackers.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{#if linkCheckLoading}{t('trackers.checkingLinks')}{:else}{editing ? t('common.save') : t('trackers.createTracker')}{/if}
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
</button>
</form>
</Card>
@@ -345,13 +345,13 @@
{/if}
{#if loaded && !loadError}
{#if trackers.length === 0 && !showForm}
{#if notificationTrackers.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiRadar" message={t('trackers.noTrackers')} />
<EmptyState icon="mdiRadar" message={t('notificationTracker.noTrackers')} />
</Card>
{:else if !showForm}
<div class="space-y-3 stagger-children">
{#each trackers as tracker}
{#each notificationTrackers as tracker}
<Card hover>
<div class="flex items-center justify-between">
<div>
@@ -359,22 +359,22 @@
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
</span>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{(tracker.collection_ids || []).length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('trackers.linkedTargets')}
{(tracker.collection_ids || []).length} {t('notificationTracker.albums_count')} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
</p>
</div>
<div class="flex items-center gap-1 flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/notification-trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<button onclick={() => toggleExpand(tracker.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('trackers.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
</button>
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
</div>
</div>
@@ -382,7 +382,7 @@
{#if expandedTracker === tracker.id}
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
{#if (tracker.tracker_targets || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('trackers.noLinkedTargets')}</p>
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
{:else}
{#each tracker.tracker_targets as tt}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
@@ -391,7 +391,7 @@
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
{#if !tt.enabled}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('trackers.paused')}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
@@ -413,7 +413,7 @@
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
</div>
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
title={tt.enabled ? t('trackers.pause') : t('trackers.resume')}
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
onclick={() => updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} />
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
onclick={() => removeTargetLink(tracker.id, tt.id)} variant="danger" />
@@ -427,7 +427,7 @@
<div class="flex items-center gap-2 mt-2">
<select bind:value={newLinkTargetId[tracker.id]}
class="text-xs px-2 py-1 border border-[var(--color-border)] rounded-md bg-[var(--color-background)] flex-1">
<option value={0}> {t('trackers.addTarget')} —</option>
<option value={0}> {t('notificationTracker.addTarget')} —</option>
{#each getUnlinkedTargets(tracker) as tgt}<option value={tgt.id}>{tgt.name} ({tgt.type})</option>{/each}
</select>
<select bind:value={newLinkTrackingConfigId[tracker.id]}
@@ -463,7 +463,7 @@
</div>
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
{#each testTypes as tt}
{@const trackerId = trackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
{@const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id}
<button
onclick={() => trackerId && testTrackerTarget(trackerId, Number(testMenuOpen), tt.key)}
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
@@ -478,33 +478,33 @@
</div>
{/if}
<Modal open={linkWarning !== null} title={t('trackers.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={() => { linkWarning = null; }}>
{#if linkWarning}
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
{t('trackers.missingLinksDesc')}
{t('notificationTracker.missingLinksDesc')}
</p>
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
{#each linkWarning.albums as album}
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
<span class="font-medium">{album.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{album.issue === 'expired' ? t('trackers.expired') : album.issue === 'password-protected' ? t('trackers.passwordProtected') : t('trackers.noLink')}
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
</span>
</div>
{/each}
</div>
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiInformation" size={14} /> {t('trackers.linksNote')}
<MdiIcon name="mdiInformation" size={14} /> {t('notificationTracker.linksNote')}
</p>
<div class="flex items-center gap-2 justify-end">
<button onclick={dismissLinkWarning}
class="px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
{t('trackers.saveWithoutLinks')}
{t('notificationTracker.saveWithoutLinks')}
</button>
{#if linkWarning.albums.some(a => a.issue === 'missing')}
<button onclick={autoCreateLinks} disabled={linkCreating}
class="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{linkCreating ? t('common.loading') : t('trackers.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
{linkCreating ? t('common.loading') : t('notificationTracker.createLinks').replace('{count}', String(linkWarning.albums.filter(a => a.issue === 'missing').length))}
</button>
{/if}
</div>
@@ -513,7 +513,7 @@
<ConfirmModal
open={!!confirmDelete}
message={t('trackers.confirmDelete')}
message={t('notificationTracker.confirmDelete')}
onconfirm={doDelete}
oncancel={() => confirmDelete = null}
/>
+19 -3
View File
@@ -23,7 +23,7 @@
let formType = $state<'telegram' | 'webhook'>('telegram');
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false });
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: '' });
let form = $state(defaultForm());
let error = $state('');
let headersError = $state('');
@@ -55,7 +55,7 @@
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false,
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? '',
};
editing = tgt.id; showTelegramSettings = false; showForm = true;
if (form.bot_id) await loadBotChats();
@@ -82,7 +82,7 @@
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions }
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined }
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
@@ -198,6 +198,19 @@
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label for="tgt-chataction" class="block text-xs mb-1">{t('targets.chatAction')}</label>
<select id="tgt-chataction" bind:value={form.chat_action}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
<option value="">{t('targets.chatActionNone')}</option>
<option value="typing">typing</option>
<option value="upload_photo">upload_photo</option>
<option value="upload_video">upload_video</option>
<option value="upload_document">upload_document</option>
<option value="record_video">record_video</option>
<option value="record_voice">record_voice</option>
</select>
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
</div>
@@ -241,6 +254,9 @@
<p class="text-sm text-[var(--color-muted-foreground)]">
{#if target.type === 'telegram'}
Chat: {#if target.chat_name}{target.chat_name} <span class="font-mono text-xs">({target.config?.chat_id})</span>{:else}{target.config?.chat_id || '***'}{/if}
{#if target.config?.chat_action}
<span class="text-xs px-1.5 py-0.5 ml-1 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.config.chat_action}</span>
{/if}
{:else}
{target.config?.url || ''}
{/if}
+52 -112
View File
@@ -108,70 +108,38 @@
let chatTesting = $state<Record<string, boolean>>({});
// Commands config editing
let cmdConfig = $state<Record<number, any>>({});
let cmdSaving = $state<Record<number, boolean>>({});
let cmdSyncing = $state<Record<number, boolean>>({});
let modeChanging = $state<Record<number, boolean>>({});
const allCommands = [
{ key: 'help', icon: 'mdiHelpCircle' },
{ key: 'status', icon: 'mdiChartBox' },
{ key: 'albums', icon: 'mdiImageMultiple' },
{ key: 'events', icon: 'mdiPulse' },
{ key: 'summary', icon: 'mdiFileDocumentEdit' },
{ key: 'latest', icon: 'mdiImagePlus' },
{ key: 'memory', icon: 'mdiHistory' },
{ key: 'random', icon: 'mdiDice3' },
{ key: 'search', icon: 'mdiMagnify' },
{ key: 'find', icon: 'mdiFileSearch' },
{ key: 'person', icon: 'mdiAccount' },
{ key: 'place', icon: 'mdiMapMarker' },
{ key: 'favorites', icon: 'mdiStar' },
{ key: 'people', icon: 'mdiAccountGroup' },
];
// Listener status: command trackers using this bot
let botListenerStatus = $state<Record<number, any[]>>({});
let botListenerLoading = $state<Record<number, boolean>>({});
function initCmdConfig(bot: any) {
if (!cmdConfig[bot.id]) {
const cfg = bot.commands_config || {};
cmdConfig = { ...cmdConfig, [bot.id]: {
enabled: cfg.enabled || ['help', 'status', 'albums', 'events', 'latest', 'random', 'favorites', 'summary', 'memory'],
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 },
}};
}
}
function toggleCmd(botId: number, cmd: string) {
const cfg = cmdConfig[botId];
if (!cfg) return;
const enabled = [...cfg.enabled];
const idx = enabled.indexOf(cmd);
if (idx >= 0) enabled.splice(idx, 1);
else enabled.push(cmd);
cmdConfig = { ...cmdConfig, [botId]: { ...cfg, enabled } };
}
async function saveCmdConfig(botId: number) {
cmdSaving = { ...cmdSaving, [botId]: true };
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
await api(`/telegram-bots/${botId}`, { method: 'PUT', body: JSON.stringify({ commands_config: cmdConfig[botId] }) });
await load();
snackSuccess(t('snack.botUpdated'));
} catch (err: any) { snackError(err.message); }
cmdSaving = { ...cmdSaving, [botId]: false };
// Load all command trackers and filter for ones that have this bot as a listener
const trackers = await api('/command-trackers');
const matched: any[] = [];
for (const trk of trackers) {
try {
const listeners = await api(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some((l: any) => l.listener_type === 'telegram_bot' && l.listener_id === botId);
if (hasBot) matched.push(trk);
} catch { /* ignore */ }
}
botListenerStatus = { ...botListenerStatus, [botId]: matched };
} catch { botListenerStatus = { ...botListenerStatus, [botId]: [] }; }
botListenerLoading = { ...botListenerLoading, [botId]: false };
}
async function syncCommands(botId: number) {
cmdSyncing = { ...cmdSyncing, [botId]: true };
modeChanging = { ...modeChanging, [botId]: true };
try {
const res = await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
if (res.success) snackSuccess(t('telegramBot.commandsSynced'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
cmdSyncing = { ...cmdSyncing, [botId]: false };
modeChanging = { ...modeChanging, [botId]: false };
}
async function switchMode(botId: number, mode: string) {
@@ -315,9 +283,14 @@
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
</button>
<button onclick={() => { initCmdConfig(bot); toggleSection(bot.id, 'commands'); }}
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
{t('telegramBot.commands')} {expandedSection[bot.id] === 'commands' ? '▲' : '▼'}
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
</button>
<button onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]}
class="text-xs text-[var(--color-primary)] hover:underline px-2 py-1 flex items-center gap-1">
<MdiIcon name="mdiSync" size={14} />
{t('telegramBot.syncCommands')}
</button>
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
</div>
@@ -360,67 +333,35 @@
</button>
</div>
{/if}
<!-- Commands section -->
{#if expandedSection[bot.id] === 'commands' && cmdConfig[bot.id]}
<!-- Listener Status section -->
{#if expandedSection[bot.id] === 'listeners'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-3" in:slide>
<!-- Command toggles -->
<div>
<p class="text-xs font-medium mb-2">{t('telegramBot.enabledCommands')}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1">
{#each allCommands as cmd}
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<input type="checkbox" checked={cmdConfig[bot.id].enabled.includes(cmd.key)}
onchange={() => toggleCmd(bot.id, cmd.key)} />
<MdiIcon name={cmd.icon} size={14} />
/{cmd.key}
</label>
{#if botListenerLoading[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (botListenerStatus[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('commandTracker.noListeners')}</p>
{:else}
<div class="space-y-1">
{#each botListenerStatus[bot.id] as trk}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
<div class="flex items-center gap-2">
<MdiIcon name={trk.icon || 'mdiConsoleLine'} size={14} />
<span class="font-medium">{trk.name}</span>
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-red-500/10 text-red-500'}">
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
</span>
</div>
<a href="/command-trackers" class="text-xs text-[var(--color-primary)] hover:underline">
{t('common.edit')}
</a>
</div>
{/each}
</div>
</div>
{/if}
<!-- Settings -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label class="block text-xs mb-1">{t('telegramBot.responseMode')}</label>
<select bind:value={cmdConfig[bot.id].response_mode}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="media">{t('telegramBot.modeMedia')}</option>
<option value="text">{t('telegramBot.modeText')}</option>
</select>
</div>
<div>
<label class="block text-xs mb-1">{t('telegramBot.cmdLocale')}</label>
<select bind:value={cmdConfig[bot.id].locale}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<div>
<label class="block text-xs mb-1">{t('telegramBot.defaultCount')}</label>
<input type="number" bind:value={cmdConfig[bot.id].default_count} min="1" max="20"
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs mb-1">{t('telegramBot.searchCooldown')}</label>
<input type="number" bind:value={cmdConfig[bot.id].rate_limits.search} min="0" max="300"
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<!-- Row 1: Config actions -->
<div class="flex items-center gap-2 flex-wrap">
<button onclick={() => saveCmdConfig(bot.id)} disabled={cmdSaving[bot.id]}
class="px-3 py-1 text-xs bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md hover:opacity-90 disabled:opacity-50">
{cmdSaving[bot.id] ? t('common.loading') : t('telegramBot.saveConfig')}
</button>
<button onclick={() => syncCommands(bot.id)} disabled={cmdSyncing[bot.id]}
class="px-3 py-1 text-xs border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)] disabled:opacity-50">
{cmdSyncing[bot.id] ? t('common.loading') : t('telegramBot.syncCommands')}
</button>
</div>
<!-- Row 2: Update mode -->
<!-- Update mode -->
<div class="border-t border-[var(--color-border)] pt-3">
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
<div class="flex items-center gap-3 flex-wrap">
@@ -459,7 +400,6 @@
class="px-2 py-1 text-xs text-[var(--color-muted-foreground)] hover:underline disabled:opacity-50">
{t('telegramBot.unregisterWebhook')}
</button>
<!-- Webhook status -->
{#if webhookStatus[bot.id]}
{@const ws = webhookStatus[bot.id]}
<span class="text-xs font-mono {ws.url ? 'text-blue-500' : 'text-[var(--color-muted-foreground)]'}">