vojo/src/app/features/bots/catalog.ts

170 lines
7.2 KiB
TypeScript

import { useMemo } from 'react';
import { useClientConfig } from '../../hooks/useClientConfig';
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
export type BotExperience = {
type: 'matrix-widget';
url: string;
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
* Resolved with the bridgev2 default `!tg` when the operator omits it. */
commandPrefix: string;
};
export type BotPreset = {
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
id: string;
/** Bot user mxid. The DM with this user IS the bot's control room. */
mxid: string;
name: string;
/** Optional operator override of the localized default. When present takes
* precedence over the i18n key `Bots.description.<id>`. Resolved at the
* consumer (see `useBotDescription`). */
description?: string;
experience?: BotExperience;
};
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
const MXID_RE = /^@[^:\s]+:[^\s]+$/;
// Defense-in-depth allowlist of widget origins acceptable in production. The
// BotWidgetDriver capability allowlist (M9) already tightly scopes what a
// widget can do — only m.text/m.notice in the current control DM, no media,
// no OpenID, no cross-room. This origin pin is an additional layer that
// catches operator-config typos or a poisoned config.json that points the
// iframe at an unrelated host. Add new entries when onboarding new widget
// hosts; the dev branch below bypasses this check for `http://localhost:*`.
const PROD_WIDGET_ORIGINS: ReadonlySet<string> = new Set(['https://widgets.vojo.chat']);
// bridgev2's Telegram connector ships `!tg` as DefaultCommandPrefix
// (mautrix/telegram pkg/connector/connector.go:68). Operators can override via
// `bridge.command_prefix` in the mautrix-telegram config; in that case they
// must mirror the override in /config.json so the widget prepends the right
// prefix to every outbound command.
const DEFAULT_BOT_COMMAND_PREFIX = '!tg';
// Reject whitespace and empties — the prefix is concatenated to outbound
// command bodies as `<prefix> <args>`, and bridgev2 strips exactly
// `<prefix>+" "` (queue.go:118). Whitespace inside the prefix would break the
// strip and route the message to the unknown-command fallback.
const COMMAND_PREFIX_RE = /^\S+$/;
const normalizeCommandPrefix = (raw: unknown): string | undefined => {
if (raw === undefined) return DEFAULT_BOT_COMMAND_PREFIX;
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!COMMAND_PREFIX_RE.test(trimmed)) return undefined;
return trimmed;
};
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset?.id === 'string' &&
BOT_ID_RE.test(preset.id) &&
typeof preset.mxid === 'string' &&
MXID_RE.test(preset.mxid) &&
typeof preset.name === 'string' &&
preset.name.trim().length > 0;
const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => {
const type = experience?.type;
const url = experience?.url?.trim();
if (type !== 'matrix-widget' || !url) return undefined;
if (url.startsWith('//')) return undefined;
// Reject the whole experience block when commandPrefix is present but
// malformed — falling back to the default would silently mask an operator
// typo in /config.json that the widget then can't recover from at runtime.
const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix);
if (commandPrefix === undefined) return undefined;
if (url.startsWith('/')) {
// Resolve once so `/widgets/../admin` collapses before the prefix check —
// a relative `/widgets/...` survives `new URL(url, base)` only if it does
// not escape its own segment. Same-origin widgets share the parent's
// localStorage/cookies under `allow-same-origin`, so /widgets/ is the only
// path the operator config is allowed to mount.
//
// Path MUST resolve to a concrete file: the last segment must contain a
// dot (e.g. /widgets/foo/index.html, /widgets/foo.js). Bare directory
// paths or extensionless paths (/widgets/foo, /widgets/foo/) get caught
// by SPA fallback in Vite dev and any history-fallback static server,
// which would then serve the parent app's index.html into the iframe —
// recursive Vojo-in-Vojo sharing localStorage and the matrix client,
// and the two copies infinite-loop on sync events. The dot-in-last-
// segment heuristic keeps the validator simple while catching the
// realistic accident class.
try {
const resolved = new URL(url, 'https://vojo.invalid');
if (resolved.username || resolved.password) return undefined;
if (!resolved.pathname.startsWith('/widgets/')) return undefined;
const lastSegment = resolved.pathname.split('/').pop() ?? '';
if (!lastSegment.includes('.')) return undefined;
return {
type,
url: `${resolved.pathname}${resolved.search}${resolved.hash}`,
commandPrefix,
};
} catch {
return undefined;
}
}
try {
const parsed = new URL(url);
// Dev-only escape hatch: accept http://localhost:* widget URLs so a Vite
// dev server in `apps/widget-<id>/` can be embedded straight from a local
// config.json edit, no proxy or rewrites needed. Vite's dead-code
// elimination drops this branch in production builds (`import.meta.env.DEV`
// collapses to a literal `false`), so it never relaxes the prod validator.
if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') {
if (parsed.username || parsed.password) return undefined;
return { type, url: parsed.toString(), commandPrefix };
}
if (parsed.protocol !== 'https:') return undefined;
if (parsed.username || parsed.password) return undefined;
if (!PROD_WIDGET_ORIGINS.has(parsed.origin)) return undefined;
return { type, url: parsed.toString(), commandPrefix };
} catch {
return undefined;
}
};
export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
const seenIds = new Set<string>();
const seenMxids = new Set<string>();
const bots: BotPreset[] = [];
const configuredBots = Array.isArray(clientConfig.bots) ? clientConfig.bots : [];
configuredBots.forEach((preset) => {
if (!isValidBotPreset(preset)) return;
if (seenIds.has(preset.id) || seenMxids.has(preset.mxid)) return;
seenIds.add(preset.id);
seenMxids.add(preset.mxid);
const description =
typeof preset.description === 'string' && preset.description.trim().length > 0
? preset.description.trim()
: undefined;
bots.push({
id: preset.id,
mxid: preset.mxid,
name: preset.name.trim(),
description,
experience: normalizeBotExperience(preset.experience),
});
});
return bots;
};
// NOTE: description rendering lives at the call site (BotShellHero) via
// `t(\`Bots.description.${preset.id}\`, { defaultValue: preset.description ?? '' })`.
// Catalog stays config-loading-only and doesn't depend on i18next.
export const useBotPresets = (): BotPreset[] => {
const clientConfig = useClientConfig();
return useMemo(() => getBotPresets(clientConfig), [clientConfig]);
};
export const findBotPresetById = (
presets: readonly BotPreset[],
id: string
): BotPreset | undefined => presets.find((preset) => preset.id === id);