diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index ae2a589cc88..251dce90f00 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -1,4 +1,5 @@ import { resolveRuntimeMatrixClient } from "../client-bootstrap.js"; +import { resolveMatrixRoomId } from "../send.js"; import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; async function ensureActionClientReadiness( @@ -65,3 +66,14 @@ export async function withStartedActionClient( ): Promise { return await withResolvedActionClient({ ...opts, readiness: "started" }, run, "persist"); } + +export async function withResolvedRoomAction( + roomId: string, + opts: MatrixActionClientOpts, + run: (client: MatrixActionClient["client"], resolvedRoom: string) => Promise, +): Promise { + return await withResolvedActionClient(opts, async (client) => { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await run(client, resolvedRoom); + }); +} diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index 3c8716e2532..f4bb12bf4a8 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -1,7 +1,7 @@ import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js"; import { isPollEventType } from "../poll-types.js"; -import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; +import { sendMessageMatrix } from "../send.js"; +import { withResolvedActionClient, withResolvedRoomAction } from "./client.js"; import { resolveMatrixActionLimit } from "./limits.js"; import { summarizeMatrixRawEvent } from "./summary.js"; import { @@ -43,8 +43,7 @@ export async function editMatrixMessage( if (!trimmed) { throw new Error("Matrix edit requires content"); } - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const newContent = { msgtype: MsgType.Text, body: trimmed, @@ -68,8 +67,7 @@ export async function deleteMatrixMessage( messageId: string, opts: MatrixActionClientOpts & { reason?: string } = {}, ) { - await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); + await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { await client.redactEvent(resolvedRoom, messageId, opts.reason); }); } @@ -86,8 +84,7 @@ export async function readMatrixMessages( nextBatch?: string | null; prevBatch?: string | null; }> { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const limit = resolveMatrixActionLimit(opts.limit, 20); const token = opts.before?.trim() || opts.after?.trim() || undefined; const dir = opts.after ? "f" : "b"; diff --git a/extensions/matrix/src/matrix/actions/pins.ts b/extensions/matrix/src/matrix/actions/pins.ts index ca5ca4a8524..bcc3a2b287e 100644 --- a/extensions/matrix/src/matrix/actions/pins.ts +++ b/extensions/matrix/src/matrix/actions/pins.ts @@ -1,34 +1,19 @@ -import { resolveMatrixRoomId } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; +import { withResolvedRoomAction } from "./client.js"; import { fetchEventSummary, readPinnedEvents } from "./summary.js"; import { EventType, type MatrixActionClientOpts, - type MatrixActionClient, type MatrixMessageSummary, type RoomPinnedEventsEventContent, } from "./types.js"; -type ActionClient = MatrixActionClient["client"]; - -async function withResolvedPinRoom( - roomId: string, - opts: MatrixActionClientOpts, - run: (client: ActionClient, resolvedRoom: string) => Promise, -): Promise { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - return await run(client, resolvedRoom); - }); -} - async function updateMatrixPins( roomId: string, messageId: string, opts: MatrixActionClientOpts, update: (current: string[]) => string[], ): Promise<{ pinned: string[] }> { - return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => { + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const current = await readPinnedEvents(client, resolvedRoom); const next = update(current); const payload: RoomPinnedEventsEventContent = { pinned: next }; @@ -61,7 +46,7 @@ export async function listMatrixPins( roomId: string, opts: MatrixActionClientOpts = {}, ): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> { - return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => { + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const pinned = await readPinnedEvents(client, resolvedRoom); const events = ( await Promise.all( diff --git a/extensions/matrix/src/matrix/actions/polls.test.ts b/extensions/matrix/src/matrix/actions/polls.test.ts new file mode 100644 index 00000000000..a06b9087387 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/polls.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; +import { voteMatrixPoll } from "./polls.js"; + +function createPollClient(pollContent?: Record) { + const getEvent = vi.fn(async () => ({ + type: "m.poll.start", + content: pollContent ?? { + "m.poll.start": { + question: { "m.text": "Favorite fruit?" }, + max_selections: 1, + answers: [ + { id: "apple", "m.text": "Apple" }, + { id: "berry", "m.text": "Berry" }, + ], + }, + }, + })); + const sendEvent = vi.fn(async () => "$vote1"); + + return { + client: { + getEvent, + sendEvent, + stop: vi.fn(), + } as unknown as MatrixClient, + getEvent, + sendEvent, + }; +} + +describe("matrix poll actions", () => { + it("votes by option index against the resolved room id", async () => { + const { client, getEvent, sendEvent } = createPollClient(); + + const result = await voteMatrixPoll("room:!room:example.org", "$poll", { + client, + optionIndex: 2, + }); + + expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll"); + expect(sendEvent).toHaveBeenCalledWith( + "!room:example.org", + "m.poll.response", + expect.objectContaining({ + "m.poll.response": { answers: ["berry"] }, + }), + ); + expect(result).toEqual({ + eventId: "$vote1", + roomId: "!room:example.org", + pollId: "$poll", + answerIds: ["berry"], + labels: ["Berry"], + maxSelections: 1, + }); + }); + + it("rejects option indexes that are outside the poll range", async () => { + const { client, sendEvent } = createPollClient(); + + await expect( + voteMatrixPoll("room:!room:example.org", "$poll", { + client, + optionIndex: 3, + }), + ).rejects.toThrow("out of range"); + + expect(sendEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/polls.ts b/extensions/matrix/src/matrix/actions/polls.ts index 346ad3b1d78..2106a9cb1b7 100644 --- a/extensions/matrix/src/matrix/actions/polls.ts +++ b/extensions/matrix/src/matrix/actions/polls.ts @@ -4,8 +4,7 @@ import { parsePollStart, type PollStartContent, } from "../poll-types.js"; -import { resolveMatrixRoomId } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; +import { withResolvedRoomAction } from "./client.js"; import type { MatrixActionClientOpts } from "./types.js"; function normalizeOptionIndexes(indexes: number[]): number[] { @@ -80,8 +79,7 @@ export async function voteMatrixPoll( optionIndexes?: number[]; } = {}, ) { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const pollEvent = await client.getEvent(resolvedRoom, pollId); const eventType = typeof pollEvent.type === "string" ? pollEvent.type : ""; if (!isPollStartType(eventType)) { diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts index 3be838198f9..6aa98dbf4d0 100644 --- a/extensions/matrix/src/matrix/actions/reactions.ts +++ b/extensions/matrix/src/matrix/actions/reactions.ts @@ -3,8 +3,7 @@ import { selectOwnMatrixReactionEventIds, summarizeMatrixReactionEvents, } from "../reaction-common.js"; -import { resolveMatrixRoomId } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; +import { withResolvedRoomAction } from "./client.js"; import { resolveMatrixActionLimit } from "./limits.js"; import { type MatrixActionClientOpts, @@ -32,8 +31,7 @@ export async function listMatrixReactions( messageId: string, opts: MatrixActionClientOpts & { limit?: number } = {}, ): Promise { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const limit = resolveMatrixActionLimit(opts.limit, 100); const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit); return summarizeMatrixReactionEvents(chunk); @@ -45,8 +43,7 @@ export async function removeMatrixReactions( messageId: string, opts: MatrixActionClientOpts & { emoji?: string } = {}, ): Promise<{ removed: number }> { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200); const userId = await client.getUserId(); if (!userId) { diff --git a/extensions/matrix/src/matrix/actions/room.test.ts b/extensions/matrix/src/matrix/actions/room.test.ts new file mode 100644 index 00000000000..e87f1fd6441 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/room.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; +import { getMatrixMemberInfo, getMatrixRoomInfo } from "./room.js"; + +function createRoomClient() { + const getRoomStateEvent = vi.fn(async (_roomId: string, eventType: string) => { + switch (eventType) { + case "m.room.name": + return { name: "Ops Room" }; + case "m.room.topic": + return { topic: "Incidents" }; + case "m.room.canonical_alias": + return { alias: "#ops:example.org" }; + default: + throw new Error(`unexpected state event ${eventType}`); + } + }); + const getJoinedRoomMembers = vi.fn(async () => [ + { user_id: "@alice:example.org" }, + { user_id: "@bot:example.org" }, + ]); + const getUserProfile = vi.fn(async () => ({ + displayname: "Alice", + avatar_url: "mxc://example.org/alice", + })); + + return { + client: { + getRoomStateEvent, + getJoinedRoomMembers, + getUserProfile, + stop: vi.fn(), + } as unknown as MatrixClient, + getRoomStateEvent, + getJoinedRoomMembers, + getUserProfile, + }; +} + +describe("matrix room actions", () => { + it("returns room details from the resolved Matrix room id", async () => { + const { client, getJoinedRoomMembers, getRoomStateEvent } = createRoomClient(); + + const result = await getMatrixRoomInfo("room:!ops:example.org", { client }); + + expect(getRoomStateEvent).toHaveBeenCalledWith("!ops:example.org", "m.room.name", ""); + expect(getJoinedRoomMembers).toHaveBeenCalledWith("!ops:example.org"); + expect(result).toEqual({ + roomId: "!ops:example.org", + name: "Ops Room", + topic: "Incidents", + canonicalAlias: "#ops:example.org", + altAliases: [], + memberCount: 2, + }); + }); + + it("resolves optional room ids when looking up member info", async () => { + const { client, getUserProfile } = createRoomClient(); + + const result = await getMatrixMemberInfo("@alice:example.org", { + client, + roomId: "room:!ops:example.org", + }); + + expect(getUserProfile).toHaveBeenCalledWith("@alice:example.org"); + expect(result).toEqual({ + userId: "@alice:example.org", + profile: { + displayName: "Alice", + avatarUrl: "mxc://example.org/alice", + }, + membership: null, + powerLevel: null, + displayName: "Alice", + roomId: "!ops:example.org", + }); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts index 8180a3dc253..87684252cbe 100644 --- a/extensions/matrix/src/matrix/actions/room.ts +++ b/extensions/matrix/src/matrix/actions/room.ts @@ -1,5 +1,5 @@ import { resolveMatrixRoomId } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; +import { withResolvedActionClient, withResolvedRoomAction } from "./client.js"; import { EventType, type MatrixActionClientOpts } from "./types.js"; export async function getMatrixMemberInfo( @@ -25,8 +25,7 @@ export async function getMatrixMemberInfo( } export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { let name: string | null = null; let topic: string | null = null; let canonicalAlias: string | null = null;