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/`. 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.`. 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 = 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 ` `, and bridgev2 strips exactly // `+" "` (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-/` 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(); const seenMxids = new Set(); 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);