Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flex-messages-postback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chat-adapter-line": minor
---

Add support for translating Chat SDK JSX cards to LINE Flex Messages and handling postback events.
106 changes: 106 additions & 0 deletions __tests__/lib/flex-messages.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
81 changes: 56 additions & 25 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -89,19 +93,16 @@ const readableToBuffer = async (
return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
};

const isLineMessageEvent = (
event: LineMessageEvent | Record<string, unknown>
): event is LineMessageEvent =>
event.type === "message" &&
const isLineEvent = (
event: LineEvent | Record<string, unknown>
): event is LineEvent =>
(event.type === "message" || event.type === "postback") &&
typeof event.source === "object" &&
event.source !== null &&
typeof (event.source as Record<string, unknown>).type === "string" &&
typeof event.timestamp === "number" &&
typeof event.replyToken === "string" &&
typeof event.message === "object" &&
event.message !== null &&
typeof (event.message as Record<string, unknown>).type === "string" &&
typeof (event.message as Record<string, unknown>).id === "string";
typeof event.webhookEventId === "string";

const getMimeType = (type: string): string => {
if (type === "image") {
Expand Down Expand Up @@ -141,7 +142,7 @@ export class LineFormatConverter extends BaseFormatConverter {
}
}

export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
export class LineAdapter implements Adapter<LineThreadId, LineEvent> {
readonly name = "line";
readonly userName: string;

Expand Down Expand Up @@ -228,7 +229,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
const channelId = this.channelId ?? payload.destination;

for (const event of payload.events) {
if (!isLineMessageEvent(event)) {
if (!isLineEvent(event)) {
continue;
}
if (event.mode !== "active") {
Expand All @@ -245,7 +246,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {

const threadId = encodeThreadId(event.source.type, channelId, sourceId);

const factory = (): Promise<Message<LineMessageEvent>> =>
const factory = (): Promise<Message<LineEvent>> =>
Promise.resolve(this.parseMessage(event));

try {
Expand All @@ -261,7 +262,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
return new Response("OK", { status: 200 });
}

parseMessage(raw: LineMessageEvent): Message<LineMessageEvent> {
parseMessage(raw: LineEvent): Message<LineEvent> {
const sourceId = getSourceIdFromEvent(raw);

if (!this.channelId) {
Expand All @@ -279,9 +280,38 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
}

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" &&
Expand Down Expand Up @@ -323,7 +353,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
async postMessage(
threadId: string,
message: AdapterPostableMessage
): Promise<RawMessage<LineMessageEvent>> {
): Promise<RawMessage<LineEvent>> {
const { sourceId } = this.decodeThreadId(threadId);

const card = extractCard(message);
Expand All @@ -335,11 +365,12 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
});
}

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") {
Expand Down Expand Up @@ -370,9 +401,9 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
threadId: string,
textStream: AsyncIterable<string | StreamChunk>,
_options?: StreamOptions
): Promise<RawMessage<LineMessageEvent>> {
): Promise<RawMessage<LineEvent>> {
const { sourceId } = this.decodeThreadId(threadId);
let lastResult: RawMessage<LineMessageEvent> | undefined;
let lastResult: RawMessage<LineEvent> | undefined;
let buffer = "";
let sentCount = 0;

Expand Down Expand Up @@ -416,7 +447,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
result: { sentMessages?: { id: string }[] },
text: string,
threadId: string
): RawMessage<LineMessageEvent> {
): RawMessage<LineEvent> {
const messageId = result.sentMessages?.[0]?.id ?? "";
return {
id: messageId,
Expand All @@ -438,7 +469,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
};
}

private buildEmptyRawMessage(threadId: string): RawMessage<LineMessageEvent> {
private buildEmptyRawMessage(threadId: string): RawMessage<LineEvent> {
return {
id: "",
raw: {
Expand All @@ -459,7 +490,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
_threadId: string,
_messageId: string,
_message: AdapterPostableMessage
): Promise<RawMessage<LineMessageEvent>> {
): Promise<RawMessage<LineEvent>> {
throw new PermissionError("line", "LINE does not support message editing");
}

Expand All @@ -486,7 +517,7 @@ export class LineAdapter implements Adapter<LineThreadId, LineMessageEvent> {
fetchMessages(
_threadId: string,
_options?: FetchOptions
): Promise<FetchResult<LineMessageEvent>> {
): Promise<FetchResult<LineEvent>> {
throw new PermissionError("line", "LINE does not provide message history");
}

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export const createLineAdapter = (
export { LineAdapter, LineFormatConverter } from "./adapter.js";
export type {
LineAdapterConfig,
LineEvent,
LineMessageEvent,
LinePostbackEvent,
LineRawMessage,
LineThreadId,
LineWebhookPayload,
Expand Down
Loading