mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
poll and profile fixes
This commit is contained in:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,9 @@ export {
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveImplicitMatrixAccountId,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
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 { 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)");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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("", {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user