diff --git a/extensions/matrix/src/matrix/poll-types.test.ts b/extensions/matrix/src/matrix/poll-types.test.ts index f239e000d68f..e4c9cb441f55 100644 --- a/extensions/matrix/src/matrix/poll-types.test.ts +++ b/extensions/matrix/src/matrix/poll-types.test.ts @@ -67,6 +67,40 @@ describe("parsePollStartContent", () => { expect(parsed?.maxSelections).toBe(2); }); + + it("drops malformed answers instead of throwing on sender-controlled content", () => { + const malformed: Array<[string, unknown]> = [ + ["answers is a string", { question: { "m.text": "Q?" }, answers: "nope" }], + ["answers entries null", { question: { "m.text": "Q?" }, answers: [null] }], + [ + "answer id non-string", + { question: { "m.text": "Q?" }, answers: [{ id: 42, "m.text": "a" }] }, + ], + [ + "question text non-string", + { question: { "m.text": 42 }, answers: [{ id: "a1", "m.text": "a" }] }, + ], + [ + "answer text non-string", + { question: { "m.text": "Q?" }, answers: [{ id: "a1", "m.text": 42 }] }, + ], + ]; + for (const [label, poll] of malformed) { + expect(() => parsePollStart({ "m.poll.start": poll } as never), label).not.toThrow(); + expect(parsePollStart({ "m.poll.start": poll } as never), label).toBeNull(); + } + }); + + it("keeps well-formed answers when other entries are malformed", () => { + const parsed = parsePollStart({ + "m.poll.start": { + question: { "m.text": "Lunch?" }, + answers: [null, { id: "a1", "m.text": "Yes" }, { id: 42, "m.text": "dropped" }], + }, + } as never); + + expect(parsed?.answers).toEqual([{ id: "a1", text: "Yes" }]); + }); }); describe("buildPollStartContent", () => { diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts index ff9aedb629fd..2207f1cf0440 100644 --- a/extensions/matrix/src/matrix/poll-types.ts +++ b/extensions/matrix/src/matrix/poll-types.ts @@ -8,7 +8,7 @@ */ import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/poll-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export const M_POLL_START = "m.poll.start" as const; const M_POLL_RESPONSE = "m.poll.response" as const; @@ -120,11 +120,12 @@ export function isPollEventType(eventType: string): boolean { return (POLL_EVENT_TYPES as readonly string[]).includes(eventType); } -function getTextContent(text?: TextContent): string { - if (!text) { +function getTextContent(text?: unknown): string { + if (!isRecord(text)) { return ""; } - return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? ""; + const value = text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body; + return normalizeOptionalString(value) ?? ""; } export function parsePollStart(content: PollStartContent): ParsedPollStart | null { @@ -136,15 +137,18 @@ export function parsePollStart(content: PollStartContent): ParsedPollStart | nul return null; } - const question = getTextContent(poll.question).trim(); + const question = getTextContent(poll.question); if (!question) { return null; } - const answers = poll.answers + // Sender-controlled event content can violate declared Matrix types; discard + // malformed answers here so context building never drops the whole message. + const rawAnswers: unknown = poll.answers; + const answers = (Array.isArray(rawAnswers) ? rawAnswers : []) .map((answer) => ({ - id: answer.id, - text: getTextContent(answer).trim(), + id: isRecord(answer) && typeof answer.id === "string" ? answer.id : "", + text: getTextContent(answer), })) .filter((answer) => answer.id.trim().length > 0 && answer.text.length > 0); if (answers.length === 0) {