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",
|
"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
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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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": "Couldn’t add {{name}}. Please try again.",
|
||||||
|
"encrypted_room_disabled": "Encrypted — {{name}} can’t 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": "Vojo’s 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."
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
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 { 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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue