170 lines
7.2 KiB
TypeScript
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);
|