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

112 lines
5.3 KiB
TypeScript

import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { Membership, StateEvent } from '../../../types/matrix/room';
import { getStateEvents, 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;
};
// A room is a "portal" (Telegram/Signal/etc. mirror chat) if it carries an
// m.bridge / uk.half-shot.bridge state event whose state_key is NOT our
// control bot. Bridge BOTS write m.bridge into their OWN control DMs too,
// keyed by the bot mxid — those are management rooms, not portals, and the
// Robots tab must own them. The plain `isBridgedRoom` helper in utils/room
// can't make this distinction (it has no concept of "our bot"); it stays
// correct for DmCallButton's call-button suppression but is too coarse for
// the bot ownership question. Single source of truth — both useBotRoom's
// classifyRoom and BotWidgetDriver.isSafeBotWidgetRoom delegate here.
export const isPortalRoomForOtherBridge = (room: Room, controlBotMxid: string): boolean => {
const events = [
...getStateEvents(room, StateEvent.RoomBridge),
...getStateEvents(room, StateEvent.RoomBridgeUnstable),
];
return events.some((ev) => ev.getStateKey() !== controlBotMxid);
};
export const isBotControlRoom = (mx: MatrixClient, room: Room, preset: BotPreset): boolean => {
if (!isRoom(room) || isPortalRoomForOtherBridge(room, preset.mxid)) 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 ||
botMember.membership === undefined ||
!ACTIVE_MEMBERSHIPS.has(botMember.membership)
) {
return false;
}
// A control DM is exactly the two of us, nobody else. Decide that from the room
// SUMMARY counts — NOT room.getMembers(): under Matrix lazy member-loading
// getMembers() returns only the members synced so far (heroes + recent senders
// + me), so a larger group whose loaded subset happens to be {me, bot} gets
// misclassified as the control DM. That bites a chat which began as a 1:1 with
// the bot (and still carries the sticky m.direct tag) but later gained people:
// it would wrongly drop out of the Direct tab (useDirectRooms excludes control
// DMs) and resurface only as a bot DM. getJoinedMemberCount()/
// getInvitedMemberCount() come from the sync summary and stay accurate without
// loading every member — the same source useAutoDirectSync trusts for its 1:1 test.
return room.getJoinedMemberCount() + room.getInvitedMemberCount() === 2;
};
export const isCatalogBotControlRoom = (
mx: MatrixClient,
room: Room,
presets: readonly BotPreset[]
): boolean => presets.some((preset) => isBotControlRoom(mx, room, preset));
// How long a bridge-ghost invite stays hidden from the Direct invite list
// while we expect the bridge's double puppet to auto-accept it. Long enough
// to cover a busy initial chat sync; short enough that a broken double
// puppet leaves the user a manual Accept button instead of a black hole.
export const BRIDGE_GHOST_INVITE_GRACE_MS = 2 * 60 * 1000;
// «Is this pending invite from a bridge ghost puppet of a catalog bot?»
// mautrix ghost mxids follow the `<network>_<remote id>` localpart
// convention and the catalog bot ids ARE the network slugs (telegram /
// whatsapp / discord — config.json is the trusted source), so the prefix
// match needs no client-side knowledge beyond the operator config. Invite
// rooms carry only stripped state — m.bridge is NOT in it — which is why
// this matches on the inviter mxid instead of `isBridgedRoom`.
//
// Used to suppress the invite-row flicker during bridge sync: the bridge's
// double puppet auto-accepts portal invites within seconds, and rendering
// Accept/Decline for something that resolves itself reads as UI noise. Only
// FRESH invites (younger than the grace window) are hidden — if auto-accept
// is broken, the invite resurfaces with manual controls.
export const isFreshBridgeGhostInvite = (
mx: MatrixClient,
room: Room,
presets: readonly BotPreset[]
): boolean => {
const myUserId = mx.getUserId();
if (!myUserId) return false;
const memberEvent = room.getMember(myUserId)?.events.member;
const inviter = memberEvent?.getSender();
if (!inviter) return false;
const ts = memberEvent?.getTs() ?? 0;
if (Date.now() - ts > BRIDGE_GHOST_INVITE_GRACE_MS) return false;
return presets.some((preset) => {
const botServer = preset.mxid.slice(preset.mxid.indexOf(':'));
return inviter.startsWith(`@${preset.id}_`) && inviter.endsWith(botServer);
});
};
// 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));