diff --git a/config.json b/config.json
index adc16608..a4c430aa 100644
--- a/config.json
+++ b/config.json
@@ -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",
diff --git a/public/locales/en.json b/public/locales/en.json
index 016491f7..f99fb1c2 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -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."
}
}
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 15239532..35d197ae 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -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."
}
}
diff --git a/src/app/features/bots/BotCard.tsx b/src/app/features/bots/BotCard.tsx
new file mode 100644
index 00000000..3294080a
--- /dev/null
+++ b/src/app/features/bots/BotCard.tsx
@@ -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 (
+
+
+
+
+
+
+ {initial}
+
+
+
+
+
+ {preset.name}
+
+
+
+ {preset.mxid}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/bots/catalog.ts b/src/app/features/bots/catalog.ts
new file mode 100644
index 00000000..c11f2850
--- /dev/null
+++ b/src/app/features/bots/catalog.ts
@@ -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/`. 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();
+ const seenMxids = new Set();
+ 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);
diff --git a/src/app/features/bots/room.ts b/src/app/features/bots/room.ts
new file mode 100644
index 00000000..650f55c4
--- /dev/null
+++ b/src/app/features/bots/room.ts
@@ -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([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));
diff --git a/src/app/features/bots/useBotRoom.ts b/src/app/features/bots/useBotRoom.ts
new file mode 100644
index 00000000..0c5595a5
--- /dev/null
+++ b/src/app/features/bots/useBotRoom.ts
@@ -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;
+
+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 = {
+ 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(() =>
+ preset ? computeBotRoomState(mx, preset) : { kind: 'none' }
+ );
+
+ useEffect(() => {
+ if (!preset) {
+ setState({ kind: 'none' });
+ return undefined;
+ }
+
+ const interestingRoomIds = new Set();
+ 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;
+};
diff --git a/src/app/features/room/RoomViewHeaderDm.tsx b/src/app/features/room/RoomViewHeaderDm.tsx
index 54dbaa59..8db4aeea 100644
--- a/src/app/features/room/RoomViewHeaderDm.tsx
+++ b/src/app/features/room/RoomViewHeaderDm.tsx
@@ -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;
diff --git a/src/app/hooks/router/useDirectSelected.ts b/src/app/hooks/router/useDirectSelected.ts
index adb2851e..1e1a9a81 100644
--- a/src/app/hooks/router/useDirectSelected.ts
+++ b/src/app/hooks/router/useDirectSelected.ts
@@ -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 => {
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 2abe6ca2..3c3371d6 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -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(null);
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 101d6b12..c525e810 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -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)
}
/>
- {/* 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/. */}
{mobile ? null : } />}
+ } />
} />
}
+ title={t('Bots.unknown_title')}
+ description={t('Bots.unknown_description')}
+ />
+ );
+ }
+
+ if (botRoomState.kind === 'none') {
+ return ;
+ }
+
+ if (botRoomState.kind === 'self-invite') {
+ return ;
+ }
+
+ if (botRoomState.kind === 'bot-invite') {
+ return ;
+ }
+
+ if (botRoomState.kind === 'bot-kicked') {
+ return ;
+ }
+
+ if (botRoomState.kind === 'unsafe-membership') {
+ return ;
+ }
+
+ const { room } = botRoomState;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/pages/client/bots/BotInvitePending.tsx b/src/app/pages/client/bots/BotInvitePending.tsx
new file mode 100644
index 00000000..778c9fe7
--- /dev/null
+++ b/src/app/pages/client/bots/BotInvitePending.tsx
@@ -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(
+ 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(
+ 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 (
+ }
+ 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 && (
+
+
+
+ {error.message || (acceptError ? t('Bots.accept_error') : t('Bots.reset_error'))}
+
+
+ )}
+ {selfInvite && (
+
+ )}
+ {selfInvite && (
+
+ )}
+
+
+ );
+}
diff --git a/src/app/pages/client/bots/BotKicked.tsx b/src/app/pages/client/bots/BotKicked.tsx
new file mode 100644
index 00000000..119be372
--- /dev/null
+++ b/src/app/pages/client/bots/BotKicked.tsx
@@ -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(
+ 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 (
+ }
+ title={t('Bots.kicked_title', { name: preset.name })}
+ description={t('Bots.kicked_description', { mxid: preset.mxid })}
+ >
+ {error && (
+
+
+
+ {error.message || t('Bots.reinvite_error')}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/pages/client/bots/BotNotConnected.tsx b/src/app/pages/client/bots/BotNotConnected.tsx
new file mode 100644
index 00000000..9437e43b
--- /dev/null
+++ b/src/app/pages/client/bots/BotNotConnected.tsx
@@ -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;
+
+const DIRECT_ACCOUNT_DATA = EventType.Direct;
+
+export function BotNotConnected({ preset }: BotNotConnectedProps) {
+ const { t } = useTranslation();
+ const mx = useMatrixClient();
+
+ const [connectState, connect] = useAsyncCallback(
+ 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 .
+ // - 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 (
+ }
+ title={t('Bots.not_connected_title', { name: preset.name })}
+ description={t('Bots.not_connected_description', { mxid: preset.mxid })}
+ >
+ {error && (
+
+
+
+ {error.message || t('Bots.connect_error')}
+
+
+ )}
+ }
+ >
+ {t('Bots.connect')}
+
+
+ );
+}
diff --git a/src/app/pages/client/bots/BotOpenChatAction.tsx b/src/app/pages/client/bots/BotOpenChatAction.tsx
new file mode 100644
index 00000000..fbf01291
--- /dev/null
+++ b/src/app/pages/client/bots/BotOpenChatAction.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/pages/client/bots/BotRoomProvider.tsx b/src/app/pages/client/bots/BotRoomProvider.tsx
new file mode 100644
index 00000000..a0635be2
--- /dev/null
+++ b/src/app/pages/client/bots/BotRoomProvider.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/src/app/pages/client/bots/BotStatePage.tsx b/src/app/pages/client/bots/BotStatePage.tsx
new file mode 100644
index 00000000..df6dc66b
--- /dev/null
+++ b/src/app/pages/client/bots/BotStatePage.tsx
@@ -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 (
+
+ {screenSize === ScreenSize.Mobile && (
+
+
+
+ {(onBack) => (
+
+
+
+ )}
+
+
+ {title}
+
+
+
+ )}
+
+
+
+
+
+ }
+ title={title}
+ subTitle={description}
+ >
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/pages/client/bots/BotUnsafeRoom.tsx b/src/app/pages/client/bots/BotUnsafeRoom.tsx
new file mode 100644
index 00000000..3d2e051b
--- /dev/null
+++ b/src/app/pages/client/bots/BotUnsafeRoom.tsx
@@ -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 (
+ }
+ title={t('Bots.unsafe_title')}
+ description={t('Bots.unsafe_description')}
+ >
+
+
+ );
+}
diff --git a/src/app/pages/client/bots/Bots.tsx b/src/app/pages/client/bots/Bots.tsx
index 14c83a2e..1a967910 100644
--- a/src/app/pages/client/bots/Bots.tsx
+++ b/src/app/pages/client/bots/Bots.tsx
@@ -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 ;
+}
export function Bots() {
- const { t } = useTranslation();
- useNavToActivePathMapper('bots');
+ const bots = useBotPresets();
return (
-
- }
- title={
-
- {t('Bots.title')}
-
- }
- />
-
+
+
+ {bots.map((preset) => (
+
+ ))}
+
+
);
}
diff --git a/src/app/pages/client/bots/index.ts b/src/app/pages/client/bots/index.ts
index 34877baa..d38a5ec3 100644
--- a/src/app/pages/client/bots/index.ts
+++ b/src/app/pages/client/bots/index.ts
@@ -1 +1,2 @@
export * from './Bots';
+export * from './BotExperienceHost';
diff --git a/src/app/pages/client/direct/DirectStreamHeader.tsx b/src/app/pages/client/direct/DirectStreamHeader.tsx
index 10728d43..027f0a21 100644
--- a/src/app/pages/client/direct/DirectStreamHeader.tsx
+++ b/src/app/pages/client/direct/DirectStreamHeader.tsx
@@ -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() {
/>
)}
- navigate(BOTS_PATH, navOpts)}
- />
+ {showBotsSegment && (
+ navigate(BOTS_PATH, navOpts)}
+ />
+ )}
);
diff --git a/src/app/pages/client/direct/useDirectRooms.ts b/src/app/pages/client/direct/useDirectRooms.ts
index e0ab97a7..69481bd2 100644
--- a/src/app/pages/client/direct/useDirectRooms.ts
+++ b/src/app/pages/client/direct/useDirectRooms.ts
@@ -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();
- 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();
+ 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;
};
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 27136b12..514dbb9b 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -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) });
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index 3be9e955..532a6cd6 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -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/';
diff --git a/src/app/utils/routeParent.ts b/src/app/utils/routeParent.ts
index c5bf77ab..38fddb6f 100644
--- a/src/app/utils/routeParent.ts
+++ b/src/app/utils/routeParent.ts
@@ -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;