/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { JSHandle, Page } from "@playwright/test"; import { PageFunctionOn } from "playwright-core/types/structs"; import { Network } from "./network"; import type { IContent, ICreateRoomOpts, ISendEventResponse, MatrixClient, Room, MatrixEvent, ReceiptType, IRoomDirectoryOptions, KnockRoomOpts, Visibility, UploadOpts, Upload, StateEvents, TimelineEvents, } from "matrix-js-sdk/src/matrix"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; import { Credentials } from "../plugins/homeserver"; export class Client { public network: Network; protected client: JSHandle; protected getClientHandle(): Promise> { return this.page.evaluateHandle(() => window.mxMatrixClientPeg.get()); } public async prepareClient(): Promise> { if (!this.client) { this.client = await this.getClientHandle(); } return this.client; } public constructor(protected readonly page: Page) { page.on("framenavigated", async () => { this.client = null; }); this.network = new Network(page, this); } public evaluate( pageFunction: PageFunctionOn, arg: Arg, ): Promise; public evaluate( pageFunction: PageFunctionOn, arg?: any, ): Promise; public async evaluate(fn: (client: MatrixClient) => T, arg?: any): Promise { await this.prepareClient(); return this.client.evaluate(fn, arg); } public evaluateHandle( pageFunction: PageFunctionOn, arg: Arg, ): Promise>; public evaluateHandle( pageFunction: PageFunctionOn, arg?: any, ): Promise>; public async evaluateHandle(fn: (client: MatrixClient) => T, arg?: any): Promise> { await this.prepareClient(); return this.client.evaluateHandle(fn, arg); } /** * @param roomId ID of the room to send the event into * @param threadId ID of the thread to send into or null for main timeline * @param eventType type of event to send * @param content the event content to send */ public async sendEvent( roomId: string, threadId: string | null, eventType: string, content: IContent, ): Promise { const client = await this.prepareClient(); return client.evaluate( async (client, { roomId, threadId, eventType, content }) => { return client.sendEvent( roomId, threadId, eventType as keyof TimelineEvents, content as TimelineEvents[keyof TimelineEvents], ); }, { roomId, threadId, eventType, content }, ); } /** * Send a message into a room * @param roomId ID of the room to send the message into * @param content the event content to send * @param threadId optional thread id */ public async sendMessage( roomId: string, content: IContent | string, threadId: string | null = null, ): Promise { if (typeof content === "string") { content = { msgtype: "m.text", body: content, }; } const client = await this.prepareClient(); return client.evaluate( (client, { roomId, content, threadId }) => { return client.sendMessage(roomId, threadId, content as RoomMessageEventContent); }, { roomId, content, threadId, }, ); } public async redactEvent(roomId: string, eventId: string, reason?: string): Promise { return this.evaluate( async (client, { roomId, eventId, reason }) => { return client.redactEvent(roomId, eventId, reason); }, { roomId, eventId, reason }, ); } /** * Create a room with given options. * @param options the options to apply when creating the room * @return the ID of the newly created room */ public async createRoom(options: ICreateRoomOpts): Promise { const client = await this.prepareClient(); return await client.evaluate(async (cli, options) => { const resp = await cli.createRoom(options); const roomId = resp.room_id; if (!cli.getRoom(roomId)) { await new Promise((resolve) => { const onRoom = (room: Room) => { if (room.roomId === roomId) { cli.off(window.matrixcs.ClientEvent.Room, onRoom); resolve(); } }; cli.on(window.matrixcs.ClientEvent.Room, onRoom); }); } return roomId; }, options); } /** * Create a space with given options. * @param options the options to apply when creating the space * @return the ID of the newly created space (room) */ public async createSpace(options: ICreateRoomOpts): Promise { return this.createRoom({ ...options, creation_content: { ...options.creation_content, type: "m.space", }, }); } /** * Joins the given room by alias or ID * @param roomIdOrAlias the id or alias of the room to join */ public async joinRoom(roomIdOrAlias: string): Promise { const client = await this.prepareClient(); await client.evaluate(async (client, roomIdOrAlias) => { return await client.joinRoom(roomIdOrAlias); }, roomIdOrAlias); } /** * Make this bot join a room by name * @param roomName Name of the room to join */ public async joinRoomByName(roomName: string): Promise { const client = await this.prepareClient(); return client.evaluate( async (client, { roomName }) => { const room = client.getRooms().find((r) => r.getDefaultRoomName(client.getUserId()) === roomName); if (room) { await client.joinRoom(room.roomId); return room.roomId; } throw new Error(`Bot room join failed. Cannot find room '${roomName}'`); }, { roomName, }, ); } /** * Wait until next sync from this client */ public async waitForNextSync(): Promise { await this.page.waitForResponse(async (response) => { const accessToken = await this.evaluate((client) => client.getAccessToken()); const authHeader = await response.request().headerValue("authorization"); return response.url().includes("/sync") && authHeader.includes(accessToken); }); } /** * Invites the given user to the given room. * @param roomId the id of the room to invite to * @param userId the id of the user to invite */ public async inviteUser(roomId: string, userId: string): Promise { const client = await this.prepareClient(); await client.evaluate((client, { roomId, userId }) => client.invite(roomId, userId), { roomId, userId, }); } /** * Knocks the given room. * @param roomId the id of the room to knock * @param opts the options to use when knocking */ public async knockRoom(roomId: string, opts?: KnockRoomOpts): Promise { const client = await this.prepareClient(); await client.evaluate((client, { roomId, opts }) => client.knockRoom(roomId, opts), { roomId, opts }); } /** * Kicks the given user from the given room. * @param roomId the id of the room to kick from * @param userId the id of the user to kick * @param reason the reason for the kick */ public async kick(roomId: string, userId: string, reason?: string): Promise { const client = await this.prepareClient(); await client.evaluate((client, { roomId, userId, reason }) => client.kick(roomId, userId, reason), { roomId, userId, reason, }); } /** * Bans the given user from the given room. * @param roomId the id of the room to ban from * @param userId the id of the user to ban * @param reason the reason for the ban */ public async ban(roomId: string, userId: string, reason?: string): Promise { const client = await this.prepareClient(); await client.evaluate((client, { roomId, userId, reason }) => client.ban(roomId, userId, reason), { roomId, userId, reason, }); } /** * Unban the given user from the given room. * @param roomId the id of the room to unban from * @param userId the id of the user to unban */ public async unban(roomId: string, userId: string): Promise { const client = await this.prepareClient(); await client.evaluate((client, { roomId, userId }) => client.unban(roomId, userId), { roomId, userId }); } /** * Wait for the client to have specific membership of a given room * * This is often useful after joining a room, when we need to wait for the sync loop to catch up. * * Times out with an error after 1 second. * * @param roomId - ID of the room to check * @param membership - required membership. */ public async awaitRoomMembership(roomId: string, membership: string = "join") { await this.evaluate( (cli: MatrixClient, { roomId, membership }) => { const isReady = () => { // Fetch the room on each check, because we get a different instance before and after the join arrives. const room = cli.getRoom(roomId); const myMembership = room?.getMyMembership(); // @ts-ignore access to private field "logger" cli.logger.info(`waiting for room ${roomId}: membership now ${myMembership}`); return myMembership === membership; }; if (isReady()) return; const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 1000)).then(() => { const room = cli.getRoom(roomId); const myMembership = room?.getMyMembership(); throw new Error( `Timeout waiting for room ${roomId} membership (now '${myMembership}', wanted '${membership}')`, ); }); const readyPromise = new Promise((resolve) => { async function onEvent() { if (isReady()) { cli.removeListener(window.matrixcs.ClientEvent.Event, onEvent); resolve(); } } cli.on(window.matrixcs.ClientEvent.Event, onEvent); }); return Promise.race([timeoutPromise, readyPromise]); }, { roomId, membership }, ); } /** * @param {MatrixEvent} event * @param {ReceiptType} receiptType * @param {boolean} unthreaded */ public async sendReadReceipt( event: JSHandle, receiptType?: ReceiptType, unthreaded?: boolean, ): Promise<{}> { const client = await this.prepareClient(); return client.evaluate( (client, { event, receiptType, unthreaded }) => { return client.sendReadReceipt(event, receiptType, unthreaded); }, { event, receiptType, unthreaded }, ); } public async publicRooms(options?: IRoomDirectoryOptions): ReturnType { const client = await this.prepareClient(); return client.evaluate((client, options) => { return client.publicRooms(options); }, options); } /** * @param {string} name * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ public async setDisplayName(name: string): Promise<{}> { const client = await this.prepareClient(); return client.evaluate(async (cli: MatrixClient, name) => cli.setDisplayName(name), name); } /** * @param {string} url * @param {module:client.callback} callback Optional. * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ public async setAvatarUrl(url: string): Promise<{}> { const client = await this.prepareClient(); return client.evaluate(async (cli: MatrixClient, url) => cli.setAvatarUrl(url), url); } /** * Upload a file to the media repository on the homeserver. * * @param {object} file The object to upload. On a browser, something that * can be sent to XMLHttpRequest.send (typically a File). Under node.js, * a Buffer, String or ReadStream. */ public async uploadContent(file: Buffer, opts?: UploadOpts): Promise> { const client = await this.prepareClient(); return client.evaluate( async (cli: MatrixClient, { file, opts }) => cli.uploadContent(new Uint8Array(file), opts), { file: [...file], opts, }, ); } /** * Bootstraps cross-signing. */ public async bootstrapCrossSigning(credentials: Credentials): Promise { const client = await this.prepareClient(); return bootstrapCrossSigningForClient(client, credentials); } /** * Sets account data for the user. * @param type The type of account data to set * @param content The content to set */ public async setAccountData(type: string, content: IContent): Promise { const client = await this.prepareClient(); return client.evaluate( async (client, { type, content }) => { await client.setAccountData(type, content); }, { type, content }, ); } /** * Sends a state event into the room. * @param roomId ID of the room to send the event into * @param eventType type of event to send * @param content the event content to send * @param stateKey the state key to use */ public async sendStateEvent( roomId: string, eventType: string, content: IContent, stateKey?: string, ): Promise { const client = await this.prepareClient(); return client.evaluate( async (client, { roomId, eventType, content, stateKey }) => { return client.sendStateEvent(roomId, eventType as keyof StateEvents, content, stateKey); }, { roomId, eventType, content, stateKey }, ); } /** * Leaves the given room. * @param roomId ID of the room to leave */ public async leave(roomId: string): Promise { const client = await this.prepareClient(); return client.evaluate(async (client, roomId) => { await client.leave(roomId); }, roomId); } /** * Sets the directory visibility for a room. * @param roomId ID of the room to set the directory visibility for * @param visibility The new visibility for the room */ public async setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise { const client = await this.prepareClient(); return client.evaluate( async (client, { roomId, visibility }) => { await client.setRoomDirectoryVisibility(roomId, visibility); }, { roomId, visibility }, ); } } /** Call `CryptoApi.bootstrapCrossSigning` on the given Matrix client, using the given credentials to authenticate * the UIA request. */ export function bootstrapCrossSigningForClient( client: JSHandle, credentials: Credentials, resetKeys: boolean = false, ) { return client.evaluate( async (client, { credentials, resetKeys }) => { await client.getCrypto().bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (func) => { await func({ type: "m.login.password", identifier: { type: "m.id.user", user: credentials.userId, }, password: credentials.password, }); }, setupNewCrossSigning: resetKeys, }); }, { credentials, resetKeys }, ); }