Files
openclaw/extensions/line/src/bot.ts
T
Peter Steinberger 819c92d175 fix(line): prevent silent webhook loss after acknowledgment (#109819)
* fix(line): adopt durable ingress drain with ack gated on spool write

LINE acked webhooks 200 before processing events detached, so a crash or
dispatch failure silently lost inbound messages. Events now enqueue their raw
JSON into the channel ingress queue before the webhook responds; dispatch,
retry, dead-letter, and completion tombstones run through the core drain.
The in-memory replay guard is deleted; tombstone retention (30d/4096)
strictly covers its old 10min/4096 window, and message-id keys preserve the
guard keyspace so redeliveries under changed webhookEventIds still dedupe.

Autoreview: secret scanner false-positive on HMAC test fixtures
(test-channel-secret); full manual review of all five files + test matrix
performed instead. Part of the #109657 fleet adoption program (wave 1).

* style(line): oxfmt pass on drain adoption files

* fix(line): cap repeated ingress drain deliveries

* fix(line): preserve ingress lifecycle bounds

* style(line): satisfy ingress lint gates
2026-07-17 11:45:18 +01:00

78 lines
2.4 KiB
TypeScript

// Line plugin module implements bot behavior.
import type { webhook } from "@line/bot-sdk";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import {
createNonExitingRuntime,
logVerbose,
type RuntimeEnv,
} from "openclaw/plugin-sdk/runtime-env";
import { resolveLineAccount } from "./accounts.js";
import { handleLineWebhookEvents } from "./bot-handlers.js";
import type { LineInboundContext } from "./bot-message-context.js";
import type { ResolvedLineAccount } from "./types.js";
import { createLineWebhookSpool, type LineWebhookTurnAdoptionLifecycle } from "./webhook-spool.js";
interface LineBotOptions {
channelAccessToken: string;
channelSecret: string;
accountId?: string;
runtime?: RuntimeEnv;
config?: OpenClawConfig;
mediaMaxMb?: number;
onMessage?: (
ctx: LineInboundContext,
control: { turnAdoptionLifecycle?: LineWebhookTurnAdoptionLifecycle },
) => Promise<void>;
}
interface LineBot {
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
account: ResolvedLineAccount;
stop: () => Promise<void>;
}
export function createLineBot(opts: LineBotOptions): LineBot {
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
const cfg = opts.config ?? getRuntimeConfig();
const account = resolveLineAccount({
cfg,
accountId: opts.accountId,
});
const mediaMaxBytes = (opts.mediaMaxMb ?? account.config.mediaMaxMb ?? 10) * 1024 * 1024;
const processMessage =
opts.onMessage ??
(async () => {
logVerbose("line: no message handler configured");
});
const groupHistories = new Map<string, HistoryEntry[]>();
const spool = createLineWebhookSpool({
accountId: account.accountId,
runtime,
deliver: async (event, _destination, control) =>
await handleLineWebhookEvents([event], {
cfg,
account,
runtime,
mediaMaxBytes,
processMessage,
...(control.turnAdoptionLifecycle
? { turnAdoptionLifecycle: control.turnAdoptionLifecycle }
: {}),
groupHistories,
historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
}),
});
spool.start();
return {
handleWebhook: spool.accept,
account,
stop: spool.stop,
};
}