Matrix: share room action helpers

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 05:25:03 -04:00
parent 2acb5037c7
commit c5dda51b13
8 changed files with 177 additions and 39 deletions

View File

@@ -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<T>(
): Promise<T> {
return await withResolvedActionClient({ ...opts, readiness: "started" }, run, "persist");
}
export async function withResolvedRoomAction<T>(
roomId: string,
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"], resolvedRoom: string) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom);
});
}

View File

@@ -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";

View File

@@ -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<T>(
roomId: string,
opts: MatrixActionClientOpts,
run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
): Promise<T> {
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(

View File

@@ -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<string, unknown>) {
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();
});
});

View File

@@ -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)) {

View File

@@ -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<MatrixReactionSummary[]> {
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) {

View File

@@ -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",
});
});
});

View File

@@ -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;