feat: Google Photos provider backend + API hardening

- Add Google Photos provider: client, models, change detector, capabilities
- Add notification templates (en/ru) for all GP event slots
- Add command templates (en/ru) for GP bot commands
- Register GP in slot/command loaders, capabilities, and seeds
- Harden provider API: validate OAuth credentials on create/update
- Add internal URL rewriting for asset fetches (LAN optimization)
- Fix template renderer to handle missing variables gracefully
- Improve webhook command routing for multi-provider support
- Add provider health check endpoint and watcher improvements
This commit is contained in:
2026-03-25 22:07:03 +03:00
parent 337276113d
commit 307871cae5
73 changed files with 1154 additions and 144 deletions
+34 -23
View File
@@ -55,9 +55,11 @@ async function doRefreshAccessToken(): Promise<boolean> {
return false;
}
const DEFAULT_TIMEOUT_MS = 30_000;
export async function api<T = any>(
path: string,
options: RequestInit = {}
options: RequestInit & { timeoutMs?: number } = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
@@ -68,31 +70,40 @@ export async function api<T = any>(
headers['Authorization'] = `Bearer ${token}`;
}
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
const { timeoutMs, ...fetchOptions } = options;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs ?? DEFAULT_TIMEOUT_MS);
const signal = options.signal ?? controller.signal;
// Try token refresh on 401
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
try {
let res = await fetch(`${API_BASE}${path}`, { ...fetchOptions, headers, signal });
// Try token refresh on 401
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(`${API_BASE}${path}`, { ...fetchOptions, headers, signal });
}
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('Unauthorized');
}
throw new Error('Unauthorized');
if (res.status === 204) return undefined as T;
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
} finally {
clearTimeout(timeout);
}
if (res.status === 204) return undefined as T;
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
@@ -136,6 +136,7 @@
});
const flatResults = $derived(results);
const flatIndexMap = $derived(new Map(flatResults.map((item, idx) => [item, idx])));
async function openPalette() {
open = true;
@@ -239,7 +240,7 @@
{group.label}
</div>
{#each group.items as item, i}
{@const flatIdx = flatResults.indexOf(item)}
{@const flatIdx = flatIndexMap.get(item) ?? -1}
<button
class="sp-item"
class:sp-active={flatIdx === activeIndex}
@@ -58,6 +58,12 @@
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
/** Clean up validate timers on unmount */
onMount(() => {
return () => {
for (const timer of Object.values(validateTimers)) clearTimeout(timer);
};
});
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en');
@@ -110,10 +116,12 @@
return form.slots[slotName]?.[activeLocale] || '';
}
/** Set slot template for current locale. */
/** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) {
if (!form.slots[slotName]) form.slots[slotName] = {};
form.slots[slotName][activeLocale] = value;
form.slots = {
...form.slots,
[slotName]: { ...(form.slots[slotName] || {}), [activeLocale]: value }
};
}
onMount(load);
@@ -425,9 +433,9 @@
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} slots</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
+2 -2
View File
@@ -56,8 +56,8 @@
} catch (err: any) {
loadError = err.message || t('providers.loadError');
} finally { loaded = true; highlightFromUrl(); }
// Ping all providers in background
for (const p of providers) {
// Ping all providers in background (use unfiltered list)
for (const p of allProviders) {
health = { ...health, [p.id]: null };
api(`/providers/${p.id}/test`, { method: 'POST' })
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })