feat(bots): add the Vojo AI bot widget and its capability-gated add-to-chat room picker

This commit is contained in:
heaven 2026-05-31 18:30:52 +03:00
parent 4158f9a232
commit fe8ba2878b
26 changed files with 3413 additions and 15 deletions

2
.vscode/tasks.json vendored
View file

@ -16,7 +16,7 @@
{ {
"label": "Deploy widgets", "label": "Deploy widgets",
"type": "shell", "type": "shell",
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL", "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; (cd apps/widget-vojo-ai && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/vojo-ai/) & PID4=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; wait $PID4 || FAIL=1; exit $FAIL",
"group": "none", "group": "none",
"presentation": { "presentation": {
"reveal": "always", "reveal": "always",

4
apps/widget-vojo-ai/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
*.local

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Vojo AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1995
apps/widget-vojo-ai/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
{
"name": "@vojo/widget-vojo-ai",
"version": "0.0.1",
"private": true,
"description": "Vojo AI bot widget — policy notice + «Add to chat», mounts inside /bots/vojo-ai",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "10.22.1"
},
"devDependencies": {
"@preact/preset-vite": "2.9.0",
"typescript": "5.4.5",
"vite": "5.4.19"
}
}

View file

@ -0,0 +1,144 @@
import { useEffect, useState } from 'preact/hooks';
import type { WidgetBootstrap } from './bootstrap';
import type { WidgetApi } from './widget-api';
import { createT, type T } from './i18n';
// Must match the host's capability string (catalog.ts BOT_CAP_ADD_TO_CHAT).
const ADD_TO_CHAT_CAP = 'vojo.add_to_chat';
// Lead glyph for the «Добавить в чат» card — a speech bubble with a «+».
// Stroke-only so it picks up `currentColor` and matches the bridge widgets'
// icon language (viewBox 20×20, stroke-width 1.6).
const AddChatIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M3 5.5A2 2 0 0 1 5 3.5h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7.5L4 16.5v-3A2 2 0 0 1 3 11.5z"
stroke-linejoin="round"
/>
<line x1="10" y1="6.2" x2="10" y2="10.8" stroke-linecap="round" />
<line x1="7.7" y1="8.5" x2="12.3" y2="8.5" stroke-linecap="round" />
</svg>
);
// Shield + check — leads the «Конфиденциальность и данные» card (mirrors the
// Telegram widget's info-card-opens-modal pattern).
const ShieldIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 2.5l6 2.2v4.6c0 3.7-2.5 6.4-6 8.2-3.5-1.8-6-4.5-6-8.2V4.7z"
stroke-linejoin="round"
/>
<path d="M7.4 9.8l1.9 1.9 3.3-3.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Full privacy notice, behind a card → modal (Telegram «О боте» pattern). This
// is where the Grok / xAI / 30-day / third-party detail lives — NOT on the
// surface. Backdrop click + Escape close; no focus-trap (small surface).
const AboutModal = ({ t, onClose }: { t: T; onClose: () => void }) => {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div
class="about-overlay"
role="dialog"
aria-modal="true"
aria-label={t('about.title')}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div class="about-panel">
<header class="about-header">
<h2 class="about-title">{t('about.title')}</h2>
<button
type="button"
class="about-close-x"
onClick={onClose}
aria-label={t('about.aria-close')}
>
×
</button>
</header>
<div class="about-body">
<p>{t('about.body-1')}</p>
<p>{t('about.body-2')}</p>
<p>{t('about.body-3')}</p>
<p>{t('about.body-4')}</p>
<p>{t('about.consent')}</p>
</div>
<div class="about-footer">
<button type="button" class="btn-primary" onClick={onClose}>
{t('about.close')}
</button>
</div>
</div>
</div>
);
};
type AppProps = {
bootstrap: WidgetBootstrap;
api: WidgetApi;
};
export function App({ bootstrap, api }: AppProps) {
const t = createT(bootstrap.clientLanguage);
const [aboutOpen, setAboutOpen] = useState(false);
// Render the action ONLY when the host advertised the capability. UI hint
// only — the host re-checks the capability against the trusted config before
// honouring the verb, so a forced render here cannot escalate anything.
const canAddToChat = bootstrap.capabilities.includes(ADD_TO_CHAT_CAP);
// Follow host theme changes (initial theme applied in main.tsx before paint).
useEffect(() => {
api.on('themeChange', (name) => {
document.documentElement.dataset.theme = name;
});
}, [api]);
return (
<div class="app">
<section class="section">
<div class="section-label">{t('section.label')}</div>
<div class="command-grid">
{canAddToChat && (
<button class="command-card" type="button" onClick={() => api.addToChat()}>
<span class="command-card-lead-icon" aria-hidden="true">
<AddChatIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.add.name')}</div>
<div class="command-card-desc">{t('card.add.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
)}
<button class="command-card" type="button" onClick={() => setAboutOpen(true)}>
<span class="command-card-lead-icon" aria-hidden="true">
<ShieldIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.privacy.name')}</div>
<div class="command-card-desc">{t('card.privacy.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
</div>
</section>
{aboutOpen && <AboutModal t={t} onClose={() => setAboutOpen(false)} />}
</div>
);
}

View file

@ -0,0 +1,69 @@
// Parse the URL params the bot widget host appends when loading experience.url.
// Source of truth on the host side:
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
// Keep this in sync if the host adds params.
export type WidgetBootstrap = {
widgetId: string;
parentUrl: string;
parentOrigin: string;
roomId: string;
userId: string;
botId: string;
botMxid: string;
/** Elevated host verbs this bot is allowed to drive, forwarded by the host
* as a CSV render hint (NOT an authorization input the host re-checks the
* capability against the trusted config before acting). Used here only to
* decide whether to draw the «Add to chat» button. Empty CSV `[]` (F19). */
capabilities: string[];
theme: 'light' | 'dark';
clientLanguage: string;
};
export type BootstrapResult =
| { ok: true; bootstrap: WidgetBootstrap }
| { ok: false; missing: string[] };
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const;
export const readBootstrap = (search: string): BootstrapResult => {
const params = new URLSearchParams(search);
const get = (k: string) => params.get(k) ?? '';
const missing = REQUIRED.filter((k) => !params.get(k));
if (missing.length > 0) return { ok: false, missing: [...missing] };
// Origin is what the widget validates against on incoming postMessage — see
// widget-api.ts. Falling back to '*' would defeat the security boundary, so a
// malformed parentUrl bails out as a missing-param error.
let parentOrigin: string;
try {
parentOrigin = new URL(get('parentUrl')).origin;
} catch {
return { ok: false, missing: ['parentUrl'] };
}
// CSV → string[]. Empty string MUST become [] — `''.split(',')` yields `['']`
// which would make `capabilities.includes(...)` subtly wrong downstream (F19).
const capsRaw = get('capabilities');
const capabilities = capsRaw ? capsRaw.split(',').filter(Boolean) : [];
const themeRaw = get('theme');
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
return {
ok: true,
bootstrap: {
widgetId: get('widgetId'),
parentUrl: get('parentUrl'),
parentOrigin,
roomId: get('roomId'),
userId: get('userId'),
botId: get('botId'),
botMxid: get('botMxid'),
capabilities,
theme,
clientLanguage: get('clientLanguage'),
},
};
};

View file

@ -0,0 +1,27 @@
// English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
// that every RU key has an EN counterpart at compile time.
import type { StringKey } from './ru';
export const EN: Record<StringKey, string> = {
'section.label': 'Robot in chats',
'card.add.name': 'Add to chat',
'card.add.desc': 'Invite Vojo AI into a room — it will reply to mentions there.',
'card.privacy.name': 'Privacy & data',
'card.privacy.desc': 'What is sent to the AI service and how it is stored',
'about.title': 'Vojo AI privacy',
'about.body-1': 'Vojo AI is an AI-powered virtual assistant. The model is provided by xAI (USA).',
'about.body-2':
'When you mention the robot in a chat or message it directly, the text of messages from that chat is sent to xAI to generate a reply and may be stored there for up to 30 days.',
'about.body-3':
'If the robot is added to a group chat, other participants messages may also reach xAI. Only add the robot where appropriate, and let the other participants know.',
'about.body-4':
'Do not send the robot personal, payment, or other confidential data. Replies are AI-generated and may contain errors.',
'about.consent':
'By adding the robot to a chat, you consent to sending that chats messages to xAI.',
'about.close': 'Close',
'about.aria-close': 'Close “Vojo AI privacy”',
'bootstrap.failed': 'Widget failed to start',
'bootstrap.missing-params': 'Missing required URL params: {names}.',
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
};

View file

@ -0,0 +1,30 @@
// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix match —
// any `en` variant). Bootstrap forwards `clientLanguage` from the host; main.tsx
// can also call `createT()` without args before bootstrap completes (falls back
// to navigator.language, then RU).
import { RU, type StringKey } from './ru';
import { EN } from './en';
const interpolate = (s: string, vars?: Record<string, string>): string => {
if (!vars) return s;
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
};
const pickDict = (clientLanguage: string | undefined): Record<StringKey, string> => {
const lang = (
clientLanguage ||
(typeof navigator !== 'undefined' ? navigator.language : '') ||
'ru'
).toLowerCase();
return lang.startsWith('en') ? EN : RU;
};
export type T = (key: StringKey, vars?: Record<string, string>) => string;
export const createT = (clientLanguage?: string): T => {
const dict = pickDict(clientLanguage);
return (key, vars) => interpolate(dict[key], vars);
};
export type { StringKey };

View file

@ -0,0 +1,37 @@
// Russian primary copy. To add a string:
// 1. add the key + RU value here (this file is the canonical key list — `en.ts`
// and the `StringKey` type derive from it),
// 2. add the same key + EN value in `en.ts`,
// 3. consume via `t('key', { var: 'x' })`.
// Interpolation uses `{name}` placeholders resolved against the second arg.
//
// The hero (name/avatar) is OWNED BY THE HOST (src/app/features/bots/BotShell).
// The widget renders action cards + a privacy modal (Telegram «О боте» pattern).
export const RU = {
'section.label': 'Робот в чатах',
// Action card.
'card.add.name': 'Добавить в чат',
'card.add.desc': 'Пригласите Vojo AI в комнату — он будет отвечать на упоминания в ней.',
// Privacy card → opens the full policy modal.
'card.privacy.name': 'Конфиденциальность и данные',
'card.privacy.desc': 'Что отправляется в ИИ-сервис и как хранится',
// Full privacy notice (the «Политика» spelled out — see card.privacy).
'about.title': 'Конфиденциальность Vojo AI',
'about.body-1':
'Vojo AI — виртуальный собеседник на базе искусственного интеллекта. Модель предоставляет компания xAI (США).',
'about.body-2':
'Когда вы упоминаете робота в чате или пишете ему напрямую, текст сообщений из этого чата передаётся в xAI для генерации ответа и может храниться там до 30 дней.',
'about.body-3':
'Если робот добавлен в групповой чат, в xAI могут попасть и сообщения других участников. Добавляйте робота только туда, где это уместно, и предупреждайте собеседников.',
'about.body-4':
'Не отправляйте роботу персональные, платёжные или иные конфиденциальные данные. Ответы генерирует ИИ — они могут содержать ошибки.',
'about.consent': 'Добавляя робота в чат, вы соглашаетесь на передачу сообщений этого чата в xAI.',
'about.close': 'Закрыть',
'about.aria-close': 'Закрыть «Конфиденциальность Vojo AI»',
'bootstrap.failed': 'Виджет не запустился',
'bootstrap.missing-params': 'Не хватает параметров URL: {names}.',
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания в Vojo по адресу {route}.',
} as const;
export type StringKey = keyof typeof RU;

View file

@ -0,0 +1,61 @@
import { render } from 'preact';
import { readBootstrap } from './bootstrap';
import { App } from './App';
import { createT } from './i18n';
import { WidgetApi } from './widget-api';
import './styles.css';
// Input-mode detector for hover styling (same rationale as widget-telegram):
// Capacitor's Android WebView synthesises a sticky `:hover` on the tapped
// element after a tap. CSS gates `:hover` on `:root[data-input="mouse"]`; truth
// comes from `pointerdown.pointerType`. Default 'mouse' is no worse than any
// interaction-media query, which mis-report on shipping devices.
const setInputMode = (mode: 'touch' | 'mouse'): void => {
document.documentElement.dataset.input = mode;
};
setInputMode('mouse');
window.addEventListener(
'pointerdown',
(event) => {
setInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch');
},
{ passive: true, capture: true }
);
const root = document.getElementById('app');
if (!root) {
throw new Error('#app root element missing — index.html out of sync');
}
const result = readBootstrap(window.location.search);
if (!result.ok) {
// Either the widget URL was opened directly (no host params) or a host bug
// failed to provide them. Render a self-contained diagnostic. Bootstrap failed
// before clientLanguage could be read, so createT falls back to
// navigator.language.
const t = createT();
render(
<div class="app">
<div class="error-banner">
<strong>{t('bootstrap.failed')}</strong>
{t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '}
{t('bootstrap.embedded-only', { route: '/bots/vojo-ai' })}
</div>
</div>,
root
);
} else {
// Apply the initial theme synchronously so the first paint isn't flashed
// through the wrong palette.
document.documentElement.dataset.theme = result.bootstrap.theme;
// Instantiate the WidgetApi BEFORE React render — its constructor attaches the
// `message` listener synchronously, so the host's capability request (fired on
// iframe `load`) is never missed on a warm/cached second mount. This widget
// requests NO matrix-widget-api capabilities (it neither reads nor sends
// events); the `add-to-chat` verb rides the separate io.vojo.bot-widget
// side-channel and is intentionally NOT a matrix-widget-api capability.
const api = new WidgetApi(result.bootstrap, []);
render(<App bootstrap={result.bootstrap} api={api} />, root);
}

View file

@ -0,0 +1,360 @@
/* Dawn palette + command-card / about-modal vocabulary a faithful subset of
* apps/widget-telegram/src/styles.css so the Vojo AI widget reads as the same
* surface as the bridge widgets (same palette, sections, command cards, and
* the «about» modal pattern for detailed copy). */
:root {
--bg: #181a20;
--bg2: #0d0e11;
--surface: #21232b;
--surface2: #2a2d36;
--divider: rgba(255, 255, 255, 0.06);
--hairline: rgba(255, 255, 255, 0.08);
--text: #e6e6e9;
--muted: rgba(230, 230, 233, 0.55);
--faint: rgba(230, 230, 233, 0.32);
--fleet: #9580ff;
--fleet-soft: #a59cff;
--green: #7dd3a8;
--rose: #c08e7b;
--section-pad-x: 40px;
}
[data-theme='light'] {
/* Light theme is intentionally a thin remap. Vojo is dark-default. */
--bg: #f5f5f7;
--bg2: #ffffff;
--surface: #f0f0f2;
--surface2: #e8e8ec;
--divider: rgba(0, 0, 0, 0.08);
--hairline: rgba(0, 0, 0, 0.1);
--text: #1a1a1d;
--muted: rgba(26, 26, 29, 0.62);
--faint: rgba(26, 26, 29, 0.4);
}
@media (max-width: 600px) {
:root {
--section-pad-x: 20px;
}
}
* {
box-sizing: border-box;
/* Kills the translucent grey overlay iOS/Android WebViews paint over a
* tapped element (read as «button stuck on grey»). Web browsers ignore it. */
-webkit-tap-highlight-color: transparent;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.app {
display: flex;
flex-direction: column;
min-height: 100%;
max-width: 960px;
margin: 0 auto;
}
/* The hero (avatar + name + handle + description + three-dots menu) is OWNED
* BY THE HOST see src/app/features/bots/BotShell.tsx. The widget body starts
* with the action/privacy section directly. */
/* ── Section ──────────────────────────────────────────────────────── */
.section {
padding: 24px var(--section-pad-x) 20px;
}
.section + .section {
padding-top: 4px;
}
/* Section label — dark-bg pill, uppercase letter-spaced caption. */
.section-label {
display: inline-flex;
align-items: center;
font-size: 13px;
line-height: 20px;
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
color: var(--muted);
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 8px;
padding: 8px 14px;
margin: 0 0 14px;
white-space: nowrap;
user-select: none;
}
/* ── Command card (action card with lead icon + name + desc + chevron) ─ */
/* Lifted verbatim from the bridge widget so «Добавить в чат» / «Конфиденци-
* альность» are pixel-identical to the Telegram login/about cards. */
.command-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: 10px;
}
.command-card {
/* The widget runs in an iframe and does NOT inherit the host's
* `button { -webkit-appearance: button }` rule, so on iOS/Android WebView a
* <button> draws a native focus/active overlay ON TOP of our background
* (the «greys out and doesn't snap back» bug). appearance:none makes our CSS
* the sole source of truth. Web browsers ignore appearance for <button>. */
-webkit-appearance: none;
appearance: none;
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
transition: border-color 0.12s, background 0.12s;
}
/* Hover scoped to mouse-mode sessions only Capacitor Android WebView reports
* `(hover: hover)` TRUE on pure-touch devices, so a media query alone would
* leave a sticky synthesised :hover after tap. `[data-input]` is set in
* main.tsx from the real `pointerdown.pointerType`. */
:root[data-input='mouse'] .command-card:hover:not(:disabled) {
background: var(--surface);
border-color: var(--hairline);
}
.command-card:focus {
outline: none;
}
:root[data-input='mouse'] .command-card:focus-visible {
outline: 2px solid var(--fleet);
outline-offset: 2px;
}
.command-card-lead-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
.command-card-body {
flex: 1;
min-width: 0;
}
.command-card-name {
font-size: 15px;
color: var(--text);
font-weight: 600;
margin-bottom: 3px;
}
.command-card-desc {
font-size: 14px;
color: var(--muted);
line-height: 19px;
}
.command-card-chevron {
color: var(--muted);
font-size: 18px;
flex-shrink: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (max-width: 600px) {
.command-grid {
grid-template-columns: minmax(0, 1fr);
}
.command-card {
padding: 12px 14px;
border-radius: 8px;
}
.command-card-name {
font-size: 14px;
margin-bottom: 2px;
}
.command-card-desc {
font-size: 13px;
line-height: 17px;
}
}
/* ── Buttons ─────────────────────────────────────────────────────────── */
.btn-primary {
-webkit-appearance: none;
appearance: none;
background: var(--fleet);
color: #0c0c0e;
border: none;
border-radius: 8px;
padding: 10px 18px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── About / policy modal ───────────────────────────────────────────── */
/* Lightweight modal fixed inside the widget iframe, not crossing into the
* host. Backdrop click + Escape close; no focus-trap library (small surface).
* Identical chrome to the Telegram widget's «О боте» modal. */
.about-overlay {
position: fixed;
inset: 0;
background: rgba(13, 14, 17, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: about-fade 0.15s ease-out;
}
@keyframes about-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.about-panel {
background: var(--bg);
border: 1px solid var(--hairline);
border-radius: 14px;
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.about-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--divider);
}
.about-title {
flex: 1;
font-size: 17px;
font-weight: 600;
color: var(--text);
margin: 0;
line-height: 1.3;
}
.about-close-x {
background: transparent;
border: none;
color: var(--muted);
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font: inherit;
font-size: 24px;
line-height: 1;
transition: background 0.12s, color 0.12s;
}
.about-close-x:hover {
background: var(--surface);
color: var(--text);
}
.about-body {
padding: 16px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.about-body p {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text);
}
.about-body a {
color: var(--fleet-soft);
text-decoration: underline;
overflow-wrap: anywhere;
}
.about-body a:hover {
color: var(--text);
}
.about-footer {
padding: 12px 18px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--divider);
}
/* ── Diagnostic banner (pre-bootstrap failure) ────────────────────── */
.error-banner {
margin: var(--section-pad-x);
padding: 14px 16px;
background: rgba(192, 142, 123, 0.08);
border: 1px solid var(--rose);
border-radius: 10px;
color: var(--rose);
font-size: 13px;
line-height: 19px;
}
.error-banner strong {
display: block;
margin-bottom: 4px;
color: var(--rose);
font-weight: 600;
}

1
apps/widget-vojo-ai/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,144 @@
// Minimal matrix-widget-api transport, implemented inline (same approach as
// apps/widget-telegram). This widget neither reads nor sends Matrix events — it
// only needs to (a) complete the host's capability handshake so the host's
// loading bar fades and `onReady` fires, (b) follow theme changes, and (c) post
// two Vojo-extension side-channel verbs: `add-to-chat` and `open-external-url`.
//
// Protocol shapes match
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// in the host repo. Default host request timeout is 10s.
import type { WidgetBootstrap } from './bootstrap';
type ToWidgetMessage = {
api: 'toWidget';
widgetId: string;
requestId: string;
action: string;
data: Record<string, unknown>;
response?: Record<string, unknown>;
};
export type Capability = string;
export type WidgetApiEvents = {
ready: () => void;
themeChange: (name: 'light' | 'dark') => void;
};
export class WidgetApi {
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
private isReady = false;
public constructor(
private readonly bootstrap: WidgetBootstrap,
private readonly capabilities: Capability[]
) {
window.addEventListener('message', this.onMessage);
}
public dispose(): void {
window.removeEventListener('message', this.onMessage);
}
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
list.push(listener);
// `ready` is a one-shot lifecycle signal. If the handshake completed before
// this listener attached (cached-bundle race: host fires the capabilities
// request on iframe `load`, we resolve it during script init, then a
// post-render effect attaches the listener), replay synchronously.
if (event === 'ready' && this.isReady) {
(listener as () => void)();
}
}
// `add-to-chat` — ask the host to invite this bot into a room the USER picks.
// The host owns the picker and substitutes the invitee (`preset.mxid`); we
// send NO room and NO mxid. Distinct `io.vojo.bot-widget` channel — does not
// route through the matrix-widget-api request/response machinery.
public addToChat(): void {
window.parent.postMessage(
{ api: 'io.vojo.bot-widget', action: 'add-to-chat', data: {} },
this.bootstrap.parentOrigin
);
}
// Open an external URL via the host (cross-origin iframes drop
// `<a target="_blank">` clicks inside Capacitor's Android WebView; the host
// routes this through `openExternalUrl`). https only — the host re-validates.
public openExternalUrl(url: string): void {
window.parent.postMessage(
{ api: 'io.vojo.bot-widget', action: 'open-external-url', data: { url } },
this.bootstrap.parentOrigin
);
}
private emit<K extends keyof WidgetApiEvents>(
event: K,
...args: Parameters<WidgetApiEvents[K]>
): void {
const list = this.listeners[event] as
| Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
| undefined;
list?.forEach((fn) => fn(...args));
}
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
window.parent.postMessage(
{
api: msg.api,
widgetId: msg.widgetId,
requestId: msg.requestId,
action: msg.action,
data: msg.data,
response,
},
this.bootstrap.parentOrigin
);
}
private onMessage = (ev: MessageEvent): void => {
if (ev.origin !== this.bootstrap.parentOrigin) return;
// Hard source guard: every legit widget-API message comes from the host
// window that embedded our iframe (window.parent). A foreign tab/frame on
// the same origin could otherwise forge a message that passes the origin
// check — `widgetId` below is a soft filter, this is the hard one.
if (ev.source !== window.parent) return;
const msg = ev.data as ToWidgetMessage | undefined;
if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'toWidget') return;
if (msg.widgetId !== this.bootstrap.widgetId) return;
if (!msg.requestId || !msg.action) return;
switch (msg.action) {
case 'capabilities': {
this.replyTo(msg, { capabilities: this.capabilities });
return;
}
case 'notify_capabilities': {
this.replyTo(msg, {});
if (!this.isReady) {
this.isReady = true;
this.emit('ready');
}
return;
}
case 'supported_api_versions': {
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
return;
}
case 'theme_change': {
const name = (msg.data?.name as string | undefined) ?? '';
this.emit('themeChange', name === 'dark' ? 'dark' : 'light');
this.replyTo(msg, {});
return;
}
default: {
// Be liberal — reply empty so the host's request promise resolves.
this.replyTo(msg, {});
}
}
};
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true
},
"include": ["src", "vite.config.ts"]
}

View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
// Build artefact lives at apps/widget-vojo-ai/dist/. The «Deploy widgets» task
// rsyncs it into ~/vojo/widgets/vojo-ai/ on the server, which Caddy serves from
// the widgets.vojo.chat block at /vojo-ai/ (mirror of the telegram widget).
//
// `base: './'` keeps every generated asset path relative so the same build can
// sit under /vojo-ai/ on widgets.vojo.chat without rewrites.
export default defineConfig({
base: './',
plugins: [preact()],
build: {
target: 'es2020',
sourcemap: true,
// Inline CSS for a single round-trip; the widget is tiny and the host's
// iframe handshake budget is already tight (10s default).
cssCodeSplit: false,
},
server: {
// 8081 telegram / 8082 discord / 8083 whatsapp / 8084 vojo-ai.
port: 8084,
host: true,
},
});

View file

@ -43,6 +43,16 @@
"url": "https://widgets.vojo.chat/whatsapp/index.html", "url": "https://widgets.vojo.chat/whatsapp/index.html",
"commandPrefix": "!wa" "commandPrefix": "!wa"
} }
},
{
"id": "vojo-ai",
"mxid": "@ai:vojo.chat",
"name": "Vojo AI",
"experience": {
"type": "matrix-widget",
"url": "https://widgets.vojo.chat/vojo-ai/index.html",
"capabilities": ["vojo.add_to_chat"]
}
} }
], ],
"push": { "push": {

View file

@ -915,6 +915,14 @@
"not_connected_description": "Create a private chat with {{mxid}} to use this robot.", "not_connected_description": "Create a private chat with {{mxid}} to use this robot.",
"connect": "Connect", "connect": "Connect",
"connect_error": "Failed to connect robot.", "connect_error": "Failed to connect robot.",
"add_to_chat_title": "Add {{name}} to a chat",
"add_to_chat_subtitle": "Pick a room. {{name}} will be invited and can reply to mentions there.",
"add_to_chat_search_placeholder": "Search your rooms…",
"add_to_chat_empty": "No rooms where you can add {{name}}.",
"add_to_chat_no_match": "No rooms match your search.",
"add_to_chat_unavailable": "You can no longer add {{name}} to this room.",
"add_to_chat_error": "Couldnt add {{name}}. Please try again.",
"encrypted_room_disabled": "Encrypted — {{name}} cant read this room",
"pending_title": "{{name}} is connecting", "pending_title": "{{name}} is connecting",
"pending_bot_invite_description": "The chat exists. Waiting for {{mxid}} to join.", "pending_bot_invite_description": "The chat exists. Waiting for {{mxid}} to join.",
"pending_self_invite_description": "You have been invited to the chat with this robot. Accept the invite to continue.", "pending_self_invite_description": "You have been invited to the chat with this robot. Accept the invite to continue.",
@ -936,12 +944,14 @@
"description": { "description": {
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.", "telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.",
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.", "discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.",
"whatsapp": "Connect WhatsApp to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal WhatsApp messages. Sign-in uses a QR code or pairing code from the WhatsApp mobile app." "whatsapp": "Connect WhatsApp to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal WhatsApp messages. Sign-in uses a QR code or pairing code from the WhatsApp mobile app.",
"vojo-ai": "Vojos AI assistant. Mention it in a chat and it replies."
}, },
"description_short": { "description_short": {
"telegram": "Telegram chat connection", "telegram": "Telegram chat connection",
"discord": "Discord chat connection", "discord": "Discord chat connection",
"whatsapp": "WhatsApp chat connection" "whatsapp": "WhatsApp chat connection",
"vojo-ai": "AI assistant"
}, },
"unknown_title": "Robot not found", "unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog." "unknown_description": "This robot is not in the Vojo catalog."

View file

@ -933,6 +933,14 @@
"not_connected_description": "Создайте приватный чат с {{mxid}}, чтобы пользоваться роботом.", "not_connected_description": "Создайте приватный чат с {{mxid}}, чтобы пользоваться роботом.",
"connect": "Подключить", "connect": "Подключить",
"connect_error": "Не удалось подключить робота.", "connect_error": "Не удалось подключить робота.",
"add_to_chat_title": "Добавить {{name}} в чат",
"add_to_chat_subtitle": "Выберите комнату. {{name}} будет приглашён и сможет отвечать на упоминания в ней.",
"add_to_chat_search_placeholder": "Поиск по вашим комнатам…",
"add_to_chat_empty": "Нет комнат, куда можно добавить {{name}}.",
"add_to_chat_no_match": "Нет комнат по вашему запросу.",
"add_to_chat_unavailable": "Добавить {{name}} в эту комнату больше нельзя.",
"add_to_chat_error": "Не удалось добавить {{name}}. Попробуйте ещё раз.",
"encrypted_room_disabled": "Зашифрована — {{name}} не читает эту комнату",
"pending_title": "{{name}} подключается", "pending_title": "{{name}} подключается",
"pending_bot_invite_description": "Чат уже создан. Ждём, пока {{mxid}} присоединится.", "pending_bot_invite_description": "Чат уже создан. Ждём, пока {{mxid}} присоединится.",
"pending_self_invite_description": "Вас пригласили в чат с роботом. Примите приглашение, чтобы продолжить.", "pending_self_invite_description": "Вас пригласили в чат с роботом. Примите приглашение, чтобы продолжить.",
@ -954,12 +962,14 @@
"description": { "description": {
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.", "telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.", "discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",
"whatsapp": "Подключите WhatsApp к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в WhatsApp как обычные сообщения. Вход — через QR-код или 8-символьный код из мобильного WhatsApp." "whatsapp": "Подключите WhatsApp к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в WhatsApp как обычные сообщения. Вход — через QR-код или 8-символьный код из мобильного WhatsApp.",
"vojo-ai": "ИИ-ассистент Vojo. Упомяните его в чате — и он ответит."
}, },
"description_short": { "description_short": {
"telegram": "Подключение чатов Telegram", "telegram": "Подключение чатов Telegram",
"discord": "Подключение чатов Discord", "discord": "Подключение чатов Discord",
"whatsapp": "Подключение чатов WhatsApp" "whatsapp": "Подключение чатов WhatsApp",
"vojo-ai": "ИИ-ассистент"
}, },
"unknown_title": "Робот не найден", "unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo." "unknown_description": "Этого робота нет в каталоге Vojo."

View file

@ -0,0 +1,311 @@
import React, {
ChangeEventHandler,
MouseEventHandler,
useCallback,
useMemo,
useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import {
Avatar,
Box,
Icon,
Icons,
Input,
Line,
MenuItem,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
Spinner,
Text,
color,
config,
toRem,
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../hooks/useAsyncSearch';
import { IPowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { getStateEvent, isSpace } from '../../utils/room';
import { getMxIdServer } from '../../utils/matrix';
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
import { nameInitials } from '../../utils/common';
import { Membership, StateEvent } from '../../../types/matrix/room';
import type { BotPreset } from './catalog';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
matchOptions: { contain: true },
normalizeOptions: { ignoreWhitespace: false },
};
type BotAddToChatPickerProps = {
preset: BotPreset;
requestClose: () => void;
};
// Host-owned room picker for the elevated `add-to-chat` verb. This modal is
// the SOLE authorization boundary for inviting the bot into a room outside its
// control DM (the side-channel verb carries no room/mxid — the host substitutes
// `preset.mxid` and the user picks the room here). NOT built on `useRoomSearch`,
// which returns every room/direct/space with no predicates (F14); the candidate
// set is filtered to the plan's predicates and every invite is re-checked
// fail-closed immediately before `mx.invite` (F9).
export function BotAddToChatPicker({ preset, requestClose }: BotAddToChatPickerProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const myUserId = mx.getSafeUserId();
const myServer = mx.getDomain();
// The bot may be added only to a personal chat — the «Личные»/Direct set
// (orphan m.direct rooms, already minus catalog bot control DMs and bridged
// portals), never a Channel/space room.
const directRoomIds = useDirectRooms();
// Plan's predicates (owner decision): joined, non-space, the bot isn't
// already a member, the user holds invite power, AND the room is hosted
// entirely on the user's own homeserver (no federated third parties — mirrors
// the bot's server-side «stays on allowed servers» rule). Encryption is handled
// separately at render (disabled row + note) and re-checked in the invite.
// The power read tolerates partial `m.room.power_levels` content —
// `readPowerLevel` fills Matrix defaults (users_default 0 / invite 0), so a
// default-open room correctly passes and a restricted room correctly fails.
const isListable = useCallback(
(room: Room): boolean => {
if (room.getMyMembership() !== Membership.Join) return false;
if (isSpace(room)) return false;
const botMembership = room.getMember(preset.mxid)?.membership;
if (botMembership === Membership.Join || botMembership === Membership.Invite) return false;
const pl =
getStateEvent(room, StateEvent.RoomPowerLevels, '')?.getContent<IPowerLevels>() ?? {};
if (readPowerLevel.user(pl, myUserId) < readPowerLevel.action(pl, 'invite')) return false;
// Hosted entirely on the user's own homeserver: every active (joined or
// invited) member shares myServer, so the bot is never invited into a
// federated room (where it would leave anyway).
const active = room
.getMembers()
.filter(
(mem) => mem.membership === Membership.Join || mem.membership === Membership.Invite
);
if (!myServer || !active.every((mem) => getMxIdServer(mem.userId) === myServer)) return false;
return true;
},
[preset.mxid, myUserId, myServer]
);
// Snapshot at open — stable identity so `useAsyncSearch` doesn't reset its
// result every render. Restricted to the «Личные»/Direct set (no Channels);
// staleness is fine — the per-invite live re-check below is the authoritative
// gate, not this list.
const candidateRoomIds = useMemo(
() =>
directRoomIds.filter((roomId) => {
const room = mx.getRoom(roomId);
return !!room && isListable(room);
}),
[mx, directRoomIds, isListable]
);
const getItemStr: SearchItemStrGetter<string> = useCallback(
(roomId) => mx.getRoom(roomId)?.name ?? roomId,
[mx]
);
const [result, search, resetSearch] = useAsyncSearch(
candidateRoomIds,
getItemStr,
SEARCH_OPTIONS
);
const roomsToRender = result ? result.items : candidateRoomIds;
const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(evt) => {
const value = evt.currentTarget.value.trim();
if (value === '') {
resetSearch();
return;
}
search(value);
},
[search, resetSearch]
);
const [pendingRoomId, setPendingRoomId] = useState<string | null>(null);
const [inviteState, invite] = useAsyncCallback<void, Error, [string]>(
useCallback(
async (roomId: string) => {
const room = mx.getRoom(roomId);
// Fail-closed live re-check (F9). The snapshot above can be stale —
// membership lost, invite power revoked, the bot invited by another
// device, or encryption enabled since the modal opened. Re-verify the
// full predicate (incl. NOT encrypted) right before the privileged
// invite. try/catch upstream surfaces server races (e.g. M_FORBIDDEN
// re-inviting a banned bot, F13) as an inline error.
if (!room || !isListable(room) || room.hasEncryptionStateEvent()) {
throw new Error(t('Bots.add_to_chat_unavailable', { name: preset.name }));
}
await mx.invite(roomId, preset.mxid);
},
[mx, preset.mxid, preset.name, isListable, t]
)
);
const inviting = inviteState.status === AsyncStatus.Loading;
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
if (inviting) return;
const roomId = evt.currentTarget.getAttribute('data-room-id');
if (!roomId) return;
setPendingRoomId(roomId);
invite(roomId)
.then(() => requestClose())
.catch(() => undefined); // error rendered inline via inviteState
},
[inviting, invite, requestClose]
);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
returnFocusOnDeactivate: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: requestClose,
escapeDeactivates: (evt) => {
evt.stopPropagation();
return true;
},
}}
>
<Modal size="400" style={{ maxHeight: toRem(480), borderRadius: config.radii.R500 }}>
<Box
shrink="No"
direction="Column"
gap="300"
style={{ padding: config.space.S400, paddingBottom: config.space.S300 }}
>
<Box direction="Column" gap="100">
<Text size="H4">{t('Bots.add_to_chat_title', { name: preset.name })}</Text>
<Text size="T200" priority="300">
{t('Bots.add_to_chat_subtitle', { name: preset.name })}
</Text>
</Box>
{/* Direct child of a Column Box stretches to full width (same as
* the canonical Search modal). A wrapping Row Box collapsed it to
* content width, which read as a cramped «Поиск» pill. */}
<Input
size="500"
variant="Background"
radii="400"
outlined
autoFocus
placeholder={t('Bots.add_to_chat_search_placeholder')}
before={<Icon size="200" src={Icons.Search} />}
onChange={handleInputChange}
/>
</Box>
<Box grow="Yes">
{roomsToRender.length === 0 ? (
<Box
style={{ padding: config.space.S700 }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
>
<Text size="T300" align="Center" priority="300">
{result
? t('Bots.add_to_chat_no_match')
: t('Bots.add_to_chat_empty', { name: preset.name })}
</Text>
</Box>
) : (
<Scroll size="300" hideTrack>
<div style={{ padding: config.space.S400, paddingRight: config.space.S200 }}>
{roomsToRender.map((roomId) => {
const room = mx.getRoom(roomId);
if (!room) return null;
const encrypted = room.hasEncryptionStateEvent();
const rowPending = pendingRoomId === roomId && inviting;
return (
<MenuItem
key={roomId}
as="button"
data-room-id={roomId}
onClick={handleRoomClick}
variant="Surface"
radii="400"
disabled={encrypted || inviting}
aria-disabled={encrypted || inviting}
before={
<Avatar size="200" radii="300">
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
</Avatar>
}
after={
// eslint-disable-next-line no-nested-ternary
rowPending ? (
<Spinner size="200" variant="Secondary" />
) : encrypted ? (
<Icon size="100" src={Icons.Lock} />
) : undefined
}
>
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{room.name}
</Text>
{encrypted && (
<Text size="T200" priority="300" truncate>
{t('Bots.encrypted_room_disabled', { name: preset.name })}
</Text>
)}
</Box>
</MenuItem>
);
})}
</div>
</Scroll>
)}
</Box>
{inviteState.status === AsyncStatus.Error && (
<>
<Line size="300" />
<Box
shrink="No"
alignItems="Center"
gap="200"
style={{ padding: config.space.S300 }}
>
<Icon
size="100"
src={Icons.Warning}
filled
style={{ color: color.Critical.Main }}
/>
<Text size="T200" style={{ color: color.Critical.Main }}>
{inviteState.error.message ||
t('Bots.add_to_chat_error', { name: preset.name })}
</Text>
</Box>
</>
)}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Avatar, AvatarImage, Box, Text, toRem } from 'folds'; import { Avatar, AvatarImage, Box, Text, toRem } from 'folds';
import { NavItem, NavItemContent, NavLink } from '../../components/nav'; import { NavItem, NavItemContent, NavLink } from '../../components/nav';
import { getBotPath } from '../../pages/pathUtils'; import { getBotPath } from '../../pages/pathUtils';
@ -33,6 +33,16 @@ export function BotCard({ preset, selected }: BotCardProps) {
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 56, 56, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 56, 56, 'crop') ?? undefined
: undefined; : undefined;
// Fall back to the letter square when the avatar image fails to load —
// mirrors the canonical UserAvatar/RoomAvatar pattern. Without this, a bot
// whose profile `avatar_url` points at media the server can't thumbnail
// (wrong format, missing thumbnail) renders the raw <img> alt text ("Vojo
// AI" → "Vojo") instead of a clean initial. Reset on src change so a later
// good avatar (config refresh / late profile fetch) gets a fresh attempt.
const [imgError, setImgError] = useState(false);
useEffect(() => setImgError(false), [avatarUrl]);
const showImage = !!avatarUrl && !imgError;
return ( return (
<NavItem <NavItem
variant="Background" variant="Background"
@ -54,8 +64,8 @@ export function BotCard({ preset, selected }: BotCardProps) {
}} }}
> >
<Avatar size="300" radii="Pill" style={{ background: AVATAR_BG, color: '#0c0c0e' }}> <Avatar size="300" radii="Pill" style={{ background: AVATAR_BG, color: '#0c0c0e' }}>
{avatarUrl ? ( {showImage ? (
<AvatarImage src={avatarUrl} alt={preset.name} /> <AvatarImage src={avatarUrl} alt={preset.name} onError={() => setImgError(true)} />
) : ( ) : (
<Text as="span" size="H6" style={{ color: '#0c0c0e', fontWeight: 700 }}> <Text as="span" size="H6" style={{ color: '#0c0c0e', fontWeight: 700 }}>
{initial} {initial}

View file

@ -19,7 +19,7 @@ import {
import { Theme } from '../../hooks/useTheme'; import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor'; import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to'; import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog'; import { BOT_CAP_ADD_TO_CHAT, type BotPreset } from './catalog';
import { import {
BotWidgetDriver, BotWidgetDriver,
sanitizeBotWidgetMessageEvent, sanitizeBotWidgetMessageEvent,
@ -43,6 +43,12 @@ export type BotWidgetEmbedOptions = {
// bot-aware: any widget that delivers a matrix.to URL via the side-channel // bot-aware: any widget that delivers a matrix.to URL via the side-channel
// (`open-matrix-to` action) reaches the same handler. // (`open-matrix-to` action) reaches the same handler.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void; onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
// Elevated `add-to-chat` verb (gated on the `vojo.add_to_chat` capability
// opt-in in config.json). The widget supplies NO room and NO mxid — the host
// substitutes the invitee from `preset.mxid` and the target room comes from
// the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like
// `onOpenMatrixToRoom`) where `mx` + navigation are available.
onAddToChat?: () => void;
}; };
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
@ -65,6 +71,11 @@ const getBotWidgetUrl = (
url.searchParams.set('botId', preset.id); url.searchParams.set('botId', preset.id);
url.searchParams.set('botMxid', preset.mxid); url.searchParams.set('botMxid', preset.mxid);
url.searchParams.set('commandPrefix', preset.experience.commandPrefix); url.searchParams.set('commandPrefix', preset.experience.commandPrefix);
// UI-only render hint: tells the widget whether to draw the «Add to chat»
// button. NOT an authorization input — the real gate is host-side in
// `onWidgetMessage` (the host re-reads `preset.experience.capabilities` from
// the trusted config). Empty CSV when no caps; the widget reads ''→[] (F19).
url.searchParams.set('capabilities', preset.experience.capabilities.join(','));
url.searchParams.set('theme', theme.kind); url.searchParams.set('theme', theme.kind);
url.searchParams.set('clientLanguage', language); url.searchParams.set('clientLanguage', language);
url.searchParams.set('baseUrl', mx.baseUrl); url.searchParams.set('baseUrl', mx.baseUrl);
@ -229,7 +240,15 @@ export class BotWidgetEmbed {
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
// of our extension and avoids the «unknown action» reply path. // of our extension and avoids the «unknown action» reply path.
// //
// Two actions today: // Three actions today:
//
// * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat`
// capability opt-in in config.json. Carries NO url and NO room/mxid:
// the host substitutes the invitee (`preset.mxid`) and the target room
// comes from the host's own picker (BotWidgetMount). A compromised
// bundle on a preset that didn't declare the cap is silently ignored —
// it cannot grant itself the capability (config.json is the trusted
// root). See the gate in the handler below.
// //
// * `open-external-url` — forwards an https:// URL to the host's // * `open-external-url` — forwards an https:// URL to the host's
// `openExternalUrl` (utils/capacitor.ts), which routes through // `openExternalUrl` (utils/capacitor.ts), which routes through
@ -260,7 +279,9 @@ export class BotWidgetEmbed {
// sibling frame on the same origin in a future deployment — // sibling frame on the same origin in a future deployment —
// could otherwise pass the origin check). // could otherwise pass the origin check).
// //
// Per-action URL validation (NOT shared, but each branch enforces): // Per-action URL validation (NOT shared — each url-branch extracts and
// checks `msg.data.url` itself; `add-to-chat` carries no url at all, which is
// why the string-typeof gate must NOT be hoisted above the dispatch — see F1):
// * `open-external-url` — requires `https:` protocol, rejecting plain // * `open-external-url` — requires `https:` protocol, rejecting plain
// http, javascript:, data:, file:, etc. We tightened from http+https // http, javascript:, data:, file:, etc. We tightened from http+https
// to https-only because no shipped widget content links over plain // to https-only because no shipped widget content links over plain
@ -278,10 +299,21 @@ export class BotWidgetEmbed {
| undefined; | undefined;
if (!msg || typeof msg !== 'object') return; if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'io.vojo.bot-widget') return; if (msg.api !== 'io.vojo.bot-widget') return;
const url = msg.data?.url;
if (typeof url !== 'string') return; // Elevated verb — must be dispatched BEFORE any url extraction. Its `data`
// is `{}` (no url), so a hoisted `typeof url === 'string'` gate would
// early-return and the verb would never fire (F1). The host gate reads the
// capability from the trusted config (`preset.experience.capabilities`),
// never from the widget message; the invitee is `preset.mxid`, host-set.
if (msg.action === 'add-to-chat') {
if (!this.options.preset.experience?.capabilities.includes(BOT_CAP_ADD_TO_CHAT)) return;
this.options.onAddToChat?.();
return;
}
if (msg.action === 'open-external-url') { if (msg.action === 'open-external-url') {
const url = msg.data?.url;
if (typeof url !== 'string') return;
try { try {
const parsed = new URL(url); const parsed = new URL(url);
if (parsed.protocol !== 'https:') return; if (parsed.protocol !== 'https:') return;
@ -306,6 +338,8 @@ export class BotWidgetEmbed {
// hop (`onOpenMatrixToRoom`) is the optional caller — embedded code // hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
// paths that don't provide a callback (e.g. future test harness) get // paths that don't provide a callback (e.g. future test harness) get
// a silent drop, not a crash. // a silent drop, not a crash.
const url = msg.data?.url;
if (typeof url !== 'string') return;
const parsed = parseMatrixToRoom(url); const parsed = parseMatrixToRoom(url);
if (!parsed) return; if (!parsed) return;
this.options.onOpenMatrixToRoom?.(parsed); this.options.onOpenMatrixToRoom?.(parsed);

View file

@ -12,6 +12,7 @@ import {
import { getChannelsSpacePath } from '../../pages/pathUtils'; import { getChannelsSpacePath } from '../../pages/pathUtils';
import type { MatrixToRoom } from '../../plugins/matrix-to'; import type { MatrixToRoom } from '../../plugins/matrix-to';
import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import { BotAddToChatPicker } from './BotAddToChatPicker';
import * as css from './BotWidgetMount.css'; import * as css from './BotWidgetMount.css';
// Anti-flicker debounce — same rationale as `SyncIndicator`'s // Anti-flicker debounce — same rationale as `SyncIndicator`'s
@ -67,12 +68,21 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
[mx, navigate] [mx, navigate]
); );
// Host-owned room picker for the elevated `add-to-chat` verb. The widget
// only posts the verb (no room, no mxid) — opening the picker and choosing a
// room is entirely host-side, so this modal IS the authorization boundary.
// `pickerOpen` is the singleton open-state: re-firing the verb while it's
// already true is a no-op, so a hostile bundle can't stack modals.
const [pickerOpen, setPickerOpen] = useState(false);
const handleAddToChat = useCallback(() => setPickerOpen(true), []);
const { ready } = useBotWidgetEmbed({ const { ready } = useBotWidgetEmbed({
containerRef, containerRef,
preset, preset,
room, room,
onError, onError,
onOpenMatrixToRoom: handleOpenMatrixToRoom, onOpenMatrixToRoom: handleOpenMatrixToRoom,
onAddToChat: handleAddToChat,
}); });
// Track Matrix sync state so the bot loading bar yields to the global // Track Matrix sync state so the bot loading bar yields to the global
@ -194,6 +204,9 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
onAnimationIteration={handleIteration} onAnimationIteration={handleIteration}
/> />
</div> </div>
{pickerOpen && (
<BotAddToChatPicker preset={preset} requestClose={() => setPickerOpen(false)} />
)}
</> </>
); );
} }

View file

@ -8,8 +8,21 @@ export type BotExperience = {
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`). /** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
* Resolved with the bridgev2 default `!tg` when the operator omits it. */ * Resolved with the bridgev2 default `!tg` when the operator omits it. */
commandPrefix: string; commandPrefix: string;
/** Elevated host verbs this bot is allowed to drive via the
* `io.vojo.bot-widget` side-channel. Always an array (never `undefined`) so
* the runtime gate `capabilities.includes(...)` is well-defined see
* `normalizeBotCaps`. Filtered against `KNOWN_BOT_CAPS` at load; absent `[]`. */
capabilities: string[];
}; };
/** The only elevated side-channel verb today: lets the widget ask the host to
* invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The
* widget never supplies the room or the mxid. Privilege originates from the
* trusted config.json opt-in, NOT from the widget claiming it. */
export const BOT_CAP_ADD_TO_CHAT = 'vojo.add_to_chat';
const KNOWN_BOT_CAPS: ReadonlySet<string> = new Set([BOT_CAP_ADD_TO_CHAT]);
export type BotPreset = { export type BotPreset = {
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */ /** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
id: string; id: string;
@ -56,6 +69,21 @@ const normalizeCommandPrefix = (raw: unknown): string | undefined => {
return trimmed; return trimmed;
}; };
// Validate `experience.capabilities` against the known allowlist. Unknown
// strings are dropped (an operator typo or a future cap the host doesn't
// implement must never reach the runtime gate). ALWAYS returns an array —
// never `undefined` — so `preset.experience.capabilities.includes(...)` is a
// live boolean rather than a no-op on `undefined?.includes` (F2: the three
// return literals below have no spread, so the field must be set explicitly in
// each, and a default of `undefined` would silently disarm the gate). Dedupes
// so a doubled config entry can't skew anything downstream.
const normalizeBotCaps = (raw: unknown): string[] => {
if (!Array.isArray(raw)) return [];
return [
...new Set(raw.filter((c): c is string => typeof c === 'string' && KNOWN_BOT_CAPS.has(c))),
];
};
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset => const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset?.id === 'string' && typeof preset?.id === 'string' &&
BOT_ID_RE.test(preset.id) && BOT_ID_RE.test(preset.id) &&
@ -76,6 +104,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix); const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix);
if (commandPrefix === undefined) return undefined; if (commandPrefix === undefined) return undefined;
// Allowlisted at load so `preset.experience.capabilities` only ever holds
// known caps; the host gate (BotWidgetEmbed) trusts this value, so the
// filtering MUST happen here, not at the UI layer. Added to every return
// literal below (no spread — F2).
const capabilities = normalizeBotCaps(experience?.capabilities);
if (url.startsWith('/')) { if (url.startsWith('/')) {
// Resolve once so `/widgets/../admin` collapses before the prefix check — // Resolve once so `/widgets/../admin` collapses before the prefix check —
// a relative `/widgets/...` survives `new URL(url, base)` only if it does // a relative `/widgets/...` survives `new URL(url, base)` only if it does
@ -102,6 +136,7 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
type, type,
url: `${resolved.pathname}${resolved.search}${resolved.hash}`, url: `${resolved.pathname}${resolved.search}${resolved.hash}`,
commandPrefix, commandPrefix,
capabilities,
}; };
} catch { } catch {
return undefined; return undefined;
@ -117,12 +152,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
// collapses to a literal `false`), so it never relaxes the prod validator. // collapses to a literal `false`), so it never relaxes the prod validator.
if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') { if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') {
if (parsed.username || parsed.password) return undefined; if (parsed.username || parsed.password) return undefined;
return { type, url: parsed.toString(), commandPrefix }; return { type, url: parsed.toString(), commandPrefix, capabilities };
} }
if (parsed.protocol !== 'https:') return undefined; if (parsed.protocol !== 'https:') return undefined;
if (parsed.username || parsed.password) return undefined; if (parsed.username || parsed.password) return undefined;
if (!PROD_WIDGET_ORIGINS.has(parsed.origin)) return undefined; if (!PROD_WIDGET_ORIGINS.has(parsed.origin)) return undefined;
return { type, url: parsed.toString(), commandPrefix }; return { type, url: parsed.toString(), commandPrefix, capabilities };
} catch { } catch {
return undefined; return undefined;
} }

View file

@ -15,6 +15,10 @@ type UseBotWidgetEmbedOptions = {
// Forwarded into the embed. Plumbed from `BotWidgetMount` where the // Forwarded into the embed. Plumbed from `BotWidgetMount` where the
// react-router context is available — the hook stays unaware of routing. // react-router context is available — the hook stays unaware of routing.
onOpenMatrixToRoom?: (target: MatrixToRoom) => void; onOpenMatrixToRoom?: (target: MatrixToRoom) => void;
// Forwarded into the embed. Fires when the widget posts the elevated
// `add-to-chat` verb (host opens its own room picker). Plumbed from
// `BotWidgetMount` where `mx` is available.
onAddToChat?: () => void;
}; };
type UseBotWidgetEmbedResult = { type UseBotWidgetEmbedResult = {
@ -35,6 +39,7 @@ export const useBotWidgetEmbed = ({
room, room,
onError, onError,
onOpenMatrixToRoom, onOpenMatrixToRoom,
onAddToChat,
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -54,6 +59,10 @@ export const useBotWidgetEmbed = ({
// embed only sees a stable shim that re-reads it. // embed only sees a stable shim that re-reads it.
const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom); const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom);
onOpenMatrixToRoomRef.current = onOpenMatrixToRoom; onOpenMatrixToRoomRef.current = onOpenMatrixToRoom;
// Same ref indirection for `onAddToChat` (closes over `mx`/picker state per
// render) so the embed lifecycle effect doesn't remount the iframe.
const onAddToChatRef = useRef(onAddToChat);
onAddToChatRef.current = onAddToChat;
// Depend on primitive identity for the embed lifecycle — using `preset` // Depend on primitive identity for the embed lifecycle — using `preset`
// directly would remount the iframe (and re-handshake with the widget) // directly would remount the iframe (and re-handshake with the widget)
@ -86,6 +95,7 @@ export const useBotWidgetEmbed = ({
// Indirection so the embed lifecycle doesn't reset when the // Indirection so the embed lifecycle doesn't reset when the
// navigate-callback closes over a new render's `mx`/`navigate`. // navigate-callback closes over a new render's `mx`/`navigate`.
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target), onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
onAddToChat: () => onAddToChatRef.current?.(),
}); });
embedRef.current = embed; embedRef.current = embed;
} catch (error) { } catch (error) {

View file

@ -23,6 +23,11 @@ export type BotConfig = {
type?: string; type?: string;
url?: string; url?: string;
commandPrefix?: string; commandPrefix?: string;
/** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`).
* Validated against an allowlist at load (catalog.ts `normalizeBotCaps`);
* unknown entries are dropped, absent `[]`. config.json is a trusted,
* operator-controlled root the widget cannot grant itself a capability. */
capabilities?: string[];
}; };
}; };