feat(bots-telegram): ship M12 login flow with BotShell host hero and Go bridgev2 dialect parser
This commit is contained in:
parent
55eaa7b502
commit
691eb8530a
30 changed files with 2972 additions and 351 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,12 @@ export type WidgetBootstrap = {
|
|||
userId: string;
|
||||
botId: string;
|
||||
botMxid: string;
|
||||
/** Bridge command prefix (e.g. `!tg`). Always non-empty — the host
|
||||
* validator (catalog.ts) defaults missing values to `!tg` and rejects
|
||||
* malformed overrides. The widget prepends `<commandPrefix> ` to every
|
||||
* outbound command and form-field value (bridgev2/queue.go:118 strips
|
||||
* exactly `prefix+" "`). */
|
||||
commandPrefix: string;
|
||||
theme: 'light' | 'dark';
|
||||
clientLanguage: string;
|
||||
};
|
||||
|
|
@ -19,7 +25,7 @@ export type BootstrapResult =
|
|||
| { ok: true; bootstrap: WidgetBootstrap }
|
||||
| { ok: false; missing: string[] };
|
||||
|
||||
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const;
|
||||
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const;
|
||||
|
||||
export const readBootstrap = (search: string): BootstrapResult => {
|
||||
const params = new URLSearchParams(search);
|
||||
|
|
@ -51,6 +57,7 @@ export const readBootstrap = (search: string): BootstrapResult => {
|
|||
userId: get('userId'),
|
||||
botId: get('botId'),
|
||||
botMxid: get('botMxid'),
|
||||
commandPrefix: get('commandPrefix'),
|
||||
theme,
|
||||
clientLanguage: get('clientLanguage'),
|
||||
},
|
||||
|
|
|
|||
309
apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts
Normal file
309
apps/widget-telegram/src/bridge-protocol/dialects/go_v2604.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
// Dialect: mautrix-telegram Go rewrite v0.2604.0 + mautrix/go bridgev2.
|
||||
// Generated against tag v0.2604.0 (commit b9f09628, 26 Apr 2026).
|
||||
//
|
||||
// Each regex is paired with its upstream source; if bridgev2 wording drifts
|
||||
// in a future patch, replace this file with a sibling go_v2607.ts (or
|
||||
// whatever) and switch the import in ../parser.ts.
|
||||
//
|
||||
// Body encoding note: bridgev2 routes replies through `format.RenderMarkdown`
|
||||
// (bridgev2/commands/event.go:58) which sets `formatted_body` to HTML and
|
||||
// `body` to the markdown source. Our host driver strips `formatted_body`
|
||||
// (Phase 2 contract), so the widget only ever sees the markdown source —
|
||||
// backticks, asterisks, escaped angle-brackets stay literal.
|
||||
|
||||
import type { LoginEvent, ListedLogin } from '../types';
|
||||
|
||||
// --- Regex table ----------------------------------------------------------
|
||||
|
||||
// list-logins, empty: bridgev2/commands/login.go:564 → `You're not logged in`
|
||||
// Note: NO trailing period. The Python v0.15.3 dialect ended with one — this
|
||||
// is a stable structural fingerprint between dialects.
|
||||
const NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
|
||||
|
||||
// list-logins, non-empty: bridgev2/user.go:185-190 ships a leading `\n` due
|
||||
// to a `make([]string, N) + append` bug. Each row is
|
||||
// `* `<id>` (<RemoteName>) - `<state>``.
|
||||
// Tolerate both leading-whitespace and a future fix that removes the bug.
|
||||
//
|
||||
// Name capture uses greedy `(.+)` (not `[^)]*`) because Telegram display
|
||||
// names commonly contain literal `)` — e.g. «Example (Work)», «Имя
|
||||
// (Личный)». The trailing anchor `\)\s+-\s+`<state>`` forces the regex
|
||||
// engine to backtrack to the LAST `)` before ` - `<…>``, so nested
|
||||
// parens parse correctly.
|
||||
const LOGIN_LIST_ROW_RE = /^\s*\*\s+`([^`]+)`\s+\((.+)\)\s+-\s+`([^`]+)`\s*$/gm;
|
||||
|
||||
// Phone prompt — bridgev2/commands/login.go:207 + connector loginphone.go:74.
|
||||
// Composed: `Please enter your <field.Name>\n<field.Description>`. Phone step
|
||||
// has no Instructions, so this is the only reply.
|
||||
const PHONE_PROMPT_RE = /^please enter your phone number\b/i;
|
||||
|
||||
// Code prompt — bridgev2/commands/login.go:207 + connector loginphone.go:98.
|
||||
// Same composition; sent on initial code request.
|
||||
const CODE_PROMPT_RE = /^please enter your code\b/i;
|
||||
|
||||
// 2fa Instructions — connector login.go:170. First of TWO replies; the second
|
||||
// is `Please enter your Password` which falls into PASSWORD_REPROMPT_RE.
|
||||
const TWOFA_INSTRUCTIONS_RE = /^you have two-factor authentication enabled\.?$/i;
|
||||
|
||||
// Password re-prompt — bridgev2/commands/login.go:207. Emitted both after
|
||||
// the 2fa instructions and after a wrong-password re-prompt.
|
||||
const PASSWORD_REPROMPT_RE = /^please enter your password\s*$/i;
|
||||
|
||||
// Code incorrect Instructions — connector loginphone.go:107. First of two.
|
||||
const CODE_INCORRECT_RE = /^incorrect code\.?$/i;
|
||||
|
||||
// Password incorrect Instructions — connector login.go:183. First of two.
|
||||
const PASSWORD_INCORRECT_RE = /^incorrect password,/i;
|
||||
|
||||
// Login success — connector login.go:290. Format string is
|
||||
// `Successfully logged in as %s (\`%d\`)` — the numeric id is wrapped in
|
||||
// markdown backticks which survive into `body`. Capture both for UI use.
|
||||
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+(.+?)\s+\(`?(\d+)`?\)\.?$/i;
|
||||
|
||||
// Logout — bridgev2/commands/login.go:591 → `Logged out` (no period).
|
||||
const LOGOUT_OK_RE = /^logged out\.?$/i;
|
||||
|
||||
// Cancel — bridgev2/commands/processor.go:198 / 200. Action for our
|
||||
// flow is always `Login` (set by userInputLoginCommandState at login.go:218).
|
||||
const CANCEL_OK_RE = /^login cancelled\.?$/i;
|
||||
const CANCEL_NO_OP_RE = /^no ongoing command\.?$/i;
|
||||
|
||||
// Login already in progress — bridgev2/commands/login.go:83.
|
||||
const LOGIN_IN_PROGRESS_RE = /^you already have an ongoing login\b/i;
|
||||
|
||||
// Max logins — bridgev2/commands/login.go:74-79. Captures the limit.
|
||||
const MAX_LOGINS_RE = /^you have reached the maximum number of logins \((\d+)\)/i;
|
||||
|
||||
// Login id not found — bridgev2/commands/login.go:587 (logout) and 68
|
||||
// (relogin). Single backtick-wrapped id capture.
|
||||
const LOGIN_NOT_FOUND_RE = /^login `([^`]+)` not found\b/i;
|
||||
|
||||
// Flow selector errors — bridgev2/commands/login.go:107 / 98.
|
||||
const FLOW_REQUIRED_RE = /^please specify a login flow\b/i;
|
||||
const FLOW_INVALID_RE = /^invalid login flow `([^`]+)`/i;
|
||||
|
||||
// Unknown command — bridgev2/commands/processor.go:163.
|
||||
const UNKNOWN_COMMAND_RE = /^unknown command, use the `help` command/i;
|
||||
|
||||
// Generic error traps. Each anchors on a distinct prefix, so order between
|
||||
// them is incidental — kept ordered for readability.
|
||||
const INVALID_VALUE_RE = /^invalid value:\s*(.*)$/i;
|
||||
const SUBMIT_FAILED_RE = /^failed to submit input:\s*(.*)$/i;
|
||||
const PREPARE_FAILED_RE = /^failed to prepare login process:\s*(.*)$/i;
|
||||
const START_FAILED_RE = /^failed to start login:\s*(.*)$/i;
|
||||
|
||||
// --- Parser ---------------------------------------------------------------
|
||||
|
||||
const trimReplyBody = (raw: string): string => {
|
||||
// Bridge sometimes emits a leading `\n` (login-list bug, user.go:185).
|
||||
// Trim outer whitespace before matching to keep regexes anchored on `^`.
|
||||
return raw.trim();
|
||||
};
|
||||
|
||||
const parseLoginList = (body: string): ListedLogin[] => {
|
||||
const logins: ListedLogin[] = [];
|
||||
// matchAll requires the global flag — preserve LOGIN_LIST_ROW_RE's lastIndex
|
||||
// by rebuilding it for each call (RegExp instances are stateful with /g).
|
||||
const re = new RegExp(LOGIN_LIST_ROW_RE.source, LOGIN_LIST_ROW_RE.flags);
|
||||
for (const match of body.matchAll(re)) {
|
||||
const [, id, name, state] = match;
|
||||
logins.push({ id, name, state });
|
||||
}
|
||||
return logins;
|
||||
};
|
||||
|
||||
export const parseGoV2604 = (rawBody: string): LoginEvent => {
|
||||
const body = trimReplyBody(rawBody);
|
||||
if (body.length === 0) return { kind: 'unknown' };
|
||||
|
||||
// Order: highly-specific terminal/transitional matches first, generic
|
||||
// error traps last. The login-list parser comes early because its anchor
|
||||
// (` * `<id>` `) wouldn't false-match anything else, and the alternative
|
||||
// — `not_logged_in` — covers the empty-list case explicitly.
|
||||
|
||||
if (NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
|
||||
|
||||
const successMatch = LOGIN_SUCCESS_RE.exec(body);
|
||||
if (successMatch) {
|
||||
return {
|
||||
kind: 'login_success',
|
||||
handle: successMatch[1].trim(),
|
||||
numericId: successMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
if (TWOFA_INSTRUCTIONS_RE.test(body)) return { kind: 'twofa_required' };
|
||||
if (CODE_INCORRECT_RE.test(body)) return { kind: 'invalid_code' };
|
||||
if (PASSWORD_INCORRECT_RE.test(body)) return { kind: 'wrong_password' };
|
||||
|
||||
if (PHONE_PROMPT_RE.test(body)) return { kind: 'awaiting_phone' };
|
||||
if (CODE_PROMPT_RE.test(body)) return { kind: 'awaiting_code' };
|
||||
if (PASSWORD_REPROMPT_RE.test(body)) return { kind: 'awaiting_password' };
|
||||
|
||||
if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
|
||||
if (CANCEL_OK_RE.test(body)) return { kind: 'cancel_ok' };
|
||||
if (CANCEL_NO_OP_RE.test(body)) return { kind: 'cancel_no_op' };
|
||||
if (LOGIN_IN_PROGRESS_RE.test(body)) return { kind: 'login_in_progress' };
|
||||
if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
|
||||
if (FLOW_REQUIRED_RE.test(body)) return { kind: 'flow_required' };
|
||||
|
||||
const maxMatch = MAX_LOGINS_RE.exec(body);
|
||||
if (maxMatch) {
|
||||
const limit = Number(maxMatch[1]);
|
||||
return { kind: 'max_logins', limit: Number.isFinite(limit) ? limit : undefined };
|
||||
}
|
||||
|
||||
const notFoundMatch = LOGIN_NOT_FOUND_RE.exec(body);
|
||||
if (notFoundMatch) return { kind: 'login_not_found', loginId: notFoundMatch[1] };
|
||||
|
||||
const flowInvalidMatch = FLOW_INVALID_RE.exec(body);
|
||||
if (flowInvalidMatch) return { kind: 'flow_invalid', flowId: flowInvalidMatch[1] };
|
||||
|
||||
const invalidValueMatch = INVALID_VALUE_RE.exec(body);
|
||||
if (invalidValueMatch) return { kind: 'invalid_value', reason: invalidValueMatch[1].trim() };
|
||||
|
||||
const submitFailedMatch = SUBMIT_FAILED_RE.exec(body);
|
||||
if (submitFailedMatch) return { kind: 'submit_failed', reason: submitFailedMatch[1].trim() };
|
||||
|
||||
const prepareFailedMatch = PREPARE_FAILED_RE.exec(body);
|
||||
if (prepareFailedMatch) return { kind: 'prepare_failed', reason: prepareFailedMatch[1].trim() };
|
||||
|
||||
const startFailedMatch = START_FAILED_RE.exec(body);
|
||||
if (startFailedMatch) return { kind: 'start_failed', reason: startFailedMatch[1].trim() };
|
||||
|
||||
// Fall-through to login-list AFTER the error traps so a row that happens to
|
||||
// start with `* ` mid-error-message doesn't get mistaken for a login list.
|
||||
const logins = parseLoginList(body);
|
||||
if (logins.length > 0) return { kind: 'logins_listed', logins };
|
||||
|
||||
return { kind: 'unknown' };
|
||||
};
|
||||
|
||||
// --- DEV sanity assertions ------------------------------------------------
|
||||
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
|
||||
// is replaced with the literal `false` and the call site collapses, so the
|
||||
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
|
||||
// first regression on reload.
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
runSanityChecks();
|
||||
}
|
||||
|
||||
function runSanityChecks(): void {
|
||||
const cases: Array<[string, LoginEvent]> = [
|
||||
["You're not logged in", { kind: 'not_logged_in' }],
|
||||
["You're not logged in.", { kind: 'not_logged_in' }],
|
||||
['Please enter your Phone number\nInclude the country code with +', { kind: 'awaiting_phone' }],
|
||||
[
|
||||
'Please enter your Code\nThe code was sent to the Telegram app on your phone',
|
||||
{ kind: 'awaiting_code' },
|
||||
],
|
||||
['You have two-factor authentication enabled.', { kind: 'twofa_required' }],
|
||||
['Please enter your Password', { kind: 'awaiting_password' }],
|
||||
['Incorrect code', { kind: 'invalid_code' }],
|
||||
[
|
||||
"Incorrect password, please try again. Use the official Telegram app to reset your password if you've forgotten it.",
|
||||
{ kind: 'wrong_password' },
|
||||
],
|
||||
[
|
||||
'Successfully logged in as @example (`123456789`)',
|
||||
{ kind: 'login_success', handle: '@example', numericId: '123456789' },
|
||||
],
|
||||
['Logged out', { kind: 'logout_ok' }],
|
||||
['Login cancelled.', { kind: 'cancel_ok' }],
|
||||
['No ongoing command.', { kind: 'cancel_no_op' }],
|
||||
[
|
||||
'You already have an ongoing login. You can use `!tg cancel` to cancel it.',
|
||||
{ kind: 'login_in_progress' },
|
||||
],
|
||||
[
|
||||
'You have reached the maximum number of logins (1). Please logout from an existing login before creating a new one. If you want to re-authenticate an existing login, use the `!tg relogin` command.',
|
||||
{ kind: 'max_logins', limit: 1 },
|
||||
],
|
||||
['Login `abc123` not found', { kind: 'login_not_found', loginId: 'abc123' }],
|
||||
['Unknown command, use the `help` command for help.', { kind: 'unknown_command' }],
|
||||
[
|
||||
'Failed to submit input: rpc error: PHONE_NUMBER_BANNED (400)',
|
||||
{ kind: 'submit_failed', reason: 'rpc error: PHONE_NUMBER_BANNED (400)' },
|
||||
],
|
||||
[
|
||||
'Failed to prepare login process: connector unavailable',
|
||||
{ kind: 'prepare_failed', reason: 'connector unavailable' },
|
||||
],
|
||||
[
|
||||
'Failed to start login: telegram connect timeout',
|
||||
{ kind: 'start_failed', reason: 'telegram connect timeout' },
|
||||
],
|
||||
['Invalid value: must start with +', { kind: 'invalid_value', reason: 'must start with +' }],
|
||||
[
|
||||
'Please specify a login flow, e.g. `login phone`.\n\n* `phone` - Login using your Telegram phone number\n* `qr` - Login by scanning a QR code from your phone\n* `bot` - Log in as a bot using the bot token provided by BotFather.\n',
|
||||
{ kind: 'flow_required' },
|
||||
],
|
||||
[
|
||||
'Invalid login flow `wat`. Available options:\n\n* `phone` - …',
|
||||
{ kind: 'flow_invalid', flowId: 'wat' },
|
||||
],
|
||||
// Truly unrecognised body — the catch-all kind keeps the transcript
|
||||
// usable even when bridgev2 wording drifts.
|
||||
['Some completely unknown bridge reply that does not match any anchor', { kind: 'unknown' }],
|
||||
// Login list with the leading-newline bug present in v0.2604.0.
|
||||
[
|
||||
'\n* `42` (Example User) - `CONNECTED`',
|
||||
{
|
||||
kind: 'logins_listed',
|
||||
logins: [{ id: '42', name: 'Example User', state: 'CONNECTED' }],
|
||||
},
|
||||
],
|
||||
// Same row without the bug — must keep matching after upstream fix.
|
||||
[
|
||||
'* `42` (Example User) - `CONNECTED`',
|
||||
{
|
||||
kind: 'logins_listed',
|
||||
logins: [{ id: '42', name: 'Example User', state: 'CONNECTED' }],
|
||||
},
|
||||
],
|
||||
// Telegram display name with literal `)` inside — common case
|
||||
// («Иван (Работа)», «Pavel (Beta)»). The greedy capture must
|
||||
// backtrack to the LAST `)` before ` - `<state>``, not stop at
|
||||
// the first one.
|
||||
[
|
||||
'* `42` (Example (Work)) - `CONNECTED`',
|
||||
{
|
||||
kind: 'logins_listed',
|
||||
logins: [{ id: '42', name: 'Example (Work)', state: 'CONNECTED' }],
|
||||
},
|
||||
],
|
||||
// Two rows in one reply (multi-login user) with leading-newline bug.
|
||||
[
|
||||
'\n* `42` (Alice) - `CONNECTED`\n* `43` (Bob) - `CONNECTED`',
|
||||
{
|
||||
kind: 'logins_listed',
|
||||
logins: [
|
||||
{ id: '42', name: 'Alice', state: 'CONNECTED' },
|
||||
{ id: '43', name: 'Bob', state: 'CONNECTED' },
|
||||
],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [body, expected] of cases) {
|
||||
const actual = parseGoV2604(body);
|
||||
if (!sameEvent(actual, expected)) {
|
||||
// Surface the diff loudly — dev overlay shows the throw, and the
|
||||
// console error gives the inputs side-by-side for debugging.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[go_v2604 sanity] mismatch', { body, actual, expected });
|
||||
throw new Error(
|
||||
`go_v2604 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
// Shallow-compare the discriminated payload. Good enough for the small
|
||||
// set of structures we emit; deeper equality would only matter if we
|
||||
// returned arbitrary nested data.
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
14
apps/widget-telegram/src/bridge-protocol/parser.ts
Normal file
14
apps/widget-telegram/src/bridge-protocol/parser.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Parser shim. The widget consumes a single `parseReply(body)` from
|
||||
// elsewhere; this file picks the active dialect. M12 ships exactly one —
|
||||
// `go_v2604` — for the operator's current bridge image. When bridgev2
|
||||
// strings drift in a future Go release, add a sibling dialect file and
|
||||
// switch the import below.
|
||||
//
|
||||
// The dialects/ subdirectory is kept as a seam for that swap; we don't
|
||||
// implement runtime autodetect (the operator owns one bridge image at a
|
||||
// time and a parser pin is honest about that).
|
||||
|
||||
import type { LoginEvent } from './types';
|
||||
import { parseGoV2604 } from './dialects/go_v2604';
|
||||
|
||||
export const parseReply = (body: string): LoginEvent => parseGoV2604(body);
|
||||
47
apps/widget-telegram/src/bridge-protocol/types.ts
Normal file
47
apps/widget-telegram/src/bridge-protocol/types.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// LoginEvent — discriminated union the parser emits and the state machine
|
||||
// consumes. One LoginEvent per inbound m.notice from the bridge bot.
|
||||
//
|
||||
// Multi-reply collapse rule: bridgev2 emits TWO replies for steps that have
|
||||
// non-empty Instructions (2FA prompt, invalid code, wrong password) — the
|
||||
// Instructions text first, then a `Please enter your <field.Name>` re-prompt.
|
||||
// The parser returns one event per notice; the state machine collapses the
|
||||
// re-prompt into a no-op when the state already matches.
|
||||
//
|
||||
// Source-of-truth for every kind below is the Go-dialect wording table in
|
||||
// docs/plans/bots_tab.md (Phase 3 → Research outcomes → R3 → Bridge response
|
||||
// wording (Go v0.2604.0 snapshot)).
|
||||
|
||||
export type ListedLogin = {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type LoginEvent =
|
||||
| { kind: 'logins_listed'; logins: ListedLogin[] }
|
||||
| { kind: 'not_logged_in' }
|
||||
| { kind: 'awaiting_phone' }
|
||||
| { kind: 'awaiting_code' }
|
||||
| { kind: 'awaiting_password' }
|
||||
| { kind: 'twofa_required' }
|
||||
| { kind: 'invalid_code' }
|
||||
| { kind: 'wrong_password' }
|
||||
| { kind: 'login_success'; handle: string; numericId: string }
|
||||
| { kind: 'logout_ok' }
|
||||
| { kind: 'cancel_ok' }
|
||||
| { kind: 'cancel_no_op' }
|
||||
| { kind: 'login_in_progress' }
|
||||
| { kind: 'max_logins'; limit?: number }
|
||||
| { kind: 'login_not_found'; loginId?: string }
|
||||
| { kind: 'flow_required' }
|
||||
| { kind: 'flow_invalid'; flowId?: string }
|
||||
| { kind: 'unknown_command' }
|
||||
| { kind: 'invalid_value'; reason?: string }
|
||||
// Catch-all for Telegram-side errors leaking through bridgev2's commands
|
||||
// layer as `Failed to submit input: <go-error>`. Surfaced to the user as a
|
||||
// yellow inline warning with the verbatim Go error tail (no sub-code parse
|
||||
// — gotd error format is unstable across patches).
|
||||
| { kind: 'submit_failed'; reason?: string }
|
||||
| { kind: 'prepare_failed'; reason?: string }
|
||||
| { kind: 'start_failed'; reason?: string }
|
||||
| { kind: 'unknown' };
|
||||
|
|
@ -4,17 +4,62 @@
|
|||
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',
|
||||
'status.unknown': 'Checking status…',
|
||||
'status.disconnected': 'Sign in to Telegram',
|
||||
'status.connected': 'Telegram linked',
|
||||
'status.connected-as': 'Telegram linked as {handle}',
|
||||
'status.logging-out': 'Signing out…',
|
||||
'section.transcript': 'Logs',
|
||||
'card.login.name': '/login',
|
||||
'card.login.desc': 'By phone number',
|
||||
'card.refresh.aria': 'Refresh status',
|
||||
'card.refresh.label': 'Refresh status',
|
||||
'landing.hint': 'The bot replies in this chat — forms appear below.',
|
||||
'auth-card.phone.title': 'Phone login',
|
||||
'auth-card.phone.label': 'Phone number',
|
||||
'auth-card.phone.placeholder': '+15551234567',
|
||||
'auth-card.phone.hint': 'SMS may take up to 30 seconds.',
|
||||
'auth-card.phone.submit': 'Send code',
|
||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
||||
'auth-card.code.title': 'Verification code',
|
||||
'auth-card.code.label': 'SMS code',
|
||||
'auth-card.code.placeholder': '123456',
|
||||
'auth-card.code.submit': 'Confirm',
|
||||
'auth-card.code.privacy-hint':
|
||||
'The Telegram code is visible in the room history — you can clear it manually.',
|
||||
'auth-card.code.privacy-hint-history':
|
||||
'The code you entered is still in the room history — clear it manually if you want.',
|
||||
'auth-card.password.title': 'Telegram cloud password',
|
||||
'auth-card.password.hint':
|
||||
'Your account has two-factor authentication enabled. Enter your Telegram cloud password — this is not your Vojo password.',
|
||||
'auth-card.password.label': 'Password',
|
||||
'auth-card.password.submit': 'Confirm',
|
||||
'auth-card.password.show': 'Show',
|
||||
'auth-card.password.hide': 'Hide',
|
||||
'auth-card.cancel': 'Cancel',
|
||||
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
||||
'auth-card.code.countdown': 'Code arriving in {seconds}s',
|
||||
'auth-card.code.countdown-done': 'No code yet — tap Cancel and try again.',
|
||||
'auth-error.invalid-code': 'Code is invalid. Please try again.',
|
||||
'auth-error.wrong-password': 'Password is incorrect. Please try again.',
|
||||
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
||||
'auth-error.submit-failed': 'Telegram refused the input: {reason}',
|
||||
'auth-error.login-in-progress':
|
||||
'The bot already has another login flow open. Click Cancel and retry.',
|
||||
'auth-error.max-logins': 'Login limit reached ({limit}). Log out of an existing account first.',
|
||||
'auth-error.unknown-command':
|
||||
'The bot does not recognise this command — check the prefix in config.json.',
|
||||
'auth-error.start-failed': 'Failed to start login: {reason}',
|
||||
'auth-error.prepare-failed': 'Failed to prepare login: {reason}',
|
||||
'card.logout.name': '/logout',
|
||||
'card.logout.desc': 'Sign out of Telegram',
|
||||
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||
'card.logout.confirm-yes': 'Sign out',
|
||||
'card.logout.confirm-no': 'Cancel',
|
||||
'card.logout.gated': 'Session identifier still loading — give it a moment.',
|
||||
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||
'diag.ready': 'Ready to send commands.',
|
||||
'diag.checking-status': 'Checking connection status…',
|
||||
'diag.send-failed': 'send failed: {message}',
|
||||
'bootstrap.failed': 'Widget failed to start',
|
||||
'bootstrap.missing-params': 'Missing required URL params: {names}.',
|
||||
|
|
|
|||
|
|
@ -4,20 +4,82 @@
|
|||
// 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.
|
||||
//
|
||||
// The widget no longer renders a hero (avatar/name/handle/description) —
|
||||
// that block lives in the host's BotShellHero. Status is surfaced inline
|
||||
// inside the relevant section, with active labels («Войдите в Telegram»
|
||||
// instead of passive «Не подключён»). Mid-flow states (awaiting_*) don't
|
||||
// have status labels because the open form is itself the indicator.
|
||||
|
||||
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': 'пусто',
|
||||
// --- Inline section status ---------------------------------------------
|
||||
'status.unknown': 'Проверка статуса…',
|
||||
'status.disconnected': 'Войдите в Telegram',
|
||||
'status.connected': 'Telegram привязан',
|
||||
'status.connected-as': 'Telegram привязан как {handle}',
|
||||
'status.logging-out': 'Завершение сеанса…',
|
||||
// --- Section headers ---------------------------------------------------
|
||||
'section.transcript': 'Логи',
|
||||
'card.login.name': '/login',
|
||||
// Card desc is descriptive (noun-style), not a third call-to-action — the
|
||||
// section status «Войдите в Telegram» already carries the imperative.
|
||||
'card.login.desc': 'По номеру телефона',
|
||||
'card.refresh.aria': 'Обновить статус',
|
||||
'card.refresh.label': 'Обновить статус',
|
||||
'landing.hint': 'Бот ответит в этом чате — формы появятся ниже.',
|
||||
// --- Phone form --------------------------------------------------------
|
||||
'auth-card.phone.title': 'Вход по номеру',
|
||||
'auth-card.phone.label': 'Номер телефона',
|
||||
'auth-card.phone.placeholder': '+79991234567',
|
||||
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
|
||||
'auth-card.phone.submit': 'Отправить код',
|
||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||
// --- Code form ---------------------------------------------------------
|
||||
'auth-card.code.title': 'Код подтверждения',
|
||||
'auth-card.code.label': 'Код из SMS',
|
||||
'auth-card.code.placeholder': '123456',
|
||||
'auth-card.code.submit': 'Подтвердить',
|
||||
'auth-card.code.privacy-hint': 'Telegram-код виден в истории комнаты — можно очистить вручную.',
|
||||
'auth-card.code.privacy-hint-history':
|
||||
'Введённый код остался в истории комнаты — при желании очистите вручную.',
|
||||
// --- 2FA password form -------------------------------------------------
|
||||
'auth-card.password.title': 'Облачный пароль Telegram',
|
||||
'auth-card.password.hint':
|
||||
'У вашего аккаунта включена двухэтапная проверка. Введите облачный пароль Telegram — это не пароль от Vojo.',
|
||||
'auth-card.password.label': 'Пароль',
|
||||
'auth-card.password.submit': 'Подтвердить',
|
||||
'auth-card.password.show': 'Показать',
|
||||
'auth-card.password.hide': 'Скрыть',
|
||||
// --- Shared form chrome ------------------------------------------------
|
||||
'auth-card.cancel': 'Отмена',
|
||||
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.',
|
||||
'auth-card.code.countdown': 'Код придёт через {seconds} сек',
|
||||
'auth-card.code.countdown-done': 'Не пришло — нажмите «Отмена» и попробуйте снова.',
|
||||
// --- Inline errors -----------------------------------------------------
|
||||
'auth-error.invalid-code': 'Код неверный. Попробуйте снова.',
|
||||
'auth-error.wrong-password': 'Пароль неверный. Попробуйте снова.',
|
||||
'auth-error.invalid-value': 'Значение не принято: {reason}',
|
||||
'auth-error.submit-failed': 'Telegram не принял ввод: {reason}',
|
||||
'auth-error.login-in-progress':
|
||||
'У бота уже идёт другой вход. Нажмите «Отмена» и попробуйте снова.',
|
||||
'auth-error.max-logins':
|
||||
'Достигнут лимит входов ({limit}). Сначала выйдите из существующего аккаунта.',
|
||||
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.',
|
||||
'auth-error.start-failed': 'Не удалось начать вход: {reason}',
|
||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||
// --- Logout ------------------------------------------------------------
|
||||
'card.logout.name': '/logout',
|
||||
'card.logout.desc': 'Выйти из Telegram',
|
||||
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||
'card.logout.confirm-yes': 'Выйти',
|
||||
'card.logout.confirm-no': 'Отмена',
|
||||
'card.logout.gated': 'Идентификатор сессии ещё загружается — подождите секунду.',
|
||||
// --- Diagnostics in transcript ----------------------------------------
|
||||
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||
'diag.ready': 'Готов отправлять команды.',
|
||||
'diag.checking-status': 'Проверяю статус подключения…',
|
||||
'diag.send-failed': 'ошибка отправки: {message}',
|
||||
// --- Bootstrap failure -------------------------------------------------
|
||||
'bootstrap.failed': 'Widget не запустился',
|
||||
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { render } from 'preact';
|
|||
import { readBootstrap } from './bootstrap';
|
||||
import { App } from './App';
|
||||
import { createT } from './i18n';
|
||||
import { WidgetApi, buildCapabilities } from './widget-api';
|
||||
import './styles.css';
|
||||
|
||||
const root = document.getElementById('app');
|
||||
|
|
@ -32,5 +33,20 @@ if (!result.ok) {
|
|||
// 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);
|
||||
|
||||
// Instantiate the WidgetApi BEFORE React render. The constructor attaches
|
||||
// the `window.addEventListener('message', ...)` listener synchronously,
|
||||
// so by the time the host's ClientWidgetApi fires its capabilities
|
||||
// request on iframe `load` we're already listening.
|
||||
//
|
||||
// The pre-fix flow built the WidgetApi inside App.tsx's useEffect, which
|
||||
// runs AFTER React's first commit. On a fresh mount the bundle parse +
|
||||
// initial render took long enough for the host's request to arrive
|
||||
// after the listener was attached, so it worked by accident. On the
|
||||
// *second* mount (after «Show chat» → «Show widget») the bundle is
|
||||
// browser-cached and parses near-instantly; the host's request raced
|
||||
// ahead of useEffect, the listener missed it, and capability handshake
|
||||
// hung forever — only the «Соединение с Vojo…» diag line ever showed.
|
||||
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
||||
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||
}
|
||||
|
|
|
|||
310
apps/widget-telegram/src/state.ts
Normal file
310
apps/widget-telegram/src/state.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
// Login state machine — consumes LoginEvent (one per inbound m.notice from
|
||||
// the bridge bot) and emits a typed UI state. The widget renders forms and
|
||||
// the status pill from this state, never from raw reply strings.
|
||||
//
|
||||
// Multi-reply collapse is implemented here: when the bot emits two notices
|
||||
// for a single transition (2fa instructions + password re-prompt; invalid
|
||||
// code + code re-prompt; wrong password + password re-prompt), the second
|
||||
// notice arrives as `awaiting_password` / `awaiting_code` and the reducer
|
||||
// recognises it as a no-op against the state already set by the first.
|
||||
//
|
||||
// State-gating policy: prompt events (`awaiting_*`) and step-error events
|
||||
// (`twofa_required`, `invalid_code`, `wrong_password`, `submit_failed`,
|
||||
// `invalid_value`) are valid only from a *plausible previous state*.
|
||||
// Without these gates, late prompt-events can resurrect cancelled or
|
||||
// completed flows — e.g. user submits phone, clicks Cancel, bot's pipeline
|
||||
// already started a Telegram API call and emits `Please enter your Code…`
|
||||
// AFTER the cancel reply lands. The reducer here ignores that late prompt
|
||||
// because we're already `disconnected`.
|
||||
|
||||
import type { LoginEvent, ListedLogin } from './bridge-protocol/types';
|
||||
|
||||
export type LoginErrorFlag =
|
||||
| { kind: 'invalid_code' }
|
||||
| { kind: 'wrong_password' }
|
||||
| { kind: 'submit_failed'; reason?: string }
|
||||
| { kind: 'invalid_value'; reason?: string }
|
||||
| { kind: 'prepare_failed'; reason?: string }
|
||||
| { kind: 'start_failed'; reason?: string }
|
||||
| { kind: 'login_in_progress' }
|
||||
| { kind: 'max_logins'; limit?: number }
|
||||
| { kind: 'unknown_command' };
|
||||
|
||||
export type LoginState =
|
||||
// Pre-handshake / pre-list-logins. Status pill: --faint.
|
||||
| { kind: 'unknown' }
|
||||
// list-logins came back empty, OR logout completed. Status pill: --rose
|
||||
// (disconnected = needs action).
|
||||
| { kind: 'disconnected'; lastError?: LoginErrorFlag }
|
||||
// After "Войти по номеру" — waiting for `Please enter your Phone number`.
|
||||
// Status pill: --amber (in flight).
|
||||
| { kind: 'awaiting_phone'; lastError?: LoginErrorFlag }
|
||||
// After phone submit — waiting for code prompt OR error reply.
|
||||
// Status pill: --amber.
|
||||
| { kind: 'awaiting_code'; lastError?: LoginErrorFlag }
|
||||
// After code submit (when the bot decided 2fa is needed) — waiting for
|
||||
// password submission. lastError carries `wrong_password` after a failed
|
||||
// password retry. Status pill: --amber.
|
||||
| { kind: 'awaiting_password'; lastError?: LoginErrorFlag }
|
||||
// logout in flight — waiting for `Logged out`. Status pill: --amber.
|
||||
| { kind: 'logging_out'; loginId: string }
|
||||
// Live session. login carries the parsed handle/numericId from
|
||||
// `Successfully logged in as <handle> (<id>)`, plus the loginId we need
|
||||
// for `!tg logout <id>`. Status pill: --green.
|
||||
| {
|
||||
kind: 'connected';
|
||||
handle: string;
|
||||
numericId?: string;
|
||||
loginId?: string;
|
||||
};
|
||||
|
||||
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||
// pending lastError; structural transitions (start_login, request_logout,
|
||||
// cancel_pending) optimistically advance state — the App rolls them back
|
||||
// on send-failure where the bot would otherwise leave us stuck.
|
||||
export type LoginAction =
|
||||
| { kind: 'event'; event: LoginEvent }
|
||||
| { kind: 'start_login' } // user clicked "Войти по номеру"
|
||||
| { kind: 'submit_phone' } // user clicked submit on phone form
|
||||
| { kind: 'submit_code' } // user clicked submit on code form
|
||||
| { kind: 'submit_password' } // user clicked submit on 2fa form
|
||||
| { kind: 'request_logout'; loginId: string } // user clicked "Выйти"
|
||||
| { kind: 'cancel_pending' }; // user clicked "Отмена"
|
||||
|
||||
export const initialLoginState: LoginState = { kind: 'unknown' };
|
||||
|
||||
const pickConnected = (logins: ListedLogin[]): LoginState => {
|
||||
if (logins.length === 0) return { kind: 'disconnected' };
|
||||
// M12 ships single-account UI (max_logins=1 in the operator's bridge
|
||||
// config). If a future deployment runs with multiple logins, we still
|
||||
// surface the first one — multi-account UI is a follow-up phase. The
|
||||
// loginId here is what the widget will pass to `!tg logout <id>`.
|
||||
const [first] = logins;
|
||||
return {
|
||||
kind: 'connected',
|
||||
handle: first.name,
|
||||
loginId: first.id,
|
||||
};
|
||||
};
|
||||
|
||||
// Whether a `awaiting_code` prompt is plausible from the current state.
|
||||
// Plausible: just submitted phone (still in awaiting_phone), or the bot
|
||||
// is re-prompting after invalid_code (we're already in awaiting_code).
|
||||
const acceptsCodePrompt = (s: LoginState): boolean =>
|
||||
s.kind === 'awaiting_phone' || s.kind === 'awaiting_code';
|
||||
|
||||
// Whether a `awaiting_password` re-prompt is plausible. The TRANSITION to
|
||||
// password (from awaiting_code) is driven by `twofa_required`, not by the
|
||||
// re-prompt itself; the re-prompt only confirms we're still waiting.
|
||||
const acceptsPasswordReprompt = (s: LoginState): boolean => s.kind === 'awaiting_password';
|
||||
|
||||
// Whether `twofa_required` is plausible. It can only follow a code submit.
|
||||
const acceptsTwofa = (s: LoginState): boolean => s.kind === 'awaiting_code';
|
||||
|
||||
// Whether step-scoped errors (invalid_code, wrong_password, invalid_value,
|
||||
// submit_failed) should land on a form. Form-scoped errors are dropped
|
||||
// when no form is open.
|
||||
const isFormState = (
|
||||
s: LoginState
|
||||
): s is
|
||||
| { kind: 'awaiting_phone'; lastError?: LoginErrorFlag }
|
||||
| { kind: 'awaiting_code'; lastError?: LoginErrorFlag }
|
||||
| { kind: 'awaiting_password'; lastError?: LoginErrorFlag } =>
|
||||
s.kind === 'awaiting_phone' || s.kind === 'awaiting_code' || s.kind === 'awaiting_password';
|
||||
|
||||
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||
if (action.kind === 'start_login') {
|
||||
return { kind: 'awaiting_phone' };
|
||||
}
|
||||
if (action.kind === 'submit_phone') {
|
||||
// Stay on the phone form until the bot confirms with `awaiting_code`.
|
||||
// Optimistic transition to awaiting_code would mis-surface a phone-side
|
||||
// error (e.g. `submit_failed: PHONE_NUMBER_BANNED`) on the code form.
|
||||
if (state.kind === 'awaiting_phone') {
|
||||
return { kind: 'awaiting_phone', lastError: undefined };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (action.kind === 'submit_code') {
|
||||
if (state.kind === 'awaiting_code') {
|
||||
return { kind: 'awaiting_code', lastError: undefined };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (action.kind === 'submit_password') {
|
||||
if (state.kind === 'awaiting_password') {
|
||||
return { kind: 'awaiting_password', lastError: undefined };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
if (action.kind === 'request_logout') {
|
||||
return { kind: 'logging_out', loginId: action.loginId };
|
||||
}
|
||||
if (action.kind === 'cancel_pending') {
|
||||
// Optimistic: drop straight back to disconnected. The bot's reply will
|
||||
// be `Login cancelled.` (cancel_ok) or `No ongoing command.`
|
||||
// (cancel_no_op) — either way the user has signalled they want out.
|
||||
return { kind: 'disconnected' };
|
||||
}
|
||||
|
||||
const event = action.event;
|
||||
switch (event.kind) {
|
||||
case 'logins_listed':
|
||||
// list-logins is the source of truth — accept from any state.
|
||||
return pickConnected(event.logins);
|
||||
|
||||
case 'not_logged_in':
|
||||
// Same gating idea as the prompt events: a late-arriving
|
||||
// `You're not logged in` from a list-logins fired before the user
|
||||
// started a fresh login flow would otherwise wipe an active form.
|
||||
// Accept only from states where flipping to disconnected is correct.
|
||||
if (
|
||||
state.kind === 'unknown' ||
|
||||
state.kind === 'disconnected' ||
|
||||
state.kind === 'logging_out'
|
||||
) {
|
||||
return { kind: 'disconnected' };
|
||||
}
|
||||
return state;
|
||||
|
||||
case 'awaiting_phone':
|
||||
// Bot's "Please enter your Phone number". Only meaningful when we
|
||||
// initiated phone-login (state already awaiting_phone). From any
|
||||
// other state — including the late-arriving prompt after a cancel
|
||||
// — drop it on the floor.
|
||||
return state.kind === 'awaiting_phone' ? state : state;
|
||||
|
||||
case 'awaiting_code':
|
||||
// Plausible after submitting phone, or as a re-prompt within the
|
||||
// code form. Late arrival after cancel/connected/logging_out is
|
||||
// ignored to avoid resurrecting dead flows.
|
||||
if (!acceptsCodePrompt(state)) return state;
|
||||
if (state.kind === 'awaiting_phone') return { kind: 'awaiting_code' };
|
||||
return state;
|
||||
|
||||
case 'awaiting_password':
|
||||
// Pure re-prompt arm. The TRANSITION to awaiting_password is driven
|
||||
// by `twofa_required` (or `wrong_password`), not by this event.
|
||||
// Ignored when we're not already on the password form.
|
||||
if (!acceptsPasswordReprompt(state)) return state;
|
||||
return state;
|
||||
|
||||
case 'twofa_required':
|
||||
// First of the two-reply 2fa transition. Only valid after a code
|
||||
// submit. Ignored from disconnected/connected/etc.
|
||||
if (!acceptsTwofa(state)) return state;
|
||||
return { kind: 'awaiting_password' };
|
||||
|
||||
case 'invalid_code':
|
||||
if (state.kind !== 'awaiting_code') return state;
|
||||
return { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } };
|
||||
|
||||
case 'wrong_password':
|
||||
if (state.kind !== 'awaiting_password') return state;
|
||||
return { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } };
|
||||
|
||||
case 'login_success':
|
||||
// Always honour — even if state somehow drifted, the bridge says we're in.
|
||||
return {
|
||||
kind: 'connected',
|
||||
handle: event.handle,
|
||||
numericId: event.numericId,
|
||||
// loginId is unknown until the post-success list-logins fires
|
||||
// (App.tsx). Until then, logout is gated.
|
||||
};
|
||||
|
||||
case 'logout_ok':
|
||||
// Late `Logged out` from a previous session can arrive while the user
|
||||
// is mid-new-flow (e.g. they cancelled, started login again, and the
|
||||
// old logout's reply finally lands). Only honour from logging_out;
|
||||
// other states keep their flow.
|
||||
if (state.kind !== 'logging_out') return state;
|
||||
return { kind: 'disconnected' };
|
||||
|
||||
case 'cancel_ok':
|
||||
case 'cancel_no_op':
|
||||
// The App's `cancel_pending` action ALWAYS optimistically lands us
|
||||
// in `disconnected` before the bot's confirmation arrives. So a
|
||||
// legitimate cancel-reply naturally finds state === 'disconnected'
|
||||
// — accepting it then is a safe idempotent no-op transition.
|
||||
//
|
||||
// From ANY other state (awaiting_*, connected, logging_out,
|
||||
// unknown), the cancel reply is stale: the user has either started
|
||||
// a new flow (state already moved on) or never cancelled in this
|
||||
// widget session at all. Letting it through would clobber an
|
||||
// active flow — exactly the race the reviewer flagged: cancel +
|
||||
// immediate re-login = late cancel_ok kicking awaiting_phone
|
||||
// back to disconnected.
|
||||
//
|
||||
// (Out-of-band manual `!tg cancel` typed in chat-fallback while
|
||||
// the widget shows an active form would also be ignored. That's
|
||||
// accepted scope: we don't run a causality/epoch system, and the
|
||||
// chat-fallback flow is an escape hatch, not a primary surface.)
|
||||
if (state.kind !== 'disconnected') return state;
|
||||
return { kind: 'disconnected' };
|
||||
|
||||
case 'login_in_progress':
|
||||
// Surfaces when the user clicked Войти по номеру but the bridge
|
||||
// already has a stale flow open. Form-level warning if a form is
|
||||
// open; otherwise dropped so we don't manufacture a disconnected
|
||||
// banner from nothing.
|
||||
if (isFormState(state)) {
|
||||
return { ...state, lastError: { kind: 'login_in_progress' } };
|
||||
}
|
||||
return state;
|
||||
|
||||
case 'max_logins':
|
||||
// Should not fire for max_logins=1 operators when our UI hides
|
||||
// login while connected. If it does fire, the user is in a race;
|
||||
// surface the error on disconnected so they can logout first.
|
||||
return { kind: 'disconnected', lastError: { kind: 'max_logins', limit: event.limit } };
|
||||
|
||||
case 'login_not_found':
|
||||
// Logout target id was wrong. Treat as disconnected — bridge clearly
|
||||
// doesn't know that login id any more.
|
||||
return { kind: 'disconnected' };
|
||||
|
||||
case 'invalid_value':
|
||||
// Bridge rejected our submitted phone/code/password (e.g. malformed
|
||||
// phone). Keep the form open with an error; if no form is open,
|
||||
// ignore so we don't pollute disconnected state.
|
||||
if (!isFormState(state)) return state;
|
||||
return { ...state, lastError: { kind: 'invalid_value', reason: event.reason } };
|
||||
|
||||
case 'submit_failed':
|
||||
// Telegram-side error (FloodWait, banned, etc.) leaked through
|
||||
// bridgev2's commands layer. Hold the current form open so the user
|
||||
// can retry; surface the verbatim Go error tail in the warning.
|
||||
if (!isFormState(state)) return state;
|
||||
return { ...state, lastError: { kind: 'submit_failed', reason: event.reason } };
|
||||
|
||||
case 'prepare_failed':
|
||||
return { kind: 'disconnected', lastError: { kind: 'prepare_failed', reason: event.reason } };
|
||||
|
||||
case 'start_failed':
|
||||
return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: event.reason } };
|
||||
|
||||
case 'flow_required':
|
||||
case 'flow_invalid':
|
||||
// We always send `login phone` so this shouldn't happen. If it does,
|
||||
// the operator-config / bridge mismatch is loud enough to fail
|
||||
// visibly on the disconnected screen.
|
||||
return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: 'flow' } };
|
||||
|
||||
case 'unknown_command':
|
||||
// Shouldn't happen — we only send commands the bridge knows. If it
|
||||
// does, the operator-config / bridge image is mismatched; surface it
|
||||
// loudly on the disconnected screen so the misconfig is visible.
|
||||
return { kind: 'disconnected', lastError: { kind: 'unknown_command' } };
|
||||
|
||||
case 'unknown':
|
||||
return state;
|
||||
|
||||
default: {
|
||||
// Exhaustiveness check — if a new LoginEvent kind is added without a
|
||||
// case, TypeScript will flag this as a compile error.
|
||||
const exhaustive: never = event;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -71,124 +71,12 @@ body {
|
|||
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;
|
||||
}
|
||||
}
|
||||
/* The hero (avatar + name + handle + description + Настроить dropdown) is
|
||||
* OWNED BY THE HOST, not the widget — see src/app/features/bots/BotShell.tsx.
|
||||
* Removing the widget-side hero collapses the duplicate header that used to
|
||||
* sit between the host's BotShellHero (which the user actually sees) and
|
||||
* the iframe content. The widget body now starts with the active-state
|
||||
* section directly. */
|
||||
|
||||
/* ── Section ──────────────────────────────────────────────────────── */
|
||||
|
||||
|
|
@ -200,23 +88,128 @@ body {
|
|||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* Section label — same dark-bg pill vocabulary as `.section-status` so the
|
||||
* two pieces in the section-header row read as a matched pair (label
|
||||
* pill + status pill). The pill chrome wraps the existing uppercase
|
||||
* letter-spaced typography; chip is non-interactive, no cursor. */
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
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;
|
||||
}
|
||||
|
||||
/* Status pill — button-styled but intentionally non-interactive (no
|
||||
* cursor:pointer, no hover). Replaces the section header for stateful
|
||||
* sections (disconnected / connected / unknown / logging_out) — the
|
||||
* pill itself carries the section's identity, so a separate
|
||||
* `.section-label` would just duplicate the meaning. Same dark-bg
|
||||
* vocabulary (--bg2 / divider border) as .refresh-button and the host
|
||||
* hero settings button. */
|
||||
.section-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
margin: 0 0 14px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-status .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-status.connected {
|
||||
color: var(--green);
|
||||
}
|
||||
.section-status.connected .dot {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
|
||||
}
|
||||
|
||||
.section-status.disconnected {
|
||||
color: var(--rose);
|
||||
}
|
||||
.section-status.disconnected .dot {
|
||||
background: var(--rose);
|
||||
}
|
||||
|
||||
.section-status.checking {
|
||||
color: var(--amber);
|
||||
}
|
||||
.section-status.checking .dot {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
/* Wraps the section-status pill + a labeled refresh action when the
|
||||
* state has no other affordance (unknown / logging_out / connected
|
||||
* without loginId). Without this row, the user can stare at a
|
||||
* «Проверка статуса…» pill forever if the first list-logins reply
|
||||
* dropped on the wire. */
|
||||
.section-recovery-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
.section-recovery-row > .section-status {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.recovery-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
.recovery-action:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--hairline);
|
||||
}
|
||||
.recovery-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.recovery-action svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ── Command card (action card with name + desc + chevron) ──────── */
|
||||
|
||||
.command-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--divider);
|
||||
|
|
@ -248,7 +241,7 @@ body {
|
|||
}
|
||||
|
||||
.command-card-name {
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||
color: var(--fleet-soft);
|
||||
font-weight: 500;
|
||||
|
|
@ -256,9 +249,9 @@ body {
|
|||
}
|
||||
|
||||
.command-card-desc {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 18px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.command-card-chevron {
|
||||
|
|
@ -280,6 +273,28 @@ body {
|
|||
line-height: 1.55;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
/* Custom scrollbar styled into the dark palette. Native browser
|
||||
* scrollbars (gray, system-themed) clash with the Dawn surface. */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface2) transparent;
|
||||
}
|
||||
|
||||
.transcript::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.transcript::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.transcript::-webkit-scrollbar-thumb {
|
||||
background: var(--surface2);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--bg2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.transcript::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--bg2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.transcript-line {
|
||||
|
|
@ -329,6 +344,337 @@ body {
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.command-card.danger .command-card-name {
|
||||
color: var(--rose);
|
||||
}
|
||||
.command-card.danger:hover:not(:disabled) {
|
||||
border-color: var(--rose);
|
||||
}
|
||||
|
||||
/* Inline confirm-in-place body for the destructive logout card. The button
|
||||
* group lives inside the same card frame — no modal, no layout shift. */
|
||||
.command-card-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-card-confirm-prompt {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-card-confirm-yes,
|
||||
.command-card-confirm-no,
|
||||
.refresh-button,
|
||||
.btn-primary,
|
||||
.btn-text,
|
||||
.btn-icon {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-card-confirm-yes {
|
||||
background: var(--rose);
|
||||
color: #0c0c0e;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command-card-confirm-no {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 7px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.command-card-confirm-yes:disabled,
|
||||
.command-card-confirm-no:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Refresh button ──────────────────────────────────────────────── */
|
||||
|
||||
.refresh-button {
|
||||
/* Square side = .command-card's content height: padding 14*2 = 28,
|
||||
* name lh ~18, name margin-bottom 3, desc lh 19, border 2 = 70px.
|
||||
* Hard-coded because flex-stretch + aspect-ratio:1 doesn't reliably
|
||||
* propagate across browsers when neither axis is explicitly sized
|
||||
* (the icon's intrinsic 18×18 wins in the cross-axis-from-aspect
|
||||
* resolution). 70px keeps the chip flush with the login card's
|
||||
* top and bottom edges. */
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--divider);
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
.refresh-button:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
border-color: var(--hairline);
|
||||
color: var(--text);
|
||||
}
|
||||
.refresh-button:active:not(:disabled) {
|
||||
background: var(--surface2);
|
||||
}
|
||||
.refresh-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.refresh-button svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Connect-row holds the `.command-grid` (flex-grows; the grid auto-fill
|
||||
* caps the login card at one column-width so it doesn't stretch across
|
||||
* the full row) and the square refresh button beside it. flex-wrap is
|
||||
* defensive for sub-360px viewports. */
|
||||
.connect-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.connect-row > .command-grid {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ── Auth card (login forms inside transcript section) ───────────── */
|
||||
|
||||
.auth-card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-card.error {
|
||||
border-color: var(--rose);
|
||||
}
|
||||
|
||||
.auth-card-title {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-card-hint {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.auth-card-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.auth-input:hover:not(:focus):not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.auth-input:focus {
|
||||
border-color: var(--fleet);
|
||||
/* Stronger ring than border-color alone — matches Dawn's emphasis on
|
||||
* accent halos (BotsDesktop avatar shadow / hero-status.ok glow). */
|
||||
box-shadow: 0 0 0 3px rgba(149, 128, 255, 0.18);
|
||||
}
|
||||
.auth-card.error .auth-input {
|
||||
border-color: var(--rose);
|
||||
}
|
||||
.auth-card.error .auth-input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
||||
}
|
||||
|
||||
.auth-input.code,
|
||||
.auth-input.password {
|
||||
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||
letter-spacing: 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.password-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--hairline);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--fleet);
|
||||
color: #0c0c0e;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-text:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-card-error {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.auth-card-warn {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.auth-card-waiting {
|
||||
font-size: 13px;
|
||||
color: var(--faint);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Countdown text on the code form: same baseline tone as waiting hint
|
||||
* but a touch more prominent because it carries an actual number. The
|
||||
* color tween softens the muted→amber transition at expiry — without it
|
||||
* the line jumps between palettes mid-sentence, which reads broken
|
||||
* against Dawn's measured aesthetic. */
|
||||
.auth-card-countdown {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.2s ease-out;
|
||||
}
|
||||
.auth-card-countdown.expired {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.auth-card-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.btn-primary,
|
||||
.btn-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Compact .command-card on mobile so its height matches the shrunk
|
||||
* refresh button below; preserves the «two-row title + chevron»
|
||||
* structure. */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Allow the grid to shrink below its 280px desktop floor so it doesn't
|
||||
* push the refresh button onto its own wrap line at sub-360px. */
|
||||
.command-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Refresh side ≈ compact card height (padding 12*2 + name 17 + margin
|
||||
* 2 + desc 17 + border 2 = 62px). Keeps the chip flush with the
|
||||
* card's top and bottom edges on mobile. */
|
||||
.refresh-button {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.refresh-button svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Linkified transcript bodies ─────────────────────────────────── */
|
||||
|
||||
.transcript-line a {
|
||||
color: var(--fleet-soft);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.transcript-line a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Hint text ───────────────────────────────────────────────────── */
|
||||
|
||||
.hint {
|
||||
|
|
|
|||
1
apps/widget-telegram/src/vite-env.d.ts
vendored
Normal file
1
apps/widget-telegram/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -83,6 +83,15 @@ export class WidgetApi {
|
|||
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`, the WidgetApi catches and
|
||||
// resolves it during script init, then React's useEffect runs *after*
|
||||
// that and attaches the `ready` listener), replay synchronously so
|
||||
// App.tsx still flips `handshakeOk` and fires `list-logins`.
|
||||
if (event === 'ready' && this.isReady) {
|
||||
(listener as () => void)();
|
||||
}
|
||||
}
|
||||
|
||||
public sendText(body: string): Promise<{ event_id: string }> {
|
||||
|
|
@ -92,6 +101,18 @@ export class WidgetApi {
|
|||
}) as Promise<{ event_id: string }>;
|
||||
}
|
||||
|
||||
// Always prefix outbound commands with `<commandPrefix> ` (trailing space —
|
||||
// bridgev2/queue.go:118 does `TrimPrefix(body, prefix+" ")`). Works in both
|
||||
// the management room and any other room the bot may have been moved to.
|
||||
// Form-field submissions (phone / code / password) go through this same
|
||||
// helper because bridgev2's stored CommandState fallback only fires after
|
||||
// queue.go:108 routes the message — and that route also requires the
|
||||
// prefix outside the management room.
|
||||
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
|
||||
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
|
||||
return this.sendText(body);
|
||||
}
|
||||
|
||||
private emit<K extends keyof WidgetApiEvents>(
|
||||
event: K,
|
||||
...args: Parameters<WidgetApiEvents[K]>
|
||||
|
|
@ -113,6 +134,14 @@ export class WidgetApi {
|
|||
|
||||
private onMessage = (ev: MessageEvent): void => {
|
||||
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
||||
// Source-window guard: every legit widget API message comes from the
|
||||
// host window that embedded our iframe — i.e. window.parent. A foreign
|
||||
// tab/frame on the same origin (think browser extension content
|
||||
// script, popup, or sibling iframe) could otherwise post a forged
|
||||
// message that passes the origin check. We only accept messages
|
||||
// whose `source` is literally `window.parent`. The `widgetId` check
|
||||
// a few lines down is a soft filter; this is the hard one.
|
||||
if (ev.source !== window.parent) return;
|
||||
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (msg.widgetId !== this.bootstrap.widgetId) return;
|
||||
|
|
|
|||
|
|
@ -940,6 +940,10 @@
|
|||
"show_chat": "Show chat",
|
||||
"show_widget": "Show robot",
|
||||
"retry_widget": "Retry robot",
|
||||
"settings_label": "Settings",
|
||||
"description": {
|
||||
"telegram": "Matrix↔Telegram bridge. Sign in by phone number to sync your Telegram chats."
|
||||
},
|
||||
"unknown_title": "Robot not found",
|
||||
"unknown_description": "This robot is not in the Vojo catalog."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -944,6 +944,10 @@
|
|||
"show_chat": "Показать чат",
|
||||
"show_widget": "Показать робота",
|
||||
"retry_widget": "Повторить",
|
||||
"settings_label": "Настроить",
|
||||
"description": {
|
||||
"telegram": "Мост Matrix↔Telegram. Войдите по номеру, чтобы синхронизировать чаты с Telegram."
|
||||
},
|
||||
"unknown_title": "Робот не найден",
|
||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||
}
|
||||
|
|
|
|||
21
src/app/features/bots/BotChatFallback.tsx
Normal file
21
src/app/features/bots/BotChatFallback.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { RoomView } from '../room/RoomView';
|
||||
|
||||
type BotChatFallbackProps = {
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
// Chat-fallback body for bot DMs. Rendered by BotExperienceHost when the
|
||||
// per-room `botShowChatAtomFamily` is true (user picked «Показать чат» in
|
||||
// the BotShell hero menu, OR the widget failed and BotShell auto-flipped
|
||||
// here). The «return to widget / retry» affordance lives as a single
|
||||
// MenuItem at the top of the standard `RoomMenu` (RoomViewHeaderDm:91-122)
|
||||
// — there is intentionally no floating overlay button here, so the chat
|
||||
// surface looks identical to a regular DM beyond that one menu item.
|
||||
//
|
||||
// We only need `eventId` from the route; bot-specific context (preset,
|
||||
// room) is consumed by the RoomMenu item via atom families keyed on
|
||||
// roomId, not via prop drilling.
|
||||
export function BotChatFallback({ eventId }: BotChatFallbackProps) {
|
||||
return <RoomView eventId={eventId} />;
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Box, Button, Text } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RoomView } from '../room/RoomView';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { BotWidgetHost } from './BotWidgetHost';
|
||||
import * as css from './BotWidgetHost.css';
|
||||
|
||||
type BotExperienceSlotProps = {
|
||||
preset: BotPreset;
|
||||
room: Room;
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
export function BotExperienceSlot({ preset, room, eventId }: BotExperienceSlotProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
// Bumped on every retry so BotWidgetHost gets a fresh `key` and remounts
|
||||
// even when preset/room/url are unchanged — without this, a retry after
|
||||
// failure would reuse the same React element and the iframe/embed lifecycle
|
||||
// would not re-run.
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const experienceUrl = preset.experience?.url;
|
||||
const hasWidget = preset.experience?.type === 'matrix-widget';
|
||||
const showRawChat = showChat || failed;
|
||||
|
||||
useEffect(() => {
|
||||
setShowChat(false);
|
||||
setFailed(false);
|
||||
setRetryCount(0);
|
||||
}, [preset.id, room.roomId, experienceUrl]);
|
||||
|
||||
const handleShowChat = useCallback(() => {
|
||||
setShowChat(true);
|
||||
}, []);
|
||||
|
||||
const handleWidgetError = useCallback(() => {
|
||||
setFailed(true);
|
||||
}, []);
|
||||
|
||||
const handleShowWidget = useCallback(() => {
|
||||
setShowChat(false);
|
||||
setFailed(false);
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
if (!hasWidget) {
|
||||
return <RoomView eventId={eventId} />;
|
||||
}
|
||||
|
||||
if (showRawChat) {
|
||||
return (
|
||||
<Box className={css.Host}>
|
||||
<Box className={css.FrameMount}>
|
||||
<RoomView eventId={eventId} />
|
||||
</Box>
|
||||
<Box className={css.Toolbar}>
|
||||
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowWidget}>
|
||||
<Text size="B300">{failed ? t('Bots.retry_widget') : t('Bots.show_widget')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={css.Host}>
|
||||
<BotWidgetHost key={retryCount} preset={preset} room={room} onError={handleWidgetError} />
|
||||
<Box className={css.Toolbar}>
|
||||
<Button variant="Secondary" fill="Soft" size="300" radii="300" onClick={handleShowChat}>
|
||||
<Text size="B300">{t('Bots.show_chat')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
252
src/app/features/bots/BotShell.css.ts
Normal file
252
src/app/features/bots/BotShell.css.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, toRem } from 'folds';
|
||||
|
||||
// BotShell is the bot-page container: it OWNS the hero and the iframe
|
||||
// mount. Standard Cinny `RoomViewHeader` is intentionally absent here —
|
||||
// the BotsDesktop mockup (stream-v2-dawn.jsx:660-672) places the hero as
|
||||
// the first row of the bot panel, with no chat-style chrome above.
|
||||
|
||||
// Shell bg = SurfaceVariant.Container (#181a20) — matches DAWN.bg. The widget
|
||||
// iframe body uses the same #181a20 (apps/widget-telegram/src/styles.css:8).
|
||||
// Mockup canon paints the bot panel on DAWN.bg sitting on a DAWN.bg2
|
||||
// (#0d0e11) parent — Background.Container would invert that and produce a
|
||||
// visible seam at the iframe's top edge. SurfaceVariant.Container keeps the
|
||||
// hero and the iframe body on the same tone.
|
||||
export const Shell = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Frame = style([
|
||||
DefaultReset,
|
||||
{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: 'relative',
|
||||
},
|
||||
]);
|
||||
|
||||
// Hero outer band — full-width strip carrying the border-bottom that
|
||||
// separates the hero from the iframe body. Vertical padding only; the
|
||||
// horizontal padding sits on `HeroInner` so the inner content can be
|
||||
// constrained to the same `max-width: 960px` the widget body uses
|
||||
// (apps/widget-telegram/src/styles.css:64-72), keeping the host hero's
|
||||
// left/right edges aligned with the body content visible inside the
|
||||
// iframe.
|
||||
export const Hero = style([
|
||||
DefaultReset,
|
||||
{
|
||||
borderBottom: `1px solid ${color.Background.ContainerLine}`,
|
||||
flexShrink: 0,
|
||||
padding: `${toRem(36)} 0 ${toRem(28)}`,
|
||||
|
||||
'@media': {
|
||||
// Compact mobile band — matches the visual height of Cinny's standard
|
||||
// chat header (~56-64px), per user's request «хедер по разумеру как
|
||||
// чат обычный». The big desktop hero (avatar 56 + 2-line title +
|
||||
// multi-line description) is too heavy on a narrow viewport.
|
||||
'(max-width: 600px)': {
|
||||
padding: `${toRem(8)} 0`,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Inner row — constrained to 960px to match the widget body. Horizontal
|
||||
// padding lives here. flex row carries the back-chevron / avatar / body /
|
||||
// settings-button stack.
|
||||
export const HeroInner = style([
|
||||
DefaultReset,
|
||||
{
|
||||
maxWidth: toRem(960),
|
||||
margin: '0 auto',
|
||||
padding: `0 ${toRem(40)}`,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: toRem(18),
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
padding: `0 ${toRem(12)}`,
|
||||
gap: toRem(10),
|
||||
// Single row on mobile — no wrap. Avatar + name + settings fit
|
||||
// as a chat-header-like strip; handle and description are hidden
|
||||
// by their own media blocks below.
|
||||
alignItems: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Mobile-only back chevron that lives at the start of the hero row. The
|
||||
// hero re-orders to flex-start on mobile (hero already wraps via the
|
||||
// max-width:600px media block), so the chevron leads the row rather than
|
||||
// stacking awkwardly with the avatar.
|
||||
export const HeroBack = style([
|
||||
DefaultReset,
|
||||
{
|
||||
flexShrink: 0,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
]);
|
||||
|
||||
// 56×56 square avatar with 14px radius, fleet violet (DAWN.fleet) bg.
|
||||
// Fleet color is hardcoded here because it's the canonical bot accent in
|
||||
// the mockup and we don't want it varying with Folds palette swaps.
|
||||
export const HeroAvatar = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(56),
|
||||
height: toRem(56),
|
||||
borderRadius: toRem(14),
|
||||
backgroundColor: '#9580ff',
|
||||
color: '#0c0c0e',
|
||||
fontSize: toRem(24),
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
width: toRem(36),
|
||||
height: toRem(36),
|
||||
borderRadius: toRem(8),
|
||||
fontSize: toRem(16),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const HeroBody = style([
|
||||
DefaultReset,
|
||||
{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
export const HeroTitleRow = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: toRem(10),
|
||||
marginBottom: toRem(4),
|
||||
flexWrap: 'wrap',
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const HeroName = style([
|
||||
DefaultReset,
|
||||
{
|
||||
fontSize: toRem(22),
|
||||
fontWeight: 700,
|
||||
color: color.Surface.OnContainer,
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
// Single-line truncated name — chat-header style.
|
||||
fontSize: toRem(16),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Handle and description are hidden on mobile to keep the hero band at
|
||||
// chat-header height. The bot's identity is already conveyed by the
|
||||
// avatar + name + the room route itself; the operator-config description
|
||||
// is supplemental and the mxid is rarely useful on phone where you
|
||||
// can't easily copy it anyway.
|
||||
export const HeroHandle = style([
|
||||
DefaultReset,
|
||||
{
|
||||
fontSize: toRem(13),
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
fontFamily: 'ui-monospace, "JetBrains Mono", "SF Mono", monospace',
|
||||
wordBreak: 'break-all',
|
||||
opacity: 0.6,
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const HeroDescription = style([
|
||||
DefaultReset,
|
||||
{
|
||||
fontSize: toRem(14),
|
||||
// toRem keeps line-height in lockstep with font-size when the user
|
||||
// scales root size or zooms; mixing raw px would break the 1.43 ratio.
|
||||
lineHeight: toRem(20),
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
maxWidth: toRem(560),
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Trailing "Настроить" button — overrides the mockup's transparent spec
|
||||
// with a dark filled chip. Sits on top of the hero's #181a20
|
||||
// (SurfaceVariant.Container) surface; Background.Container resolves to
|
||||
// #0d0e11 and reads as a darker chip — the visual partner of the widget-
|
||||
// side status pill that sits directly below it on the right edge.
|
||||
export const HeroSettingsButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
background: color.Background.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
border: `1px solid ${color.Background.ContainerLine}`,
|
||||
borderRadius: toRem(8),
|
||||
padding: `${toRem(10)} ${toRem(18)}`,
|
||||
fontSize: toRem(14),
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
|
||||
selectors: {
|
||||
'&:hover:not(:disabled)': {
|
||||
background: color.Background.ContainerHover,
|
||||
borderColor: color.Background.ContainerActive,
|
||||
},
|
||||
'&[aria-pressed="true"]': {
|
||||
background: color.Background.ContainerActive,
|
||||
borderColor: color.Background.ContainerActive,
|
||||
},
|
||||
},
|
||||
|
||||
'@media': {
|
||||
'(max-width: 600px)': {
|
||||
padding: `${toRem(6)} ${toRem(12)}`,
|
||||
fontSize: toRem(13),
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
53
src/app/features/bots/BotShell.tsx
Normal file
53
src/app/features/bots/BotShell.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { BotShellHero } from './BotShellHero';
|
||||
import { BotWidgetMount } from './BotWidgetMount';
|
||||
import { botFailedAtomFamily, botShowChatAtomFamily } from './botExperienceState';
|
||||
import * as css from './BotShell.css';
|
||||
|
||||
type BotShellProps = {
|
||||
preset: BotPreset;
|
||||
room: Room;
|
||||
};
|
||||
|
||||
// Bot-page layout. Owns the hero (host-side, mockup BotsDesktop:660-672) and
|
||||
// the iframe mount. Standard Cinny `RoomViewHeader` is intentionally absent
|
||||
// here — BotExperienceHost branches above `<Room>`, picking BotShell when the
|
||||
// user's `botShowChatAtomFamily(roomId)` is false. The chat fallback path
|
||||
// uses the regular Room layout via BotChatFallback.
|
||||
//
|
||||
// Implicit dependency: BotShellMenu reaches into `useRoomUnread`,
|
||||
// `useRoomsNotificationPreferencesContext`, and friends — all of which
|
||||
// expect a `<RoomProvider>` somewhere above. BotExperienceHost wraps both
|
||||
// branches in `<BotRoomProvider>` (which mounts RoomProvider +
|
||||
// IsOneOnOneProvider), so this is satisfied today. A future refactor that
|
||||
// mounts BotShell standalone (e.g. a multi-bot dashboard preview) will
|
||||
// fail at runtime in obscure ways unless that wrap is preserved. Don't
|
||||
// move BotShell out of the BotRoomProvider context without rewiring the
|
||||
// menu.
|
||||
//
|
||||
// Failure handling: when BotWidgetMount reports an iframe error, we set the
|
||||
// per-room failure flag AND flip the show-chat atom — this bounces the user
|
||||
// to the standard Room view (which lives behind the same atom) and the chat
|
||||
// fallback overlay surfaces a «Retry widget» button. Clearing both atoms
|
||||
// re-mounts BotShell with a fresh iframe.
|
||||
export function BotShell({ preset, room }: BotShellProps) {
|
||||
const setFailed = useSetAtom(botFailedAtomFamily(room.roomId));
|
||||
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setFailed(true);
|
||||
setShowChat(true);
|
||||
}, [setFailed, setShowChat]);
|
||||
|
||||
return (
|
||||
<div className={css.Shell}>
|
||||
<BotShellHero preset={preset} room={room} />
|
||||
<div className={css.Frame}>
|
||||
<BotWidgetMount preset={preset} room={room} onError={handleError} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/app/features/bots/BotShellHero.tsx
Normal file
111
src/app/features/bots/BotShellHero.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { Icon, IconButton, Icons, PopOut, RectCords } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { BotShellMenu } from './BotShellMenu';
|
||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './BotShell.css';
|
||||
|
||||
type BotShellHeroProps = {
|
||||
preset: BotPreset;
|
||||
room: Room;
|
||||
};
|
||||
|
||||
// Initial for the avatar block. Prefer the human name's first character
|
||||
// (matches mockup BOT.name = «build-bot» → «B»); fall back to the mxid
|
||||
// localpart, then to «?» when neither is usable. The previous «T» literal
|
||||
// hardcoded the Telegram preset and fails the moment a second bot ships.
|
||||
const heroInitial = (preset: BotPreset): string => {
|
||||
const fromName = preset.name.trim().charAt(0);
|
||||
if (fromName) return fromName.toUpperCase();
|
||||
const local = preset.mxid.split(':')[0].replace('@', '');
|
||||
return local.charAt(0).toUpperCase() || '?';
|
||||
};
|
||||
|
||||
export function BotShellHero({ preset, room }: BotShellHeroProps) {
|
||||
const { t } = useTranslation();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
// Operator override from /config.json wins; otherwise fall back to the
|
||||
// localized `Bots.description.<id>` key. Empty string suppresses the
|
||||
// line entirely so a missing translation doesn't ship the key.
|
||||
const description = t(`Bots.description.${preset.id}`, {
|
||||
defaultValue: preset.description ?? '',
|
||||
});
|
||||
const initial = heroInitial(preset);
|
||||
|
||||
return (
|
||||
<header className={css.Hero}>
|
||||
<div className={css.HeroInner}>
|
||||
{/* Mobile back chevron — bypasses Cinny's standard RoomViewHeader
|
||||
* (which BotShell deliberately doesn't mount), so the user retains
|
||||
* the «walk up the route tree» affordance the rest of the client
|
||||
* provides on phone. Desktop relies on the sidebar to navigate. */}
|
||||
{isMobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton
|
||||
className={css.HeroBack}
|
||||
fill="None"
|
||||
onClick={onBack}
|
||||
aria-label={t('Room.close')}
|
||||
>
|
||||
<Icon src={Icons.ChevronLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
<div className={css.HeroAvatar} aria-hidden="true">
|
||||
{initial}
|
||||
</div>
|
||||
<div className={css.HeroBody}>
|
||||
<div className={css.HeroTitleRow}>
|
||||
<span className={css.HeroName}>{preset.name}</span>
|
||||
<span className={css.HeroHandle}>{preset.mxid}</span>
|
||||
</div>
|
||||
{description ? <p className={css.HeroDescription}>{description}</p> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={css.HeroSettingsButton}
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
>
|
||||
{t('Bots.settings_label')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<BotShellMenu room={room} requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
134
src/app/features/bots/BotShellMenu.tsx
Normal file
134
src/app/features/bots/BotShellMenu.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
getRoomNotificationModeIcon,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { botShowChatAtomFamily } from './botExperienceState';
|
||||
|
||||
type BotShellMenuProps = {
|
||||
room: Room;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
// Slim dropdown for the bot hero's «Настроить» button. Only items that make
|
||||
// sense in a 1:1 with a bridge bot:
|
||||
// - Show chat (chat-fallback toggle, switches BotExperienceHost branch)
|
||||
// - Mark as read
|
||||
// - Notifications (shared switcher)
|
||||
// - Leave room
|
||||
// Search / Pinned / Invite / Copy-link / Room-settings / Jump-to-date are
|
||||
// dropped — none apply to a bridge bot's control DM.
|
||||
export const BotShellMenu = forwardRef<HTMLDivElement, BotShellMenuProps>(
|
||||
({ room, requestClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||
// Setter-only — the menu's only job around showChat is to flip it.
|
||||
// useSetAtom skips the value-subscription that useAtom registers.
|
||||
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
|
||||
|
||||
const handleShowChat = () => {
|
||||
setShowChat(true);
|
||||
requestClose();
|
||||
};
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(240), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleShowChat}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Message} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Bots.show_chat')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Room.mark_as_read')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
||||
{(handleOpen, opened, changing) => (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={
|
||||
changing ? (
|
||||
<Spinner size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
|
||||
)
|
||||
}
|
||||
radii="300"
|
||||
aria-pressed={opened}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Room.notifications')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
</RoomNotificationModeSwitcher>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<UseStateProvider initial={false}>
|
||||
{(promptLeave, setPromptLeave) => (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => setPromptLeave(true)}
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
||||
radii="300"
|
||||
aria-pressed={promptLeave}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Room.leave_room')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{promptLeave && (
|
||||
<LeaveRoomPrompt
|
||||
roomId={room.roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -49,6 +49,7 @@ const getBotWidgetUrl = (
|
|||
url.searchParams.set('userId', mx.getSafeUserId());
|
||||
url.searchParams.set('botId', preset.id);
|
||||
url.searchParams.set('botMxid', preset.mxid);
|
||||
url.searchParams.set('commandPrefix', preset.experience.commandPrefix);
|
||||
url.searchParams.set('theme', theme.kind);
|
||||
url.searchParams.set('clientLanguage', language);
|
||||
url.searchParams.set('baseUrl', mx.baseUrl);
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||
import * as css from './BotWidgetHost.css';
|
||||
|
||||
type BotWidgetHostProps = {
|
||||
preset: BotPreset;
|
||||
room: Room;
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
export function BotWidgetHost({ preset, room, onError }: BotWidgetHostProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useBotWidgetEmbed({ containerRef, preset, room, onError });
|
||||
|
||||
return <div ref={containerRef} className={css.FrameMount} />;
|
||||
}
|
||||
22
src/app/features/bots/BotWidgetMount.tsx
Normal file
22
src/app/features/bots/BotWidgetMount.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||
import * as css from './BotWidgetMount.css';
|
||||
|
||||
// Renders the iframe-mount target and wires up `useBotWidgetEmbed` to
|
||||
// drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell
|
||||
// refactor — «Host» was overloaded with BotExperienceHost (page-level
|
||||
// dispatcher); «Mount» reads as the literal DOM mount point this is.
|
||||
type BotWidgetMountProps = {
|
||||
preset: BotPreset;
|
||||
room: Room;
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useBotWidgetEmbed({ containerRef, preset, room, onError });
|
||||
|
||||
return <div ref={containerRef} className={css.FrameMount} />;
|
||||
}
|
||||
20
src/app/features/bots/botExperienceState.ts
Normal file
20
src/app/features/bots/botExperienceState.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { atomFamily } from 'jotai/utils';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
// Per-room toggle: when true, BotExperienceHost renders the standard chat
|
||||
// (Room + RoomView) instead of BotShell. Owned by an atom so the «Настроить»
|
||||
// dropdown in the host hero can write it without prop-drilling, and so
|
||||
// remounting the page for the same room preserves the user's last selection.
|
||||
//
|
||||
// AtomFamily keyed by roomId — switching rooms keeps each room's toggle
|
||||
// isolated. NOT auto-GC'd; for current single-bot deployments the cardinality
|
||||
// is bounded. Multi-bot scope (Phase 4+) should call
|
||||
// `botShowChatAtomFamily.remove(roomId)` on leave-room.
|
||||
export const botShowChatAtomFamily = atomFamily((_roomId: string) => atom(false));
|
||||
|
||||
// Per-room sticky failure flag. Set when the widget iframe fails to load or
|
||||
// errors during runtime. Held alongside `showChat` so the chat-fallback
|
||||
// view can offer «Retry widget» (vs. plain «Show widget») and so the user
|
||||
// can recover from a stuck state without losing context. Cleared explicitly
|
||||
// on retry / clean reload of BotShell.
|
||||
export const botFailedAtomFamily = atomFamily((_roomId: string) => atom(false));
|
||||
|
|
@ -5,6 +5,9 @@ import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
|
|||
export type BotExperience = {
|
||||
type: 'matrix-widget';
|
||||
url: string;
|
||||
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
|
||||
* Resolved with the bridgev2 default `!tg` when the operator omits it. */
|
||||
commandPrefix: string;
|
||||
};
|
||||
|
||||
export type BotPreset = {
|
||||
|
|
@ -13,6 +16,10 @@ export type BotPreset = {
|
|||
/** Bot user mxid. The DM with this user IS the bot's control room. */
|
||||
mxid: string;
|
||||
name: string;
|
||||
/** Optional operator override of the localized default. When present takes
|
||||
* precedence over the i18n key `Bots.description.<id>`. Resolved at the
|
||||
* consumer (see `useBotDescription`). */
|
||||
description?: string;
|
||||
experience?: BotExperience;
|
||||
};
|
||||
|
||||
|
|
@ -28,6 +35,27 @@ const MXID_RE = /^@[^:\s]+:[^\s]+$/;
|
|||
// hosts; the dev branch below bypasses this check for `http://localhost:*`.
|
||||
const PROD_WIDGET_ORIGINS: ReadonlySet<string> = new Set(['https://widgets.vojo.chat']);
|
||||
|
||||
// bridgev2's Telegram connector ships `!tg` as DefaultCommandPrefix
|
||||
// (mautrix/telegram pkg/connector/connector.go:68). Operators can override via
|
||||
// `bridge.command_prefix` in the mautrix-telegram config; in that case they
|
||||
// must mirror the override in /config.json so the widget prepends the right
|
||||
// prefix to every outbound command.
|
||||
const DEFAULT_BOT_COMMAND_PREFIX = '!tg';
|
||||
|
||||
// Reject whitespace and empties — the prefix is concatenated to outbound
|
||||
// command bodies as `<prefix> <args>`, and bridgev2 strips exactly
|
||||
// `<prefix>+" "` (queue.go:118). Whitespace inside the prefix would break the
|
||||
// strip and route the message to the unknown-command fallback.
|
||||
const COMMAND_PREFIX_RE = /^\S+$/;
|
||||
|
||||
const normalizeCommandPrefix = (raw: unknown): string | undefined => {
|
||||
if (raw === undefined) return DEFAULT_BOT_COMMAND_PREFIX;
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!COMMAND_PREFIX_RE.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
|
||||
typeof preset?.id === 'string' &&
|
||||
BOT_ID_RE.test(preset.id) &&
|
||||
|
|
@ -42,6 +70,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
|
|||
if (type !== 'matrix-widget' || !url) return undefined;
|
||||
if (url.startsWith('//')) return undefined;
|
||||
|
||||
// Reject the whole experience block when commandPrefix is present but
|
||||
// malformed — falling back to the default would silently mask an operator
|
||||
// typo in /config.json that the widget then can't recover from at runtime.
|
||||
const commandPrefix = normalizeCommandPrefix(experience?.commandPrefix);
|
||||
if (commandPrefix === undefined) return undefined;
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
// Resolve once so `/widgets/../admin` collapses before the prefix check —
|
||||
// a relative `/widgets/...` survives `new URL(url, base)` only if it does
|
||||
|
|
@ -64,7 +98,11 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
|
|||
if (!resolved.pathname.startsWith('/widgets/')) return undefined;
|
||||
const lastSegment = resolved.pathname.split('/').pop() ?? '';
|
||||
if (!lastSegment.includes('.')) return undefined;
|
||||
return { type, url: `${resolved.pathname}${resolved.search}${resolved.hash}` };
|
||||
return {
|
||||
type,
|
||||
url: `${resolved.pathname}${resolved.search}${resolved.hash}`,
|
||||
commandPrefix,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -79,12 +117,12 @@ const normalizeBotExperience = (experience: BotConfig['experience']): BotExperie
|
|||
// collapses to a literal `false`), so it never relaxes the prod validator.
|
||||
if (import.meta.env.DEV && parsed.protocol === 'http:' && parsed.hostname === 'localhost') {
|
||||
if (parsed.username || parsed.password) return undefined;
|
||||
return { type, url: parsed.toString() };
|
||||
return { type, url: parsed.toString(), commandPrefix };
|
||||
}
|
||||
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() };
|
||||
return { type, url: parsed.toString(), commandPrefix };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -101,10 +139,15 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
|
|||
if (seenIds.has(preset.id) || seenMxids.has(preset.mxid)) return;
|
||||
seenIds.add(preset.id);
|
||||
seenMxids.add(preset.mxid);
|
||||
const description =
|
||||
typeof preset.description === 'string' && preset.description.trim().length > 0
|
||||
? preset.description.trim()
|
||||
: undefined;
|
||||
bots.push({
|
||||
id: preset.id,
|
||||
mxid: preset.mxid,
|
||||
name: preset.name.trim(),
|
||||
description,
|
||||
experience: normalizeBotExperience(preset.experience),
|
||||
});
|
||||
});
|
||||
|
|
@ -112,6 +155,10 @@ export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
|
|||
return bots;
|
||||
};
|
||||
|
||||
// NOTE: description rendering lives at the call site (BotShellHero) via
|
||||
// `t(\`Bots.description.${preset.id}\`, { defaultValue: preset.description ?? '' })`.
|
||||
// Catalog stays config-loading-only and doesn't depend on i18next.
|
||||
|
||||
export const useBotPresets = (): BotPreset[] => {
|
||||
const clientConfig = useClientConfig();
|
||||
return useMemo(() => getBotPresets(clientConfig), [clientConfig]);
|
||||
|
|
|
|||
|
|
@ -56,3 +56,13 @@ export const isCatalogBotControlRoom = (
|
|||
room: Room,
|
||||
presets: readonly BotPreset[]
|
||||
): boolean => presets.some((preset) => isBotControlRoom(mx, room, preset));
|
||||
|
||||
// Returns the BotPreset that owns this room, or undefined if the room is
|
||||
// not a bot control DM. Used by the RoomMenu's «Show widget» item to
|
||||
// know which `/bots/:id` route to navigate to. Wraps the same matcher as
|
||||
// `isCatalogBotControlRoom`.
|
||||
export const findBotPresetForRoom = (
|
||||
mx: MatrixClient,
|
||||
room: Room,
|
||||
presets: readonly BotPreset[]
|
||||
): BotPreset | undefined => presets.find((preset) => isBotControlRoom(mx, room, preset));
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ import {
|
|||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
|
|
@ -80,20 +81,71 @@ import { markAsRead } from '../../utils/notifications';
|
|||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useBotPresets } from '../bots/catalog';
|
||||
import { isCatalogBotControlRoom } from '../bots/room';
|
||||
import { findBotPresetForRoom, isCatalogBotControlRoom } from '../bots/room';
|
||||
import { botFailedAtomFamily, botShowChatAtomFamily } from '../bots/botExperienceState';
|
||||
import { getBotPath } from '../../pages/pathUtils';
|
||||
import { JumpToTime } from './jump-to-time';
|
||||
import { RoomPinMenu } from './room-pin-menu';
|
||||
import * as css from './RoomViewHeaderDm.css';
|
||||
|
||||
// Single bot-aware menu item rendered at the top of RoomMenu when the
|
||||
// current room is a Vojo bot control DM. Reads `botFailedAtomFamily` to
|
||||
// label correctly («Retry widget» when a prior load failed, «Show widget»
|
||||
// otherwise) and clears both atoms on click.
|
||||
//
|
||||
// IMPORTANT: this menu surfaces in BOTH `/bots/:botId` (chat-fallback) and
|
||||
// `/direct/:roomId` (regular DM that happens to be a bot's control room).
|
||||
// In the second case clearing the atoms alone is invisible — the user is
|
||||
// not on the route that observes them. We navigate to `/bots/:botId` so
|
||||
// the BotShell actually mounts.
|
||||
function BotShowWidgetMenuItem({
|
||||
roomId,
|
||||
botId,
|
||||
requestClose,
|
||||
}: {
|
||||
roomId: string;
|
||||
botId: string;
|
||||
requestClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const setShowChat = useSetAtom(botShowChatAtomFamily(roomId));
|
||||
const [failed, setFailed] = useAtom(botFailedAtomFamily(roomId));
|
||||
const handleClick = () => {
|
||||
setFailed(false);
|
||||
setShowChat(false);
|
||||
navigate(getBotPath(botId));
|
||||
requestClose();
|
||||
};
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={handleClick}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Terminal} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
callView?: boolean;
|
||||
// When true the room is a Vojo bot control DM rendered in chat-fallback
|
||||
// mode. The menu prepends a «Show widget / Retry widget» item so the
|
||||
// user can return to BotShell without hunting for an overlay button.
|
||||
// Other items stay standard — bots in chat-fallback should look like
|
||||
// normal rooms beyond that one affordance.
|
||||
botControlRoom?: boolean;
|
||||
onSearch: () => void;
|
||||
onPin: (cords: RectCords) => void;
|
||||
requestClose: () => void;
|
||||
};
|
||||
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
|
||||
({ room, callView, onSearch, onPin, requestClose }, ref) => {
|
||||
({ room, callView, botControlRoom, onSearch, onPin, requestClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
|
|
@ -108,6 +160,11 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
|
|||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const openSettings = useOpenRoomSettings();
|
||||
const parentSpace = useSpaceOptionally();
|
||||
// Look up the matching bot preset only when this menu IS the bot
|
||||
// variant. The lookup walks the preset list once; cheap. Returns
|
||||
// undefined for non-bot rooms — we won't render the menu item.
|
||||
const bots = useBotPresets();
|
||||
const botPreset = botControlRoom ? findBotPresetForRoom(mx, room, bots) : undefined;
|
||||
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
|
||||
|
|
@ -148,6 +205,18 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{botControlRoom && botPreset && (
|
||||
<>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<BotShowWidgetMenuItem
|
||||
roomId={room.roomId}
|
||||
botId={botPreset.id}
|
||||
requestClose={requestClose}
|
||||
/>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
</>
|
||||
)}
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
|
|
@ -643,6 +712,7 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
<RoomMenu
|
||||
room={room}
|
||||
callView={callView}
|
||||
botControlRoom={isBotControlRoom}
|
||||
onSearch={handleSearch}
|
||||
onPin={(cords) => setPinMenuAnchor(cords)}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,13 @@ export type BotConfig = {
|
|||
id?: string;
|
||||
mxid?: string;
|
||||
name?: string;
|
||||
/** Optional operator override of the localized description. Falls back
|
||||
* to i18n key `Bots.description.<id>` when absent. */
|
||||
description?: string;
|
||||
experience?: {
|
||||
type?: string;
|
||||
url?: string;
|
||||
commandPrefix?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import React from 'react';
|
|||
import { useParams } from 'react-router-dom';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { findBotPresetById, useBotPresets } from '../../../features/bots/catalog';
|
||||
import { BotExperienceSlot } from '../../../features/bots/BotExperienceSlot';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { Room as MatrixRoom } from 'matrix-js-sdk';
|
||||
import { findBotPresetById, useBotPresets, type BotPreset } from '../../../features/bots/catalog';
|
||||
import { BotChatFallback } from '../../../features/bots/BotChatFallback';
|
||||
import { BotShell } from '../../../features/bots/BotShell';
|
||||
import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState';
|
||||
import { useBotRoom } from '../../../features/bots/useBotRoom';
|
||||
import { Room } from '../../../features/room';
|
||||
import { BotInvitePending } from './BotInvitePending';
|
||||
|
|
@ -13,6 +17,19 @@ import { BotRoomProvider } from './BotRoomProvider';
|
|||
import { BotStatePage } from './BotStatePage';
|
||||
import { BotUnsafeRoom } from './BotUnsafeRoom';
|
||||
|
||||
// Branches between BotShell (widget mode, no Cinny header) and the standard
|
||||
// Room layout (chat fallback) based on the per-room showChat atom. Lives
|
||||
// inside BotRoomProvider so both arms see the same room context and so an
|
||||
// atom write from BotShellMenu (via «Показать чат») is observed here
|
||||
// without prop-drilling.
|
||||
function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) {
|
||||
const showChat = useAtomValue(botShowChatAtomFamily(room.roomId));
|
||||
if (showChat) {
|
||||
return <Room renderRoomView={({ eventId }) => <BotChatFallback eventId={eventId} />} />;
|
||||
}
|
||||
return <BotShell preset={preset} room={room} />;
|
||||
}
|
||||
|
||||
export function BotExperienceHost() {
|
||||
const { t } = useTranslation();
|
||||
const { botId } = useParams();
|
||||
|
|
@ -54,11 +71,7 @@ export function BotExperienceHost() {
|
|||
|
||||
return (
|
||||
<BotRoomProvider room={room}>
|
||||
<Room
|
||||
renderRoomView={({ eventId }) => (
|
||||
<BotExperienceSlot preset={preset} room={room} eventId={eventId} />
|
||||
)}
|
||||
/>
|
||||
<BotExperienceRoute preset={preset} room={room} />
|
||||
</BotRoomProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue