Add runtime-configured bot tab

This commit is contained in:
heaven 2026-05-01 20:21:55 +03:00
parent 357a2024f4
commit 83e246da1f
26 changed files with 1038 additions and 49 deletions

View file

@ -14,7 +14,13 @@
"enabled": false,
"basename": "/"
},
"bots": [
{
"id": "telegram",
"mxid": "@telegrambot:vojo.chat",
"name": "Telegram"
}
],
"push": {
"vapidPublicKey": "BHmGRaixeMlWHyxMuRIYDA72dqQIV6mSdap4smklDixZsWS4ZhL01cv9YRHEW6NO0iumXeQ-T0_yirtcHNB5tZw",
"gatewayUrl": "http://sygnal:5000/_matrix/push/v1/notify",

View file

@ -919,6 +919,25 @@
"invite_body_generic": "New invitation"
},
"Bots": {
"title": "Robots"
"not_connected_title": "{{name}} is not connected",
"not_connected_description": "Create a private chat with {{mxid}} to use this robot.",
"connect": "Connect",
"connect_error": "Failed to connect robot.",
"pending_title": "{{name}} is connecting",
"pending_bot_invite_description": "The chat exists. Waiting for {{mxid}} to join.",
"pending_self_invite_description": "You have been invited to the chat with this robot. Accept the invite to continue.",
"accept_invite": "Accept invite",
"accept_error": "Failed to accept invite.",
"decline_invite": "Decline invite",
"reset_error": "Failed to reset connection.",
"kicked_title": "{{name}} disconnected",
"kicked_description": "The chat exists, but {{mxid}} is no longer in it.",
"reinvite": "Re-invite {{name}}",
"reinvite_error": "Failed to re-invite robot.",
"unsafe_title": "This robot chat is not private",
"unsafe_description": "The robot is blocked because another active member is present in the chat.",
"open_chat": "Open chat",
"unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog."
}
}

View file

@ -923,6 +923,25 @@
"invite_body_generic": "Новое приглашение"
},
"Bots": {
"title": "Роботы"
"not_connected_title": "{{name}} не подключён",
"not_connected_description": "Создайте приватный чат с {{mxid}}, чтобы пользоваться роботом.",
"connect": "Подключить",
"connect_error": "Не удалось подключить робота.",
"pending_title": "{{name}} подключается",
"pending_bot_invite_description": "Чат уже создан. Ждём, пока {{mxid}} присоединится.",
"pending_self_invite_description": "Вас пригласили в чат с роботом. Примите приглашение, чтобы продолжить.",
"accept_invite": "Принять приглашение",
"accept_error": "Не удалось принять приглашение.",
"decline_invite": "Отклонить приглашение",
"reset_error": "Не удалось сбросить подключение.",
"kicked_title": "{{name}} отключён",
"kicked_description": "Чат с роботом существует, но {{mxid}} больше не в нём.",
"reinvite": "Пригласить {{name}} заново",
"reinvite_error": "Не удалось пригласить робота заново.",
"unsafe_title": "Чат с роботом не приватный",
"unsafe_description": "Робот заблокирован: в чате присутствует посторонний участник.",
"open_chat": "Открыть чат",
"unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo."
}
}

View file

@ -0,0 +1,65 @@
import React from 'react';
import { Avatar, Box, Text, toRem } from 'folds';
import { NavItem, NavItemContent, NavLink } from '../../components/nav';
import { getBotPath } from '../../pages/pathUtils';
import type { BotPreset } from './catalog';
const MONO_FONT = '"JetBrains Mono Variable", ui-monospace, monospace';
const ROW_MIN_HEIGHT = toRem(56);
const AVATAR_BG = '#7ab6d9';
type BotCardProps = {
preset: BotPreset;
selected?: boolean;
};
export function BotCard({ preset, selected }: BotCardProps) {
const initial = preset.name.trim().charAt(0).toUpperCase() || '?';
return (
<NavItem
variant="Background"
radii="400"
aria-selected={selected}
style={{ minHeight: ROW_MIN_HEIGHT }}
>
<NavLink to={getBotPath(preset.id)}>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(6)} 0`,
}}
>
<Avatar size="300" radii="400" style={{ background: AVATAR_BG, color: '#0c0c0e' }}>
<Text as="span" size="H6" style={{ color: '#0c0c0e', fontWeight: 700 }}>
{initial}
</Text>
</Avatar>
<Box
as="span"
direction="Column"
grow="Yes"
gap="100"
style={{ minWidth: 0, overflow: 'hidden' }}
>
<Box as="span" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
{preset.name}
</Text>
</Box>
<Text as="span" size="T200" truncate style={{ opacity: 0.6, fontFamily: MONO_FONT }}>
{preset.mxid}
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
);
}

View file

@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { useClientConfig } from '../../hooks/useClientConfig';
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
export type BotPreset = {
/** Stable URL slug — `/bots/<id>`. Never reuse across bots. */
id: string;
/** Bot user mxid. The DM with this user IS the bot's control room. */
mxid: string;
name: string;
};
const BOT_ID_RE = /^[A-Za-z0-9_-]+$/;
const MXID_RE = /^@[^:\s]+:[^\s]+$/;
const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
typeof preset?.id === 'string' &&
BOT_ID_RE.test(preset.id) &&
typeof preset.mxid === 'string' &&
MXID_RE.test(preset.mxid) &&
typeof preset.name === 'string' &&
preset.name.trim().length > 0;
export const getBotPresets = (clientConfig: ClientConfig): BotPreset[] => {
const seenIds = new Set<string>();
const seenMxids = new Set<string>();
const bots: BotPreset[] = [];
const configuredBots = Array.isArray(clientConfig.bots) ? clientConfig.bots : [];
configuredBots.forEach((preset) => {
if (!isValidBotPreset(preset)) return;
if (seenIds.has(preset.id) || seenMxids.has(preset.mxid)) return;
seenIds.add(preset.id);
seenMxids.add(preset.mxid);
bots.push({
id: preset.id,
mxid: preset.mxid,
name: preset.name.trim(),
});
});
return bots;
};
export const useBotPresets = (): BotPreset[] => {
const clientConfig = useClientConfig();
return useMemo(() => getBotPresets(clientConfig), [clientConfig]);
};
export const findBotPresetById = (
presets: readonly BotPreset[],
id: string
): BotPreset | undefined => presets.find((preset) => preset.id === id);

View file

@ -0,0 +1,41 @@
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { Membership, StateEvent } from '../../../types/matrix/room';
import { isBridgedRoom, isRoom } from '../../utils/room';
import type { BotPreset } from './catalog';
const ACTIVE_MEMBERSHIPS = new Set<string>([Membership.Join, Membership.Invite]);
export const isBridgeStateEvent = (ev: MatrixEvent): boolean => {
const type = ev.getType();
return type === StateEvent.RoomBridge || type === StateEvent.RoomBridgeUnstable;
};
export const isBotControlRoom = (mx: MatrixClient, room: Room, preset: BotPreset): boolean => {
if (!isRoom(room) || isBridgedRoom(room)) return false;
const myUserId = mx.getUserId();
if (!myUserId) return false;
const myMembership = room.getMyMembership();
if (!ACTIVE_MEMBERSHIPS.has(myMembership)) return false;
const botMember = room.getMember(preset.mxid);
if (!botMember) return false;
const allowedMembers = new Set([myUserId, preset.mxid]);
const activeMembers = room
.getMembers()
.filter(
(member) => member.membership !== undefined && ACTIVE_MEMBERSHIPS.has(member.membership)
);
return (
activeMembers.length > 0 && activeMembers.every((member) => allowedMembers.has(member.userId))
);
};
export const isCatalogBotControlRoom = (
mx: MatrixClient,
room: Room,
presets: readonly BotPreset[]
): boolean => presets.some((preset) => isBotControlRoom(mx, room, preset));

View file

@ -0,0 +1,217 @@
import { useEffect, useState } from 'react';
import {
ClientEvent,
EventType,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
RoomStateEvent,
} from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { Membership } from '../../../types/matrix/room';
import { removeRoomIdFromMDirect } from '../../utils/matrix';
import { isBridgedRoom } from '../../utils/room';
import { isBotControlRoom, isBridgeStateEvent } from './room';
import type { BotPreset } from './catalog';
// Discriminated union over the lifecycle of a bot DM. Mount-eligible only at
// `kind === 'ready'`; everything else is a UI state with a different CTA.
//
// Using getDMRoomFor here would be wrong: it returns only self-Joined active
// 1:1 rooms, so self-invite and bot-kicked rooms would silently degrade to
// `none` and the «Connect» button would create a duplicate control DM.
// Instead we read m.direct[preset.mxid] raw plus legacy untagged control rooms,
// walk the rooms, and classify by (myMembership, botMembership) tuple.
export type BotRoomState =
| { kind: 'none' }
| { kind: 'self-invite'; room: Room }
| { kind: 'bot-invite'; room: Room }
| { kind: 'bot-kicked'; room: Room }
| { kind: 'unsafe-membership'; room: Room }
| { kind: 'ready'; room: Room };
type MDirectMap = Record<string, string[]>;
const classifyRoom = (
mx: MatrixClient,
room: Room,
preset: BotPreset
): BotRoomState | undefined => {
if (isBridgedRoom(room)) return undefined;
const my = room.getMyMembership();
const bot = room.getMember(preset.mxid)?.membership;
// A bot control room is mount-ready only while active membership is exactly
// self + bot. Extra joined/invited members block the bot surface before any
// sensitive commands can be read or sent.
const activeMembers = room
.getMembers()
.filter((m) => m.membership === Membership.Join || m.membership === Membership.Invite);
const allowedMembers = new Set([mx.getSafeUserId(), preset.mxid]);
if (activeMembers.some((m) => !allowedMembers.has(m.userId))) {
return { kind: 'unsafe-membership', room };
}
if (my === Membership.Invite) return { kind: 'self-invite', room };
if (my !== Membership.Join) return undefined;
if (bot === Membership.Invite) return { kind: 'bot-invite', room };
if (bot === Membership.Leave || bot === Membership.Ban) return { kind: 'bot-kicked', room };
if (bot === Membership.Join && activeMembers.length === 2) return { kind: 'ready', room };
return undefined;
};
// Order: prefer `ready` first, then live invites, then stale states. Within a
// kind, sort by recent activity (last room timestamp). Without this, walking
// `m.direct[mxid]` in JSON order can pin the UI on a Leave-state stale room
// while a fresh Join-state room sits later in the array.
const STATE_PRIORITY: Record<BotRoomState['kind'], number> = {
ready: 0,
'self-invite': 1,
'bot-invite': 2,
'unsafe-membership': 3,
'bot-kicked': 4,
none: 5,
};
const DIRECT_ACCOUNT_DATA = EventType.Direct;
const getBotRoomIds = (mx: MatrixClient, preset: BotPreset, mDirect: MDirectMap): string[] => {
const ids = new Set(mDirect[preset.mxid] ?? []);
mx.getRooms().forEach((room) => {
if (isBotControlRoom(mx, room, preset)) {
ids.add(room.roomId);
}
});
return Array.from(ids);
};
const computeBotRoomState = (mx: MatrixClient, preset: BotPreset): BotRoomState => {
const mDirectsEvent = mx.getAccountData(DIRECT_ACCOUNT_DATA);
const mDirect = (mDirectsEvent?.getContent() ?? {}) as MDirectMap;
const roomIds = getBotRoomIds(mx, preset, mDirect);
// Sweep dead pointers (purged rooms, my=Leave/Ban) from m.direct so the
// array doesn't grow unbounded across reconnect cycles. Fire-and-forget;
// the next reactive tick will recompute against the cleaned account data.
const deadIds: string[] = [];
const candidates: BotRoomState[] = [];
roomIds.forEach((roomId) => {
const room = mx.getRoom(roomId);
if (!room) {
deadIds.push(roomId);
return;
}
const my = room.getMyMembership();
if (my === Membership.Leave || my === Membership.Ban) {
deadIds.push(roomId);
return;
}
const classified = classifyRoom(mx, room, preset);
if (classified) candidates.push(classified);
});
if (deadIds.length > 0) {
Promise.all(deadIds.map((id) => removeRoomIdFromMDirect(mx, id))).catch(() => undefined);
}
if (candidates.length === 0) return { kind: 'none' };
candidates.sort((a, b) => {
const byKind = STATE_PRIORITY[a.kind] - STATE_PRIORITY[b.kind];
if (byKind !== 0) return byKind;
const aRoom = 'room' in a ? a.room : undefined;
const bRoom = 'room' in b ? b.room : undefined;
const aTs = aRoom?.getLastActiveTimestamp() ?? 0;
const bTs = bRoom?.getLastActiveTimestamp() ?? 0;
return bTs - aTs;
});
return candidates[0];
};
// Subscribe to client-level events that can change the (room set × membership)
// tuple. Listeners are gated by `roomId` membership of the bot's room set so
// we don't recompute on every member event in every room of the account.
//
// - ClientEvent.AccountData — m.direct mutated (createRoom flow)
// - ClientEvent.Room — first sync of a freshly-created room
// - RoomEvent.MyMembership — accept/decline of self-invite
// - RoomStateEvent.Members — bot accepted invite, was kicked, etc.
// - RoomStateEvent.Events — late m.bridge state demotes a portal
// room out of the Robots control surface
//
// We snapshot the «interesting» room id set on each recompute so the listeners
// know which room ids to react to. Bot `m.direct` arrays are tiny (≤ a couple
// of entries per bot) so the set lookup is O(1) effectively.
export const useBotRoom = (preset: BotPreset | undefined): BotRoomState => {
const mx = useMatrixClient();
const [state, setState] = useState<BotRoomState>(() =>
preset ? computeBotRoomState(mx, preset) : { kind: 'none' }
);
useEffect(() => {
if (!preset) {
setState({ kind: 'none' });
return undefined;
}
const interestingRoomIds = new Set<string>();
const refreshInterestingRoomIds = () => {
const mDirect = (mx.getAccountData(DIRECT_ACCOUNT_DATA)?.getContent() ?? {}) as MDirectMap;
interestingRoomIds.clear();
getBotRoomIds(mx, preset, mDirect).forEach((id) => interestingRoomIds.add(id));
};
const reEvaluate = () => {
refreshInterestingRoomIds();
setState(computeBotRoomState(mx, preset));
};
reEvaluate();
const onAccountData = (ev: MatrixEvent) => {
if (ev.getType() === DIRECT_ACCOUNT_DATA) reEvaluate();
};
const onRoom = (room: Room) => {
// A freshly-created bot DM may not be in m.direct yet — recompute always.
// Otherwise gate by mxid membership so we ignore unrelated room events.
if (interestingRoomIds.has(room.roomId) || room.getMember(preset.mxid)) {
reEvaluate();
}
};
const onMyMembership = (room: Room) => {
if (interestingRoomIds.has(room.roomId) || room.getMember(preset.mxid)) reEvaluate();
};
const onMembers = (ev: MatrixEvent) => {
const roomId = ev.getRoomId();
if (roomId && (interestingRoomIds.has(roomId) || ev.getStateKey() === preset.mxid)) {
reEvaluate();
}
};
const onStateEvent = (ev: MatrixEvent) => {
if (!isBridgeStateEvent(ev)) return;
const roomId = ev.getRoomId();
const room = roomId ? mx.getRoom(roomId) : undefined;
if (roomId && (interestingRoomIds.has(roomId) || room?.getMember(preset.mxid))) {
reEvaluate();
}
};
mx.on(ClientEvent.AccountData, onAccountData);
mx.on(ClientEvent.Room, onRoom);
mx.on(RoomEvent.MyMembership, onMyMembership);
mx.on(RoomStateEvent.Members, onMembers);
mx.on(RoomStateEvent.Events, onStateEvent);
return () => {
mx.removeListener(ClientEvent.AccountData, onAccountData);
mx.removeListener(ClientEvent.Room, onRoom);
mx.removeListener(RoomEvent.MyMembership, onMyMembership);
mx.removeListener(RoomStateEvent.Members, onMembers);
mx.removeListener(RoomStateEvent.Events, onStateEvent);
};
}, [mx, preset]);
return state;
};

View file

@ -79,6 +79,8 @@ import { stopPropagation } from '../../utils/keyboard';
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 { JumpToTime } from './jump-to-time';
import { RoomPinMenu } from './room-pin-menu';
import * as css from './RoomViewHeaderDm.css';
@ -385,7 +387,10 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
// `isBridgedRoom(room)` call would miss.
const mDirects = useAtomValue(mDirectAtom);
const isBridged = useIsBridgedRoom(room);
const callButtonVisible = !callView && isOneOnOne && mDirects.has(room.roomId) && !isBridged;
const bots = useBotPresets();
const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots);
const callButtonVisible =
!callView && isOneOnOne && mDirects.has(room.roomId) && !isBridged && !isBotControlRoom;
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;

View file

@ -1,5 +1,5 @@
import { useMatch } from 'react-router-dom';
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
import { getBotsPath, getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
export const useDirectSelected = (): boolean => {
const directMatch = useMatch({
@ -7,8 +7,13 @@ export const useDirectSelected = (): boolean => {
caseSensitive: true,
end: false,
});
const botsMatch = useMatch({
path: getBotsPath(),
caseSensitive: true,
end: false,
});
return !!directMatch;
return !!directMatch || !!botsMatch;
};
export const useDirectCreateSelected = (): boolean => {

View file

@ -12,6 +12,12 @@ export type PushConfig = {
fcmAppId?: string;
};
export type BotConfig = {
id?: string;
mxid?: string;
name?: string;
};
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@ -27,6 +33,8 @@ export type ClientConfig = {
hashRouter?: HashRouterConfig;
push?: PushConfig;
bots?: BotConfig[];
};
const ClientConfigContext = createContext<ClientConfig | null>(null);

View file

@ -51,7 +51,7 @@ import { getMxIdServer, isUserId } from '../utils/matrix';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
import { HomeRouteRoomProvider } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { Bots } from './client/bots';
import { BotExperienceHost, Bots } from './client/bots';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
import { Notifications, Inbox, Invites } from './client/inbox';
@ -245,7 +245,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
/>
</Route>
{/* Bots reuses DirectStreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. Real bot list lands in M2. */}
{/* Bots reuses DirectStreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
<Route
path={BOTS_PATH}
element={
@ -261,6 +261,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route path=":botId" element={<BotExperienceHost />} />
</Route>
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
<Route

View file

@ -0,0 +1,59 @@
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 { useBotRoom } from '../../../features/bots/useBotRoom';
import { Room } from '../../../features/room';
import { BotInvitePending } from './BotInvitePending';
import { BotKicked } from './BotKicked';
import { BotNotConnected } from './BotNotConnected';
import { BotRoomProvider } from './BotRoomProvider';
import { BotStatePage } from './BotStatePage';
import { BotUnsafeRoom } from './BotUnsafeRoom';
export function BotExperienceHost() {
const { t } = useTranslation();
const { botId } = useParams();
const bots = useBotPresets();
const preset = findBotPresetById(bots, botId ?? '');
const botRoomState = useBotRoom(preset);
if (!preset) {
return (
<BotStatePage
icon={<Icon size="600" src={Icons.Warning} />}
title={t('Bots.unknown_title')}
description={t('Bots.unknown_description')}
/>
);
}
if (botRoomState.kind === 'none') {
return <BotNotConnected preset={preset} />;
}
if (botRoomState.kind === 'self-invite') {
return <BotInvitePending preset={preset} room={botRoomState.room} selfInvite />;
}
if (botRoomState.kind === 'bot-invite') {
return <BotInvitePending preset={preset} room={botRoomState.room} />;
}
if (botRoomState.kind === 'bot-kicked') {
return <BotKicked preset={preset} room={botRoomState.room} />;
}
if (botRoomState.kind === 'unsafe-membership') {
return <BotUnsafeRoom room={botRoomState.room} />;
}
const { room } = botRoomState;
return (
<BotRoomProvider room={room}>
<Room />
</BotRoomProvider>
);
}

View file

@ -0,0 +1,96 @@
import React, { useCallback } from 'react';
import { MatrixError, Room as MatrixRoom } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { Box, Button, Icon, Icons, Spinner, Text, color } from 'folds';
import type { BotPreset } from '../../../features/bots/catalog';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { removeRoomIdFromMDirect } from '../../../utils/matrix';
import { BotOpenChatAction } from './BotOpenChatAction';
import { BotStatePage } from './BotStatePage';
type BotInvitePendingProps = {
preset: BotPreset;
room: MatrixRoom;
selfInvite?: boolean;
};
export function BotInvitePending({ preset, room, selfInvite }: BotInvitePendingProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
// Self-invite: user has been invited to the control DM (e.g. from another
// device or by the bot itself). Accept inline so the user doesn't have to
// bounce through /direct/ to continue.
const [acceptState, accept] = useAsyncCallback<void, Error | MatrixError, []>(
useCallback(async () => {
await mx.joinRoom(room.roomId);
}, [mx, room])
);
// Self-invite decline: leave the invited control DM and drop its m.direct
// pointer. We intentionally do not expose this reset while waiting for the
// bot to join; creating a replacement room there can race with the bridge
// accepting the original invite and recreate the duplicate-DM bug.
const [resetState, reset] = useAsyncCallback<void, Error | MatrixError, []>(
useCallback(async () => {
await mx.leave(room.roomId).catch(() => undefined);
await removeRoomIdFromMDirect(mx, room.roomId);
mx.forget(room.roomId).catch(() => undefined);
}, [mx, room])
);
const acceptLoading = acceptState.status === AsyncStatus.Loading;
const acceptError = acceptState.status === AsyncStatus.Error ? acceptState.error : undefined;
const resetLoading = resetState.status === AsyncStatus.Loading;
const resetError =
selfInvite && resetState.status === AsyncStatus.Error ? resetState.error : undefined;
const error = acceptError ?? resetError;
return (
<BotStatePage
icon={<Icon size="600" src={Icons.Info} />}
title={t('Bots.pending_title', { name: preset.name })}
description={
selfInvite
? t('Bots.pending_self_invite_description')
: t('Bots.pending_bot_invite_description', { mxid: preset.mxid })
}
>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>{error.message || (acceptError ? t('Bots.accept_error') : t('Bots.reset_error'))}</b>
</Text>
</Box>
)}
{selfInvite && (
<Button
variant="Primary"
size="500"
radii="400"
onClick={() => accept().catch(() => undefined)}
disabled={acceptLoading || resetLoading}
before={acceptLoading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">{t('Bots.accept_invite')}</Text>
</Button>
)}
{selfInvite && (
<Button
variant="Secondary"
fill="Soft"
size="500"
radii="400"
onClick={() => reset().catch(() => undefined)}
disabled={acceptLoading || resetLoading}
before={resetLoading && <Spinner variant="Secondary" fill="Soft" size="200" />}
>
<Text size="B500">{t('Bots.decline_invite')}</Text>
</Button>
)}
<BotOpenChatAction room={room} />
</BotStatePage>
);
}

View file

@ -0,0 +1,59 @@
import React, { useCallback } from 'react';
import { MatrixError, Room as MatrixRoom } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { Box, Button, Icon, Icons, Spinner, Text, color } from 'folds';
import type { BotPreset } from '../../../features/bots/catalog';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { BotOpenChatAction } from './BotOpenChatAction';
import { BotStatePage } from './BotStatePage';
type BotKickedProps = {
preset: BotPreset;
room: MatrixRoom;
};
export function BotKicked({ preset, room }: BotKickedProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
// Re-invite the bot back into the existing control DM. useBotRoom will
// re-classify on the resulting RoomStateEvent.Members and transition the
// experience host to bot-invite → ready without minting a new room.
const [reinviteState, reinvite] = useAsyncCallback<void, Error | MatrixError, []>(
useCallback(async () => {
await mx.invite(room.roomId, preset.mxid);
}, [mx, room, preset])
);
const loading = reinviteState.status === AsyncStatus.Loading;
const error = reinviteState.status === AsyncStatus.Error ? reinviteState.error : undefined;
return (
<BotStatePage
icon={<Icon size="600" src={Icons.Warning} />}
title={t('Bots.kicked_title', { name: preset.name })}
description={t('Bots.kicked_description', { mxid: preset.mxid })}
>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>{error.message || t('Bots.reinvite_error')}</b>
</Text>
</Box>
)}
<Button
variant="Primary"
size="500"
radii="400"
onClick={() => reinvite().catch(() => undefined)}
disabled={loading}
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">{t('Bots.reinvite', { name: preset.name })}</Text>
</Button>
<BotOpenChatAction room={room} />
</BotStatePage>
);
}

View file

@ -0,0 +1,120 @@
import React, { useCallback } from 'react';
import { EventType, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { Box, Button, Icon, Icons, Spinner, Text, color } from 'folds';
import type { BotPreset } from '../../../features/bots/catalog';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { Membership } from '../../../../types/matrix/room';
import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../../utils/matrix';
import { isBotControlRoom } from '../../../features/bots/room';
import { BotStatePage } from './BotStatePage';
type BotNotConnectedProps = {
preset: BotPreset;
};
type MDirectMap = Record<string, string[]>;
const DIRECT_ACCOUNT_DATA = EventType.Direct;
export function BotNotConnected({ preset }: BotNotConnectedProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [connectState, connect] = useAsyncCallback<string, Error | MatrixError, []>(
useCallback(async () => {
// Walk m.direct[bot.mxid] and decide what to do with each pointer:
// - Active strict control DM (Join/Invite, only self + bot): reuse.
// Portal/group rooms are ignored even if broken m.direct account
// data points at them.
// useBotRoom will pick real control rooms up via reactive listeners
// and BotExperienceHost will swap us out of this <BotNotConnected />.
// - Dead (Leave/Ban/null/unknown): drop the pointer. Private DMs
// created with TrustedPrivateChat cannot be rejoined unilaterally
// (server returns 403 «You are not invited»), so the only way
// forward is to forget the dead room and mint a new control DM.
// The forgotten room is invisible from the SDK's joined-rooms atom,
// so it won't surface in /direct/ either.
const mDirect = (mx.getAccountData(DIRECT_ACCOUNT_DATA)?.getContent() ?? {}) as MDirectMap;
const existingRooms = (mDirect[preset.mxid] ?? []).map((roomId) => {
const room = mx.getRoom(roomId);
return { roomId, room, myMembership: room?.getMyMembership() };
});
const deadRooms = existingRooms.filter(
({ room, myMembership }) =>
!room || (myMembership !== Membership.Join && myMembership !== Membership.Invite)
);
if (deadRooms.length > 0) {
// Null room (purged from store), Leave, Ban, or unknown — all dead.
// Drop the pointer so this loop doesn't keep finding it forever.
await Promise.all(deadRooms.map(({ roomId }) => removeRoomIdFromMDirect(mx, roomId)));
deadRooms.forEach(({ room, myMembership, roomId }) => {
if (room && myMembership === Membership.Leave) {
// Forget so the SDK can fully GC; no-op if forget fails.
mx.forget(roomId).catch(() => undefined);
}
});
}
const reusableRoom = existingRooms.find(({ room, myMembership }) => {
if (!room || (myMembership !== Membership.Join && myMembership !== Membership.Invite)) {
return false;
}
const botMembership = room.getMember(preset.mxid)?.membership;
return (
(botMembership === Membership.Join || botMembership === Membership.Invite) &&
isBotControlRoom(mx, room, preset)
);
});
if (reusableRoom) {
return reusableRoom.roomId;
}
const { room_id: roomId } = await mx.createRoom({
is_direct: true,
invite: [preset.mxid],
visibility: Visibility.Private,
preset: Preset.TrustedPrivateChat,
});
await addRoomIdToMDirect(mx, roomId, preset.mxid);
return roomId;
}, [mx, preset])
);
const loading = connectState.status === AsyncStatus.Loading;
const error = connectState.status === AsyncStatus.Error ? connectState.error : undefined;
const handleConnect = () => {
connect().catch(() => undefined);
};
return (
<BotStatePage
icon={<Icon size="600" src={Icons.Message} />}
title={t('Bots.not_connected_title', { name: preset.name })}
description={t('Bots.not_connected_description', { mxid: preset.mxid })}
>
{error && (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="200">
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>{error.message || t('Bots.connect_error')}</b>
</Text>
</Box>
)}
<Button
variant="Primary"
size="500"
radii="400"
onClick={handleConnect}
disabled={loading}
before={loading && <Spinner variant="Primary" fill="Solid" size="200" />}
>
<Text size="B500">{t('Bots.connect')}</Text>
</Button>
</BotStatePage>
);
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import { Room as MatrixRoom } from 'matrix-js-sdk';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button, Text } from 'folds';
import { getDirectRoomPath } from '../../pathUtils';
type BotOpenChatActionProps = {
room: MatrixRoom;
};
// Shared «escape hatch» CTA used by BotKicked, BotInvitePending and
// BotUnsafeRoom. Drops the user into the underlying control DM via /direct/
// so they can see members, accept invites, kick offenders or read raw bot
// replies — anything Bots tab refuses to surface.
export function BotOpenChatAction({ room }: BotOpenChatActionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Button
variant="Secondary"
fill="Soft"
size="500"
radii="400"
onClick={() => navigate(getDirectRoomPath(room.roomId))}
>
<Text size="B500">{t('Bots.open_chat')}</Text>
</Button>
);
}

View file

@ -0,0 +1,19 @@
import React, { ReactNode } from 'react';
import { Room as MatrixRoom } from 'matrix-js-sdk';
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
type BotRoomProviderProps = {
room: MatrixRoom;
children: ReactNode;
};
export function BotRoomProvider({ room, children }: BotRoomProviderProps) {
const isOneOnOne = useIsOneOnOneRoom(room);
return (
<RoomProvider key={room.roomId} value={room}>
<IsOneOnOneProvider value={isOneOnOne}>{children}</IsOneOnOneProvider>
</RoomProvider>
);
}

View file

@ -0,0 +1,70 @@
import React, { ReactNode } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import {
Page,
PageContent,
PageContentCenter,
PageHeader,
PageHero,
PageHeroSection,
} from '../../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
type BotStatePageProps = {
title: ReactNode;
description: ReactNode;
icon?: ReactNode;
children?: ReactNode;
};
export function BotStatePage({ title, description, icon, children }: BotStatePageProps) {
const screenSize = useScreenSizeContext();
return (
<Page>
{screenSize === ScreenSize.Mobile && (
<PageHeader balance outlined={false}>
<Box grow="Yes" alignItems="Center" gap="200">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
<Text size="H4" truncate>
{title}
</Text>
</Box>
</PageHeader>
)}
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<PageHeroSection>
<PageHero
icon={icon ?? <Icon size="600" src={Icons.Info} />}
title={title}
subTitle={description}
>
{children && (
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ paddingTop: config.space.S200 }}
>
{children}
</Box>
)}
</PageHero>
</PageHeroSection>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -0,0 +1,24 @@
import React from 'react';
import { Room as MatrixRoom } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { Icon, Icons } from 'folds';
import { BotOpenChatAction } from './BotOpenChatAction';
import { BotStatePage } from './BotStatePage';
type BotUnsafeRoomProps = {
room: MatrixRoom;
};
export function BotUnsafeRoom({ room }: BotUnsafeRoomProps) {
const { t } = useTranslation();
return (
<BotStatePage
icon={<Icon size="600" src={Icons.Warning} />}
title={t('Bots.unsafe_title')}
description={t('Bots.unsafe_description')}
>
<BotOpenChatAction room={room} />
</BotStatePage>
);
}

View file

@ -1,28 +1,43 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, Icons, Text } from 'folds';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
import { PageNav } from '../../../components/page';
import { Box, color, config, toRem } from 'folds';
import { useMatch } from 'react-router-dom';
import { PageNav, PageNavContent } from '../../../components/page';
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
import { useBotPresets } from '../../../features/bots/catalog';
import type { BotPreset } from '../../../features/bots/catalog';
import { BotCard } from '../../../features/bots/BotCard';
import { BOTS_BOT_PATH } from '../../paths';
// Static preset links only. Room discovery belongs to /bots/:botId so the list
// doesn't install Matrix listeners or scan rooms for every catalog entry.
function BotRow({ preset }: { preset: BotPreset }) {
// `end: false` so future child routes under :botId keep the parent card
// selected. RR-v6 already URL-decodes `params.botId`, no extra decode here.
const match = useMatch({ path: BOTS_BOT_PATH, caseSensitive: true, end: false });
const selected = match?.params.botId === preset.id;
return <BotCard preset={preset} selected={selected} />;
}
export function Bots() {
const { t } = useTranslation();
useNavToActivePathMapper('bots');
const bots = useBotPresets();
return (
<PageNav size="500">
<DirectStreamHeader />
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Bulb} />}
title={
<Text size="H5" align="Center">
{t('Bots.title')}
</Text>
}
/>
</NavEmptyCenter>
<PageNavContent>
<Box
direction="Column"
gap="100"
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
{bots.map((preset) => (
<BotRow key={preset.id} preset={preset} />
))}
</Box>
</PageNavContent>
</PageNav>
);
}

View file

@ -1 +1,2 @@
export * from './Bots';
export * from './BotExperienceHost';

View file

@ -5,6 +5,7 @@ import { Box, Text, Tooltip, TooltipProvider, color, toRem } from 'folds';
import { PageNavHeader } from '../../../components/page';
import { BOTS_PATH, DIRECT_PATH } from '../../paths';
import { isNativePlatform } from '../../../utils/capacitor';
import { useBotPresets } from '../../../features/bots/catalog';
type SegmentProps = {
active: boolean;
@ -44,9 +45,11 @@ export function DirectStreamHeader() {
const { t } = useTranslation();
const navigate = useNavigate();
const comingSoon = t('Direct.segment_coming_soon');
const bots = useBotPresets();
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false });
const showBotsSegment = bots.length > 0 || !!botsMatch;
const navOpts = { replace: isNativePlatform() };
@ -76,11 +79,13 @@ export function DirectStreamHeader() {
/>
)}
</TooltipProvider>
<Segment
active={!!botsMatch}
label={t('Direct.segment_bots')}
onClick={() => navigate(BOTS_PATH, navOpts)}
/>
{showBotsSegment && (
<Segment
active={!!botsMatch}
label={t('Direct.segment_bots')}
onClick={() => navigate(BOTS_PATH, navOpts)}
/>
)}
</Box>
</PageNavHeader>
);

View file

@ -1,13 +1,18 @@
import { useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { MatrixEvent, RoomStateEvent } from 'matrix-js-sdk';
import { useEffect, useMemo, useState } from 'react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useDirects, useOrphanRooms } from '../../../state/hooks/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useBotPresets } from '../../../features/bots/catalog';
import { isBridgeStateEvent, isCatalogBotControlRoom } from '../../../features/bots/room';
// After P3c the Direct tab is universal: every joined non-space «orphan»
// room renders here, regardless of `m.direct`. Implementation =
// room renders here, regardless of `m.direct`, except curated bot control DMs.
// Those belong to the Robots tab, while bridged Telegram portal rooms remain
// normal Direct candidates. Implementation =
// `useOrphanRooms useDirects`:
//
// - `useOrphanRooms` = `isRoom && !mDirects.has && !roomToParents.has` →
@ -32,21 +37,54 @@ export const useDirectRooms = (): string[] => {
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
const directs = useDirects(mx, allRoomsAtom, mDirects);
return useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
orphanRooms.forEach((id) => {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
const bots = useBotPresets();
const directCandidates = useMemo(
() => new Set([...orphanRooms, ...directs]),
[orphanRooms, directs]
);
const [, setRoomStateTick] = useState(0);
useEffect(() => {
const bumpIfCandidate = (roomId: string | undefined) => {
if (roomId && directCandidates.has(roomId)) {
setRoomStateTick((tick) => tick + 1);
}
});
directs.forEach((id) => {
if (!seen.has(id)) {
seen.add(id);
out.push(id);
}
});
return out;
}, [orphanRooms, directs]);
};
const onMembers = (ev: MatrixEvent) => {
bumpIfCandidate(ev.getRoomId());
};
const onStateEvent = (ev: MatrixEvent) => {
if (isBridgeStateEvent(ev)) bumpIfCandidate(ev.getRoomId());
};
mx.on(RoomStateEvent.Members, onMembers);
mx.on(RoomStateEvent.Events, onStateEvent);
return () => {
mx.removeListener(RoomStateEvent.Members, onMembers);
mx.removeListener(RoomStateEvent.Events, onStateEvent);
};
}, [mx, directCandidates]);
const seen = new Set<string>();
const out: string[] = [];
const isVisibleDirect = (id: string): boolean => {
const room = mx.getRoom(id);
return !room || !isCatalogBotControlRoom(mx, room, bots);
};
orphanRooms.forEach((id) => {
if (!seen.has(id) && isVisibleDirect(id)) {
seen.add(id);
out.push(id);
}
});
directs.forEach((id) => {
if (!seen.has(id) && isVisibleDirect(id)) {
seen.add(id);
out.push(id);
}
});
return out;
};

View file

@ -1,5 +1,6 @@
import { generatePath, Path } from 'react-router-dom';
import {
BOTS_BOT_PATH,
BOTS_PATH,
DIRECT_CREATE_PATH,
DIRECT_PATH,
@ -161,3 +162,5 @@ export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
export const getBotsPath = (): string => BOTS_PATH;
export const getBotPath = (botId: string): string =>
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });

View file

@ -86,6 +86,7 @@ export const USER_LINK_HOST = 'vojo.chat';
export const USER_LINK_PATH = '/u/:userIdOrLocalPart';
export const BOTS_PATH = '/bots/';
export const BOTS_BOT_PATH = '/bots/:botId/';
export const _NOTIFICATIONS_PATH = 'notifications/';
export const _INVITES_PATH = 'invites/';

View file

@ -1,6 +1,14 @@
import { matchPath } from 'react-router-dom';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
import {
BOTS_PATH,
DIRECT_PATH,
EXPLORE_PATH,
HOME_PATH,
INBOX_PATH,
SPACE_PATH,
} from '../pages/paths';
import {
getBotsPath,
getDirectPath,
getExplorePath,
getHomePath,
@ -22,6 +30,7 @@ export const getRouteSectionParent = (pathname: string): string | null => {
if (under(HOME_PATH)) return atRoot(HOME_PATH) ? null : getHomePath();
if (under(DIRECT_PATH)) return atRoot(DIRECT_PATH) ? null : getDirectPath();
if (under(BOTS_PATH)) return atRoot(BOTS_PATH) ? null : getBotsPath();
const spaceMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: false }, pathname);
const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias;