fix(line): synthesize media/auth/routing webhook regressions (openclaw#32546) thanks @Takhoffman

Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Tak Hoffman
2026-03-02 23:47:56 -06:00
committed by GitHub
parent 0b3bbfec06
commit 9a5bfb1fe5
11 changed files with 409 additions and 76 deletions

View File

@@ -8,6 +8,11 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.
- LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
- LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
- LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
## 2026.3.2 ## 2026.3.2
### Changes ### Changes

View File

@@ -16,10 +16,15 @@ vi.mock("../pairing/pairing-messages.js", () => ({
buildPairingReply: () => "pairing-reply", buildPairingReply: () => "pairing-reply",
})); }));
const { downloadLineMediaMock } = vi.hoisted(() => ({
downloadLineMediaMock: vi.fn(async () => ({
path: "/tmp/line-media-file.pdf",
contentType: "application/pdf",
})),
}));
vi.mock("./download.js", () => ({ vi.mock("./download.js", () => ({
downloadLineMedia: async () => { downloadLineMedia: downloadLineMediaMock,
throw new Error("downloadLineMedia should not be called from bot-handlers tests");
},
})); }));
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
@@ -80,6 +85,7 @@ describe("handleLineWebhookEvents", () => {
beforeEach(() => { beforeEach(() => {
buildLineMessageContextMock.mockClear(); buildLineMessageContextMock.mockClear();
buildLinePostbackContextMock.mockClear(); buildLinePostbackContextMock.mockClear();
downloadLineMediaMock.mockClear();
readAllowFromStoreMock.mockClear(); readAllowFromStoreMock.mockClear();
upsertPairingRequestMock.mockClear(); upsertPairingRequestMock.mockClear();
}); });
@@ -248,4 +254,94 @@ describe("handleLineWebhookEvents", () => {
expect(processMessage).not.toHaveBeenCalled(); expect(processMessage).not.toHaveBeenCalled();
expect(buildLineMessageContextMock).not.toHaveBeenCalled(); expect(buildLineMessageContextMock).not.toHaveBeenCalled();
}); });
it("downloads file attachments and forwards media refs to message context", async () => {
const processMessage = vi.fn();
const event = {
type: "message",
message: { id: "mf-1", type: "file", fileName: "doc.pdf", fileSize: "42" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "user", userId: "user-file" },
mode: "active",
webhookEventId: "evt-file-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
await handleLineWebhookEvents([event], {
cfg: { channels: { line: {} } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { dmPolicy: "open" },
},
runtime: createRuntime(),
mediaMaxBytes: 1234,
processMessage,
});
expect(downloadLineMediaMock).toHaveBeenCalledTimes(1);
expect(downloadLineMediaMock).toHaveBeenCalledWith("mf-1", "token", 1234);
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
expect(buildLineMessageContextMock).toHaveBeenCalledWith(
expect.objectContaining({
commandAuthorized: false,
allMedia: [
{
path: "/tmp/line-media-file.pdf",
contentType: "application/pdf",
},
],
}),
);
expect(processMessage).toHaveBeenCalledTimes(1);
});
it("continues processing later events when one event handler fails", async () => {
const failingEvent = {
type: "message",
message: { id: "m-err", type: "text", text: "hi" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "user", userId: "user-err" },
mode: "active",
webhookEventId: "evt-err",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const laterEvent = {
...failingEvent,
message: { id: "m-later", type: "text", text: "hello" },
webhookEventId: "evt-later",
} as MessageEvent;
const runtime = createRuntime();
let invocation = 0;
const processMessage = vi.fn(async () => {
if (invocation === 0) {
invocation += 1;
throw new Error("boom");
}
invocation += 1;
});
await handleLineWebhookEvents([failingEvent, laterEvent], {
cfg: { channels: { line: {} } },
account: {
accountId: "default",
enabled: true,
channelAccessToken: "token",
channelSecret: "secret",
tokenSource: "config",
config: { dmPolicy: "open" },
},
runtime,
mediaMaxBytes: 1234,
processMessage,
});
expect(processMessage).toHaveBeenCalledTimes(2);
expect(runtime.error).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -7,6 +7,8 @@ import type {
LeaveEvent, LeaveEvent,
PostbackEvent, PostbackEvent,
} from "@line/bot-sdk"; } from "@line/bot-sdk";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { import {
resolveAllowlistProviderRuntimeGroupPolicy, resolveAllowlistProviderRuntimeGroupPolicy,
@@ -42,6 +44,19 @@ interface MediaRef {
contentType?: string; contentType?: string;
} }
const LINE_DOWNLOADABLE_MESSAGE_TYPES: ReadonlySet<string> = new Set([
"image",
"video",
"audio",
"file",
]);
function isDownloadableLineMessageType(
messageType: MessageEvent["message"]["type"],
): messageType is "image" | "video" | "audio" | "file" {
return LINE_DOWNLOADABLE_MESSAGE_TYPES.has(messageType);
}
export interface LineHandlerContext { export interface LineHandlerContext {
cfg: OpenClawConfig; cfg: OpenClawConfig;
account: ResolvedLineAccount; account: ResolvedLineAccount;
@@ -113,10 +128,15 @@ async function sendLinePairingReply(params: {
} }
} }
type LineAccessDecision = {
allowed: boolean;
commandAuthorized: boolean;
};
async function shouldProcessLineEvent( async function shouldProcessLineEvent(
event: MessageEvent | PostbackEvent, event: MessageEvent | PostbackEvent,
context: LineHandlerContext, context: LineHandlerContext,
): Promise<boolean> { ): Promise<LineAccessDecision> {
const { cfg, account } = context; const { cfg, account } = context;
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source); const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
const senderId = userId ?? ""; const senderId = userId ?? "";
@@ -159,45 +179,59 @@ async function shouldProcessLineEvent(
log: (message) => logVerbose(message), log: (message) => logVerbose(message),
}); });
const denied = { allowed: false, commandAuthorized: false };
if (isGroup) { if (isGroup) {
if (groupConfig?.enabled === false) { if (groupConfig?.enabled === false) {
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`); logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
return false; return denied;
} }
if (typeof groupAllowOverride !== "undefined") { if (typeof groupAllowOverride !== "undefined") {
if (!senderId) { if (!senderId) {
logVerbose("Blocked line group message (group allowFrom override, no sender ID)"); logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
return false; return denied;
} }
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) { if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`); logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
return false; return denied;
} }
} }
if (groupPolicy === "disabled") { if (groupPolicy === "disabled") {
logVerbose("Blocked line group message (groupPolicy: disabled)"); logVerbose("Blocked line group message (groupPolicy: disabled)");
return false; return denied;
} }
if (groupPolicy === "allowlist") { if (groupPolicy === "allowlist") {
if (!senderId) { if (!senderId) {
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)"); logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
return false; return denied;
} }
if (!effectiveGroupAllow.hasEntries) { if (!effectiveGroupAllow.hasEntries) {
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)"); logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
return false; return denied;
} }
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) { if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`); logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
return false; return denied;
} }
} }
return true;
// Resolve command authorization using the same pattern as Telegram/Discord/Slack.
const allowForCommands = effectiveGroupAllow;
const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId });
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const rawText = resolveEventRawText(event);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
});
return { allowed: true, commandAuthorized: commandGate.commandAuthorized };
} }
if (dmPolicy === "disabled") { if (dmPolicy === "disabled") {
logVerbose("Blocked line sender (dmPolicy: disabled)"); logVerbose("Blocked line sender (dmPolicy: disabled)");
return false; return denied;
} }
const dmAllowed = dmPolicy === "open" || isSenderAllowed({ allow: effectiveDmAllow, senderId }); const dmAllowed = dmPolicy === "open" || isSenderAllowed({ allow: effectiveDmAllow, senderId });
@@ -205,7 +239,7 @@ async function shouldProcessLineEvent(
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
if (!senderId) { if (!senderId) {
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)"); logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
return false; return denied;
} }
await sendLinePairingReply({ await sendLinePairingReply({
senderId, senderId,
@@ -215,24 +249,51 @@ async function shouldProcessLineEvent(
} else { } else {
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`); logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
} }
return false; return denied;
} }
return true; // Resolve command authorization for DMs.
const allowForCommands = effectiveDmAllow;
const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId });
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const rawText = resolveEventRawText(event);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
});
return { allowed: true, commandAuthorized: commandGate.commandAuthorized };
}
/** Extract raw text from a LINE message or postback event for command detection. */
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
if (event.type === "message") {
const msg = event.message;
if (msg.type === "text") {
return msg.text;
}
return "";
}
if (event.type === "postback") {
return event.postback?.data?.trim() ?? "";
}
return "";
} }
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> { async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context; const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
const message = event.message; const message = event.message;
if (!(await shouldProcessLineEvent(event, context))) { const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return; return;
} }
// Download media if applicable // Download media if applicable
const allMedia: MediaRef[] = []; const allMedia: MediaRef[] = [];
if (message.type === "image" || message.type === "video" || message.type === "audio") { if (isDownloadableLineMessageType(message.type)) {
try { try {
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes); const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
allMedia.push({ allMedia.push({
@@ -255,6 +316,7 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
allMedia, allMedia,
cfg, cfg,
account, account,
commandAuthorized: decision.commandAuthorized,
}); });
if (!messageContext) { if (!messageContext) {
@@ -298,7 +360,8 @@ async function handlePostbackEvent(
const data = event.postback.data; const data = event.postback.data;
logVerbose(`line: received postback: ${data}`); logVerbose(`line: received postback: ${data}`);
if (!(await shouldProcessLineEvent(event, context))) { const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return; return;
} }
@@ -306,6 +369,7 @@ async function handlePostbackEvent(
event, event,
cfg: context.cfg, cfg: context.cfg,
account: context.account, account: context.account,
commandAuthorized: decision.commandAuthorized,
}); });
if (!postbackContext) { if (!postbackContext) {
return; return;
@@ -344,6 +408,9 @@ export async function handleLineWebhookEvents(
} }
} catch (err) { } catch (err) {
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`)); context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
// Continue processing remaining events in this batch. Webhook ACK is sent
// before processing, so dropping later events here would make them unrecoverable.
continue;
} }
} }
} }

View File

@@ -75,6 +75,7 @@ describe("buildLineMessageContext", () => {
allMedia: [], allMedia: [],
cfg, cfg,
account, account,
commandAuthorized: true,
}); });
expect(context).not.toBeNull(); expect(context).not.toBeNull();
if (!context) { if (!context) {
@@ -92,6 +93,7 @@ describe("buildLineMessageContext", () => {
event, event,
cfg, cfg,
account, account,
commandAuthorized: true,
}); });
expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2"); expect(context?.ctxPayload.OriginatingTo).toBe("line:group:group-2");
@@ -105,9 +107,127 @@ describe("buildLineMessageContext", () => {
event, event,
cfg, cfg,
account, account,
commandAuthorized: true,
}); });
expect(context?.ctxPayload.OriginatingTo).toBe("line:room:room-1"); expect(context?.ctxPayload.OriginatingTo).toBe("line:room:room-1");
expect(context?.ctxPayload.To).toBe("line:room:room-1"); expect(context?.ctxPayload.To).toBe("line:room:room-1");
}); });
it("sets CommandAuthorized=true when authorized", async () => {
const event = createMessageEvent({ type: "user", userId: "user-auth" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
commandAuthorized: true,
});
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
});
it("sets CommandAuthorized=false when not authorized", async () => {
const event = createMessageEvent({ type: "user", userId: "user-noauth" });
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg,
account,
commandAuthorized: false,
});
expect(context?.ctxPayload.CommandAuthorized).toBe(false);
});
it("sets CommandAuthorized on postback context", async () => {
const event = createPostbackEvent({ type: "user", userId: "user-pb" });
const context = await buildLinePostbackContext({
event,
cfg,
account,
commandAuthorized: true,
});
expect(context?.ctxPayload.CommandAuthorized).toBe(true);
});
it("group peer binding matches raw groupId without prefix (#21907)", async () => {
const groupId = "Cc7e3bece1234567890abcdef";
const bindingCfg: OpenClawConfig = {
session: { store: storePath },
agents: {
list: [{ id: "main" }, { id: "line-group-agent" }],
},
bindings: [
{
agentId: "line-group-agent",
match: { channel: "line", peer: { kind: "group", id: groupId } },
},
],
};
const event = {
type: "message",
message: { id: "msg-1", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "group", groupId, userId: "user-1" },
mode: "active",
webhookEventId: "evt-1",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg: bindingCfg,
account,
commandAuthorized: true,
});
expect(context).not.toBeNull();
expect(context!.route.agentId).toBe("line-group-agent");
expect(context!.route.matchedBy).toBe("binding.peer");
});
it("room peer binding matches raw roomId without prefix (#21907)", async () => {
const roomId = "Rr1234567890abcdef";
const bindingCfg: OpenClawConfig = {
session: { store: storePath },
agents: {
list: [{ id: "main" }, { id: "line-room-agent" }],
},
bindings: [
{
agentId: "line-room-agent",
match: { channel: "line", peer: { kind: "group", id: roomId } },
},
],
};
const event = {
type: "message",
message: { id: "msg-2", type: "text", text: "hello" },
replyToken: "reply-token",
timestamp: Date.now(),
source: { type: "room", roomId, userId: "user-2" },
mode: "active",
webhookEventId: "evt-2",
deliveryContext: { isRedelivery: false },
} as MessageEvent;
const context = await buildLineMessageContext({
event,
allMedia: [],
cfg: bindingCfg,
account,
commandAuthorized: true,
});
expect(context).not.toBeNull();
expect(context!.route.agentId).toBe("line-room-agent");
expect(context!.route.matchedBy).toBe("binding.peer");
});
}); });

View File

@@ -22,6 +22,7 @@ interface BuildLineMessageContextParams {
allMedia: MediaRef[]; allMedia: MediaRef[];
cfg: OpenClawConfig; cfg: OpenClawConfig;
account: ResolvedLineAccount; account: ResolvedLineAccount;
commandAuthorized: boolean;
} }
export type LineSourceInfo = { export type LineSourceInfo = {
@@ -49,10 +50,10 @@ export function getLineSourceInfo(source: EventSource): LineSourceInfo {
function buildPeerId(source: EventSource): string { function buildPeerId(source: EventSource): string {
if (source.type === "group" && source.groupId) { if (source.type === "group" && source.groupId) {
return `group:${source.groupId}`; return source.groupId;
} }
if (source.type === "room" && source.roomId) { if (source.type === "room" && source.roomId) {
return `room:${source.roomId}`; return source.roomId;
} }
if (source.type === "user" && source.userId) { if (source.type === "user" && source.userId) {
return source.userId; return source.userId;
@@ -229,6 +230,7 @@ async function finalizeLineInboundContext(params: {
rawBody: string; rawBody: string;
timestamp: number; timestamp: number;
messageSid: string; messageSid: string;
commandAuthorized: boolean;
media: { media: {
firstPath: string | undefined; firstPath: string | undefined;
firstContentType?: string; firstContentType?: string;
@@ -300,6 +302,7 @@ async function finalizeLineInboundContext(params: {
MediaUrls: params.media.paths, MediaUrls: params.media.paths,
MediaTypes: params.media.types, MediaTypes: params.media.types,
...params.locationContext, ...params.locationContext,
CommandAuthorized: params.commandAuthorized,
OriginatingChannel: "line" as const, OriginatingChannel: "line" as const,
OriginatingTo: originatingTo, OriginatingTo: originatingTo,
GroupSystemPrompt: params.source.isGroup GroupSystemPrompt: params.source.isGroup
@@ -359,7 +362,7 @@ async function finalizeLineInboundContext(params: {
} }
export async function buildLineMessageContext(params: BuildLineMessageContextParams) { export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
const { event, allMedia, cfg, account } = params; const { event, allMedia, cfg, account, commandAuthorized } = params;
const source = event.source; const source = event.source;
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({ const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
@@ -405,6 +408,7 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar
rawBody, rawBody,
timestamp, timestamp,
messageSid: messageId, messageSid: messageId,
commandAuthorized,
media: { media: {
firstPath: allMedia[0]?.path, firstPath: allMedia[0]?.path,
firstContentType: allMedia[0]?.contentType, firstContentType: allMedia[0]?.contentType,
@@ -435,8 +439,9 @@ export async function buildLinePostbackContext(params: {
event: PostbackEvent; event: PostbackEvent;
cfg: OpenClawConfig; cfg: OpenClawConfig;
account: ResolvedLineAccount; account: ResolvedLineAccount;
commandAuthorized: boolean;
}) { }) {
const { event, cfg, account } = params; const { event, cfg, account, commandAuthorized } = params;
const source = event.source; const source = event.source;
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({ const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
@@ -468,6 +473,7 @@ export async function buildLinePostbackContext(params: {
rawBody, rawBody,
timestamp, timestamp,
messageSid, messageSid,
commandAuthorized,
media: { media: {
firstPath: "", firstPath: "",
firstContentType: undefined, firstContentType: undefined,

View File

@@ -67,46 +67,24 @@ describe("downloadLineMedia", () => {
expect(writeSpy).not.toHaveBeenCalled(); expect(writeSpy).not.toHaveBeenCalled();
}); });
it("detects M4A audio from ftyp major brand (#29751)", async () => { it("classifies M4A ftyp major brand as audio/mp4", async () => {
// Real M4A magic bytes: size(4) + "ftyp" + "M4A " const m4aHeader = Buffer.from([
const m4a = Buffer.from([ 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x4d, 0x34, 0x41, 0x20,
0x00,
0x00,
0x00,
0x1c, // box size
0x66,
0x74,
0x79,
0x70, // "ftyp"
0x4d,
0x34,
0x41,
0x20, // "M4A " major brand
]); ]);
getMessageContentMock.mockResolvedValueOnce(chunks([m4a])); getMessageContentMock.mockResolvedValueOnce(chunks([m4aHeader]));
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined); const writeSpy = vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);
const result = await downloadLineMedia("mid-m4a", "token"); const result = await downloadLineMedia("mid-audio", "token");
const writtenPath = writeSpy.mock.calls[0]?.[0];
expect(result.contentType).toBe("audio/mp4"); expect(result.contentType).toBe("audio/mp4");
expect(result.path).toMatch(/\.m4a$/); expect(result.path).toMatch(/\.m4a$/);
expect(writtenPath).toBe(result.path);
}); });
it("detects MP4 video from ftyp major brand (isom)", async () => { it("detects MP4 video from ftyp major brand (isom)", async () => {
// MP4 video magic bytes: size(4) + "ftyp" + "isom"
const mp4 = Buffer.from([ const mp4 = Buffer.from([
0x00, 0x00, 0x00, 0x00, 0x1c, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d,
0x00,
0x00,
0x1c,
0x66,
0x74,
0x79,
0x70,
0x69,
0x73,
0x6f,
0x6d, // "isom" major brand
]); ]);
getMessageContentMock.mockResolvedValueOnce(chunks([mp4])); getMessageContentMock.mockResolvedValueOnce(chunks([mp4]));
vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined); vi.spyOn(fs.promises, "writeFile").mockResolvedValueOnce(undefined);

View File

@@ -9,6 +9,8 @@ interface DownloadResult {
size: number; size: number;
} }
const AUDIO_BRANDS = new Set(["m4a ", "m4b ", "m4p ", "m4r ", "f4a ", "f4b "]);
export async function downloadLineMedia( export async function downloadLineMedia(
messageId: string, messageId: string,
channelAccessToken: string, channelAccessToken: string,
@@ -53,6 +55,13 @@ export async function downloadLineMedia(
} }
function detectContentType(buffer: Buffer): string { function detectContentType(buffer: Buffer): string {
const hasFtypBox =
buffer.length >= 12 &&
buffer[4] === 0x66 &&
buffer[5] === 0x74 &&
buffer[6] === 0x79 &&
buffer[7] === 0x70;
// Check magic bytes // Check magic bytes
if (buffer.length >= 2) { if (buffer.length >= 2) {
// JPEG // JPEG
@@ -80,17 +89,11 @@ function detectContentType(buffer: Buffer): string {
) { ) {
return "image/webp"; return "image/webp";
} }
// MPEG-4 container (ftyp box) — distinguish audio (M4A) from video (MP4) if (hasFtypBox) {
// by checking the major brand at bytes 8-11. // ISO BMFF containers share `ftyp`; use major brand to separate common
if ( // M4A audio payloads from video mp4 containers.
buffer.length >= 12 && const majorBrand = buffer.toString("ascii", 8, 12).toLowerCase();
buffer[4] === 0x66 && if (AUDIO_BRANDS.has(majorBrand)) {
buffer[5] === 0x74 &&
buffer[6] === 0x79 &&
buffer[7] === 0x70
) {
const brand = String.fromCharCode(buffer[8], buffer[9], buffer[10], buffer[11]);
if (brand === "M4A " || brand === "M4B ") {
return "audio/mp4"; return "audio/mp4";
} }
return "video/mp4"; return "video/mp4";

View File

@@ -69,6 +69,23 @@ describe("createLineNodeWebhookHandler", () => {
expect(res.body).toBe("OK"); expect(res.body).toBe("OK");
}); });
it("returns 204 for HEAD", async () => {
const bot = { handleWebhook: vi.fn(async () => {}) };
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const handler = createLineNodeWebhookHandler({
channelSecret: "secret",
bot,
runtime,
readBody: async () => "",
});
const { res } = createRes();
await handler({ method: "HEAD", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(204);
expect(res.body).toBeUndefined();
});
it("returns 200 for verification request (empty events, no signature)", async () => { it("returns 200 for verification request (empty events, no signature)", async () => {
const rawBody = JSON.stringify({ events: [] }); const rawBody = JSON.stringify({ events: [] });
const { bot, handler } = createPostWebhookTestHarness(rawBody); const { bot, handler } = createPostWebhookTestHarness(rawBody);
@@ -82,14 +99,14 @@ describe("createLineNodeWebhookHandler", () => {
expect(bot.handleWebhook).not.toHaveBeenCalled(); expect(bot.handleWebhook).not.toHaveBeenCalled();
}); });
it("returns 405 for non-GET/non-POST methods", async () => { it("returns 405 for non-GET/HEAD/POST methods", async () => {
const { bot, handler } = createPostWebhookTestHarness(JSON.stringify({ events: [] })); const { bot, handler } = createPostWebhookTestHarness(JSON.stringify({ events: [] }));
const { res, headers } = createRes(); const { res, headers } = createRes();
await handler({ method: "PUT", headers: {} } as unknown as IncomingMessage, res); await handler({ method: "PUT", headers: {} } as unknown as IncomingMessage, res);
expect(res.statusCode).toBe(405); expect(res.statusCode).toBe(405);
expect(headers.allow).toBe("GET, POST"); expect(headers.allow).toBe("GET, HEAD, POST");
expect(bot.handleWebhook).not.toHaveBeenCalled(); expect(bot.handleWebhook).not.toHaveBeenCalled();
}); });
@@ -178,6 +195,32 @@ describe("createLineNodeWebhookHandler", () => {
); );
}); });
it("returns 200 immediately and logs when event processing fails", async () => {
const rawBody = JSON.stringify({ events: [{ type: "message" }] });
const { secret } = createPostWebhookTestHarness(rawBody);
const failingBot = {
handleWebhook: vi.fn(async () => {
throw new Error("transient failure");
}),
};
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const failingHandler = createLineNodeWebhookHandler({
channelSecret: secret,
bot: failingBot,
runtime,
readBody: async () => rawBody,
});
const { res } = createRes();
await runSignedPost({ handler: failingHandler, rawBody, secret, res });
await Promise.resolve();
expect(res.statusCode).toBe(200);
expect(res.body).toBe(JSON.stringify({ status: "ok" }));
expect(failingBot.handleWebhook).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledTimes(1);
});
it("returns 400 for invalid JSON payload even when signature is valid", async () => { it("returns 400 for invalid JSON payload even when signature is valid", async () => {
const rawBody = "not json"; const rawBody = "not json";
const { bot, handler, secret } = createPostWebhookTestHarness(rawBody); const { bot, handler, secret } = createPostWebhookTestHarness(rawBody);

View File

@@ -39,8 +39,13 @@ export function createLineNodeWebhookHandler(params: {
const readBody = params.readBody ?? readLineWebhookRequestBody; const readBody = params.readBody ?? readLineWebhookRequestBody;
return async (req: IncomingMessage, res: ServerResponse) => { return async (req: IncomingMessage, res: ServerResponse) => {
// Handle GET requests for webhook verification // Some webhook validators and health probes use GET/HEAD.
if (req.method === "GET") { if (req.method === "GET" || req.method === "HEAD") {
if (req.method === "HEAD") {
res.statusCode = 204;
res.end();
return;
}
res.statusCode = 200; res.statusCode = 200;
res.setHeader("Content-Type", "text/plain"); res.setHeader("Content-Type", "text/plain");
res.end("OK"); res.end("OK");
@@ -50,7 +55,7 @@ export function createLineNodeWebhookHandler(params: {
// Only accept POST requests // Only accept POST requests
if (req.method !== "POST") { if (req.method !== "POST") {
res.statusCode = 405; res.statusCode = 405;
res.setHeader("Allow", "GET, POST"); res.setHeader("Allow", "GET, HEAD, POST");
res.setHeader("Content-Type", "application/json"); res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Method Not Allowed" })); res.end(JSON.stringify({ error: "Method Not Allowed" }));
return; return;
@@ -106,15 +111,13 @@ export function createLineNodeWebhookHandler(params: {
return; return;
} }
// Respond immediately with 200 to avoid LINE timeout
res.statusCode = 200; res.statusCode = 200;
res.setHeader("Content-Type", "application/json"); res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ status: "ok" })); res.end(JSON.stringify({ status: "ok" }));
// Process events asynchronously
if (body.events && body.events.length > 0) { if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`); logVerbose(`line: received ${body.events.length} webhook events`);
await params.bot.handleWebhook(body).catch((err) => { void params.bot.handleWebhook(body).catch((err) => {
params.runtime.error?.(danger(`line webhook handler failed: ${String(err)}`)); params.runtime.error?.(danger(`line webhook handler failed: ${String(err)}`));
}); });
} }

