diff --git a/extensions/line/src/reply-payload-transform.test.ts b/extensions/line/src/reply-payload-transform.test.ts index 3336068a64f5..2bb6113c738d 100644 --- a/extensions/line/src/reply-payload-transform.test.ts +++ b/extensions/line/src/reply-payload-transform.test.ts @@ -381,6 +381,54 @@ describe("parseLineDirectives", () => { }); }); + describe("blank comma-separated entries", () => { + // A trailing or doubled comma in agent output must not emit empty Flex text + // components (agenda) or empty action labels (device); LINE rejects those with + // HTTP 400 "May not be empty". + const collectRenderedStrings = (node: unknown, out: string[]): string[] => { + if (Array.isArray(node)) { + for (const item of node) { + collectRenderedStrings(item, out); + } + } else if (node && typeof node === "object") { + const record = node as Record; + if (record.type === "text" && typeof record.text === "string") { + out.push(record.text); + } + if (typeof record.label === "string") { + out.push(record.label); + } + for (const value of Object.values(record)) { + collectRenderedStrings(value, out); + } + } + return out; + }; + + it("drops trailing commas in agenda events", () => { + const result = parseLineDirectives({ text: "[[agenda: Today | Standup:9am, Lunch:12pm,]]" }); + const flexMessage = requireFlexMessage( + getLineData(result).flexMessage, + "agenda trailing comma", + ); + expect(flexMessage.altText).toBe("📋 Today (2 events)"); + expect(collectRenderedStrings(flexMessage.contents, [])).not.toContain(""); + }); + + it("drops doubled commas and blank labels in device controls", () => { + const result = parseLineDirectives({ + text: "[[device: TV | Box | Playing | Play:toggle,, :ignored, Menu:menu]]", + }); + const flexMessage = requireFlexMessage( + getLineData(result).flexMessage, + "device blank controls", + ); + const renderedStrings = collectRenderedStrings(flexMessage.contents, []); + expect(renderedStrings).toEqual(expect.arrayContaining(["Play", "Menu"])); + expect(renderedStrings).not.toContain(""); + }); + }); + describe("device", () => { it("parses device variants", () => { const cases = [ diff --git a/extensions/line/src/reply-payload-transform.ts b/extensions/line/src/reply-payload-transform.ts index 5082a9ac430f..db0b55ee38e3 100644 --- a/extensions/line/src/reply-payload-transform.ts +++ b/extensions/line/src/reply-payload-transform.ts @@ -287,7 +287,9 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload { if (parts.length >= 2) { const title = expectDefined(parts[0], "agenda title field"); const eventsStr = expectDefined(parts[1], "agenda events field"); - const events = eventsStr.split(",").map((eventStr) => { + // normalizeStringEntries drops blank entries from trailing/double commas; an empty + // event title becomes an empty Flex text component, which LINE rejects with HTTP 400. + const events = normalizeStringEntries(eventsStr.split(",")).map((eventStr) => { const trimmed = eventStr.trim(); const colonIdx = trimmed.lastIndexOf(":"); if (colonIdx > 0) { @@ -321,12 +323,16 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload { const [, deviceType, status, controlsStr] = parts; const deviceKey = toSlug(deviceName || "device"); const controls = controlsStr - ? controlsStr.split(",").map((ctrlStr) => { + ? normalizeStringEntries(controlsStr.split(",")).flatMap((ctrlStr) => { const controlParts = ctrlStr.split(":").map((s) => s.trim()); const label = expectDefined(controlParts[0], "device control label"); + // A nonempty raw entry can still parse to `:data`; LINE rejects a blank action label. + if (!label) { + return []; + } const data = controlParts[1]; const action = data || normalizeLowercaseStringOrEmpty(label).replace(/\s+/g, "_"); - return { label, data: lineActionData(action, { "line.device": deviceKey }) }; + return [{ label, data: lineActionData(action, { "line.device": deviceKey }) }]; }) : [];