mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:28:38 +00:00
poll and profile fixes
This commit is contained in:
@@ -105,4 +105,25 @@ describe("matrixMessageActions account propagation", () => {
|
|||||||
expect.any(Object),
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,10 +189,15 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "set-profile") {
|
if (action === "set-profile") {
|
||||||
|
const avatarPath =
|
||||||
|
readStringParam(params, "avatarPath") ??
|
||||||
|
readStringParam(params, "path") ??
|
||||||
|
readStringParam(params, "filePath");
|
||||||
return await dispatch({
|
return await dispatch({
|
||||||
action: "setProfile",
|
action: "setProfile",
|
||||||
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
||||||
avatarUrl: readStringParam(params, "avatarUrl"),
|
avatarUrl: readStringParam(params, "avatarUrl"),
|
||||||
|
avatarPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const getActiveMatrixClientMock = vi.fn();
|
|||||||
const createMatrixClientMock = vi.fn();
|
const createMatrixClientMock = vi.fn();
|
||||||
const isBunRuntimeMock = vi.fn(() => false);
|
const isBunRuntimeMock = vi.fn(() => false);
|
||||||
const resolveMatrixAuthMock = vi.fn();
|
const resolveMatrixAuthMock = vi.fn();
|
||||||
|
const resolveMatrixAuthContextMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../runtime.js", () => ({
|
vi.mock("../../runtime.js", () => ({
|
||||||
getMatrixRuntime: () => ({
|
getMatrixRuntime: () => ({
|
||||||
@@ -23,6 +24,7 @@ vi.mock("../client.js", () => ({
|
|||||||
createMatrixClient: createMatrixClientMock,
|
createMatrixClient: createMatrixClientMock,
|
||||||
isBunRuntime: () => isBunRuntimeMock(),
|
isBunRuntime: () => isBunRuntimeMock(),
|
||||||
resolveMatrixAuth: resolveMatrixAuthMock,
|
resolveMatrixAuth: resolveMatrixAuthMock,
|
||||||
|
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let resolveActionClient: typeof import("./client.js").resolveActionClient;
|
let resolveActionClient: typeof import("./client.js").resolveActionClient;
|
||||||
@@ -47,6 +49,21 @@ describe("resolveActionClient", () => {
|
|||||||
deviceId: "DEVICE123",
|
deviceId: "DEVICE123",
|
||||||
encryption: false,
|
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());
|
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
|
||||||
|
|
||||||
({ resolveActionClient } = await import("./client.js"));
|
({ resolveActionClient } = await import("./client.js"));
|
||||||
@@ -84,4 +101,55 @@ describe("resolveActionClient", () => {
|
|||||||
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
||||||
expect(createMatrixClientMock).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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import type { CoreConfig } from "../../types.js";
|
import type { CoreConfig } from "../../types.js";
|
||||||
import { getActiveMatrixClient } from "../active-client.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";
|
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||||
|
|
||||||
export function ensureNodeRuntime() {
|
export function ensureNodeRuntime() {
|
||||||
@@ -17,13 +22,18 @@ export async function resolveActionClient(
|
|||||||
if (opts.client) {
|
if (opts.client) {
|
||||||
return { client: opts.client, stopOnDone: false };
|
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) {
|
if (active) {
|
||||||
return { client: active, stopOnDone: false };
|
return { client: active, stopOnDone: false };
|
||||||
}
|
}
|
||||||
const auth = await resolveMatrixAuth({
|
const auth = await resolveMatrixAuth({
|
||||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: authContext.accountId,
|
||||||
});
|
});
|
||||||
const client = await createMatrixClient({
|
const client = await createMatrixClient({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
@@ -33,7 +43,7 @@ export async function resolveActionClient(
|
|||||||
deviceId: auth.deviceId,
|
deviceId: auth.deviceId,
|
||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
localTimeoutMs: opts.timeoutMs,
|
localTimeoutMs: opts.timeoutMs,
|
||||||
accountId: opts.accountId,
|
accountId: authContext.accountId,
|
||||||
autoBootstrapCrypto: false,
|
autoBootstrapCrypto: false,
|
||||||
});
|
});
|
||||||
await client.prepareForOneOff();
|
await client.prepareForOneOff();
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export async function updateMatrixOwnProfile(
|
|||||||
opts: MatrixActionClientOpts & {
|
opts: MatrixActionClientOpts & {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
avatarPath?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<MatrixProfileSyncResult> {
|
): Promise<MatrixProfileSyncResult> {
|
||||||
const displayName = opts.displayName?.trim();
|
const displayName = opts.displayName?.trim();
|
||||||
const avatarUrl = opts.avatarUrl?.trim();
|
const avatarUrl = opts.avatarUrl?.trim();
|
||||||
|
const avatarPath = opts.avatarPath?.trim();
|
||||||
const runtime = getMatrixRuntime();
|
const runtime = getMatrixRuntime();
|
||||||
return await withResolvedActionClient(
|
return await withResolvedActionClient(
|
||||||
opts,
|
opts,
|
||||||
@@ -21,7 +23,10 @@ export async function updateMatrixOwnProfile(
|
|||||||
userId,
|
userId,
|
||||||
displayName: displayName || undefined,
|
displayName: displayName || undefined,
|
||||||
avatarUrl: avatarUrl || undefined,
|
avatarUrl: avatarUrl || undefined,
|
||||||
|
avatarPath: avatarPath || undefined,
|
||||||
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
|
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
|
||||||
|
loadAvatarFromPath: async (path, maxBytes) =>
|
||||||
|
await runtime.media.loadWebMedia(path, maxBytes),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"persist",
|
"persist",
|
||||||
|
|||||||
@@ -311,4 +311,42 @@ describe("resolveMatrixAuth", () => {
|
|||||||
encryption: true,
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ export {
|
|||||||
resolveMatrixConfig,
|
resolveMatrixConfig,
|
||||||
resolveMatrixConfigForAccount,
|
resolveMatrixConfigForAccount,
|
||||||
resolveScopedMatrixEnvConfig,
|
resolveScopedMatrixEnvConfig,
|
||||||
|
resolveImplicitMatrixAccountId,
|
||||||
resolveMatrixAuth,
|
resolveMatrixAuth,
|
||||||
|
resolveMatrixAuthContext,
|
||||||
} from "./client/config.js";
|
} from "./client/config.js";
|
||||||
export { createMatrixClient } from "./client/create-client.js";
|
export { createMatrixClient } from "./client/create-client.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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 { getMatrixRuntime } from "../../runtime.js";
|
||||||
import { normalizeResolvedSecretInputString } from "../../secret-input.js";
|
import { normalizeResolvedSecretInputString } from "../../secret-input.js";
|
||||||
import type { CoreConfig } from "../../types.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 { MatrixClient } from "../sdk.js";
|
||||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.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?: {
|
export async function resolveMatrixAuth(params?: {
|
||||||
cfg?: CoreConfig;
|
cfg?: CoreConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): Promise<MatrixAuth> {
|
}): Promise<MatrixAuth> {
|
||||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
|
||||||
const env = params?.env ?? process.env;
|
|
||||||
const accountId = params?.accountId;
|
|
||||||
const resolved = accountId
|
|
||||||
? resolveMatrixConfigForAccount(cfg, accountId, env)
|
|
||||||
: resolveMatrixConfig(cfg, env);
|
|
||||||
if (!resolved.homeserver) {
|
if (!resolved.homeserver) {
|
||||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,292 @@
|
|||||||
import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
|
import type { RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { setMatrixRuntime } from "../../runtime.js";
|
||||||
import type { MatrixClient } from "../sdk.js";
|
import type { MatrixClient } from "../sdk.js";
|
||||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||||
import { EventType, type MatrixRawEvent } from "./types.js";
|
import { EventType, type MatrixRawEvent } from "./types.js";
|
||||||
|
|
||||||
describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
describe("createMatrixRoomMessageHandler inbound body formatting", () => {
|
||||||
it("stores sender-labeled BodyForAgent for group thread messages", async () => {
|
beforeEach(() => {
|
||||||
const recordInboundSession = vi.fn().mockResolvedValue(undefined);
|
setMatrixRuntime({
|
||||||
const formatInboundEnvelope = vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((params: { senderLabel?: string; body: string }) => params.body);
|
|
||||||
const finalizeInboundContext = vi
|
|
||||||
.fn()
|
|
||||||
.mockImplementation((ctx: Record<string, unknown>) => ctx);
|
|
||||||
|
|
||||||
const core = {
|
|
||||||
channel: {
|
channel: {
|
||||||
pairing: {
|
mentions: {
|
||||||
readAllowFromStore: vi.fn().mockResolvedValue([]),
|
matchesMentionPatterns: () => false,
|
||||||
},
|
},
|
||||||
routing: {
|
media: {
|
||||||
resolveAgentRoute: vi.fn().mockReturnValue({
|
saveMediaBuffer: vi.fn(),
|
||||||
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"),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
system: {
|
config: {
|
||||||
enqueueSystemEvent: vi.fn(),
|
loadConfig: () => ({}),
|
||||||
},
|
},
|
||||||
} as unknown as PluginRuntime;
|
state: {
|
||||||
|
resolveStateDir: () => "/tmp",
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
const runtime = {
|
it("records thread metadata for group thread messages", async () => {
|
||||||
error: vi.fn(),
|
const recordInboundSession = vi.fn(async () => {});
|
||||||
} as unknown as RuntimeEnv;
|
const finalizeInboundContext = vi.fn((ctx) => ctx);
|
||||||
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;
|
|
||||||
|
|
||||||
const handler = createMatrixRoomMessageHandler({
|
const handler = createMatrixRoomMessageHandler({
|
||||||
client,
|
client: {
|
||||||
core,
|
getUserId: async () => "@bot:example.org",
|
||||||
cfg: {},
|
getEvent: async () => ({
|
||||||
runtime,
|
event_id: "$thread-root",
|
||||||
logger,
|
sender: "@alice:example.org",
|
||||||
logVerboseMessage,
|
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: [],
|
allowFrom: [],
|
||||||
roomsConfig: undefined,
|
|
||||||
mentionRegexes: [],
|
mentionRegexes: [],
|
||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
replyToMode: "first",
|
replyToMode: "off",
|
||||||
threadReplies: "inbound",
|
threadReplies: "inbound",
|
||||||
dmEnabled: true,
|
dmEnabled: true,
|
||||||
dmPolicy: "open",
|
dmPolicy: "open",
|
||||||
textLimit: 4000,
|
textLimit: 8_000,
|
||||||
mediaMaxBytes: 5 * 1024 * 1024,
|
mediaMaxBytes: 10_000_000,
|
||||||
startupMs: Date.now(),
|
startupMs: 0,
|
||||||
startupGraceMs: 60_000,
|
startupGraceMs: 0,
|
||||||
directTracker: {
|
directTracker: {
|
||||||
isDirectMessage: vi.fn().mockResolvedValue(false),
|
isDirectMessage: async () => false,
|
||||||
},
|
},
|
||||||
getRoomInfo: vi.fn().mockResolvedValue({
|
getRoomInfo: async () => ({ altAliases: [] }),
|
||||||
name: "Dev Room",
|
getMemberDisplayName: async (_roomId, userId) =>
|
||||||
canonicalAlias: "#dev:matrix.example.org",
|
userId === "@alice:example.org" ? "Alice" : "sender",
|
||||||
altAliases: [],
|
|
||||||
}),
|
|
||||||
getMemberDisplayName: vi.fn().mockResolvedValue("Bu"),
|
|
||||||
accountId: "default",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const event = {
|
await handler("!room:example.org", {
|
||||||
type: EventType.RoomMessage,
|
type: EventType.RoomMessage,
|
||||||
event_id: "$event1",
|
sender: "@user:example.org",
|
||||||
sender: "@bu:matrix.example.org",
|
event_id: "$reply1",
|
||||||
origin_server_ts: Date.now(),
|
origin_server_ts: Date.now(),
|
||||||
content: {
|
content: {
|
||||||
msgtype: "m.text",
|
msgtype: "m.text",
|
||||||
body: "show me my commits",
|
body: "follow up",
|
||||||
"m.mentions": { user_ids: ["@bot:matrix.example.org"] },
|
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
rel_type: "m.thread",
|
rel_type: "m.thread",
|
||||||
event_id: "$thread-root",
|
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(finalizeInboundContext).toHaveBeenCalledWith(
|
||||||
|
|
||||||
expect(formatInboundEnvelope).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
chatType: "channel",
|
MessageThreadId: "$thread-root",
|
||||||
senderLabel: "Bu (bu)",
|
ThreadStarterBody: "Matrix thread root $thread-root from Alice:\nRoot topic",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(recordInboundSession).toHaveBeenCalledWith(
|
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
ctx: expect.objectContaining({
|
sessionKey: "agent:ops:main",
|
||||||
ChatType: "thread",
|
}),
|
||||||
BodyForAgent: "Bu (bu): show me my commits",
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ import {
|
|||||||
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
||||||
import {
|
import {
|
||||||
formatPollAsText,
|
formatPollAsText,
|
||||||
|
formatPollResultsAsText,
|
||||||
|
isPollEventType,
|
||||||
isPollStartType,
|
isPollStartType,
|
||||||
parsePollStartContent,
|
parsePollStartContent,
|
||||||
|
resolvePollReferenceEventId,
|
||||||
|
buildPollResultsSummary,
|
||||||
type PollStartContent,
|
type PollStartContent,
|
||||||
} from "../poll-types.js";
|
} from "../poll-types.js";
|
||||||
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
|
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
|
||||||
@@ -166,7 +170,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPollEvent = isPollStartType(eventType);
|
const isPollEvent = isPollEventType(eventType);
|
||||||
const isReactionEvent = eventType === EventType.Reaction;
|
const isReactionEvent = eventType === EventType.Reaction;
|
||||||
const locationContent = event.content as LocationMessageEventContent;
|
const locationContent = event.content as LocationMessageEventContent;
|
||||||
const isLocationEvent =
|
const isLocationEvent =
|
||||||
@@ -213,22 +217,61 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
|
|
||||||
let content = event.content as RoomMessageEventContent;
|
let content = event.content as RoomMessageEventContent;
|
||||||
if (isPollEvent) {
|
if (isPollEvent) {
|
||||||
const pollStartContent = event.content as PollStartContent;
|
const pollEventId = isPollStartType(eventType)
|
||||||
const pollSummary = parsePollStartContent(pollStartContent);
|
? (event.event_id ?? "")
|
||||||
if (pollSummary) {
|
: resolvePollReferenceEventId(event.content);
|
||||||
pollSummary.eventId = event.event_id ?? "";
|
if (!pollEventId) {
|
||||||
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 {
|
|
||||||
return;
|
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 (
|
if (
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildPollResultsSummary,
|
||||||
buildPollResponseContent,
|
buildPollResponseContent,
|
||||||
buildPollStartContent,
|
buildPollStartContent,
|
||||||
|
formatPollResultsAsText,
|
||||||
parsePollStart,
|
parsePollStart,
|
||||||
|
parsePollResponseAnswerIds,
|
||||||
parsePollStartContent,
|
parsePollStartContent,
|
||||||
|
resolvePollReferenceEventId,
|
||||||
} from "./poll-types.js";
|
} from "./poll-types.js";
|
||||||
|
|
||||||
describe("parsePollStartContent", () => {
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,6 +77,16 @@ export type PollSummary = {
|
|||||||
maxSelections: number;
|
maxSelections: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PollResultsSummary = PollSummary & {
|
||||||
|
entries: Array<{
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
votes: number;
|
||||||
|
}>;
|
||||||
|
totalVotes: number;
|
||||||
|
closed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ParsedPollStart = {
|
export type ParsedPollStart = {
|
||||||
question: string;
|
question: string;
|
||||||
answers: PollParsedAnswer[];
|
answers: PollParsedAnswer[];
|
||||||
@@ -101,6 +111,18 @@ export function isPollStartType(eventType: string): boolean {
|
|||||||
return (POLL_START_TYPES as readonly string[]).includes(eventType);
|
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 {
|
export function getTextContent(text?: TextContent): string {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return "";
|
return "";
|
||||||
@@ -174,6 +196,182 @@ export function formatPollAsText(summary: PollSummary): string {
|
|||||||
return lines.join("\n");
|
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 {
|
function buildTextContent(body: string): TextContent {
|
||||||
return {
|
return {
|
||||||
"m.text": body,
|
"m.text": body,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe("matrix profile sync", () => {
|
|||||||
|
|
||||||
expect(result.skipped).toBe(true);
|
expect(result.skipped).toBe(true);
|
||||||
expectNoUpdates(result);
|
expectNoUpdates(result);
|
||||||
|
expect(result.uploadedAvatarSource).toBeNull();
|
||||||
expect(client.setDisplayName).not.toHaveBeenCalled();
|
expect(client.setDisplayName).not.toHaveBeenCalled();
|
||||||
expect(client.setAvatarUrl).not.toHaveBeenCalled();
|
expect(client.setAvatarUrl).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -49,6 +50,7 @@ describe("matrix profile sync", () => {
|
|||||||
expect(result.skipped).toBe(false);
|
expect(result.skipped).toBe(false);
|
||||||
expect(result.displayNameUpdated).toBe(true);
|
expect(result.displayNameUpdated).toBe(true);
|
||||||
expect(result.avatarUpdated).toBe(false);
|
expect(result.avatarUpdated).toBe(false);
|
||||||
|
expect(result.uploadedAvatarSource).toBeNull();
|
||||||
expect(client.setDisplayName).toHaveBeenCalledWith("New Name");
|
expect(client.setDisplayName).toHaveBeenCalledWith("New Name");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ describe("matrix profile sync", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.convertedAvatarFromHttp).toBe(true);
|
expect(result.convertedAvatarFromHttp).toBe(true);
|
||||||
|
expect(result.uploadedAvatarSource).toBe("http");
|
||||||
expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar");
|
expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar");
|
||||||
expect(result.avatarUpdated).toBe(true);
|
expect(result.avatarUpdated).toBe(true);
|
||||||
expect(loadAvatarFromUrl).toHaveBeenCalledWith(
|
expect(loadAvatarFromUrl).toHaveBeenCalledWith(
|
||||||
@@ -102,6 +105,34 @@ describe("matrix profile sync", () => {
|
|||||||
expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar");
|
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 () => {
|
it("rejects unsupported avatar URL schemes", async () => {
|
||||||
const client = createClientStub();
|
const client = createClientStub();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type MatrixProfileSyncResult = {
|
|||||||
displayNameUpdated: boolean;
|
displayNameUpdated: boolean;
|
||||||
avatarUpdated: boolean;
|
avatarUpdated: boolean;
|
||||||
resolvedAvatarUrl: string | null;
|
resolvedAvatarUrl: string | null;
|
||||||
|
uploadedAvatarSource: "http" | "path" | null;
|
||||||
convertedAvatarFromHttp: boolean;
|
convertedAvatarFromHttp: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,16 +43,54 @@ export function isSupportedMatrixAvatarSource(value: string): boolean {
|
|||||||
return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value);
|
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: {
|
async function resolveAvatarUrl(params: {
|
||||||
client: MatrixProfileClient;
|
client: MatrixProfileClient;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
avatarPath?: string | null;
|
||||||
avatarMaxBytes: number;
|
avatarMaxBytes: number;
|
||||||
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
|
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);
|
const avatarUrl = normalizeOptionalText(params.avatarUrl);
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
return {
|
return {
|
||||||
resolvedAvatarUrl: null,
|
resolvedAvatarUrl: null,
|
||||||
|
uploadedAvatarSource: null,
|
||||||
convertedAvatarFromHttp: false,
|
convertedAvatarFromHttp: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +98,7 @@ async function resolveAvatarUrl(params: {
|
|||||||
if (isMatrixMxcUri(avatarUrl)) {
|
if (isMatrixMxcUri(avatarUrl)) {
|
||||||
return {
|
return {
|
||||||
resolvedAvatarUrl: avatarUrl,
|
resolvedAvatarUrl: avatarUrl,
|
||||||
|
uploadedAvatarSource: null,
|
||||||
convertedAvatarFromHttp: false,
|
convertedAvatarFromHttp: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,15 +111,14 @@ async function resolveAvatarUrl(params: {
|
|||||||
throw new Error("Matrix avatar URL conversion requires a media loader.");
|
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 {
|
return {
|
||||||
resolvedAvatarUrl: uploadedMxc,
|
resolvedAvatarUrl: await uploadAvatarMedia({
|
||||||
|
client: params.client,
|
||||||
|
avatarSource: avatarUrl,
|
||||||
|
avatarMaxBytes: params.avatarMaxBytes,
|
||||||
|
loadAvatar: params.loadAvatarFromUrl,
|
||||||
|
}),
|
||||||
|
uploadedAvatarSource: "http",
|
||||||
convertedAvatarFromHttp: true,
|
convertedAvatarFromHttp: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,15 +128,19 @@ export async function syncMatrixOwnProfile(params: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
avatarPath?: string | null;
|
||||||
avatarMaxBytes?: number;
|
avatarMaxBytes?: number;
|
||||||
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
|
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
|
||||||
|
loadAvatarFromPath?: (path: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
|
||||||
}): Promise<MatrixProfileSyncResult> {
|
}): Promise<MatrixProfileSyncResult> {
|
||||||
const desiredDisplayName = normalizeOptionalText(params.displayName);
|
const desiredDisplayName = normalizeOptionalText(params.displayName);
|
||||||
const avatar = await resolveAvatarUrl({
|
const avatar = await resolveAvatarUrl({
|
||||||
client: params.client,
|
client: params.client,
|
||||||
avatarUrl: params.avatarUrl ?? null,
|
avatarUrl: params.avatarUrl ?? null,
|
||||||
|
avatarPath: params.avatarPath ?? null,
|
||||||
avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES,
|
avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES,
|
||||||
loadAvatarFromUrl: params.loadAvatarFromUrl,
|
loadAvatarFromUrl: params.loadAvatarFromUrl,
|
||||||
|
loadAvatarFromPath: params.loadAvatarFromPath,
|
||||||
});
|
});
|
||||||
const desiredAvatarUrl = avatar.resolvedAvatarUrl;
|
const desiredAvatarUrl = avatar.resolvedAvatarUrl;
|
||||||
|
|
||||||
@@ -107,6 +150,7 @@ export async function syncMatrixOwnProfile(params: {
|
|||||||
displayNameUpdated: false,
|
displayNameUpdated: false,
|
||||||
avatarUpdated: false,
|
avatarUpdated: false,
|
||||||
resolvedAvatarUrl: null,
|
resolvedAvatarUrl: null,
|
||||||
|
uploadedAvatarSource: avatar.uploadedAvatarSource,
|
||||||
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
|
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -138,6 +182,7 @@ export async function syncMatrixOwnProfile(params: {
|
|||||||
displayNameUpdated,
|
displayNameUpdated,
|
||||||
avatarUpdated,
|
avatarUpdated,
|
||||||
resolvedAvatarUrl: desiredAvatarUrl,
|
resolvedAvatarUrl: desiredAvatarUrl,
|
||||||
|
uploadedAvatarSource: avatar.uploadedAvatarSource,
|
||||||
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
|
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,11 +103,13 @@ type MatrixJsClientStub = EventEmitter & {
|
|||||||
mxcUrlToHttp: ReturnType<typeof vi.fn>;
|
mxcUrlToHttp: ReturnType<typeof vi.fn>;
|
||||||
uploadContent: ReturnType<typeof vi.fn>;
|
uploadContent: ReturnType<typeof vi.fn>;
|
||||||
fetchRoomEvent: ReturnType<typeof vi.fn>;
|
fetchRoomEvent: ReturnType<typeof vi.fn>;
|
||||||
|
getEventMapper: ReturnType<typeof vi.fn>;
|
||||||
sendTyping: ReturnType<typeof vi.fn>;
|
sendTyping: ReturnType<typeof vi.fn>;
|
||||||
getRoom: ReturnType<typeof vi.fn>;
|
getRoom: ReturnType<typeof vi.fn>;
|
||||||
getRooms: ReturnType<typeof vi.fn>;
|
getRooms: ReturnType<typeof vi.fn>;
|
||||||
getCrypto: ReturnType<typeof vi.fn>;
|
getCrypto: ReturnType<typeof vi.fn>;
|
||||||
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
|
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
|
||||||
|
relations: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createMatrixJsClientStub(): MatrixJsClientStub {
|
function createMatrixJsClientStub(): MatrixJsClientStub {
|
||||||
@@ -132,11 +134,42 @@ function createMatrixJsClientStub(): MatrixJsClientStub {
|
|||||||
client.mxcUrlToHttp = vi.fn(() => null);
|
client.mxcUrlToHttp = vi.fn(() => null);
|
||||||
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
|
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
|
||||||
client.fetchRoomEvent = vi.fn(async () => ({}));
|
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.sendTyping = vi.fn(async () => {});
|
||||||
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
|
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
|
||||||
client.getRooms = vi.fn(() => []);
|
client.getRooms = vi.fn(() => []);
|
||||||
client.getCrypto = vi.fn(() => undefined);
|
client.getCrypto = vi.fn(() => undefined);
|
||||||
client.decryptEventIfNeeded = vi.fn(async () => {});
|
client.decryptEventIfNeeded = vi.fn(async () => {});
|
||||||
|
client.relations = vi.fn(async () => ({
|
||||||
|
originalEvent: null,
|
||||||
|
events: [],
|
||||||
|
nextBatch: null,
|
||||||
|
prevBatch: null,
|
||||||
|
}));
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +216,90 @@ describe("MatrixClient request hardening", () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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 () => {
|
it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => {
|
||||||
const fetchMock = vi.fn(async () => {
|
const fetchMock = vi.fn(async () => {
|
||||||
return new Response("", {
|
return new Response("", {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "fake-indexeddb/auto";
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import {
|
import {
|
||||||
ClientEvent,
|
ClientEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
createClient as createMatrixJsClient,
|
createClient as createMatrixJsClient,
|
||||||
type MatrixClient as MatrixJsClient,
|
type MatrixClient as MatrixJsClient,
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
@@ -23,6 +24,7 @@ import type {
|
|||||||
MatrixClientEventMap,
|
MatrixClientEventMap,
|
||||||
MatrixCryptoBootstrapApi,
|
MatrixCryptoBootstrapApi,
|
||||||
MatrixDeviceVerificationStatusLike,
|
MatrixDeviceVerificationStatusLike,
|
||||||
|
MatrixRelationsPage,
|
||||||
MatrixRawEvent,
|
MatrixRawEvent,
|
||||||
MessageEventContent,
|
MessageEventContent,
|
||||||
} from "./sdk/types.js";
|
} from "./sdk/types.js";
|
||||||
@@ -539,7 +541,42 @@ export class MatrixClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getEvent(roomId: string, eventId: string): Promise<Record<string, unknown>> {
|
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> {
|
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export type MatrixRawEvent = {
|
|||||||
state_key?: string;
|
state_key?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MatrixRelationsPage = {
|
||||||
|
originalEvent?: MatrixRawEvent | null;
|
||||||
|
events: MatrixRawEvent[];
|
||||||
|
nextBatch?: string | null;
|
||||||
|
prevBatch?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type MatrixClientEventMap = {
|
export type MatrixClientEventMap = {
|
||||||
"room.event": [roomId: string, event: MatrixRawEvent];
|
"room.event": [roomId: string, event: MatrixRawEvent];
|
||||||
"room.message": [roomId: string, event: MatrixRawEvent];
|
"room.message": [roomId: string, event: MatrixRawEvent];
|
||||||
|
|||||||
@@ -343,4 +343,36 @@ describe("voteMatrixPoll", () => {
|
|||||||
).rejects.toThrow("is not a Matrix poll start event");
|
).rejects.toThrow("is not a Matrix poll start event");
|
||||||
expect(sendEvent).not.toHaveBeenCalled();
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type MatrixProfileUpdateResult = {
|
|||||||
displayNameUpdated: boolean;
|
displayNameUpdated: boolean;
|
||||||
avatarUpdated: boolean;
|
avatarUpdated: boolean;
|
||||||
resolvedAvatarUrl: string | null;
|
resolvedAvatarUrl: string | null;
|
||||||
|
uploadedAvatarSource: "http" | "path" | null;
|
||||||
convertedAvatarFromHttp: boolean;
|
convertedAvatarFromHttp: boolean;
|
||||||
};
|
};
|
||||||
configPath: string;
|
configPath: string;
|
||||||
@@ -21,25 +22,26 @@ export async function applyMatrixProfileUpdate(params: {
|
|||||||
account?: string;
|
account?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
avatarPath?: string;
|
||||||
}): Promise<MatrixProfileUpdateResult> {
|
}): Promise<MatrixProfileUpdateResult> {
|
||||||
const runtime = getMatrixRuntime();
|
const runtime = getMatrixRuntime();
|
||||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||||
const accountId = normalizeAccountId(params.account);
|
const accountId = normalizeAccountId(params.account);
|
||||||
const displayName = params.displayName?.trim() || null;
|
const displayName = params.displayName?.trim() || null;
|
||||||
const avatarUrl = params.avatarUrl?.trim() || null;
|
const avatarUrl = params.avatarUrl?.trim() || null;
|
||||||
if (!displayName && !avatarUrl) {
|
const avatarPath = params.avatarPath?.trim() || null;
|
||||||
throw new Error("Provide name/displayName and/or avatarUrl.");
|
if (!displayName && !avatarUrl && !avatarPath) {
|
||||||
|
throw new Error("Provide name/displayName and/or avatarUrl/avatarPath.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const synced = await updateMatrixOwnProfile({
|
const synced = await updateMatrixOwnProfile({
|
||||||
accountId,
|
accountId,
|
||||||
displayName: displayName ?? undefined,
|
displayName: displayName ?? undefined,
|
||||||
avatarUrl: avatarUrl ?? undefined,
|
avatarUrl: avatarUrl ?? undefined,
|
||||||
|
avatarPath: avatarPath ?? undefined,
|
||||||
});
|
});
|
||||||
const persistedAvatarUrl =
|
const persistedAvatarUrl =
|
||||||
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
|
synced.uploadedAvatarSource && synced.resolvedAvatarUrl ? synced.resolvedAvatarUrl : avatarUrl;
|
||||||
? synced.resolvedAvatarUrl
|
|
||||||
: avatarUrl;
|
|
||||||
const updated = updateMatrixAccountConfig(cfg, accountId, {
|
const updated = updateMatrixAccountConfig(cfg, accountId, {
|
||||||
name: displayName ?? undefined,
|
name: displayName ?? undefined,
|
||||||
avatarUrl: persistedAvatarUrl ?? undefined,
|
avatarUrl: persistedAvatarUrl ?? undefined,
|
||||||
@@ -54,6 +56,7 @@ export async function applyMatrixProfileUpdate(params: {
|
|||||||
displayNameUpdated: synced.displayNameUpdated,
|
displayNameUpdated: synced.displayNameUpdated,
|
||||||
avatarUpdated: synced.avatarUpdated,
|
avatarUpdated: synced.avatarUpdated,
|
||||||
resolvedAvatarUrl: synced.resolvedAvatarUrl,
|
resolvedAvatarUrl: synced.resolvedAvatarUrl,
|
||||||
|
uploadedAvatarSource: synced.uploadedAvatarSource,
|
||||||
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
|
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
|
||||||
},
|
},
|
||||||
configPath: resolveMatrixConfigPath(updated, accountId),
|
configPath: resolveMatrixConfigPath(updated, accountId),
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe("handleMatrixAction pollVote", () => {
|
|||||||
displayNameUpdated: true,
|
displayNameUpdated: true,
|
||||||
avatarUpdated: true,
|
avatarUpdated: true,
|
||||||
resolvedAvatarUrl: "mxc://example/avatar",
|
resolvedAvatarUrl: "mxc://example/avatar",
|
||||||
|
uploadedAvatarSource: null,
|
||||||
convertedAvatarFromHttp: false,
|
convertedAvatarFromHttp: false,
|
||||||
},
|
},
|
||||||
configPath: "channels.matrix.accounts.ops",
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -264,10 +264,15 @@ export async function handleMatrixAction(
|
|||||||
if (!isActionEnabled("profile")) {
|
if (!isActionEnabled("profile")) {
|
||||||
throw new Error("Matrix profile updates are disabled.");
|
throw new Error("Matrix profile updates are disabled.");
|
||||||
}
|
}
|
||||||
|
const avatarPath =
|
||||||
|
readStringParam(params, "avatarPath") ??
|
||||||
|
readStringParam(params, "path") ??
|
||||||
|
readStringParam(params, "filePath");
|
||||||
const result = await applyMatrixProfileUpdate({
|
const result = await applyMatrixProfileUpdate({
|
||||||
account: accountId,
|
account: accountId,
|
||||||
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
||||||
avatarUrl: readStringParam(params, "avatarUrl"),
|
avatarUrl: readStringParam(params, "avatarUrl"),
|
||||||
|
avatarPath,
|
||||||
});
|
});
|
||||||
return jsonResult({ ok: true, ...result });
|
return jsonResult({ ok: true, ...result });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ describe("message tool schema scoping", () => {
|
|||||||
expect(properties.pollOptionIndex).toBeDefined();
|
expect(properties.pollOptionIndex).toBeDefined();
|
||||||
expect(properties.pollOptionId).toBeDefined();
|
expect(properties.pollOptionId).toBeDefined();
|
||||||
expect(properties.avatarUrl).toBeDefined();
|
expect(properties.avatarUrl).toBeDefined();
|
||||||
|
expect(properties.avatarPath).toBeDefined();
|
||||||
expect(properties.displayName).toBeDefined();
|
expect(properties.displayName).toBeDefined();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -445,6 +445,18 @@ function buildProfileSchema() {
|
|||||||
"snake_case alias of avatarUrl for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
|
"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.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user