import { MatrixClient } from "./MatrixClient";
import { EncryptionAlgorithm } from "./models/Crypto";
import { LogService } from "./logging/LogService";
/**
* Handles DM (direct messages) matching between users. Note that bots which
* existed prior to this might not have DM rooms populated correctly - the
* account data can be populated externally and that will be reflected here.
*
* Note that DM status is persisted across all access tokens for a user and
* is not persisted with the regular stores. The DM map is instead tracked
* on the homeserver as account data and thus survives the bot's own storage
* being wiped.
* @category Utilities
*/
export class DMs {
private cached = new Map<string, string[]>();
private ready: Promise<void>;
/**
* Creates a new DM map.
* @param {MatrixClient} client The client the DM map is for.
*/
public constructor(private client: MatrixClient) {
this.client.on("account_data", (ev) => {
if (ev['type'] !== 'm.direct') return;
// noinspection JSIgnoredPromiseFromCall
this.updateFromAccountData();
});
this.client.on("room.invite", (rid, ev) => this.handleInvite(rid, ev));
}
private async updateFromAccountData() {
// Don't trust the sync update
let map = {};
try {
map = await this.client.getAccountData("m.direct");
} catch (e) {
if (e.body?.errcode !== "M_NOT_FOUND" && e.statusCode !== 404) {
LogService.warn("DMs", "Error getting m.direct account data: ", e);
}
}
this.cached = new Map<string, string[]>();
for (const [userId, roomIds] of Object.entries(map)) {
this.cached.set(userId, roomIds as string[]);
}
}
private async handleInvite(roomId: string, ev: any) {
if (ev['content']?.['is_direct'] === true) {
const userId = ev['sender'];
if (!this.cached.has(userId)) this.cached.set(userId, []);
this.cached.set(userId, [roomId, ...this.cached.get(userId)]);
await this.persistCache();
}
}
private async persistCache() {
const obj: Record<string, string[]> = {};
for (const [uid, rids] of this.cached.entries()) {
obj[uid] = rids;
}
await this.client.setAccountData("m.direct", obj);
}
private async fixDms(userId: string) {
const currentRooms = this.cached.get(userId);
if (!currentRooms) return;
const toKeep: string[] = [];
for (const roomId of currentRooms) {
try {
const members = await this.client.getAllRoomMembers(roomId);
const joined = members.filter(m => m.effectiveMembership === "join" || m.effectiveMembership === "invite");
if (joined.some(m => m.membershipFor === userId)) {
toKeep.push(roomId);
}
} catch (e) {
LogService.warn("DMs", `Unable to check ${roomId} for room members - assuming invalid DM`);
}
}
if (toKeep.length === currentRooms.length) return; // no change
if (toKeep.length > 0) {
this.cached.set(userId, toKeep);
} else {
this.cached.delete(userId);
}
await this.persistCache();
}
/**
* Forces an update of the DM cache.
* @returns {Promise<void>} Resolves when complete.
*/
public async update(): Promise<void> {
await this.ready; // finish the existing call if present
this.ready = this.updateFromAccountData();
return this.ready;
}
/**
* Gets or creates a DM with a given user. If a DM needs to be created, it will
* be created as an encrypted DM (if both the MatrixClient and target user support
* crypto). Otherwise, the createFn can be used to override the call. Note that
* when creating a DM room the room should have `is_direct: true` set.
* @param {string} userId The user ID to get/create a DM for.
* @param {Function} createFn Optional function to use to create the room. Resolves
* to the created room ID.
* @returns {Promise<string>} Resolves to the DM room ID.
*/
public async getOrCreateDm(userId: string, createFn?: (targetUserId: string) => Promise<string>): Promise<string> {
await this.ready;
await this.fixDms(userId);
const rooms = this.cached.get(userId);
if (rooms?.length) return rooms[0];
let roomId: string;
if (createFn) {
roomId = await createFn(userId);
} else {
let hasKeys = false;
if (!!this.client.crypto) {
const keys = await this.client.getUserDevices([userId]);
const userKeys = keys?.device_keys?.[userId] ?? {};
hasKeys = Object.values(userKeys).filter(device => Object.values(device).length > 0).length > 0;
}
roomId = await this.client.createRoom({
invite: [userId],
is_direct: true,
preset: "trusted_private_chat",
initial_state: hasKeys ? [{
type: "m.room.encryption",
state_key: "",
content: { algorithm: EncryptionAlgorithm.MegolmV1AesSha2 },
}] : [],
});
}
if (!this.cached.has(userId)) this.cached.set(userId, []);
this.cached.set(userId, [roomId, ...this.cached.get(userId)]);
await this.persistCache();
return roomId;
}
/**
* Determines if a given room is a DM according to the cache.
* @param {string} roomId The room ID.
* @returns {boolean} True if the room ID is a cached DM room ID.
*/
public isDm(roomId: string): boolean {
for (const val of this.cached.values()) {
if (val.includes(roomId)) {
return true;
}
}
return false;
}
}
Source