poll and profile fixes

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 00:29:10 -04:00
parent 6407cc9d2d
commit 3e6dd9a2ff
23 changed files with 1183 additions and 143 deletions

View File

@@ -105,4 +105,25 @@ describe("matrixMessageActions account propagation", () => {
expect.any(Object),
);
});
it("forwards local avatar paths for self-profile updates", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "set-profile",
accountId: "ops",
params: {
path: "/tmp/avatar.jpg",
},
}),
);
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "setProfile",
accountId: "ops",
avatarPath: "/tmp/avatar.jpg",
}),
expect.any(Object),
);
});
});

View File

@@ -189,10 +189,15 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
}
if (action === "set-profile") {
const avatarPath =
readStringParam(params, "avatarPath") ??
readStringParam(params, "path") ??
readStringParam(params, "filePath");
return await dispatch({
action: "setProfile",
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
avatarUrl: readStringParam(params, "avatarUrl"),
avatarPath,
});
}

View File

@@ -6,6 +6,7 @@ const getActiveMatrixClientMock = vi.fn();
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
const resolveMatrixAuthMock = vi.fn();
const resolveMatrixAuthContextMock = vi.fn();
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
@@ -23,6 +24,7 @@ vi.mock("../client.js", () => ({
createMatrixClient: createMatrixClientMock,
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: resolveMatrixAuthMock,
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
}));
let resolveActionClient: typeof import("./client.js").resolveActionClient;
@@ -47,6 +49,21 @@ describe("resolveActionClient", () => {
deviceId: "DEVICE123",
encryption: false,
});
resolveMatrixAuthContextMock.mockImplementation(
({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({
cfg,
env: process.env,
accountId: accountId ?? undefined,
resolved: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
},
}),
);
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
({ resolveActionClient } = await import("./client.js"));
@@ -84,4 +101,55 @@ describe("resolveActionClient", () => {
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
it("uses the implicit resolved account id for active client lookup and storage", async () => {
loadConfigMock.mockReturnValue({
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
},
},
},
});
resolveMatrixAuthContextMock.mockReturnValue({
cfg: loadConfigMock(),
env: process.env,
accountId: "ops",
resolved: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
encryption: true,
},
});
resolveMatrixAuthMock.mockResolvedValue({
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
password: undefined,
deviceId: "OPSDEVICE",
encryption: true,
});
await resolveActionClient({});
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops");
expect(resolveMatrixAuthMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
}),
);
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
homeserver: "https://ops.example.org",
}),
);
});
});

View File

@@ -1,7 +1,12 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { getActiveMatrixClient } from "../active-client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveMatrixAuthContext,
} from "../client.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
export function ensureNodeRuntime() {
@@ -17,13 +22,18 @@ export async function resolveActionClient(
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(opts.accountId);
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
const authContext = resolveMatrixAuthContext({
cfg,
accountId: opts.accountId,
});
const active = getActiveMatrixClient(authContext.accountId);
if (active) {
return { client: active, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId: opts.accountId,
cfg,
accountId: authContext.accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
@@ -33,7 +43,7 @@ export async function resolveActionClient(
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
accountId: authContext.accountId,
autoBootstrapCrypto: false,
});
await client.prepareForOneOff();

View File

@@ -7,10 +7,12 @@ export async function updateMatrixOwnProfile(
opts: MatrixActionClientOpts & {
displayName?: string;
avatarUrl?: string;
avatarPath?: string;
} = {},
): Promise<MatrixProfileSyncResult> {
const displayName = opts.displayName?.trim();
const avatarUrl = opts.avatarUrl?.trim();
const avatarPath = opts.avatarPath?.trim();
const runtime = getMatrixRuntime();
return await withResolvedActionClient(
opts,
@@ -21,7 +23,10 @@ export async function updateMatrixOwnProfile(
userId,
displayName: displayName || undefined,
avatarUrl: avatarUrl || undefined,
avatarPath: avatarPath || undefined,
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
loadAvatarFromPath: async (path, maxBytes) =>
await runtime.media.loadWebMedia(path, maxBytes),
});
},
"persist",

View File

@@ -311,4 +311,42 @@ describe("resolveMatrixAuth", () => {
encryption: true,
});
});
it("falls back to the sole configured account when no global homeserver is set", async () => {
const cfg = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
encryption: true,
},
},
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth).toMatchObject({
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
encryption: true,
});
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://ops.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
deviceId: "OPSDEVICE",
}),
expect.any(Object),
"ops",
);
});
});

View File

@@ -6,7 +6,9 @@ export {
resolveMatrixConfig,
resolveMatrixConfigForAccount,
resolveScopedMatrixEnvConfig,
resolveImplicitMatrixAccountId,
resolveMatrixAuth,
resolveMatrixAuthContext,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export {

View File

@@ -1,8 +1,16 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../../runtime.js";
import { normalizeResolvedSecretInputString } from "../../secret-input.js";
import type { CoreConfig } from "../../types.js";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js";
import {
findMatrixAccountConfig,
resolveMatrixAccountsMap,
resolveMatrixBaseConfig,
} from "../account-config.js";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
@@ -230,17 +238,89 @@ export function resolveMatrixConfigForAccount(
};
}
function listNormalizedMatrixAccountIds(cfg: CoreConfig): string[] {
const accounts = resolveMatrixAccountsMap(cfg);
return [
...new Set(
Object.keys(accounts)
.filter(Boolean)
.map((accountId) => normalizeAccountId(accountId)),
),
];
}
function hasMatrixAuthInputs(config: MatrixResolvedConfig): boolean {
return Boolean(config.homeserver && (config.accessToken || (config.userId && config.password)));
}
export function resolveImplicitMatrixAccountId(
cfg: CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): string | null {
const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
if (configuredDefault) {
const resolved = resolveMatrixConfigForAccount(cfg, configuredDefault, env);
if (hasMatrixAuthInputs(resolved)) {
return configuredDefault;
}
}
const accountIds = listNormalizedMatrixAccountIds(cfg);
if (accountIds.length === 0) {
return null;
}
const readyIds = accountIds.filter((accountId) =>
hasMatrixAuthInputs(resolveMatrixConfigForAccount(cfg, accountId, env)),
);
if (readyIds.length === 1) {
return readyIds[0] ?? null;
}
if (readyIds.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return null;
}
export function resolveMatrixAuthContext(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): {
cfg: CoreConfig;
env: NodeJS.ProcessEnv;
accountId?: string;
resolved: MatrixResolvedConfig;
} {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
const defaultResolved = resolveMatrixConfig(cfg, env);
const effectiveAccountId =
explicitAccountId ??
(defaultResolved.homeserver
? undefined
: (resolveImplicitMatrixAccountId(cfg, env) ?? undefined));
const resolved = effectiveAccountId
? resolveMatrixConfigForAccount(cfg, effectiveAccountId, env)
: defaultResolved;
return {
cfg,
env,
accountId: effectiveAccountId,
resolved,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const accountId = params?.accountId;
const resolved = accountId
? resolveMatrixConfigForAccount(cfg, accountId, env)
: resolveMatrixConfig(cfg, env);
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}

View File

@@ -1,141 +1,292 @@
import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { EventType, type MatrixRawEvent } from "./types.js";
describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
it("stores sender-labeled BodyForAgent for group thread messages", async () => {
const recordInboundSession = vi.fn().mockResolvedValue(undefined);
const formatInboundEnvelope = vi
.fn()
.mockImplementation((params: { senderLabel?: string; body: string }) => params.body);
const finalizeInboundContext = vi
.fn()
.mockImplementation((ctx: Record<string, unknown>) => ctx);
const core = {
describe("createMatrixRoomMessageHandler inbound body formatting", () => {
beforeEach(() => {
setMatrixRuntime({
channel: {
pairing: {
readAllowFromStore: vi.fn().mockResolvedValue([]),
mentions: {
matchesMentionPatterns: () => false,
},
routing: {
resolveAgentRoute: vi.fn().mockReturnValue({
agentId: "main",
accountId: undefined,
sessionKey: "agent:main:matrix:channel:!room:example.org",
mainSessionKey: "agent:main:main",
}),
},
session: {
resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"),
readSessionUpdatedAt: vi.fn().mockReturnValue(123),
recordInboundSession,
},
reply: {
resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}),
formatInboundEnvelope,
formatAgentEnvelope: vi
.fn()
.mockImplementation((params: { body: string }) => params.body),
finalizeInboundContext,
resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined),
createReplyDispatcherWithTyping: vi.fn().mockReturnValue({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
}),
withReplyDispatcher: vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } }),
},
commands: {
shouldHandleTextCommands: vi.fn().mockReturnValue(true),
},
text: {
hasControlCommand: vi.fn().mockReturnValue(false),
resolveMarkdownTableMode: vi.fn().mockReturnValue("code"),
media: {
saveMediaBuffer: vi.fn(),
},
},
system: {
enqueueSystemEvent: vi.fn(),
config: {
loadConfig: () => ({}),
},
} as unknown as PluginRuntime;
state: {
resolveStateDir: () => "/tmp",
},
} as never);
});
const runtime = {
error: vi.fn(),
} as unknown as RuntimeEnv;
const logger = {
info: vi.fn(),
warn: vi.fn(),
} as unknown as RuntimeLogger;
const logVerboseMessage = vi.fn();
const client = {
getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"),
} as unknown as MatrixClient;
it("records thread metadata for group thread messages", async () => {
const recordInboundSession = vi.fn(async () => {});
const finalizeInboundContext = vi.fn((ctx) => ctx);
const handler = createMatrixRoomMessageHandler({
client,
core,
cfg: {},
runtime,
logger,
logVerboseMessage,
client: {
getUserId: async () => "@bot:example.org",
getEvent: async () => ({
event_id: "$thread-root",
sender: "@alice:example.org",
type: EventType.RoomMessage,
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "Root topic",
},
}),
} as never,
core: {
channel: {
pairing: {
readAllowFromStore: async () => [] as string[],
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
},
commands: {
shouldHandleTextCommands: () => false,
},
text: {
hasControlCommand: () => false,
resolveMarkdownTableMode: () => "preserve",
},
routing: {
resolveAgentRoute: () => ({
agentId: "ops",
channel: "matrix",
accountId: "ops",
sessionKey: "agent:ops:main",
mainSessionKey: "agent:ops:main",
matchedBy: "binding.account",
}),
},
session: {
resolveStorePath: () => "/tmp/session-store",
readSessionUpdatedAt: () => undefined,
recordInboundSession,
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: ({ body }: { body: string }) => body,
finalizeInboundContext,
createReplyDispatcherWithTyping: () => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
}),
resolveHumanDelayConfig: () => undefined,
dispatchReplyFromConfig: async () => ({
queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 },
}),
},
reactions: {
shouldAckReaction: () => false,
},
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {
error: () => {},
} as RuntimeEnv,
logger: {
info: () => {},
warn: () => {},
} as RuntimeLogger,
logVerboseMessage: () => {},
allowFrom: [],
roomsConfig: undefined,
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "first",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "open",
textLimit: 4000,
mediaMaxBytes: 5 * 1024 * 1024,
startupMs: Date.now(),
startupGraceMs: 60_000,
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: vi.fn().mockResolvedValue(false),
isDirectMessage: async () => false,
},
getRoomInfo: vi.fn().mockResolvedValue({
name: "Dev Room",
canonicalAlias: "#dev:matrix.example.org",
altAliases: [],
}),
getMemberDisplayName: vi.fn().mockResolvedValue("Bu"),
accountId: "default",
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async (_roomId, userId) =>
userId === "@alice:example.org" ? "Alice" : "sender",
});
const event = {
await handler("!room:example.org", {
type: EventType.RoomMessage,
event_id: "$event1",
sender: "@bu:matrix.example.org",
sender: "@user:example.org",
event_id: "$reply1",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "show me my commits",
"m.mentions": { user_ids: ["@bot:matrix.example.org"] },
body: "follow up",
"m.relates_to": {
rel_type: "m.thread",
event_id: "$thread-root",
"m.in_reply_to": { event_id: "$thread-root" },
},
"m.mentions": { room: true },
},
} as unknown as MatrixRawEvent;
} as MatrixRawEvent);
await handler("!room:example.org", event);
expect(formatInboundEnvelope).toHaveBeenCalledWith(
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
chatType: "channel",
senderLabel: "Bu (bu)",
MessageThreadId: "$thread-root",
ThreadStarterBody: "Matrix thread root $thread-root from Alice:\nRoot topic",
}),
);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
ChatType: "thread",
BodyForAgent: "Bu (bu): show me my commits",
sessionKey: "agent:ops:main",
}),
);
});
it("records formatted poll results for inbound poll response events", async () => {
const recordInboundSession = vi.fn(async () => {});
const finalizeInboundContext = vi.fn((ctx) => ctx);
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
getEvent: async () => ({
event_id: "$poll",
sender: "@bot:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Pizza" },
{ id: "a2", "m.text": "Sushi" },
],
},
},
}),
getRelations: async () => ({
events: [
{
type: "m.poll.response",
event_id: "$vote1",
sender: "@user:example.org",
origin_server_ts: 2,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
],
nextBatch: null,
prevBatch: null,
}),
} as unknown as MatrixClient,
core: {
channel: {
pairing: {
readAllowFromStore: async () => [] as string[],
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
},
commands: {
shouldHandleTextCommands: () => false,
},
text: {
hasControlCommand: () => false,
resolveMarkdownTableMode: () => "preserve",
},
routing: {
resolveAgentRoute: () => ({
agentId: "ops",
channel: "matrix",
accountId: "ops",
sessionKey: "agent:ops:main",
mainSessionKey: "agent:ops:main",
matchedBy: "binding.account",
}),
},
session: {
resolveStorePath: () => "/tmp/session-store",
readSessionUpdatedAt: () => undefined,
recordInboundSession,
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: ({ body }: { body: string }) => body,
finalizeInboundContext,
createReplyDispatcherWithTyping: () => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
}),
resolveHumanDelayConfig: () => undefined,
dispatchReplyFromConfig: async () => ({
queuedFinal: false,
counts: { final: 0, block: 0, tool: 0 },
}),
},
reactions: {
shouldAckReaction: () => false,
},
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {
error: () => {},
} as RuntimeEnv,
logger: {
info: () => {},
warn: () => {},
} as RuntimeLogger,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "open",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => true,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async (_roomId, userId) =>
userId === "@bot:example.org" ? "Bot" : "sender",
});
await handler("!room:example.org", {
type: "m.poll.response",
sender: "@user:example.org",
event_id: "$vote1",
origin_server_ts: 2,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
} as MatrixRawEvent);
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
RawBody: expect.stringMatching(/1\. Pizza \(1 vote\)[\s\S]*Total voters: 1/),
}),
);
expect(recordInboundSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:ops:main",
}),
);
});

