vojo/src/app/features/bots/useBotRoom.ts

216 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { isBotControlRoom, isBridgeStateEvent, isPortalRoomForOtherBridge } 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 (isPortalRoomForOtherBridge(room, preset.mxid)) 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;
};