mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:18:37 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)}`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)}`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user