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([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 `_` 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));