View File

@@ -111,4 +111,18 @@ describe("createLineWebhookMiddleware", () => {
}); });
expect(onEvents).not.toHaveBeenCalled(); expect(onEvents).not.toHaveBeenCalled();
}); });
it("returns 200 immediately when onEvents fails", async () => {
const { res, onEvents } = await invokeWebhook({
body: JSON.stringify({ events: [{ type: "message" }] }),
onEvents: vi.fn(async () => {
throw new Error("transient failure");
}),
});
await Promise.resolve();
expect(onEvents).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ status: "ok" });
});
}); });

View File

@@ -71,13 +71,11 @@ export function createLineWebhookMiddleware(
return; return;
} }
// Respond immediately to avoid timeout
res.status(200).json({ status: "ok" }); res.status(200).json({ status: "ok" });
// Process events asynchronously
if (body.events && body.events.length > 0) { if (body.events && body.events.length > 0) {
logVerbose(`line: received ${body.events.length} webhook events`); logVerbose(`line: received ${body.events.length} webhook events`);
await onEvents(body).catch((err) => { void onEvents(body).catch((err) => {
runtime?.error?.(danger(`line webhook handler failed: ${String(err)}`)); runtime?.error?.(danger(`line webhook handler failed: ${String(err)}`));
}); });
} }