fix(line): drop blank entries from trailing commas in agenda/device directives (#105381)

* fix(line): drop blank entries from trailing commas in agenda/device directives

* fix(line): drop blank device action labels

Co-authored-by: Eden <146086744+edenfunf@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Eden
2026-07-15 15:32:30 +08:00
committed by GitHub
parent 53cc2977ac
commit cce791607e
2 changed files with 57 additions and 3 deletions
@@ -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<string, unknown>;
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 = [
@@ -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 }) }];
})
: [];