feat(bots-telegram): land Phase 3 widget scaffold with Dawn UI, dev config overlay, and prod origin allowlist

This commit is contained in:
heaven 2026-05-02 13:22:25 +03:00
parent d961dddfbc
commit e43b0fb597
19 changed files with 3289 additions and 1 deletions

1
.gitignore vendored
View file

@ -2,6 +2,7 @@ experiment
dist
node_modules
devAssets
config.local.json
.DS_Store
.idea

4
apps/widget-telegram/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,176 @@
# @vojo/widget-telegram
Vojo Telegram bridge management widget — mounts inside `/bots/telegram`
in the Vojo client. See [`docs/plans/bots_tab.md`](../../docs/plans/bots_tab.md)
Phase 3 for product context and the matrix-widget-api contract.
This is **not** a Telegram client. It's a small panel that drives the
mautrix-telegram bridge bot (`@telegrambot:vojo.chat`) by sending text
commands in the control DM and rendering the bot's text replies. M11
ships only the bootstrap + a `ping` button to verify the host handshake.
## Layout
```
src/
├── bootstrap.ts Parse URL params the host appends (matches BotWidgetEmbed.ts)
├── widget-api.ts Inline matrix-widget-api postMessage transport (no SDK)
├── App.tsx UI: bootstrap card, action buttons, transcript pane
├── main.tsx Entry: init bootstrap, render App or diagnostic
└── styles.css Theme-aware CSS variables
```
## Local development
**Don't touch the committed `config.json`.** Create `config.local.json` at
the project root once — gitignored, never deployed. The host's Vite dev
server overlays it on top of `/config.json` responses (see
`serveLocalConfigOverlay` in `vite.config.js`); prod builds ignore the
overlay entirely.
```bash
# one-time: install widget deps
cd apps/widget-telegram && npm install
# one-time: create config.local.json (gitignored) at the project root
cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON'
{
"bots": [
{
"id": "telegram",
"experience": {
"type": "matrix-widget",
"url": "http://localhost:8081/"
}
}
]
}
JSON
```
The overlay merges `bots[]` by `id`, so just `{ id, experience }` is
enough — base bot's `mxid` and `name` are preserved. Top-level fields
not present in `config.local.json` are inherited from `config.json`.
Run both servers:
```bash
# terminal 1 — widget on :8081 with HMR
cd apps/widget-telegram && npm run dev
# terminal 2 — host SPA on :8080
cd /home/ubuntu/projects/vojo/cinny && npm start
```
Open `http://localhost:8080/bots/telegram`. Iframe loads cross-origin
from the widget dev server, HMR works, no proxy.
`http://localhost:*` URLs are accepted by the host's URL validator only
in dev builds (`import.meta.env.DEV` branch in
`src/app/features/bots/catalog.ts`); production builds drop the branch
via Vite's dead-code elimination, AND production-only enforces an origin
allowlist (`PROD_WIDGET_ORIGINS`) so prod can never embed `localhost` even
if config.json is poisoned.
Deploy is unchanged. `config.local.json` is gitignored, never shipped.
You don't need to revert anything before `Deploy to vojo.chat` — there
is nothing in tracked files that points at localhost.
Standalone preview of the widget bundle (no host, useful for visual
iteration):
```bash
cd apps/widget-telegram
npm run dev # vite dev server on :8081 — shows missing-params banner
# without host, expected.
npm run preview # serves the production build from dist/
```
## Build
```bash
npm run build
```
Outputs to `apps/widget-telegram/dist/`. Deploy by rsyncing `dist/*`
into `~/vojo/widgets/telegram/` on the production host (Caddy serves
this via the `widgets.vojo.chat` block). One parent `~/vojo/widgets/`
directory hosts every bot widget — adding a second one is `mkdir
~/vojo/widgets/<slug>/` plus a Caddy block, no docker-compose edit.
## Hosting (server-side, runbook)
1. DNS: `widgets.vojo.chat` A/AAAA → server. Verify with `dig`.
2. `~/vojo/docker-compose.yml` — Caddy `volumes:` adds (one parent mount,
future widgets reuse it):
```yaml
- ./widgets:/var/www/widgets
```
3. `~/vojo/caddy/Caddyfile` — append:
```
widgets.vojo.chat {
encode zstd gzip
header {
Content-Security-Policy "frame-ancestors https://vojo.chat https://localhost"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
Cache-Control "no-cache, no-store, must-revalidate"
-Server
}
handle_path /telegram/* {
root * /var/www/widgets/telegram
try_files {path} /index.html
file_server
}
handle {
respond "Not Found" 404
}
}
```
4. `mkdir -p ~/vojo/widgets/telegram` (placeholder so cert provisioning
has something to serve), then `docker compose up -d caddy` to apply.
5. Verify directly: `curl -I https://widgets.vojo.chat/telegram/index.html`
should return 200 and the `Content-Security-Policy` header.
## Updating the production /config.json
Once the widget is live at `https://widgets.vojo.chat/telegram/index.html`,
add to the host repo's `config.json`:
```json
"experience": {
"type": "matrix-widget",
"url": "https://widgets.vojo.chat/telegram/index.html"
}
```
## Capacitor (Android)
`capacitor.config.ts` already has a placeholder. Uncomment and set:
```ts
server: { allowNavigation: ['widgets.vojo.chat'] }
```
Without this, Android's WebView hijacks the cross-origin iframe URL into
`Intent.ACTION_VIEW` and the iframe stays blank. Rebuild the APK after.
## Capability contract
The widget requests EXACTLY this set (matches the host's
`BotWidgetDriver.getBotWidgetCapabilities`):
```
org.matrix.msc2762.timeline:<roomId>
org.matrix.msc2762.send.event:m.room.message#m.text
org.matrix.msc2762.receive.event:m.room.message#m.text
org.matrix.msc2762.receive.event:m.room.message#m.notice
org.matrix.msc2762.receive.state_event:m.room.member
```
Anything else is silently dropped by the host. To extend the surface,
update `BotWidgetDriver.ts` upstream — that requires a security review
per Phase 2 plan §M9.

View file

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

1992
apps/widget-telegram/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
{
"name": "@vojo/widget-telegram",
"version": "0.0.1",
"private": true,
"description": "Vojo Telegram bridge management widget — mounts inside /bots/telegram",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "10.22.1"
},
"devDependencies": {
"@preact/preset-vite": "2.9.0",
"typescript": "5.4.5",
"vite": "5.4.19"
}
}

View file

@ -0,0 +1,176 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { WidgetBootstrap } from './bootstrap';
import { WidgetApi, buildCapabilities, type RoomEvent } from './widget-api';
import { createT } from './i18n';
// Visual canon: «Боты · Commands IDE» mockup at
// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
// function BotsDesktop (lines 651-707) — hero with 56px square avatar in
// fleet color, 22/700 name + monospace handle, uppercase muted section
// labels, 2-col command-card grid, monospace transcript. The widget is the
// Telegram-bridge half of that mockup, mounted inside the chat slot.
type TranscriptLine = {
id: string;
ts: number;
kind: 'from-bot' | 'from-user' | 'diag' | 'error';
text: string;
};
type HandshakeState = 'waiting' | 'ok';
type Props = {
bootstrap: WidgetBootstrap;
};
const TRANSCRIPT_MAX = 200;
const formatTime = (ts: number): string => {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
};
// Initial inferred from preset name: first character of the name, uppercase.
// Falls back to "T" so the avatar never renders blank for the Telegram preset.
const heroInitial = (name: string): string => {
const first = name.trim().charAt(0).toUpperCase();
return first || 'T';
};
export function App({ bootstrap }: Props) {
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
const [handshake, setHandshake] = useState<HandshakeState>('waiting');
const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
const [pinging, setPinging] = useState(false);
const apiRef = useRef<WidgetApi | null>(null);
const seenEventIds = useRef(new Set<string>());
const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]);
const capabilities = useMemo(() => buildCapabilities(bootstrap.roomId), [bootstrap.roomId]);
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
const append = (line: Omit<TranscriptLine, 'id' | 'ts'>) => {
setTranscript((prev) => {
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];
return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next;
});
};
useEffect(() => {
const api = new WidgetApi(bootstrap, capabilities);
apiRef.current = api;
api.on('ready', () => {
setHandshake('ok');
append({ kind: 'diag', text: t('diag.ready') });
});
api.on('themeChange', (name) => {
setTheme(name);
});
api.on('liveEvent', (ev: RoomEvent) => {
if (seenEventIds.current.has(ev.event_id)) return;
seenEventIds.current.add(ev.event_id);
const body = ev.content.body ?? '';
const fromUser = ev.sender === bootstrap.userId;
append({
kind: fromUser ? 'from-user' : 'from-bot',
text: `${fromUser ? '→' : '←'} ${body}`,
});
});
append({ kind: 'diag', text: t('diag.connecting') });
return () => {
api.dispose();
apiRef.current = null;
};
}, [bootstrap, capabilities, t]);
const handlePing = async () => {
const api = apiRef.current;
if (!api || pinging || handshake !== 'ok') return;
setPinging(true);
append({ kind: 'from-user', text: '→ ping' });
try {
await api.sendText('ping');
} catch (err) {
append({
kind: 'error',
text: t('diag.send-failed', { message: (err as Error).message }),
});
} finally {
// Light debounce — bridge bots flag rapid pings as flooding.
window.setTimeout(() => setPinging(false), 1500);
}
};
const initial = heroInitial(bootstrap.botMxid.split(':')[0].replace('@', '') || 'telegram');
const statusLabel = handshake === 'ok' ? t('status.ok') : t('status.waiting');
return (
<div class="app">
<header class="hero">
<div class="hero-avatar" aria-hidden="true">
{initial}
</div>
<div class="hero-body">
<div class="hero-title-row">
<span class="hero-name">Telegram</span>
<span class="hero-handle">{bootstrap.botMxid}</span>
</div>
<p class="hero-description">{t('hero.description')}</p>
</div>
<div class={`hero-status ${handshake === 'ok' ? 'ok' : 'waiting'}`}>
<span class="dot" />
{statusLabel}
</div>
</header>
<section class="section">
<h2 class="section-label">{t('section.check')}</h2>
<div class="command-grid">
<button
class="command-card"
onClick={handlePing}
disabled={handshake !== 'ok' || pinging}
type="button"
>
<div class="command-card-body">
<div class="command-card-name">/ping</div>
<div class="command-card-desc">{t('card.ping.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
</div>
<p class="hint">{t('hint.m11')}</p>
</section>
<section class="section">
<h2 class="section-label">{t('section.transcript')}</h2>
<div class="transcript" role="log" aria-live="polite">
{transcript.length === 0 ? (
<div class="transcript-empty">{t('transcript.empty')}</div>
) : (
transcript.map((line) => (
<div key={line.id} class={`transcript-line ${line.kind}`}>
<span class="ts">{formatTime(line.ts)}</span>
<span class="body">{line.text}</span>
</div>
))
)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,58 @@
// Parse the URL params the Phase 2 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;
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'] };
}
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'),
theme,
clientLanguage: get('clientLanguage'),
},
};
};

View file

@ -0,0 +1,22 @@
// 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> = {
'hero.description':
'Manage the Telegram bridge. Commands are sent as text into the control DM; replies are visible in the transcript.',
'status.waiting': 'Connecting…',
'status.ok': 'Ready',
'section.check': 'Check',
'section.transcript': 'Transcript',
'card.ping.desc': 'Check Telegram authentication status via the bot.',
'hint.m11': 'M11: handshake and bot connectivity check only. Login commands arrive in M12.',
'transcript.empty': 'empty',
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
'diag.ready': 'Ready to send commands.',
'diag.send-failed': 'send failed: {message}',
'bootstrap.failed': 'Widget failed to start',
'bootstrap.missing-params': 'Missing required URL params: {names}.',
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
};

View file

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

View file

@ -0,0 +1,26 @@
// 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' })` in components.
// Interpolation uses `{name}` placeholders resolved against the second arg.
export const RU = {
'hero.description':
'Управление мостом Telegram. Команды отправляются текстом в контрольный DM, ответы видны в транскрипте.',
'status.waiting': 'Подключение…',
'status.ok': 'Готов',
'section.check': 'Проверка',
'section.transcript': 'Транскрипт',
'card.ping.desc': 'Проверить статус авторизации в Telegram через бот.',
'hint.m11': "M11: только проверка handshake'а и связи с ботом. Команды логина появятся в M12.",
'transcript.empty': 'пусто',
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
'diag.ready': 'Готов отправлять команды.',
'diag.send-failed': 'ошибка отправки: {message}',
'bootstrap.failed': 'Widget не запустился',
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
} as const;
export type StringKey = keyof typeof RU;

View file

@ -0,0 +1,36 @@
import { render } from 'preact';
import { readBootstrap } from './bootstrap';
import { App } from './App';
import { createT } from './i18n';
import './styles.css';
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 someone opened the widget URL directly (no host params), or a
// host bug failed to provide them. Either way render a self-contained
// diagnostic instead of going silent. Bootstrap failed before we could
// read clientLanguage from the URL, so let createT fall 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/telegram' })}
</div>
</div>,
root
);
} else {
// Apply initial theme synchronously so the first paint isn't flashed
// through the wrong palette.
document.documentElement.dataset.theme = result.bootstrap.theme;
render(<App bootstrap={result.bootstrap} />, root);
}

View file

@ -0,0 +1,368 @@
/* Dawn palette must stay in sync with
* docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
* (DAWN const, lines 4-23). The widget renders inside the Vojo chat slot
* which is itself a Dawn surface; the iframe inherits the same visual
* canon to feel like a continuation of the host. */
: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;
--amber: #d4b88a;
--rose: #c08e7b;
--section-pad-x: 40px;
}
[data-theme='light'] {
/* Light theme is intentionally a thin remap. Vojo is dark-default; the
* theme param exists so we don't fight an explicit user/host setting,
* not because we expect daily light-mode use. */
--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;
}
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;
}
/* ── Hero ─────────────────────────────────────────────────────────── */
.hero {
display: flex;
align-items: flex-start;
gap: 18px;
padding: 36px var(--section-pad-x) 28px;
border-bottom: 1px solid var(--divider);
}
.hero-avatar {
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--fleet);
color: #0c0c0e;
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-body {
flex: 1;
min-width: 0;
}
.hero-title-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.hero-name {
font-size: 22px;
font-weight: 700;
color: var(--text);
}
.hero-handle {
font-size: 13px;
color: var(--faint);
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
word-break: break-all;
}
.hero-description {
font-size: 14px;
line-height: 20px;
color: var(--muted);
max-width: 560px;
}
.hero-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--divider);
font-size: 13px;
color: var(--muted);
flex-shrink: 0;
white-space: nowrap;
}
.hero-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
flex-shrink: 0;
}
.hero-status.ok {
color: var(--green);
}
.hero-status.ok .dot {
background: var(--green);
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
}
.hero-status.waiting {
color: var(--amber);
}
.hero-status.waiting .dot {
background: var(--amber);
}
.hero-status.error {
color: var(--rose);
}
.hero-status.error .dot {
background: var(--rose);
}
@media (max-width: 600px) {
.hero {
flex-wrap: wrap;
gap: 14px;
padding-top: 24px;
padding-bottom: 18px;
}
.hero-status {
order: 3;
margin-left: 0;
}
.hero-name {
font-size: 19px;
}
.hero-avatar {
width: 48px;
height: 48px;
font-size: 20px;
}
}
/* ── Section ──────────────────────────────────────────────────────── */
.section {
padding: 24px var(--section-pad-x) 20px;
}
.section + .section {
padding-top: 4px;
}
.section-label {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
margin-bottom: 14px;
}
/* ── Command card (single + 2-col grid both fit) ─────────────────── */
.command-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.command-card {
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;
}
.command-card:hover:not(:disabled) {
background: var(--surface);
border-color: var(--hairline);
}
.command-card:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.command-card-body {
flex: 1;
min-width: 0;
}
.command-card-name {
font-size: 14px;
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
color: var(--fleet-soft);
font-weight: 500;
margin-bottom: 3px;
}
.command-card-desc {
font-size: 13px;
color: var(--muted);
line-height: 18px;
}
.command-card-chevron {
color: var(--muted);
font-size: 18px;
flex-shrink: 0;
line-height: 1;
}
/* ── Transcript ──────────────────────────────────────────────────── */
.transcript {
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 12px 14px;
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
font-size: 12.5px;
line-height: 1.55;
max-height: 360px;
overflow-y: auto;
}
.transcript-line {
padding: 4px 0;
display: flex;
gap: 10px;
align-items: flex-start;
white-space: pre-wrap;
word-break: break-word;
}
.transcript-line + .transcript-line {
border-top: 1px dashed var(--divider);
}
.transcript-line .ts {
color: var(--faint);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.transcript-line .body {
flex: 1;
min-width: 0;
}
.transcript-line.from-bot .body {
color: var(--text);
}
.transcript-line.from-user .body {
color: var(--fleet-soft);
}
.transcript-line.diag .body {
color: var(--muted);
}
.transcript-line.error .body {
color: var(--rose);
}
.transcript-empty {
color: var(--faint);
text-align: center;
padding: 16px 0;
font-style: italic;
}
/* ── Hint text ───────────────────────────────────────────────────── */
.hint {
font-size: 12px;
color: var(--faint);
margin-top: 8px;
line-height: 17px;
}
/* ── 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;
}
.error-banner code {
background: var(--bg2);
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text);
}

View file

@ -0,0 +1,229 @@
// Minimal matrix-widget-api transport implemented inline. We don't pull
// the full SDK because:
// - it's CommonJS and forces ESM interop juggling we hit on the dev
// fixture in Phase 2 (esm.sh wrapping made WidgetApi unavailable as
// a constructor);
// - the surface we use is small: capabilities reply, theme_change reply,
// send_event request, read_events request, get_openid request, live
// event delivery via send_event toWidget.
//
// Protocol shapes match
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// (in the host repo). Default request timeout on the host transport is
// 10 s — keep that in mind for bridge-bot replies that take time.
import type { WidgetBootstrap } from './bootstrap';
export type RoomEvent = {
type: string;
event_id: string;
room_id: string;
sender: string;
origin_server_ts: number;
content: { msgtype?: string; body?: string; [k: string]: unknown };
unsigned: Record<string, unknown>;
};
type ToWidgetMessage = {
api: 'toWidget';
widgetId: string;
requestId: string;
action: string;
data: Record<string, unknown>;
// Present when this message IS a reply to a prior toWidget request.
// Per matrix-widget-api PostmessageTransport: replies preserve the original
// `api` field and add `response`. Both directions follow the same shape.
response?: Record<string, unknown>;
};
type FromWidgetMessage = {
api: 'fromWidget';
widgetId: string;
requestId: string;
action: string;
data: Record<string, unknown>;
response?: Record<string, unknown>;
};
export type Capability = string;
export type WidgetApiEvents = {
ready: () => void;
liveEvent: (ev: RoomEvent) => void;
themeChange: (name: 'light' | 'dark') => void;
};
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
export class WidgetApi {
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
private readonly pending = new Map<
string,
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
>();
private requestSeq = 0;
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);
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
this.pending.clear();
}
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
list.push(listener);
}
public sendText(body: string): Promise<{ event_id: string }> {
return this.fromWidget('send_event', {
type: 'm.room.message',
content: { msgtype: 'm.text', body },
}) as Promise<{ event_id: string }>;
}
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 nextRequestId(): string {
this.requestSeq += 1;
return `widget-tg-${Date.now()}-${this.requestSeq}`;
}
private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void {
window.parent.postMessage(msg, this.bootstrap.parentOrigin);
}
private onMessage = (ev: MessageEvent): void => {
if (ev.origin !== this.bootstrap.parentOrigin) return;
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
if (!msg || typeof msg !== 'object') return;
if (msg.widgetId !== this.bootstrap.widgetId) return;
if (msg.api === 'toWidget') {
this.handleToWidget(msg);
return;
}
if (msg.api === 'fromWidget' && msg.response) {
const pending = this.pending.get(msg.requestId);
if (!pending) return;
this.pending.delete(msg.requestId);
const err = (msg.response as { error?: { message?: string } }).error;
if (err) pending.reject(new Error(err.message ?? 'request failed'));
else pending.resolve(msg.response);
}
};
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
this.postToHost({
api: msg.api,
widgetId: msg.widgetId,
requestId: msg.requestId,
action: msg.action,
data: msg.data,
response,
});
}
private handleToWidget(msg: ToWidgetMessage): void {
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) ?? '';
const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light';
this.emit('themeChange', themed);
this.replyTo(msg, {});
return;
}
case 'send_event': {
// Live event push from host. We forward only m.room.message —
// m.room.member state updates also arrive here but we don't
// surface them in M11.
const data = msg.data as Partial<RoomEvent> | undefined;
if (data && data.type === 'm.room.message' && data.event_id) {
this.emit('liveEvent', data as RoomEvent);
}
this.replyTo(msg, {});
return;
}
case 'update_state': {
// Initial room state push from host (m.room.member members).
// M11 ignores this; future milestones can use it for header chrome.
this.replyTo(msg, {});
return;
}
default: {
// Be liberal — reply empty so the host's request promise resolves.
this.replyTo(msg, {});
}
}
}
private fromWidget(
action: string,
data: Record<string, unknown>
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const requestId = this.nextRequestId();
this.pending.set(requestId, { resolve, reject });
this.postToHost({
api: 'fromWidget',
widgetId: this.bootstrap.widgetId,
requestId,
action,
data,
});
window.setTimeout(() => {
if (this.pending.has(requestId)) {
this.pending.delete(requestId);
reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`));
}
}, FROM_WIDGET_REQUEST_TIMEOUT_MS);
});
}
}
// Capability set must match docs/plans/bots_tab.md (Phase 3 contract) and
// the host's BotWidgetDriver.getBotWidgetCapabilities. Anything else is
// silently dropped by the host's validateCapabilities — keep this aligned.
export const buildCapabilities = (roomId: string): Capability[] => [
`org.matrix.msc2762.timeline:${roomId}`,
'org.matrix.msc2762.send.event:m.room.message#m.text',
'org.matrix.msc2762.receive.event:m.room.message#m.text',
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
'org.matrix.msc2762.receive.state_event:m.room.member',
];

View file

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

View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
// Build artefact lives at apps/widget-telegram/dist/. The deploy step
// (out of repo) rsyncs this into ~/vojo/widgets/telegram/ on the server,
// which Caddy serves from /var/www/widgets/telegram via the
// widgets.vojo.chat block (see docs/plans/bots_tab.md Phase 3).
//
// `base: './'` keeps every generated asset path relative so the same
// build can sit under /telegram/ 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 small and the
// host's iframe handshake budget is already tight (10s default).
cssCodeSplit: false,
},
server: {
port: 8081,
host: true,
},
});

View file

@ -18,7 +18,11 @@
{
"id": "telegram",
"mxid": "@telegrambot:vojo.chat",
"name": "Telegram"
"name": "Telegram",
"experience": {
"type": "matrix-widget",
"url": "https://widgets.vojo.chat/telegram/index.html"
}
}
],
"push": {

View file

@ -19,6 +19,15 @@ export type BotPreset = {
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
const MXID_RE = /^@[^:\s]+:[^\s]+$/;
// Defense-in-depth allowlist of widget origins acceptable in production. The
// BotWidgetDriver capability allowlist (M9) already tightly scopes what a
// widget can do — only m.text/m.notice in the current control DM, no media,
// no OpenID, no cross-room. This origin pin is an additional layer that
// catches operator-config typos or a poisoned config.json that points the
// iframe at an unrelated host. Add new entries when onboarding new widget
// hosts; the dev branch below bypasses this check for `http://localhost:*`.
const PROD_WIDGET_ORIGINS: ReadonlySet<string> = new Set(['https://widgets.vojo.chat']);
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset?.id === 'string' &&
BOT_ID_RE.test(preset.id) &&
@ -63,8 +72,18 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
try {
const parsed = new URL(url);
// Dev-only escape hatch: accept http://localhost:* widget URLs so a Vite
// dev server in `apps/widget-<id>/` can be embedded straight from a local
// config.json edit, no proxy or rewrites needed. Vite's dead-code
// elimination drops this branch in production builds (`import.meta.env.DEV`
// collapses to a literal `false`), so it never relaxes the prod validator.
if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') {
if (parsed.username || parsed.password) return undefined;
return { type, url: parsed.toString() };
}
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() };
} catch {
return undefined;

View file

@ -78,6 +78,74 @@ const copyFiles = {
],
};
// Dev-only overlay for runtime config. The SPA fetches `/config.json` at boot
// to read homeserver list, bot presets, push gateway config, etc. — production
// ships this file unmodified from `~/vojo/cinny/config.json` on the server.
// Locally we want per-developer overrides (e.g. `bots[id=telegram].experience.url`
// → `http://localhost:8081/` for a widget dev server) WITHOUT touching the
// committed `config.json`. This middleware reads optional `config.local.json`
// at the project root and overlays it on top, then serves the merged JSON.
//
// Merge contract:
// - top-level fields: shallow override (local wins).
// - `bots[]`: merged by `id` — the local entry shallow-merges over the base
// entry with the same id, so `{ id: "telegram", experience: {...} }` is
// enough to override one field of an existing bot. Bots that exist only
// in local are appended as-is.
//
// Production builds ignore this plugin (`apply: 'serve'`) so prod
// `config.json` is served untouched by Caddy. `config.local.json` is in
// `.gitignore` and never deployed.
function mergeBotsById(base, local) {
if (!Array.isArray(local)) return base;
if (!Array.isArray(base)) return local;
const byId = new Map();
base.forEach((bot) => {
if (bot && typeof bot.id === 'string') byId.set(bot.id, bot);
});
local.forEach((overlay) => {
if (!overlay || typeof overlay.id !== 'string') return;
const baseBot = byId.get(overlay.id);
byId.set(overlay.id, baseBot ? { ...baseBot, ...overlay } : overlay);
});
return [...byId.values()];
}
function mergeRuntimeConfig(base, local) {
if (!local || typeof local !== 'object') return base;
const merged = { ...base, ...local };
if (Array.isArray(local.bots) || Array.isArray(base?.bots)) {
merged.bots = mergeBotsById(base?.bots, local.bots);
}
return merged;
}
function serveLocalConfigOverlay() {
return {
name: 'vite-plugin-serve-local-config-overlay',
apply: 'serve',
configureServer(server) {
server.middlewares.use('/config.json', (req, res, next) => {
const localPath = path.resolve('config.local.json');
if (!fs.existsSync(localPath)) {
next();
return;
}
try {
const baseRaw = fs.readFileSync(path.resolve('config.json'), 'utf8');
const localRaw = fs.readFileSync(localPath, 'utf8');
const merged = mergeRuntimeConfig(JSON.parse(baseRaw), JSON.parse(localRaw));
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-cache');
res.end(JSON.stringify(merged));
} catch (err) {
next(err);
}
});
},
};
}
function serverMatrixSdkCryptoWasm(wasmFilePath) {
return {
name: 'vite-plugin-serve-matrix-sdk-crypto-wasm',
@ -123,6 +191,7 @@ export default defineConfig({
},
},
plugins: [
serveLocalConfigOverlay(),
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
topLevelAwait({
// The export name of top-level await promise for each chunk module