feat(bots): add the Vojo AI bot widget and its capability-gated add-to-chat room picker
This commit is contained in:
parent
4158f9a232
commit
fe8ba2878b
26 changed files with 3413 additions and 15 deletions
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
|
|
@ -16,7 +16,7 @@
|
|||
{
|
||||
"label": "Deploy widgets",
|
||||
"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",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
|
|
|
|||
4
apps/widget-vojo-ai/.gitignore
vendored
Normal file
4
apps/widget-vojo-ai/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.local
|
||||
12
apps/widget-vojo-ai/index.html
Normal file
12
apps/widget-vojo-ai/index.html
Normal 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
1995
apps/widget-vojo-ai/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
apps/widget-vojo-ai/package.json
Normal file
20
apps/widget-vojo-ai/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
144
apps/widget-vojo-ai/src/App.tsx
Normal file
144
apps/widget-vojo-ai/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
apps/widget-vojo-ai/src/bootstrap.ts
Normal file
69
apps/widget-vojo-ai/src/bootstrap.ts
Normal 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'),
|
||||
},
|
||||
};
|
||||
};
|
||||
27
apps/widget-vojo-ai/src/i18n/en.ts
Normal file
27
apps/widget-vojo-ai/src/i18n/en.ts
Normal 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 chat’s 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}.',
|
||||
};
|
||||
30
apps/widget-vojo-ai/src/i18n/index.ts
Normal file
30
apps/widget-vojo-ai/src/i18n/index.ts
Normal 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 };
|
||||
37
apps/widget-vojo-ai/src/i18n/ru.ts
Normal file
37
apps/widget-vojo-ai/src/i18n/ru.ts
Normal 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;
|
||||
61
apps/widget-vojo-ai/src/main.tsx
Normal file
61
apps/widget-vojo-ai/src/main.tsx
Normal 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);
|
||||
}
|
||||
360
apps/widget-vojo-ai/src/styles.css
Normal file
360
apps/widget-vojo-ai/src/styles.css
Normal 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
1
apps/widget-vojo-ai/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
144
apps/widget-vojo-ai/src/widget-api.ts
Normal file
144
apps/widget-vojo-ai/src/widget-api.ts
Normal 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, {});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
21
apps/widget-vojo-ai/tsconfig.json
Normal file
21
apps/widget-vojo-ai/tsconfig.json
Normal 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"]
|
||||
}
|
||||
25
apps/widget-vojo-ai/vite.config.ts
Normal file
25
apps/widget-vojo-ai/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
10
config.json
10
config.json
|
|
@ -43,6 +43,16 @@
|
|||
"url": "https://widgets.vojo.chat/whatsapp/index.html",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -915,6 +915,14 @@
|
|||
"not_connected_description": "Create a private chat with {{mxid}} to use this robot.",
|
||||
"connect": "Connect",
|
||||
"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": "Couldn’t add {{name}}. Please try again.",
|
||||
"encrypted_room_disabled": "Encrypted — {{name}} can’t read this room",
|
||||
"pending_title": "{{name}} is connecting",
|
||||
"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.",
|
||||
|
|
@ -936,12 +944,14 @@
|
|||
"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.",
|
||||
"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": "Vojo’s AI assistant. Mention it in a chat and it replies."
|
||||
},
|
||||
"description_short": {
|
||||
"telegram": "Telegram chat connection",
|
||||
"discord": "Discord chat connection",
|
||||
"whatsapp": "WhatsApp chat connection"
|
||||
"whatsapp": "WhatsApp chat connection",
|
||||
"vojo-ai": "AI assistant"
|
||||
},
|
||||
"unknown_title": "Robot not found",
|
||||
"unknown_description": "This robot is not in the Vojo catalog."
|
||||
|
|
|
|||
|
|
@ -933,6 +933,14 @@
|
|||
"not_connected_description": "Создайте приватный чат с {{mxid}}, чтобы пользоваться роботом.",
|
||||
"connect": "Подключить",
|
||||
"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_bot_invite_description": "Чат уже создан. Ждём, пока {{mxid}} присоединится.",
|
||||
"pending_self_invite_description": "Вас пригласили в чат с роботом. Примите приглашение, чтобы продолжить.",
|
||||
|
|
@ -954,12 +962,14 @@
|
|||
"description": {
|
||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
||||
"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": {
|
||||
"telegram": "Подключение чатов Telegram",
|
||||
"discord": "Подключение чатов Discord",
|
||||
"whatsapp": "Подключение чатов WhatsApp"
|
||||
"whatsapp": "Подключение чатов WhatsApp",
|
||||
"vojo-ai": "ИИ-ассистент"
|
||||
},
|
||||
"unknown_title": "Робот не найден",
|
||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||
|
|
|
|||
311
src/app/features/bots/BotAddToChatPicker.tsx
Normal file
311
src/app/features/bots/BotAddToChatPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, AvatarImage, Box, Text, toRem } from 'folds';
|
||||
import { NavItem, NavItemContent, NavLink } from '../../components/nav';
|
||||
import { getBotPath } from '../../pages/pathUtils';
|
||||
|
|
@ -33,6 +33,16 @@ export function BotCard({ preset, selected }: BotCardProps) {
|
|||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 56, 56, 'crop') ?? 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 (
|
||||
<NavItem
|
||||
variant="Background"
|
||||
|
|
@ -54,8 +64,8 @@ export function BotCard({ preset, selected }: BotCardProps) {
|
|||
}}
|
||||
>
|
||||
<Avatar size="300" radii="Pill" style={{ background: AVATAR_BG, color: '#0c0c0e' }}>
|
||||
{avatarUrl ? (
|
||||
<AvatarImage src={avatarUrl} alt={preset.name} />
|
||||
{showImage ? (
|
||||
<AvatarImage src={avatarUrl} alt={preset.name} onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<Text as="span" size="H6" style={{ color: '#0c0c0e', fontWeight: 700 }}>
|
||||
{initial}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
import { Theme } from '../../hooks/useTheme';
|
||||
import { openExternalUrl } from '../../utils/capacitor';
|
||||
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { BOT_CAP_ADD_TO_CHAT, type BotPreset } from './catalog';
|
||||
import {
|
||||
BotWidgetDriver,
|
||||
sanitizeBotWidgetMessageEvent,
|
||||
|
|
@ -43,6 +43,12 @@ export type BotWidgetEmbedOptions = {
|
|||
// bot-aware: any widget that delivers a matrix.to URL via the side-channel
|
||||
// (`open-matrix-to` action) reaches the same handler.
|
||||
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}`;
|
||||
|
|
@ -65,6 +71,11 @@ const getBotWidgetUrl = (
|
|||
url.searchParams.set('botId', preset.id);
|
||||
url.searchParams.set('botMxid', preset.mxid);
|
||||
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('clientLanguage', language);
|
||||
url.searchParams.set('baseUrl', mx.baseUrl);
|
||||
|
|
@ -229,7 +240,15 @@ export class BotWidgetEmbed {
|
|||
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
|
||||
// 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
|
||||
// `openExternalUrl` (utils/capacitor.ts), which routes through
|
||||
|
|
@ -260,7 +279,9 @@ export class BotWidgetEmbed {
|
|||
// sibling frame on the same origin in a future deployment —
|
||||
// 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
|
||||
// http, javascript:, data:, file:, etc. We tightened from http+https
|
||||
// to https-only because no shipped widget content links over plain
|
||||
|
|
@ -278,10 +299,21 @@ export class BotWidgetEmbed {
|
|||
| undefined;
|
||||
if (!msg || typeof msg !== 'object') 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') {
|
||||
const url = msg.data?.url;
|
||||
if (typeof url !== 'string') return;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') return;
|
||||
|
|
@ -306,6 +338,8 @@ export class BotWidgetEmbed {
|
|||
// hop (`onOpenMatrixToRoom`) is the optional caller — embedded code
|
||||
// paths that don't provide a callback (e.g. future test harness) get
|
||||
// a silent drop, not a crash.
|
||||
const url = msg.data?.url;
|
||||
if (typeof url !== 'string') return;
|
||||
const parsed = parseMatrixToRoom(url);
|
||||
if (!parsed) return;
|
||||
this.options.onOpenMatrixToRoom?.(parsed);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { getChannelsSpacePath } from '../../pages/pathUtils';
|
||||
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||
import { BotAddToChatPicker } from './BotAddToChatPicker';
|
||||
import * as css from './BotWidgetMount.css';
|
||||
|
||||
// Anti-flicker debounce — same rationale as `SyncIndicator`'s
|
||||
|
|
@ -67,12 +68,21 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
|||
[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({
|
||||
containerRef,
|
||||
preset,
|
||||
room,
|
||||
onError,
|
||||
onOpenMatrixToRoom: handleOpenMatrixToRoom,
|
||||
onAddToChat: handleAddToChat,
|
||||
});
|
||||
|
||||
// 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}
|
||||
/>
|
||||
</div>
|
||||
{pickerOpen && (
|
||||
<BotAddToChatPicker preset={preset} requestClose={() => setPickerOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,21 @@ export type BotExperience = {
|
|||
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
|
||||
* Resolved with the bridgev2 default `!tg` when the operator omits it. */
|
||||
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 = {
|
||||
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
|
||||
id: string;
|
||||
|
|
@ -56,6 +69,21 @@ const normalizeCommandPrefix = (raw: unknown): string | undefined => {
|
|||
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 =>
|
||||
typeof preset?.id === 'string' &&
|
||||
BOT_ID_RE.test(preset.id) &&
|
||||
|
|
@ -76,6 +104,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
|
|||
const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix);
|
||||
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('/')) {
|
||||
// Resolve once so `/widgets/../admin` collapses before the prefix check —
|
||||
// a relative `/widgets/...` survives `new URL(url, base)` only if it does
|
||||
|
|
@ -102,6 +136,7 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
|
|||
type,
|
||||
url: `${resolved.pathname}${resolved.search}${resolved.hash}`,
|
||||
commandPrefix,
|
||||
capabilities,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
|
|
@ -117,12 +152,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
|
|||
// 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 };
|
||||
return { type, url: parsed.toString(), commandPrefix, capabilities };
|
||||
}
|
||||
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 };
|
||||
return { type, url: parsed.toString(), commandPrefix, capabilities };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ type UseBotWidgetEmbedOptions = {
|
|||
// Forwarded into the embed. Plumbed from `BotWidgetMount` where the
|
||||
// react-router context is available — the hook stays unaware of routing.
|
||||
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 = {
|
||||
|
|
@ -35,6 +39,7 @@ export const useBotWidgetEmbed = ({
|
|||
room,
|
||||
onError,
|
||||
onOpenMatrixToRoom,
|
||||
onAddToChat,
|
||||
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
||||
const { i18n } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -54,6 +59,10 @@ export const useBotWidgetEmbed = ({
|
|||
// embed only sees a stable shim that re-reads it.
|
||||
const onOpenMatrixToRoomRef = useRef(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`
|
||||
// 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
|
||||
// navigate-callback closes over a new render's `mx`/`navigate`.
|
||||
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
|
||||
onAddToChat: () => onAddToChatRef.current?.(),
|
||||
});
|
||||
embedRef.current = embed;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ export type BotConfig = {
|
|||
type?: string;
|
||||
url?: 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[];
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue