diff --git a/.changeset/flex-messages-postback.md b/.changeset/flex-messages-postback.md new file mode 100644 index 0000000..2e231ae --- /dev/null +++ b/.changeset/flex-messages-postback.md @@ -0,0 +1,5 @@ +--- +"chat-adapter-line": minor +--- + +Add support for translating Chat SDK JSX cards to LINE Flex Messages and handling postback events. diff --git a/__tests__/lib/flex-messages.test.ts b/__tests__/lib/flex-messages.test.ts new file mode 100644 index 0000000..0ed0bdd --- /dev/null +++ b/__tests__/lib/flex-messages.test.ts @@ -0,0 +1,106 @@ +import { ValidationError } from "@chat-adapter/shared"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildFlexMessage, + serializePostbackData, + deserializePostbackData, +} from "../../src/lib/flex-messages.js"; + +describe("Flex Messages Utility", () => { + describe("Postback Data Serialization", () => { + it("should serialize id and value correctly", () => { + const result = serializePostbackData("btn-1", "val-1"); + expect(result).toBe("id=btn-1&v=val-1"); + }); + + it("should serialize id only", () => { + const result = serializePostbackData("btn-1"); + expect(result).toBe("id=btn-1"); + }); + + it("should throw ValidationError if length exceeds 300 chars", () => { + const longValue = "a".repeat(300); + expect(() => serializePostbackData("btn-1", longValue)).toThrowError( + ValidationError + ); + }); + }); + + describe("Postback Data Deserialization", () => { + it("should deserialize id and value correctly", () => { + const result = deserializePostbackData("id=btn-1&v=val-1"); + expect(result).toEqual({ id: "btn-1", value: "val-1" }); + }); + + it("should deserialize id only", () => { + const result = deserializePostbackData("id=btn-1"); + expect(result).toEqual({ id: "btn-1", value: undefined }); + }); + + it("should return null for invalid data", () => { + const result = deserializePostbackData("v=val-1"); + expect(result).toBeNull(); + }); + }); + + describe("buildFlexMessage", () => { + it("should convert a basic Card to Flex Message", () => { + const card: unknown = { + props: { + children: [ + { + props: { + children: "Hello World", + }, + type: "CardText", + }, + { + props: { + children: [ + { + props: { + children: "Click Me", + id: "btn-1", + style: "primary", + value: "val-1", + }, + type: "Button", + }, + ], + }, + type: "Actions", + }, + ], + title: "My Title", + }, + type: "Card", + }; + + const flexMessage = buildFlexMessage(card); + expect(flexMessage.type).toBe("flex"); + expect(flexMessage.altText).toBe("My Title"); + + const contents = flexMessage.contents as unknown as { + type: string; + body: { contents: unknown[] }; + footer: { contents: unknown[] }; + }; + + expect(contents.type).toBe("bubble"); + expect(contents.body.contents.length).toBe(2); + expect((contents.body.contents[0] as { text: string }).text).toBe( + "My Title" + ); + expect((contents.body.contents[1] as { text: string }).text).toBe( + "Hello World" + ); + + expect(contents.footer.contents.length).toBe(1); + expect( + (contents.footer.contents[0] as { action: { data: string } }).action + .data + ).toBe("id=btn-1&v=val-1"); + }); + }); +}); diff --git a/src/adapter.ts b/src/adapter.ts index f64372c..90fe7bf 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -35,11 +35,15 @@ import { stringifyMarkdown, } from "chat"; +import { + buildFlexMessage, + deserializePostbackData, +} from "./lib/flex-messages.js"; import { decodeThreadId, encodeThreadId, isDM } from "./lib/thread-id.js"; import { toPlainText } from "./lib/to-plain-text.js"; import type { LineAdapterConfig, - LineMessageEvent, + LineEvent, LineThreadId, LineWebhookPayload, } from "./types.js"; @@ -68,7 +72,7 @@ const verifySignature = ( return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(hash)); }; -const getSourceIdFromEvent = (event: LineMessageEvent): string | undefined => { +const getSourceIdFromEvent = (event: LineEvent): string | undefined => { const { source } = event; if (source.type === "user") { return source.userId; @@ -89,19 +93,16 @@ const readableToBuffer = async ( return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks); }; -const isLineMessageEvent = ( - event: LineMessageEvent | Record -): event is LineMessageEvent => - event.type === "message" && +const isLineEvent = ( + event: LineEvent | Record +): event is LineEvent => + (event.type === "message" || event.type === "postback") && typeof event.source === "object" && event.source !== null && typeof (event.source as Record).type === "string" && typeof event.timestamp === "number" && typeof event.replyToken === "string" && - typeof event.message === "object" && - event.message !== null && - typeof (event.message as Record).type === "string" && - typeof (event.message as Record).id === "string"; + typeof event.webhookEventId === "string"; const getMimeType = (type: string): string => { if (type === "image") { @@ -141,7 +142,7 @@ export class LineFormatConverter extends BaseFormatConverter { } } -export class LineAdapter implements Adapter { +export class LineAdapter implements Adapter { readonly name = "line"; readonly userName: string; @@ -228,7 +229,7 @@ export class LineAdapter implements Adapter { const channelId = this.channelId ?? payload.destination; for (const event of payload.events) { - if (!isLineMessageEvent(event)) { + if (!isLineEvent(event)) { continue; } if (event.mode !== "active") { @@ -245,7 +246,7 @@ export class LineAdapter implements Adapter { const threadId = encodeThreadId(event.source.type, channelId, sourceId); - const factory = (): Promise> => + const factory = (): Promise> => Promise.resolve(this.parseMessage(event)); try { @@ -261,7 +262,7 @@ export class LineAdapter implements Adapter { return new Response("OK", { status: 200 }); } - parseMessage(raw: LineMessageEvent): Message { + parseMessage(raw: LineEvent): Message { const sourceId = getSourceIdFromEvent(raw); if (!this.channelId) { @@ -279,9 +280,38 @@ export class LineAdapter implements Adapter { } const userId = raw.source.userId ?? "unknown"; - const text = raw.message.type === "text" ? (raw.message.text ?? "") : ""; const threadId = encodeThreadId(raw.source.type, this.channelId, sourceId); + if (raw.type === "postback") { + const data = deserializePostbackData(raw.postback.data); + const actionIds = data?.id ? [data.id] : undefined; + const metadata = data?.value ? { actionValue: data.value } : undefined; + + return new Message({ + attachments: [], + author: { + fullName: "", + isBot: false, + isMe: false, + userId, + userName: userId, + }, + formatted: this.converter.toAst(""), + id: raw.webhookEventId, + metadata: { + dateSent: new Date(raw.timestamp), + edited: false, + ...(actionIds ? { actionIds } : {}), + ...metadata, + }, + raw, + text: "", + threadId, + }); + } + + const text = raw.message.type === "text" ? (raw.message.text ?? "") : ""; + const attachments: Attachment[] = []; if ( raw.message.type !== "text" && @@ -323,7 +353,7 @@ export class LineAdapter implements Adapter { async postMessage( threadId: string, message: AdapterPostableMessage - ): Promise> { + ): Promise> { const { sourceId } = this.decodeThreadId(threadId); const card = extractCard(message); @@ -335,11 +365,12 @@ export class LineAdapter implements Adapter { }); } - const lineMessages: messagingApi.Message[] = []; + const lineMessages: (messagingApi.Message | messagingApi.FlexMessage)[] = + []; if (card) { - const rendered = this.converter.renderPostable({ card }); - lineMessages.push({ text: rendered, type: "text" }); + const flexMessage = buildFlexMessage(card); + lineMessages.push(flexMessage); } else if (typeof message === "string") { lineMessages.push({ text: message, type: "text" }); } else if ("text" in message && typeof message.text === "string") { @@ -370,9 +401,9 @@ export class LineAdapter implements Adapter { threadId: string, textStream: AsyncIterable, _options?: StreamOptions - ): Promise> { + ): Promise> { const { sourceId } = this.decodeThreadId(threadId); - let lastResult: RawMessage | undefined; + let lastResult: RawMessage | undefined; let buffer = ""; let sentCount = 0; @@ -416,7 +447,7 @@ export class LineAdapter implements Adapter { result: { sentMessages?: { id: string }[] }, text: string, threadId: string - ): RawMessage { + ): RawMessage { const messageId = result.sentMessages?.[0]?.id ?? ""; return { id: messageId, @@ -438,7 +469,7 @@ export class LineAdapter implements Adapter { }; } - private buildEmptyRawMessage(threadId: string): RawMessage { + private buildEmptyRawMessage(threadId: string): RawMessage { return { id: "", raw: { @@ -459,7 +490,7 @@ export class LineAdapter implements Adapter { _threadId: string, _messageId: string, _message: AdapterPostableMessage - ): Promise> { + ): Promise> { throw new PermissionError("line", "LINE does not support message editing"); } @@ -486,7 +517,7 @@ export class LineAdapter implements Adapter { fetchMessages( _threadId: string, _options?: FetchOptions - ): Promise> { + ): Promise> { throw new PermissionError("line", "LINE does not provide message history"); } diff --git a/src/index.ts b/src/index.ts index 6144512..e531888 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,7 +59,9 @@ export const createLineAdapter = ( export { LineAdapter, LineFormatConverter } from "./adapter.js"; export type { LineAdapterConfig, + LineEvent, LineMessageEvent, + LinePostbackEvent, LineRawMessage, LineThreadId, LineWebhookPayload, diff --git a/src/lib/flex-messages.ts b/src/lib/flex-messages.ts new file mode 100644 index 0000000..4bea5aa --- /dev/null +++ b/src/lib/flex-messages.ts @@ -0,0 +1,216 @@ +import { ValidationError } from "@chat-adapter/shared"; +import type { messagingApi } from "@line/bot-sdk"; + +const MAX_POSTBACK_DATA_LENGTH = 300; + +/** + * Serializes a button ID and value into a URL-encoded string. + * Format: id=&v= + */ +export const serializePostbackData = (id: string, value?: string): string => { + const params = new URLSearchParams(); + params.set("id", id); + if (value) { + params.set("v", value); + } + const serialized = params.toString(); + + if (serialized.length > MAX_POSTBACK_DATA_LENGTH) { + throw new ValidationError( + "line", + `Button data exceeds LINE's ${MAX_POSTBACK_DATA_LENGTH} character limit: ${serialized}` + ); + } + + return serialized; +}; + +/** + * Deserializes a URL-encoded string back into an ID and value. + */ +export const deserializePostbackData = ( + data: string +): { id: string; value?: string } | null => { + try { + const params = new URLSearchParams(data); + const id = params.get("id"); + const value = params.get("v") || undefined; + + if (!id) { + return null; + } + + return { id, value }; + } catch { + return null; + } +}; + +/** + * Converts Chat SDK Card JSX into a LINE Flex Message payload. + */ +// eslint-disable-next-line complexity +export const buildFlexMessage = (card: unknown): messagingApi.FlexMessage => { + const bodyContents: messagingApi.FlexComponent[] = []; + const footerContents: messagingApi.FlexButton[] = []; + + const typedCard = card as Record; + const props = (typedCard?.props as Record) || {}; + + if (props.title) { + bodyContents.push({ + size: "xl", + text: props.title as string, + type: "text", + weight: "bold", + wrap: true, + }); + } + + let children: unknown[]; + if (Array.isArray(props.children)) { + ({ children } = props); + } else if (props.children) { + children = [props.children]; + } else { + children = []; + } + + for (const child of children) { + if (!child || typeof child !== "object") { + continue; + } + + const typedChild = child as Record; + const { type } = typedChild; + const childProps = (typedChild.props as Record) || {}; + + if (type === "CardText") { + bodyContents.push({ + margin: "md", + size: "md", + text: (childProps.children as string) || "", + type: "text", + wrap: true, + }); + } else if (type === "Section") { + if (childProps.title) { + bodyContents.push({ + color: "#aaaaaa", + margin: "md", + size: "sm", + text: childProps.title as string, + type: "text", + weight: "bold", + }); + } + + let sectionChildren: unknown[]; + if (Array.isArray(childProps.children)) { + sectionChildren = childProps.children; + } else if (childProps.children) { + sectionChildren = [childProps.children]; + } else { + sectionChildren = []; + } + + for (const sectionChild of sectionChildren) { + if (!sectionChild || typeof sectionChild !== "object") { + continue; + } + + const typedSectionChild = sectionChild as Record; + const sectionChildType = typedSectionChild.type; + const sectionChildProps = + (typedSectionChild.props as Record) || {}; + + if (sectionChildType === "CardText") { + bodyContents.push({ + margin: "sm", + size: "sm", + text: (sectionChildProps.children as string) || "", + type: "text", + wrap: true, + }); + } + } + } else if (type === "Actions") { + let actionChildren: unknown[]; + if (Array.isArray(childProps.children)) { + actionChildren = childProps.children; + } else if (childProps.children) { + actionChildren = [childProps.children]; + } else { + actionChildren = []; + } + + for (const actionChild of actionChildren) { + if (!actionChild || typeof actionChild !== "object") { + continue; + } + + const typedActionChild = actionChild as Record; + const actionChildType = typedActionChild.type; + const actionChildProps = + (typedActionChild.props as Record) || {}; + + if (actionChildType === "Button") { + const actionId = + (actionChildProps.id as string) || + (actionChildProps.actionId as string); + const actionValue = actionChildProps.value as string; + + if (!actionId) { + continue; + } + + const label = (actionChildProps.children as string) || actionId; + + footerContents.push({ + action: { + data: serializePostbackData(actionId, actionValue), + displayText: String(label).slice(0, 300), + label: String(label).slice(0, 20), + type: "postback", + }, + style: + actionChildProps.style === "primary" ? "primary" : "secondary", + type: "button", + }); + } + } + } + } + + if (bodyContents.length === 0) { + bodyContents.push({ + text: "Empty Card", + type: "text", + wrap: true, + }); + } + + const bubble: messagingApi.FlexBubble = { + body: { + contents: bodyContents, + layout: "vertical", + type: "box", + }, + type: "bubble", + }; + + if (footerContents.length > 0) { + bubble.footer = { + contents: footerContents, + layout: "vertical", + spacing: "sm", + type: "box", + }; + } + + return { + altText: (props.title as string) || "Flex Message", + contents: bubble, + type: "flex", + }; +}; diff --git a/src/types.ts b/src/types.ts index 5358599..d71fd21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,10 +53,38 @@ export interface LineMessageEvent { }; } +/** Raw LINE webhook postback event */ +export interface LinePostbackEvent { + type: "postback"; + postback: { + data: string; + params?: { + date?: string; + time?: string; + datetime?: string; + }; + }; + timestamp: number; + source: { + type: "user" | "group" | "room"; + userId?: string; + groupId?: string; + roomId?: string; + }; + replyToken: string; + mode: "active" | "standby"; + webhookEventId: string; + deliveryContext: { + isRedelivery: boolean; + }; +} + +export type LineEvent = LineMessageEvent | LinePostbackEvent; + /** Raw LINE webhook payload (top-level) */ export interface LineWebhookPayload { destination: string; - events: (LineMessageEvent | Record)[]; + events: (LineEvent | Record)[]; } /** Response from LINE send message API */ diff --git a/tsconfig.json b/tsconfig.json index 439c219..e42b4ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,8 @@ "strict": true, "target": "ES2022", "strictNullChecks": true, - "types": ["vite/client", "node"] + "types": ["vite/client", "node"], + "jsx": "react-jsx", + "jsxImportSource": "chat" } }