feat(bots-telegram): land Phase 3 widget scaffold with Dawn UI, dev config overlay, and prod origin allowlist
This commit is contained in:
parent
9233a1e172
commit
55eaa7b502
19 changed files with 3289 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,6 +2,7 @@ experiment
|
|||
dist
|
||||
node_modules
|
||||
devAssets
|
||||
config.local.json
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
|
|
|
|||
4
apps/widget-telegram/.gitignore
vendored
Normal file
4
apps/widget-telegram/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.local
|
||||
176
apps/widget-telegram/README.md
Normal file
176
apps/widget-telegram/README.md
Normal 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.
|
||||
12
apps/widget-telegram/index.html
Normal file
12
apps/widget-telegram/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>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
1992
apps/widget-telegram/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
apps/widget-telegram/package.json
Normal file
20
apps/widget-telegram/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
176
apps/widget-telegram/src/App.tsx
Normal file
176
apps/widget-telegram/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/widget-telegram/src/bootstrap.ts
Normal file
58
apps/widget-telegram/src/bootstrap.ts
Normal 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'),
|
||||
},
|
||||
};
|
||||
};
|
||||
22
apps/widget-telegram/src/i18n/en.ts
Normal file
22
apps/widget-telegram/src/i18n/en.ts
Normal 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}.',
|
||||
};
|
||||
30
apps/widget-telegram/src/i18n/index.ts
Normal file
30
apps/widget-telegram/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 };
|
||||
26
apps/widget-telegram/src/i18n/ru.ts
Normal file
26
apps/widget-telegram/src/i18n/ru.ts
Normal 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;
|
||||
36
apps/widget-telegram/src/main.tsx
Normal file
36
apps/widget-telegram/src/main.tsx
Normal 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);
|
||||
}
|
||||
368
apps/widget-telegram/src/styles.css
Normal file
368
apps/widget-telegram/src/styles.css
Normal 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);
|
||||
}
|
||||
229
apps/widget-telegram/src/widget-api.ts
Normal file
229
apps/widget-telegram/src/widget-api.ts
Normal 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',
|
||||
];
|
||||
21
apps/widget-telegram/tsconfig.json
Normal file
21
apps/widget-telegram/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-telegram/vite.config.ts
Normal file
25
apps/widget-telegram/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-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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue