112 lines
5.3 KiB
TypeScript
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));
|