Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
511
extensions/line/src/monitor.ts
Normal file
511
extensions/line/src/monitor.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
// Line plugin module implements monitor behavior.
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
danger,
|
||||
logVerbose,
|
||||
waitForAbortSignal,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
isRequestBodyLimitError,
|
||||
normalizePluginHttpPath,
|
||||
normalizeWebhookPath,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
requestBodyErrorToText,
|
||||
resolveSingleWebhookTarget,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { resolveDefaultLineAccountId } from "./accounts.js";
|
||||
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
||||
import { createLineBot } from "./bot.js";
|
||||
import { processLineMessage } from "./markdown-to-line.js";
|
||||
import { resolveLineDurableReplyOptions } from "./monitor-durable.js";
|
||||
import { sendLineReplyChunks } from "./reply-chunks.js";
|
||||
import { getLineRuntime } from "./runtime.js";
|
||||
import {
|
||||
createFlexMessage,
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
createQuickReplyItems,
|
||||
createTextMessageWithQuickReplies,
|
||||
getUserDisplayName,
|
||||
pushMessageLine,
|
||||
pushMessagesLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
replyMessageLine,
|
||||
showLoadingAnimation,
|
||||
} from "./send.js";
|
||||
import { buildTemplateMessageFromPayload } from "./template-messages.js";
|
||||
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
||||
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
|
||||
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
|
||||
|
||||
export interface MonitorLineProviderOptions {
|
||||
channelAccessToken: string;
|
||||
channelSecret: string;
|
||||
accountId?: string;
|
||||
config: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
webhookUrl?: string;
|
||||
webhookPath?: string;
|
||||
}
|
||||
|
||||
export interface LineProviderMonitor {
|
||||
account: ResolvedLineAccount;
|
||||
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
const runtimeState = new Map<
|
||||
string,
|
||||
{
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
}
|
||||
>();
|
||||
const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
|
||||
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
|
||||
|
||||
type LineWebhookTarget = {
|
||||
accountId: string;
|
||||
bot: ReturnType<typeof createLineBot>;
|
||||
channelSecret: string;
|
||||
path: string;
|
||||
runtime: RuntimeEnv;
|
||||
};
|
||||
|
||||
const lineWebhookTargets = new Map<string, LineWebhookTarget[]>();
|
||||
|
||||
function recordChannelRuntimeState(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
state: Partial<{
|
||||
running: boolean;
|
||||
lastStartAt: number | null;
|
||||
lastStopAt: number | null;
|
||||
lastError: string | null;
|
||||
lastInboundAt: number | null;
|
||||
lastOutboundAt: number | null;
|
||||
}>;
|
||||
}): void {
|
||||
const key = `${params.channel}:${params.accountId}`;
|
||||
const existing = runtimeState.get(key) ?? {
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
runtimeState.set(key, { ...existing, ...params.state });
|
||||
}
|
||||
|
||||
export function getLineRuntimeState(accountId: string) {
|
||||
return runtimeState.get(`line:${accountId}`);
|
||||
}
|
||||
|
||||
export function clearLineRuntimeStateForTests() {
|
||||
runtimeState.clear();
|
||||
}
|
||||
|
||||
function startLineLoadingKeepalive(params: {
|
||||
cfg: OpenClawConfig;
|
||||
userId: string;
|
||||
accountId?: string;
|
||||
intervalMs?: number;
|
||||
loadingSeconds?: number;
|
||||
}): () => void {
|
||||
const intervalMs = params.intervalMs ?? 18_000;
|
||||
const loadingSeconds = params.loadingSeconds ?? 20;
|
||||
let stopped = false;
|
||||
|
||||
const trigger = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
void showLoadingAnimation(params.userId, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
loadingSeconds,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
trigger();
|
||||
const timer = setInterval(trigger, intervalMs);
|
||||
|
||||
return () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
export async function monitorLineProvider(
|
||||
opts: MonitorLineProviderOptions,
|
||||
): Promise<LineProviderMonitor> {
|
||||
const {
|
||||
channelAccessToken,
|
||||
channelSecret,
|
||||
accountId,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal,
|
||||
webhookPath,
|
||||
} = opts;
|
||||
const resolvedAccountId = accountId ?? resolveDefaultLineAccountId(config);
|
||||
const token = channelAccessToken.trim();
|
||||
const secret = channelSecret.trim();
|
||||
|
||||
if (!token) {
|
||||
throw new Error("LINE webhook mode requires a non-empty channel access token.");
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error("LINE webhook mode requires a non-empty channel secret.");
|
||||
}
|
||||
|
||||
const bot = createLineBot({
|
||||
channelAccessToken: token,
|
||||
channelSecret: secret,
|
||||
accountId,
|
||||
runtime,
|
||||
config,
|
||||
onMessage: async (ctx) => {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctxPayload, replyToken, route } = ctx;
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
lastInboundAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
|
||||
|
||||
const displayNamePromise = ctx.userId
|
||||
? getUserDisplayName(ctx.userId, { cfg: config, accountId: ctx.accountId })
|
||||
: Promise.resolve(ctxPayload.From);
|
||||
|
||||
const stopLoading = shouldShowLoading
|
||||
? startLineLoadingKeepalive({
|
||||
cfg: config,
|
||||
userId: ctx.userId!,
|
||||
accountId: ctx.accountId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const displayName = await displayNamePromise;
|
||||
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
|
||||
|
||||
try {
|
||||
const textLimit = 5000;
|
||||
let replyTokenUsed = false;
|
||||
const core = getLineRuntime();
|
||||
const turnResult = await core.channel.inbound.run({
|
||||
channel: "line",
|
||||
accountId: route.accountId,
|
||||
raw: ctx,
|
||||
adapter: {
|
||||
ingest: () => ({
|
||||
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
|
||||
rawText: ctxPayload.RawBody ?? ctxPayload.BodyForAgent ?? "",
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: config,
|
||||
channel: "line",
|
||||
accountId: route.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath: ctx.turn.storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
record: ctx.turn.record,
|
||||
replyPipeline: {},
|
||||
delivery: {
|
||||
durable: (payload, info) =>
|
||||
resolveLineDurableReplyOptions({
|
||||
payload,
|
||||
infoKind: info.kind,
|
||||
to: ctxPayload.From,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
}),
|
||||
deliver: async (payload) => {
|
||||
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
||||
|
||||
if (ctx.userId && !ctx.isGroup) {
|
||||
void showLoadingAnimation(ctx.userId, {
|
||||
cfg: config,
|
||||
accountId: ctx.accountId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
|
||||
payload,
|
||||
lineData,
|
||||
to: ctxPayload.From,
|
||||
replyToken,
|
||||
replyTokenUsed,
|
||||
accountId: ctx.accountId,
|
||||
cfg: config,
|
||||
textLimit,
|
||||
deps: {
|
||||
buildTemplateMessageFromPayload,
|
||||
processLineMessage,
|
||||
chunkMarkdownText,
|
||||
sendLineReplyChunks,
|
||||
replyMessageLine,
|
||||
pushMessageLine,
|
||||
pushTextMessageWithQuickReplies,
|
||||
createQuickReplyItems,
|
||||
createTextMessageWithQuickReplies,
|
||||
pushMessagesLine,
|
||||
createFlexMessage,
|
||||
createImageMessage,
|
||||
createLocationMessage,
|
||||
onReplyError: (replyErr) => {
|
||||
logVerbose(
|
||||
`line: reply token failed, falling back to push: ${String(replyErr)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
replyTokenUsed = nextReplyTokenUsed;
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
lastOutboundAt: Date.now(),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
|
||||
if (!hasFinalInboundReplyDispatch(dispatchResult)) {
|
||||
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
|
||||
|
||||
if (replyToken) {
|
||||
try {
|
||||
await replyMessageLine(
|
||||
replyToken,
|
||||
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
|
||||
{ cfg: config, accountId: ctx.accountId },
|
||||
);
|
||||
} catch (replyErr) {
|
||||
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stopLoading?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const normalizedPath = normalizeWebhookPath(
|
||||
normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook",
|
||||
);
|
||||
const createScopedLineWebhookHandler = (target: LineWebhookTarget) =>
|
||||
createLineNodeWebhookHandler({
|
||||
channelSecret: target.channelSecret,
|
||||
bot: target.bot,
|
||||
runtime: target.runtime,
|
||||
});
|
||||
const { unregister: unregisterHttp } = registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: lineWebhookTargets,
|
||||
target: {
|
||||
accountId: resolvedAccountId,
|
||||
bot,
|
||||
channelSecret: secret,
|
||||
path: normalizedPath,
|
||||
runtime,
|
||||
},
|
||||
route: {
|
||||
auth: "plugin",
|
||||
pluginId: "line",
|
||||
accountId: resolvedAccountId,
|
||||
log: (msg) => logVerbose(msg),
|
||||
handler: async (req, res) => {
|
||||
const targets = lineWebhookTargets.get(normalizedPath) ?? [];
|
||||
const firstTarget = targets[0];
|
||||
if (req.method !== "POST") {
|
||||
if (!firstTarget) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
await createScopedLineWebhookHandler(firstTarget)(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestLifecycle = beginWebhookRequestPipelineOrReject({
|
||||
req,
|
||||
res,
|
||||
inFlightLimiter: lineWebhookInFlightLimiter,
|
||||
inFlightKey: `line:${normalizedPath}`,
|
||||
});
|
||||
if (!requestLifecycle.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const signatureHeader = req.headers["x-line-signature"];
|
||||
const signature =
|
||||
typeof signatureHeader === "string"
|
||||
? signatureHeader.trim()
|
||||
: Array.isArray(signatureHeader)
|
||||
? (signatureHeader[0] ?? "").trim()
|
||||
: "";
|
||||
|
||||
if (!signature) {
|
||||
logVerbose("line: webhook missing X-Line-Signature header");
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = await readLineWebhookRequestBody(
|
||||
req,
|
||||
LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES,
|
||||
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
||||
);
|
||||
const match = resolveSingleWebhookTarget(targets, (target) =>
|
||||
validateLineSignature(rawBody, signature, target.channelSecret),
|
||||
);
|
||||
if (match.kind === "none") {
|
||||
logVerbose("line: webhook signature validation failed");
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid signature" }));
|
||||
return;
|
||||
}
|
||||
if (match.kind === "ambiguous") {
|
||||
logVerbose("line: webhook signature matched multiple accounts");
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Ambiguous webhook target" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = parseLineWebhookBody(rawBody);
|
||||
if (!body) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
||||
return;
|
||||
}
|
||||
|
||||
requestLifecycle.release();
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
|
||||
if (body.events && body.events.length > 0) {
|
||||
logVerbose(`line: received ${body.events.length} webhook events`);
|
||||
void Promise.resolve()
|
||||
.then(() => match.target.bot.handleWebhook(body))
|
||||
.catch((err: unknown) => {
|
||||
match.target.runtime.error?.(
|
||||
danger(`line webhook dispatch failed: ${String(err)}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
||||
res.statusCode = 413;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Payload too large" }));
|
||||
return;
|
||||
}
|
||||
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
||||
res.statusCode = 408;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
||||
return;
|
||||
}
|
||||
runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Internal server error" }));
|
||||
}
|
||||
} finally {
|
||||
requestLifecycle.release();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
||||
|
||||
let stopped = false;
|
||||
const stopHandler = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
|
||||
unregisterHttp();
|
||||
recordChannelRuntimeState({
|
||||
channel: "line",
|
||||
accountId: resolvedAccountId,
|
||||
state: {
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
stopHandler();
|
||||
} else if (abortSignal) {
|
||||
abortSignal.addEventListener("abort", stopHandler, { once: true });
|
||||
await waitForAbortSignal(abortSignal);
|
||||
}
|
||||
|
||||
return {
|
||||
account: bot.account,
|
||||
handleWebhook: bot.handleWebhook,
|
||||
stop: () => {
|
||||
stopHandler();
|
||||
abortSignal?.removeEventListener("abort", stopHandler);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user