View File

@@ -17,8 +17,12 @@ import {
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
import {
formatPollAsText,
formatPollResultsAsText,
isPollEventType,
isPollStartType,
parsePollStartContent,
resolvePollReferenceEventId,
buildPollResultsSummary,
type PollStartContent,
} from "../poll-types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
@@ -166,7 +170,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
const isPollEvent = isPollStartType(eventType);
const isPollEvent = isPollEventType(eventType);
const isReactionEvent = eventType === EventType.Reaction;
const locationContent = event.content as LocationMessageEventContent;
const isLocationEvent =
@@ -213,22 +217,61 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
let content = event.content as RoomMessageEventContent;
if (isPollEvent) {
const pollStartContent = event.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (pollSummary) {
pollSummary.eventId = event.event_id ?? "";
pollSummary.roomId = roomId;
pollSummary.sender = senderId;
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
pollSummary.senderName = senderDisplayName;
const pollText = formatPollAsText(pollSummary);
content = {
msgtype: "m.text",
body: pollText,
} as unknown as RoomMessageEventContent;
} else {
const pollEventId = isPollStartType(eventType)
? (event.event_id ?? "")
: resolvePollReferenceEventId(event.content);
if (!pollEventId) {
return;
}
const pollEvent = isPollStartType(eventType)
? event
: await client.getEvent(roomId, pollEventId).catch((err) => {
logVerboseMessage(
`matrix: failed resolving poll root room=${roomId} id=${pollEventId}: ${String(err)}`,
);
return null;
});
if (
!pollEvent ||
!isPollStartType(typeof pollEvent.type === "string" ? pollEvent.type : "")
) {
return;
}
const pollStartContent = pollEvent.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (!pollSummary) {
return;
}
pollSummary.eventId = pollEventId;
pollSummary.roomId = roomId;
pollSummary.sender = typeof pollEvent.sender === "string" ? pollEvent.sender : senderId;
pollSummary.senderName = await getMemberDisplayName(roomId, pollSummary.sender);
const relationEvents: MatrixRawEvent[] = [];
let nextBatch: string | undefined;
do {
const page = await client.getRelations(roomId, pollEventId, "m.reference", undefined, {
from: nextBatch,
});
relationEvents.push(...page.events);
nextBatch = page.nextBatch ?? undefined;
} while (nextBatch);
const pollResults = buildPollResultsSummary({
pollEventId,
roomId,
sender: pollSummary.sender,
senderName: pollSummary.senderName,
content: pollStartContent,
relationEvents,
});
const pollText = pollResults
? formatPollResultsAsText(pollResults)
: formatPollAsText(pollSummary);
content = {
msgtype: "m.text",
body: pollText,
} as unknown as RoomMessageEventContent;
}
if (

View File

@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import {
buildPollResultsSummary,
buildPollResponseContent,
buildPollStartContent,
formatPollResultsAsText,
parsePollStart,
parsePollResponseAnswerIds,
parsePollStartContent,
resolvePollReferenceEventId,
} from "./poll-types.js";
describe("parsePollStartContent", () => {
@@ -93,3 +97,109 @@ describe("buildPollResponseContent", () => {
});
});
});
describe("poll relation parsing", () => {
it("parses stable and unstable poll response answer ids", () => {
expect(
parsePollResponseAnswerIds({
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
}),
).toEqual(["a1"]);
expect(
parsePollResponseAnswerIds({
"org.matrix.msc3381.poll.response": { answers: ["a2"] },
}),
).toEqual(["a2"]);
});
it("extracts poll relation targets", () => {
expect(
resolvePollReferenceEventId({
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
}),
).toBe("$poll");
});
});
describe("buildPollResultsSummary", () => {
it("counts only the latest valid response from each sender", () => {
const summary = buildPollResultsSummary({
pollEventId: "$poll",
roomId: "!room:example.org",
sender: "@alice:example.org",
senderName: "Alice",
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Pizza" },
{ id: "a2", "m.text": "Sushi" },
],
},
},
relationEvents: [
{
event_id: "$vote1",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 1,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
{
event_id: "$vote2",
sender: "@bob:example.org",
type: "m.poll.response",
origin_server_ts: 2,
content: {
"m.poll.response": { answers: ["a2"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
{
event_id: "$vote3",
sender: "@carol:example.org",
type: "m.poll.response",
origin_server_ts: 3,
content: {
"m.poll.response": { answers: [] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
},
],
});
expect(summary?.entries).toEqual([
{ id: "a1", text: "Pizza", votes: 0 },
{ id: "a2", text: "Sushi", votes: 1 },
]);
expect(summary?.totalVotes).toBe(1);
});
it("formats disclosed poll results with vote totals", () => {
const text = formatPollResultsAsText({
eventId: "$poll",
roomId: "!room:example.org",
sender: "@alice:example.org",
senderName: "Alice",
question: "Lunch?",
answers: ["Pizza", "Sushi"],
kind: "m.poll.disclosed",
maxSelections: 1,
entries: [
{ id: "a1", text: "Pizza", votes: 1 },
{ id: "a2", text: "Sushi", votes: 0 },
],
totalVotes: 1,
closed: false,
});
expect(text).toContain("1. Pizza (1 vote)");
expect(text).toContain("Total voters: 1");
});
});

View File

@@ -77,6 +77,16 @@ export type PollSummary = {
maxSelections: number;
};
export type PollResultsSummary = PollSummary & {
entries: Array<{
id: string;
text: string;
votes: number;
}>;
totalVotes: number;
closed: boolean;
};
export type ParsedPollStart = {
question: string;
answers: PollParsedAnswer[];
@@ -101,6 +111,18 @@ export function isPollStartType(eventType: string): boolean {
return (POLL_START_TYPES as readonly string[]).includes(eventType);
}
export function isPollResponseType(eventType: string): boolean {
return (POLL_RESPONSE_TYPES as readonly string[]).includes(eventType);
}
export function isPollEndType(eventType: string): boolean {
return (POLL_END_TYPES as readonly string[]).includes(eventType);
}
export function isPollEventType(eventType: string): boolean {
return (POLL_EVENT_TYPES as readonly string[]).includes(eventType);
}
export function getTextContent(text?: TextContent): string {
if (!text) {
return "";
@@ -174,6 +196,182 @@ export function formatPollAsText(summary: PollSummary): string {
return lines.join("\n");
}
export function resolvePollReferenceEventId(content: unknown): string | null {
if (!content || typeof content !== "object") {
return null;
}
const relates = (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"];
if (!relates || typeof relates.event_id !== "string") {
return null;
}
const eventId = relates.event_id.trim();
return eventId.length > 0 ? eventId : null;
}
export function parsePollResponseAnswerIds(content: unknown): string[] | null {
if (!content || typeof content !== "object") {
return null;
}
const response =
(content as Record<string, PollResponseSubtype | undefined>)[M_POLL_RESPONSE] ??
(content as Record<string, PollResponseSubtype | undefined>)[ORG_POLL_RESPONSE];
if (!response || !Array.isArray(response.answers)) {
return null;
}
return response.answers.filter((answer): answer is string => typeof answer === "string");
}
export function buildPollResultsSummary(params: {
pollEventId: string;
roomId: string;
sender: string;
senderName: string;
content: PollStartContent;
relationEvents: Array<{
event_id?: string;
sender?: string;
type?: string;
origin_server_ts?: number;
content?: Record<string, unknown>;
unsigned?: {
redacted_because?: unknown;
};
}>;
}): PollResultsSummary | null {
const parsed = parsePollStart(params.content);
if (!parsed) {
return null;
}
let pollClosedAt = Number.POSITIVE_INFINITY;
for (const event of params.relationEvents) {
if (event.unsigned?.redacted_because) {
continue;
}
if (!isPollEndType(typeof event.type === "string" ? event.type : "")) {
continue;
}
if (event.sender !== params.sender) {
continue;
}
const ts =
typeof event.origin_server_ts === "number" && Number.isFinite(event.origin_server_ts)
? event.origin_server_ts
: Number.POSITIVE_INFINITY;
if (ts < pollClosedAt) {
pollClosedAt = ts;
}
}
const answerIds = new Set(parsed.answers.map((answer) => answer.id));
const latestVoteBySender = new Map<
string,
{
ts: number;
eventId: string;
answerIds: string[];
}
>();
const orderedRelationEvents = [...params.relationEvents].sort((left, right) => {
const leftTs =
typeof left.origin_server_ts === "number" && Number.isFinite(left.origin_server_ts)
? left.origin_server_ts
: Number.POSITIVE_INFINITY;
const rightTs =
typeof right.origin_server_ts === "number" && Number.isFinite(right.origin_server_ts)
? right.origin_server_ts
: Number.POSITIVE_INFINITY;
if (leftTs !== rightTs) {
return leftTs - rightTs;
}
return (left.event_id ?? "").localeCompare(right.event_id ?? "");
});
for (const event of orderedRelationEvents) {
if (event.unsigned?.redacted_because) {
continue;
}
if (!isPollResponseType(typeof event.type === "string" ? event.type : "")) {
continue;
}
const senderId = typeof event.sender === "string" ? event.sender.trim() : "";
if (!senderId) {
continue;
}
const eventTs =
typeof event.origin_server_ts === "number" && Number.isFinite(event.origin_server_ts)
? event.origin_server_ts
: Number.POSITIVE_INFINITY;
if (eventTs > pollClosedAt) {
continue;
}
const rawAnswers = parsePollResponseAnswerIds(event.content) ?? [];
const normalizedAnswers = Array.from(
new Set(
rawAnswers
.map((answerId) => answerId.trim())
.filter((answerId) => answerIds.has(answerId))
.slice(0, parsed.maxSelections),
),
);
latestVoteBySender.set(senderId, {
ts: eventTs,
eventId: typeof event.event_id === "string" ? event.event_id : "",
answerIds: normalizedAnswers,
});
}
const voteCounts = new Map(parsed.answers.map((answer) => [answer.id, 0] as const));
let totalVotes = 0;
for (const latestVote of latestVoteBySender.values()) {
if (latestVote.answerIds.length === 0) {
continue;
}
totalVotes += 1;
for (const answerId of latestVote.answerIds) {
voteCounts.set(answerId, (voteCounts.get(answerId) ?? 0) + 1);
}
}
return {
eventId: params.pollEventId,
roomId: params.roomId,
sender: params.sender,
senderName: params.senderName,
question: parsed.question,
answers: parsed.answers.map((answer) => answer.text),
kind: parsed.kind,
maxSelections: parsed.maxSelections,
entries: parsed.answers.map((answer) => ({
id: answer.id,
text: answer.text,
votes: voteCounts.get(answer.id) ?? 0,
})),
totalVotes,
closed: Number.isFinite(pollClosedAt),
};
}
export function formatPollResultsAsText(summary: PollResultsSummary): string {
const lines = [summary.closed ? "[Poll closed]" : "[Poll]", summary.question, ""];
const revealResults = summary.kind === "m.poll.disclosed" || summary.closed;
for (const [index, entry] of summary.entries.entries()) {
if (!revealResults) {
lines.push(`${index + 1}. ${entry.text}`);
continue;
}
lines.push(`${index + 1}. ${entry.text} (${entry.votes} vote${entry.votes === 1 ? "" : "s"})`);
}
lines.push("");
if (!revealResults) {
lines.push("Responses are hidden until the poll closes.");
} else {
lines.push(`Total voters: ${summary.totalVotes}`);
}
return lines.join("\n");
}
function buildTextContent(body: string): TextContent {
return {
"m.text": body,

View File

@@ -29,6 +29,7 @@ describe("matrix profile sync", () => {
expect(result.skipped).toBe(true);
expectNoUpdates(result);
expect(result.uploadedAvatarSource).toBeNull();
expect(client.setDisplayName).not.toHaveBeenCalled();
expect(client.setAvatarUrl).not.toHaveBeenCalled();
});
@@ -49,6 +50,7 @@ describe("matrix profile sync", () => {
expect(result.skipped).toBe(false);
expect(result.displayNameUpdated).toBe(true);
expect(result.avatarUpdated).toBe(false);
expect(result.uploadedAvatarSource).toBeNull();
expect(client.setDisplayName).toHaveBeenCalledWith("New Name");
});
@@ -93,6 +95,7 @@ describe("matrix profile sync", () => {
});
expect(result.convertedAvatarFromHttp).toBe(true);
expect(result.uploadedAvatarSource).toBe("http");
expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar");
expect(result.avatarUpdated).toBe(true);
expect(loadAvatarFromUrl).toHaveBeenCalledWith(
@@ -102,6 +105,34 @@ describe("matrix profile sync", () => {
expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar");
});
it("uploads avatar media from a local path and then updates profile avatar", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Bot",
avatar_url: "mxc://example/old",
});
client.uploadContent.mockResolvedValue("mxc://example/path-avatar");
const loadAvatarFromPath = vi.fn(async () => ({
buffer: Buffer.from("avatar-bytes"),
contentType: "image/jpeg",
fileName: "avatar.jpg",
}));
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
avatarPath: "/tmp/avatar.jpg",
loadAvatarFromPath,
});
expect(result.convertedAvatarFromHttp).toBe(false);
expect(result.uploadedAvatarSource).toBe("path");
expect(result.resolvedAvatarUrl).toBe("mxc://example/path-avatar");
expect(result.avatarUpdated).toBe(true);
expect(loadAvatarFromPath).toHaveBeenCalledWith("/tmp/avatar.jpg", 10 * 1024 * 1024);
expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/path-avatar");
});
it("rejects unsupported avatar URL schemes", async () => {
const client = createClientStub();

View File

@@ -18,6 +18,7 @@ export type MatrixProfileSyncResult = {
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
uploadedAvatarSource: "http" | "path" | null;
convertedAvatarFromHttp: boolean;
};
@@ -42,16 +43,54 @@ export function isSupportedMatrixAvatarSource(value: string): boolean {
return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value);
}
async function uploadAvatarMedia(params: {
client: MatrixProfileClient;
avatarSource: string;
avatarMaxBytes: number;
loadAvatar: (source: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<string> {
const media = await params.loadAvatar(params.avatarSource, params.avatarMaxBytes);
return await params.client.uploadContent(
media.buffer,
media.contentType,
media.fileName || "avatar",
);
}
async function resolveAvatarUrl(params: {
client: MatrixProfileClient;
avatarUrl: string | null;
avatarPath?: string | null;
avatarMaxBytes: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<{ resolvedAvatarUrl: string | null; convertedAvatarFromHttp: boolean }> {
loadAvatarFromPath?: (path: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<{
resolvedAvatarUrl: string | null;
uploadedAvatarSource: "http" | "path" | null;
convertedAvatarFromHttp: boolean;
}> {
const avatarPath = normalizeOptionalText(params.avatarPath);
if (avatarPath) {
if (!params.loadAvatarFromPath) {
throw new Error("Matrix avatar path upload requires a media loader.");
}
return {
resolvedAvatarUrl: await uploadAvatarMedia({
client: params.client,
avatarSource: avatarPath,
avatarMaxBytes: params.avatarMaxBytes,
loadAvatar: params.loadAvatarFromPath,
}),
uploadedAvatarSource: "path",
convertedAvatarFromHttp: false,
};
}
const avatarUrl = normalizeOptionalText(params.avatarUrl);
if (!avatarUrl) {
return {
resolvedAvatarUrl: null,
uploadedAvatarSource: null,
convertedAvatarFromHttp: false,
};
}
@@ -59,6 +98,7 @@ async function resolveAvatarUrl(params: {
if (isMatrixMxcUri(avatarUrl)) {
return {
resolvedAvatarUrl: avatarUrl,
uploadedAvatarSource: null,
convertedAvatarFromHttp: false,
};
}
@@ -71,15 +111,14 @@ async function resolveAvatarUrl(params: {
throw new Error("Matrix avatar URL conversion requires a media loader.");
}
const media = await params.loadAvatarFromUrl(avatarUrl, params.avatarMaxBytes);
const uploadedMxc = await params.client.uploadContent(
media.buffer,
media.contentType,
media.fileName || "avatar",
);
return {
resolvedAvatarUrl: uploadedMxc,
resolvedAvatarUrl: await uploadAvatarMedia({
client: params.client,
avatarSource: avatarUrl,
avatarMaxBytes: params.avatarMaxBytes,
loadAvatar: params.loadAvatarFromUrl,
}),
uploadedAvatarSource: "http",
convertedAvatarFromHttp: true,
};
}
@@ -89,15 +128,19 @@ export async function syncMatrixOwnProfile(params: {
userId: string;
displayName?: string | null;
avatarUrl?: string | null;
avatarPath?: string | null;
avatarMaxBytes?: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
loadAvatarFromPath?: (path: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<MatrixProfileSyncResult> {
const desiredDisplayName = normalizeOptionalText(params.displayName);
const avatar = await resolveAvatarUrl({
client: params.client,
avatarUrl: params.avatarUrl ?? null,
avatarPath: params.avatarPath ?? null,
avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES,
loadAvatarFromUrl: params.loadAvatarFromUrl,
loadAvatarFromPath: params.loadAvatarFromPath,
});
const desiredAvatarUrl = avatar.resolvedAvatarUrl;
@@ -107,6 +150,7 @@ export async function syncMatrixOwnProfile(params: {
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
uploadedAvatarSource: avatar.uploadedAvatarSource,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}
@@ -138,6 +182,7 @@ export async function syncMatrixOwnProfile(params: {
displayNameUpdated,
avatarUpdated,
resolvedAvatarUrl: desiredAvatarUrl,
uploadedAvatarSource: avatar.uploadedAvatarSource,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}

View File

@@ -103,11 +103,13 @@ type MatrixJsClientStub = EventEmitter & {
mxcUrlToHttp: ReturnType<typeof vi.fn>;
uploadContent: ReturnType<typeof vi.fn>;
fetchRoomEvent: ReturnType<typeof vi.fn>;
getEventMapper: ReturnType<typeof vi.fn>;
sendTyping: ReturnType<typeof vi.fn>;
getRoom: ReturnType<typeof vi.fn>;
getRooms: ReturnType<typeof vi.fn>;
getCrypto: ReturnType<typeof vi.fn>;
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
relations: ReturnType<typeof vi.fn>;
};
function createMatrixJsClientStub(): MatrixJsClientStub {
@@ -132,11 +134,42 @@ function createMatrixJsClientStub(): MatrixJsClientStub {
client.mxcUrlToHttp = vi.fn(() => null);
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
client.fetchRoomEvent = vi.fn(async () => ({}));
client.getEventMapper = vi.fn(
() =>
(
raw: Partial<{
room_id: string;
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
state_key?: string;
unsigned?: { age?: number; redacted_because?: unknown };
}>,
) =>
new FakeMatrixEvent({
roomId: raw.room_id ?? "!mapped:example.org",
eventId: raw.event_id ?? "$mapped",
sender: raw.sender ?? "@mapped:example.org",
type: raw.type ?? "m.room.message",
ts: raw.origin_server_ts ?? Date.now(),
content: raw.content ?? {},
stateKey: raw.state_key,
unsigned: raw.unsigned,
}),
);
client.sendTyping = vi.fn(async () => {});
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
client.getRooms = vi.fn(() => []);
client.getCrypto = vi.fn(() => undefined);
client.decryptEventIfNeeded = vi.fn(async () => {});
client.relations = vi.fn(async () => ({
originalEvent: null,
events: [],
nextBatch: null,
prevBatch: null,
}));
return client;
}
@@ -183,6 +216,90 @@ describe("MatrixClient request hardening", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("decrypts encrypted room events returned by getEvent", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
matrixJsClient.fetchRoomEvent = vi.fn(async () => ({
room_id: "!room:example.org",
event_id: "$poll",
sender: "@alice:example.org",
type: "m.room.encrypted",
origin_server_ts: 1,
content: {},
}));
matrixJsClient.decryptEventIfNeeded = vi.fn(async (event: FakeMatrixEvent) => {
event.emit(
"decrypted",
new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
answers: [{ id: "a1", "m.text": "Pizza" }],
},
},
}),
);
});
const event = await client.getEvent("!room:example.org", "$poll");
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(event).toMatchObject({
event_id: "$poll",
type: "m.poll.start",
sender: "@alice:example.org",
});
});
it("maps relations pages back to raw events", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
matrixJsClient.relations = vi.fn(async () => ({
originalEvent: new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
answers: [{ id: "a1", "m.text": "Pizza" }],
},
},
}),
events: [
new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$vote",
sender: "@bob:example.org",
type: "m.poll.response",
ts: 2,
content: {
"m.poll.response": { answers: ["a1"] },
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
},
}),
],
nextBatch: null,
prevBatch: null,
}));
const page = await client.getRelations("!room:example.org", "$poll", "m.reference");
expect(page.originalEvent).toMatchObject({ event_id: "$poll", type: "m.poll.start" });
expect(page.events).toEqual([
expect.objectContaining({
event_id: "$vote",
type: "m.poll.response",
sender: "@bob:example.org",
}),
]);
});
it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => {
const fetchMock = vi.fn(async () => {
return new Response("", {

View File

@@ -3,6 +3,7 @@ import "fake-indexeddb/auto";
import { EventEmitter } from "node:events";
import {
ClientEvent,
MatrixEventEvent,
createClient as createMatrixJsClient,
type MatrixClient as MatrixJsClient,
type MatrixEvent,
@@ -23,6 +24,7 @@ import type {
MatrixClientEventMap,
MatrixCryptoBootstrapApi,
MatrixDeviceVerificationStatusLike,
MatrixRelationsPage,
MatrixRawEvent,
MessageEventContent,
} from "./sdk/types.js";
@@ -539,7 +541,42 @@ export class MatrixClient {
}
async getEvent(roomId: string, eventId: string): Promise<Record<string, unknown>> {
return (await this.client.fetchRoomEvent(roomId, eventId)) as Record<string, unknown>;
const rawEvent = (await this.client.fetchRoomEvent(roomId, eventId)) as Record<string, unknown>;
if (rawEvent.type !== "m.room.encrypted") {
return rawEvent;
}
const mapper = this.client.getEventMapper();
const event = mapper(rawEvent);
let decryptedEvent: MatrixEvent | undefined;
const onDecrypted = (candidate: MatrixEvent) => {
decryptedEvent = candidate;
};
event.once(MatrixEventEvent.Decrypted, onDecrypted);
try {
await this.client.decryptEventIfNeeded(event);
} finally {
event.off(MatrixEventEvent.Decrypted, onDecrypted);
}
return matrixEventToRaw(decryptedEvent ?? event);
}
async getRelations(
roomId: string,
eventId: string,
relationType: string | null,
eventType?: string | null,
opts: {
from?: string;
} = {},
): Promise<MatrixRelationsPage> {
const result = await this.client.relations(roomId, eventId, relationType, eventType, opts);
return {
originalEvent: result.originalEvent ? matrixEventToRaw(result.originalEvent) : null,
events: result.events.map((event) => matrixEventToRaw(event)),
nextBatch: result.nextBatch ?? null,
prevBatch: result.prevBatch ?? null,
};
}
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {

View File

@@ -13,6 +13,13 @@ export type MatrixRawEvent = {
state_key?: string;
};
export type MatrixRelationsPage = {
originalEvent?: MatrixRawEvent | null;
events: MatrixRawEvent[];
nextBatch?: string | null;
prevBatch?: string | null;
};
export type MatrixClientEventMap = {
"room.event": [roomId: string, event: MatrixRawEvent];
"room.message": [roomId: string, event: MatrixRawEvent];

View File

@@ -343,4 +343,36 @@ describe("voteMatrixPoll", () => {
).rejects.toThrow("is not a Matrix poll start event");
expect(sendEvent).not.toHaveBeenCalled();
});
it("accepts decrypted poll start events returned from encrypted rooms", async () => {
const { client, getEvent, sendEvent } = makeClient();
getEvent.mockResolvedValue({
type: "m.poll.start",
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
max_selections: 1,
answers: [{ id: "a1", "m.text": "Pizza" }],
},
},
});
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
optionIndex: 1,
}),
).resolves.toMatchObject({
pollId: "$poll",
answerIds: ["a1"],
});
expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", {
"m.poll.response": { answers: ["a1"] },
"org.matrix.msc3381.poll.response": { answers: ["a1"] },
"m.relates_to": {
rel_type: "m.reference",
event_id: "$poll",
},
});
});
});

View File

@@ -12,6 +12,7 @@ export type MatrixProfileUpdateResult = {
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
uploadedAvatarSource: "http" | "path" | null;
convertedAvatarFromHttp: boolean;
};
configPath: string;
@@ -21,25 +22,26 @@ export async function applyMatrixProfileUpdate(params: {
account?: string;
displayName?: string;
avatarUrl?: string;
avatarPath?: string;
}): Promise<MatrixProfileUpdateResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const accountId = normalizeAccountId(params.account);
const displayName = params.displayName?.trim() || null;
const avatarUrl = params.avatarUrl?.trim() || null;
if (!displayName && !avatarUrl) {
throw new Error("Provide name/displayName and/or avatarUrl.");
const avatarPath = params.avatarPath?.trim() || null;
if (!displayName && !avatarUrl && !avatarPath) {
throw new Error("Provide name/displayName and/or avatarUrl/avatarPath.");
}
const synced = await updateMatrixOwnProfile({
accountId,
displayName: displayName ?? undefined,
avatarUrl: avatarUrl ?? undefined,
avatarPath: avatarPath ?? undefined,
});
const persistedAvatarUrl =
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
? synced.resolvedAvatarUrl
: avatarUrl;
synced.uploadedAvatarSource && synced.resolvedAvatarUrl ? synced.resolvedAvatarUrl : avatarUrl;
const updated = updateMatrixAccountConfig(cfg, accountId, {
name: displayName ?? undefined,
avatarUrl: persistedAvatarUrl ?? undefined,
@@ -54,6 +56,7 @@ export async function applyMatrixProfileUpdate(params: {
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl: synced.resolvedAvatarUrl,
uploadedAvatarSource: synced.uploadedAvatarSource,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
},
configPath: resolveMatrixConfigPath(updated, accountId),

View File

@@ -68,6 +68,7 @@ describe("handleMatrixAction pollVote", () => {
displayNameUpdated: true,
avatarUpdated: true,
resolvedAvatarUrl: "mxc://example/avatar",
uploadedAvatarSource: null,
convertedAvatarFromHttp: false,
},
configPath: "channels.matrix.accounts.ops",
@@ -262,4 +263,22 @@ describe("handleMatrixAction pollVote", () => {
},
});
});
it("accepts local avatar paths for self-profile updates", async () => {
await handleMatrixAction(
{
action: "setProfile",
accountId: "ops",
path: "/tmp/avatar.jpg",
},
{ channels: { matrix: { actions: { profile: true } } } } as CoreConfig,
);
expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith({
account: "ops",
displayName: undefined,
avatarUrl: undefined,
avatarPath: "/tmp/avatar.jpg",
});
});
});

View File

@@ -264,10 +264,15 @@ export async function handleMatrixAction(
if (!isActionEnabled("profile")) {
throw new Error("Matrix profile updates are disabled.");
}
const avatarPath =
readStringParam(params, "avatarPath") ??
readStringParam(params, "path") ??
readStringParam(params, "filePath");
const result = await applyMatrixProfileUpdate({
account: accountId,
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
avatarUrl: readStringParam(params, "avatarUrl"),
avatarPath,
});
return jsonResult({ ok: true, ...result });
}

View File

@@ -245,6 +245,7 @@ describe("message tool schema scoping", () => {
expect(properties.pollOptionIndex).toBeDefined();
expect(properties.pollOptionId).toBeDefined();
expect(properties.avatarUrl).toBeDefined();
expect(properties.avatarPath).toBeDefined();
expect(properties.displayName).toBeDefined();
},
);

View File

@@ -445,6 +445,18 @@ function buildProfileSchema() {
"snake_case alias of avatarUrl for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
}),
),
avatarPath: Type.Optional(
Type.String({
description:
"Local avatar file path for self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
}),
),
avatar_path: Type.Optional(
Type.String({
description:
"snake_case alias of avatarPath for self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
}),
),
};
}