fix(line): harden outbound send behavior

This commit is contained in:
Peter Steinberger
2026-02-22 11:28:49 +00:00
parent 32a1273d82
commit 05358173da
2 changed files with 336 additions and 230 deletions

View File

@@ -1,11 +1,228 @@
import { describe, expect, it } from "vitest"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createQuickReplyItems } from "./send.js";
describe("createQuickReplyItems", () => { const {
it("limits items to 13 (LINE maximum)", () => { pushMessageMock,
const labels = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`); replyMessageMock,
const quickReply = createQuickReplyItems(labels); showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
loadConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
} = vi.hoisted(() => {
const pushMessageMock = vi.fn();
const replyMessageMock = vi.fn();
const showLoadingAnimationMock = vi.fn();
const getProfileMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
const loadConfigMock = vi.fn(() => ({}));
const resolveLineAccountMock = vi.fn(() => ({ accountId: "default" }));
const resolveLineChannelAccessTokenMock = vi.fn(() => "line-token");
const recordChannelActivityMock = vi.fn();
const logVerboseMock = vi.fn();
return {
pushMessageMock,
replyMessageMock,
showLoadingAnimationMock,
getProfileMock,
MessagingApiClientMock,
loadConfigMock,
resolveLineAccountMock,
resolveLineChannelAccessTokenMock,
recordChannelActivityMock,
logVerboseMock,
};
});
vi.mock("@line/bot-sdk", () => ({
messagingApi: { MessagingApiClient: MessagingApiClientMock },
}));
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.mock("./accounts.js", () => ({
resolveLineAccount: resolveLineAccountMock,
}));
vi.mock("./channel-access-token.js", () => ({
resolveLineChannelAccessToken: resolveLineChannelAccessTokenMock,
}));
vi.mock("../infra/channel-activity.js", () => ({
recordChannelActivity: recordChannelActivityMock,
}));
vi.mock("../globals.js", () => ({
logVerbose: logVerboseMock,
}));
let sendModule: typeof import("./send.js");
describe("LINE send helpers", () => {
beforeAll(async () => {
sendModule = await import("./send.js");
});
beforeEach(() => {
pushMessageMock.mockReset();
replyMessageMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockClear();
loadConfigMock.mockReset();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
loadConfigMock.mockReturnValue({});
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
pushMessageMock.mockResolvedValue({});
replyMessageMock.mockResolvedValue({});
showLoadingAnimationMock.mockResolvedValue({});
});
afterEach(() => {
vi.useRealTimers();
});
it("limits quick reply items to 13", () => {
const labels = Array.from({ length: 20 }, (_, index) => `Option ${index + 1}`);
const quickReply = sendModule.createQuickReplyItems(labels);
expect(quickReply.items).toHaveLength(13); expect(quickReply.items).toHaveLength(13);
}); });
it("pushes images via normalized LINE target", async () => {
const result = await sendModule.pushImageMessage(
"line:user:U123",
"https://example.com/original.jpg",
undefined,
{ verbose: true },
);
expect(pushMessageMock).toHaveBeenCalledWith({
to: "U123",
messages: [
{
type: "image",
originalContentUrl: "https://example.com/original.jpg",
previewImageUrl: "https://example.com/original.jpg",
},
],
});
expect(recordChannelActivityMock).toHaveBeenCalledWith({
channel: "line",
accountId: "default",
direction: "outbound",
});
expect(logVerboseMock).toHaveBeenCalledWith("line: pushed image to U123");
expect(result).toEqual({ messageId: "push", chatId: "U123" });
});
it("replies when reply token is provided", async () => {
const result = await sendModule.sendMessageLine("line:group:C1", "Hello", {
replyToken: "reply-token",
mediaUrl: "https://example.com/media.jpg",
verbose: true,
});
expect(replyMessageMock).toHaveBeenCalledTimes(1);
expect(pushMessageMock).not.toHaveBeenCalled();
expect(replyMessageMock).toHaveBeenCalledWith({
replyToken: "reply-token",
messages: [
{
type: "image",
originalContentUrl: "https://example.com/media.jpg",
previewImageUrl: "https://example.com/media.jpg",
},
{
type: "text",
text: "Hello",
},
],
});
expect(logVerboseMock).toHaveBeenCalledWith("line: replied to C1");
expect(result).toEqual({ messageId: "reply", chatId: "C1" });
});
it("throws when push messages are empty", async () => {
await expect(sendModule.pushMessagesLine("U123", [])).rejects.toThrow(
"Message must be non-empty for LINE sends",
);
});
it("logs HTTP body when push fails", async () => {
const err = new Error("LINE push failed") as Error & {
status: number;
statusText: string;
body: string;
};
err.status = 400;
err.statusText = "Bad Request";
err.body = "invalid flex payload";
pushMessageMock.mockRejectedValueOnce(err);
await expect(
sendModule.pushMessagesLine("U999", [{ type: "text", text: "hello" }]),
).rejects.toThrow("LINE push failed");
expect(logVerboseMock).toHaveBeenCalledWith(
"line: push message failed (400 Bad Request): invalid flex payload",
);
});
it("caches profile results by default", async () => {
getProfileMock.mockResolvedValue({
displayName: "Peter",
pictureUrl: "https://example.com/peter.jpg",
});
const first = await sendModule.getUserProfile("U-cache");
const second = await sendModule.getUserProfile("U-cache");
expect(first).toEqual({
displayName: "Peter",
pictureUrl: "https://example.com/peter.jpg",
});
expect(second).toEqual(first);
expect(getProfileMock).toHaveBeenCalledTimes(1);
});
it("continues when loading animation is unsupported", async () => {
showLoadingAnimationMock.mockRejectedValueOnce(new Error("unsupported"));
await expect(sendModule.showLoadingAnimation("line:room:R1")).resolves.toBeUndefined();
expect(logVerboseMock).toHaveBeenCalledWith(
expect.stringContaining("line: loading animation failed (non-fatal)"),
);
});
it("pushes quick-reply text and caps to 13 buttons", async () => {
await sendModule.pushTextMessageWithQuickReplies(
"U-quick",
"Pick one",
Array.from({ length: 20 }, (_, index) => `Choice ${index + 1}`),
);
expect(pushMessageMock).toHaveBeenCalledTimes(1);
const firstCall = pushMessageMock.mock.calls[0] as [
{ messages: Array<{ quickReply?: { items: unknown[] } }> },
];
expect(firstCall[0].messages[0].quickReply?.items).toHaveLength(13);
});
}); });

View File

@@ -32,6 +32,18 @@ interface LineSendOpts {
replyToken?: string; replyToken?: string;
} }
type LineClientOpts = Pick<LineSendOpts, "channelAccessToken" | "accountId">;
type LinePushOpts = Pick<LineSendOpts, "channelAccessToken" | "accountId" | "verbose">;
interface LinePushBehavior {
errorContext?: string;
verboseMessage?: (chatId: string, messageCount: number) => string;
}
interface LineReplyBehavior {
verboseMessage?: (messageCount: number) => string;
}
function normalizeTarget(to: string): string { function normalizeTarget(to: string): string {
const trimmed = to.trim(); const trimmed = to.trim();
if (!trimmed) { if (!trimmed) {
@@ -52,7 +64,7 @@ function normalizeTarget(to: string): string {
return normalized; return normalized;
} }
function createLineMessagingClient(opts: { channelAccessToken?: string; accountId?: string }): { function createLineMessagingClient(opts: LineClientOpts): {
account: ReturnType<typeof resolveLineAccount>; account: ReturnType<typeof resolveLineAccount>;
client: messagingApi.MessagingApiClient; client: messagingApi.MessagingApiClient;
} { } {
@@ -70,7 +82,7 @@ function createLineMessagingClient(opts: { channelAccessToken?: string; accountI
function createLinePushContext( function createLinePushContext(
to: string, to: string,
opts: { channelAccessToken?: string; accountId?: string }, opts: LineClientOpts,
): { ): {
account: ReturnType<typeof resolveLineAccount>; account: ReturnType<typeof resolveLineAccount>;
client: messagingApi.MessagingApiClient; client: messagingApi.MessagingApiClient;
@@ -126,23 +138,85 @@ function logLineHttpError(err: unknown, context: string): void {
} }
} }
function recordLineOutboundActivity(accountId: string): void {
recordChannelActivity({
channel: "line",
accountId,
direction: "outbound",
});
}
async function pushLineMessages(
to: string,
messages: Message[],
opts: LinePushOpts = {},
behavior: LinePushBehavior = {},
): Promise<LineSendResult> {
if (messages.length === 0) {
throw new Error("Message must be non-empty for LINE sends");
}
const { account, client, chatId } = createLinePushContext(to, opts);
const pushRequest = client.pushMessage({
to: chatId,
messages,
});
if (behavior.errorContext) {
const errorContext = behavior.errorContext;
await pushRequest.catch((err) => {
logLineHttpError(err, errorContext);
throw err;
});
} else {
await pushRequest;
}
recordLineOutboundActivity(account.accountId);
if (opts.verbose) {
const logMessage =
behavior.verboseMessage?.(chatId, messages.length) ??
`line: pushed ${messages.length} messages to ${chatId}`;
logVerbose(logMessage);
}
return {
messageId: "push",
chatId,
};
}
async function replyLineMessages(
replyToken: string,
messages: Message[],
opts: LinePushOpts = {},
behavior: LineReplyBehavior = {},
): Promise<void> {
const { account, client } = createLineMessagingClient(opts);
await client.replyMessage({
replyToken,
messages,
});
recordLineOutboundActivity(account.accountId);
if (opts.verbose) {
logVerbose(
behavior.verboseMessage?.(messages.length) ??
`line: replied with ${messages.length} messages`,
);
}
}
export async function sendMessageLine( export async function sendMessageLine(
to: string, to: string,
text: string, text: string,
opts: LineSendOpts = {}, opts: LineSendOpts = {},
): Promise<LineSendResult> { ): Promise<LineSendResult> {
const cfg = loadConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
const chatId = normalizeTarget(to); const chatId = normalizeTarget(to);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
const messages: Message[] = []; const messages: Message[] = [];
// Add media if provided // Add media if provided
@@ -161,21 +235,10 @@ export async function sendMessageLine(
// Use reply if we have a reply token, otherwise push // Use reply if we have a reply token, otherwise push
if (opts.replyToken) { if (opts.replyToken) {
await client.replyMessage({ await replyLineMessages(opts.replyToken, messages, opts, {
replyToken: opts.replyToken, verboseMessage: () => `line: replied to ${chatId}`,
messages,
}); });
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: replied to ${chatId}`);
}
return { return {
messageId: "reply", messageId: "reply",
chatId, chatId,
@@ -183,25 +246,9 @@ export async function sendMessageLine(
} }
// Push message (for proactive messaging) // Push message (for proactive messaging)
await client.pushMessage({ return pushLineMessages(chatId, messages, opts, {
to: chatId, verboseMessage: (resolvedChatId) => `line: pushed message to ${resolvedChatId}`,
messages,
}); });
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed message to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
} }
export async function pushMessageLine( export async function pushMessageLine(
@@ -216,61 +263,19 @@ export async function pushMessageLine(
export async function replyMessageLine( export async function replyMessageLine(
replyToken: string, replyToken: string,
messages: Message[], messages: Message[],
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, opts: LinePushOpts = {},
): Promise<void> { ): Promise<void> {
const { account, client } = createLineMessagingClient(opts); await replyLineMessages(replyToken, messages, opts);
await client.replyMessage({
replyToken,
messages,
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: replied with ${messages.length} messages`);
}
} }
export async function pushMessagesLine( export async function pushMessagesLine(
to: string, to: string,
messages: Message[], messages: Message[],
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, opts: LinePushOpts = {},
): Promise<LineSendResult> { ): Promise<LineSendResult> {
if (messages.length === 0) { return pushLineMessages(to, messages, opts, {
throw new Error("Message must be non-empty for LINE sends"); errorContext: "push message",
}
const { account, client, chatId } = createLinePushContext(to, opts);
await client
.pushMessage({
to: chatId,
messages,
})
.catch((err) => {
logLineHttpError(err, "push message");
throw err;
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
}); });
if (opts.verbose) {
logVerbose(`line: pushed ${messages.length} messages to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
} }
export function createFlexMessage( export function createFlexMessage(
@@ -291,31 +296,11 @@ export async function pushImageMessage(
to: string, to: string,
originalContentUrl: string, originalContentUrl: string,
previewImageUrl?: string, previewImageUrl?: string,
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, opts: LinePushOpts = {},
): Promise<LineSendResult> { ): Promise<LineSendResult> {
const { account, client, chatId } = createLinePushContext(to, opts); return pushLineMessages(to, [createImageMessage(originalContentUrl, previewImageUrl)], opts, {
verboseMessage: (chatId) => `line: pushed image to ${chatId}`,
const imageMessage = createImageMessage(originalContentUrl, previewImageUrl);
await client.pushMessage({
to: chatId,
messages: [imageMessage],
}); });
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed image to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
} }
/** /**
@@ -329,31 +314,11 @@ export async function pushLocationMessage(
latitude: number; latitude: number;
longitude: number; longitude: number;
}, },
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, opts: LinePushOpts = {},
): Promise<LineSendResult> { ): Promise<LineSendResult> {
const { account, client, chatId } = createLinePushContext(to, opts); return pushLineMessages(to, [createLocationMessage(location)], opts, {
verboseMessage: (chatId) => `line: pushed location to ${chatId}`,
const locationMessage = createLocationMessage(location);
await client.pushMessage({
to: chatId,
messages: [locationMessage],
}); });
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed location to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
} }
/** /**
@@ -363,40 +328,18 @@ export async function pushFlexMessage(
to: string, to: string,
altText: string, altText: string,
contents: FlexContainer, contents: FlexContainer,
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, opts: LinePushOpts = {},
): Promise<LineSendResult> { ): Promise<LineSendResult> {
const { account, client, chatId } = createLinePushContext(to, opts);
const flexMessage: FlexMessage = { const flexMessage: FlexMessage = {
type: "flex", type: "flex",
altText: altText.slice(0, 400), // LINE limit altText: altText.slice(0, 400), // LINE limit
contents, contents,
}; };
await client return pushLineMessages(to, [flexMessage], opts, {
.pushMessage({ errorContext: "push flex message",
to: chatId, verboseMessage: (chatId) => `line: pushed flex message to ${chatId}`,
messages: [flexMessage],
})
.catch((err) => {
logLineHttpError(err, "push flex message");
throw err;
});
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
}); });
if (opts.verbose) {
logVerbose(`line: pushed flex message to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
} }
/** /**
@@ -405,29 +348,11 @@ export async function pushFlexMessage(
export async function pushTemplateMessage( export async function pushTemplateMessage(
to: string, to: string,
template: TemplateMessage, template: TemplateMessage,
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, opts: LinePushOpts = {},
): Promise<LineSendResult> { ): Promise<LineSendResult> {
const { account, client, chatId } = createLinePushContext(to, opts); return pushLineMessages(to, [template], opts, {
verboseMessage: (chatId) => `line: pushed template message to ${chatId}`,
await client.pushMessage({
to: chatId,
messages: [template],
}); });
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed template message to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
} }
/** /**
@@ -437,31 +362,13 @@ export async function pushTextMessageWithQuickReplies(
to: string, to: string,
text: string, text: string,
quickReplyLabels: string[], quickReplyLabels: string[],
opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, opts: LinePushOpts = {},
): Promise<LineSendResult> { ): Promise<LineSendResult> {
const { account, client, chatId } = createLinePushContext(to, opts);
const message = createTextMessageWithQuickReplies(text, quickReplyLabels); const message = createTextMessageWithQuickReplies(text, quickReplyLabels);
await client.pushMessage({ return pushLineMessages(to, [message], opts, {
to: chatId, verboseMessage: (chatId) => `line: pushed message with quick replies to ${chatId}`,
messages: [message],
}); });
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "outbound",
});
if (opts.verbose) {
logVerbose(`line: pushed message with quick replies to ${chatId}`);
}
return {
messageId: "push",
chatId,
};
} }
/** /**
@@ -500,16 +407,7 @@ export async function showLoadingAnimation(
chatId: string, chatId: string,
opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {}, opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {},
): Promise<void> { ): Promise<void> {
const cfg = loadConfig(); const { client } = createLineMessagingClient(opts);
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
try { try {
await client.showLoadingAnimation({ await client.showLoadingAnimation({
@@ -540,16 +438,7 @@ export async function getUserProfile(
} }
} }
const cfg = loadConfig(); const { client } = createLineMessagingClient(opts);
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const token = resolveLineChannelAccessToken(opts.channelAccessToken, account);
const client = new messagingApi.MessagingApiClient({
channelAccessToken: token,
});
try { try {
const profile = await client.getProfile(userId); const profile = await client.getProfile(userId);