@openclaw/line 2026.7.2-beta.1 → 2026.7.2-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,30 @@
1
- import { i as resolveLineAccount, r as resolveDefaultLineAccountId } from "./accounts-DJVOv1JI.js";
2
- import { f as resolveLineGroupConfigEntry, i as buildLineQuickReplyFallbackText, s as getLineRuntime } from "./reply-payload-transform-BsbwCJGH.js";
3
- import { A as buildTemplateMessageFromPayload, C as pushMessagesLine, E as replyMessageLine, O as showLoadingAnimation, S as pushMessageLine, T as pushTextMessageWithQuickReplies, _ as getUserDisplayName, c as processLineMessage, d as createFlexMessage, f as createImageMessage, h as createTextMessageWithQuickReplies, m as createQuickReplyItems, p as createLocationMessage } from "./markdown-to-line-CixmAKiG.js";
1
+ import { i as resolveLineAccount, r as resolveDefaultLineAccountId } from "./accounts-BUBOoW5Q.js";
2
+ import { a as buildLineMediaMessage, i as buildLineQuickReplyFallbackText, l as getLineRuntime, m as resolveLineGroupConfigEntry, o as hasLineSpecificMediaOptions } from "./reply-payload-transform-r9sa-TYT.js";
3
+ import { A as buildTemplateMessageFromPayload, C as pushMessagesLine, E as replyMessageLine, O as showLoadingAnimation, S as pushMessageLine, T as pushTextMessageWithQuickReplies, _ as getUserDisplayName, c as processLineMessage, d as createFlexMessage, h as createTextMessageWithQuickReplies, m as createQuickReplyItems, p as createLocationMessage } from "./markdown-to-line-D1vapcdP.js";
4
4
  import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
5
5
  import { normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
6
- import { createMessageReceiveContext } from "openclaw/plugin-sdk/channel-outbound";
6
+ import { DEFAULT_INGRESS_ADOPTION_STALL_MS, DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS, bindIngressLifecycleToReplyOptions, createChannelIngressDrain } from "openclaw/plugin-sdk/channel-outbound";
7
7
  import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
8
+ import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
8
9
  import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
9
10
  import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
10
11
  import { firstDefined } from "openclaw/plugin-sdk/allow-from";
11
- import { messagingApi } from "@line/bot-sdk";
12
+ import { Readable } from "node:stream";
13
+ import { finished } from "node:stream/promises";
14
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
12
15
  import { saveMediaStream } from "openclaw/plugin-sdk/media-store";
13
16
  import { createNonExitingRuntime, danger, logVerbose, shouldLogVerbose, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
17
+ import { fetchWithRuntimeDispatcherOrMockedGlobal } from "openclaw/plugin-sdk/runtime-fetch";
14
18
  import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
15
19
  import { buildMentionRegexes, formatInboundEnvelope, formatInboundMediaUnavailableText, formatLocationText, hasFinalInboundReplyDispatch, matchesMentionPatterns, resolveInboundSessionEnvelopeContext, toLocationContext } from "openclaw/plugin-sdk/channel-inbound";
16
20
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
17
21
  import { isRequestBodyLimitError, normalizePluginHttpPath, normalizeWebhookPath, registerWebhookTargetWithPluginRoute, requestBodyErrorToText, resolveSingleWebhookTarget } from "openclaw/plugin-sdk/webhook-ingress";
18
- import { beginWebhookRequestPipelineOrReject, createWebhookInFlightLimiter, isRequestBodyLimitError as isRequestBodyLimitError$1, readRequestBodyWithLimit, requestBodyErrorToText as requestBodyErrorToText$1 } from "openclaw/plugin-sdk/webhook-request-guards";
22
+ import { beginWebhookRequestPipelineOrReject, createWebhookInFlightLimiter, isRequestBodyLimitError as isRequestBodyLimitError$1, readRequestBodyWithLimit, requestBodyErrorToText as requestBodyErrorToText$1, runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
19
23
  import { DEFAULT_GROUP_HISTORY_LIMIT, createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
20
24
  import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
21
25
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
22
- import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth-native";
26
+ import { hasControlCommand } from "openclaw/plugin-sdk/command-auth-native";
23
27
  import { ensureConfiguredBindingRouteReady, readChannelAllowFromStore, resolveConfiguredBindingRoute, resolvePairingIdLabel, resolvePinnedMainDmOwnerFromAllowlist, resolveRuntimeConversationBindingRoute, upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
24
- import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
25
28
  import { resolveAgentRoute, resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
26
29
  import { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";
27
30
  import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
@@ -44,8 +47,51 @@ const normalizeAllowFrom = (list) => {
44
47
  };
45
48
  //#endregion
46
49
  //#region extensions/line/src/download.ts
50
+ const CONTENT_READY_MAX_ATTEMPTS = 6;
51
+ const CONTENT_READY_BASE_DELAY_MS = 500;
52
+ const CONTENT_READY_MAX_DELAY_MS = 4e3;
53
+ const CONTENT_READY_TIMEOUT_MS = 15e3;
54
+ const LINE_CONTENT_BASE_URL = "https://api-data.line.me/v2/bot/message";
55
+ function contentBackoffDelayMs(attempt) {
56
+ return Math.min(CONTENT_READY_BASE_DELAY_MS * 2 ** attempt, CONTENT_READY_MAX_DELAY_MS);
57
+ }
58
+ async function fetchLineContentWhenReady(messageId, channelAccessToken) {
59
+ const controller = new AbortController();
60
+ const deadline = setTimeout(() => controller.abort(), CONTENT_READY_TIMEOUT_MS);
61
+ deadline.unref();
62
+ try {
63
+ for (let attempt = 0; attempt < CONTENT_READY_MAX_ATTEMPTS; attempt++) {
64
+ const response = await fetchWithRuntimeDispatcherOrMockedGlobal(`${LINE_CONTENT_BASE_URL}/${encodeURIComponent(messageId)}/content`, {
65
+ headers: { Authorization: `Bearer ${channelAccessToken}` },
66
+ redirect: "error",
67
+ signal: controller.signal
68
+ });
69
+ if (response.status === 200) {
70
+ if (!response.body) throw new Error(`LINE media response for message ${messageId} had no body`);
71
+ return Readable.fromWeb(response.body);
72
+ }
73
+ await response.body?.cancel();
74
+ if (response.status !== 202) throw new Error(`LINE media download failed for message ${messageId} (HTTP ${response.status})`);
75
+ if (attempt < CONTENT_READY_MAX_ATTEMPTS - 1) await setTimeout$1(contentBackoffDelayMs(attempt), void 0, { signal: controller.signal });
76
+ }
77
+ } catch (err) {
78
+ if (controller.signal.aborted) throw new Error(`LINE media for message ${messageId} did not become ready within ${CONTENT_READY_TIMEOUT_MS / 1e3} seconds`, { cause: err });
79
+ throw err;
80
+ } finally {
81
+ clearTimeout(deadline);
82
+ }
83
+ throw new Error(`LINE media for message ${messageId} was still preparing (HTTP 202) after ${CONTENT_READY_MAX_ATTEMPTS} attempts`);
84
+ }
47
85
  async function downloadLineMedia(messageId, channelAccessToken, maxBytes = 10 * 1024 * 1024, options) {
48
- const saved = await saveMediaStream(await new messagingApi.MessagingApiBlobClient({ channelAccessToken }).getMessageContent(messageId), void 0, "inbound", maxBytes, options?.originalFilename);
86
+ const content = await fetchLineContentWhenReady(messageId, channelAccessToken);
87
+ let saved;
88
+ try {
89
+ saved = await saveMediaStream(content, void 0, "inbound", maxBytes, options?.originalFilename);
90
+ } catch (err) {
91
+ content.destroy();
92
+ await finished(content).catch(() => void 0);
93
+ throw err;
94
+ }
49
95
  logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
50
96
  return {
51
97
  path: saved.path,
@@ -55,15 +101,19 @@ async function downloadLineMedia(messageId, channelAccessToken, maxBytes = 10 *
55
101
  }
56
102
  //#endregion
57
103
  //#region extensions/line/src/auto-reply-delivery.ts
104
+ function toLineDeliveryError(error) {
105
+ return error instanceof Error ? error : new Error("LINE rich or media message send failed", { cause: error });
106
+ }
58
107
  function markLineVisibleDeliveryError(error) {
59
- if (error instanceof Error && Object.isExtensible(error)) {
60
- Object.assign(error, {
108
+ const deliveryError = toLineDeliveryError(error);
109
+ if (Object.isExtensible(deliveryError)) {
110
+ Object.assign(deliveryError, {
61
111
  sentBeforeError: true,
62
112
  visibleReplySent: true
63
113
  });
64
- return error;
114
+ return deliveryError;
65
115
  }
66
- const visibleError = new Error("LINE rich or media message send failed", { cause: error });
116
+ const visibleError = new Error("LINE rich or media message send failed", { cause: deliveryError });
67
117
  Object.assign(visibleError, {
68
118
  sentBeforeError: true,
69
119
  visibleReplySent: true
@@ -73,12 +123,26 @@ function markLineVisibleDeliveryError(error) {
73
123
  async function deliverLineAutoReply(params) {
74
124
  const { payload, lineData, replyToken, accountId, to, textLimit, deps } = params;
75
125
  let replyTokenUsed = params.replyTokenUsed;
126
+ let visibleReplySent = false;
127
+ const sendVisible = async (send) => {
128
+ try {
129
+ const result = await send();
130
+ visibleReplySent = true;
131
+ return result;
132
+ } catch (error) {
133
+ if (visibleReplySent) throw markLineVisibleDeliveryError(error);
134
+ throw error;
135
+ }
136
+ };
137
+ const replyVisible = (...args) => sendVisible(() => deps.replyMessageLine(...args));
138
+ const pushTextVisible = (...args) => sendVisible(() => deps.pushMessageLine(...args));
139
+ const pushQuickRepliesVisible = (...args) => sendVisible(() => deps.pushTextMessageWithQuickReplies(...args));
76
140
  const pushLineMessages = async (messages) => {
77
141
  if (messages.length === 0) return;
78
- for (let i = 0; i < messages.length; i += 5) await deps.pushMessagesLine(to, messages.slice(i, i + 5), {
142
+ for (let i = 0; i < messages.length; i += 5) await sendVisible(() => deps.pushMessagesLine(to, messages.slice(i, i + 5), {
79
143
  cfg: params.cfg,
80
144
  accountId
81
- });
145
+ }));
82
146
  };
83
147
  const sendLineMessages = async (messages, allowReplyToken) => {
84
148
  if (messages.length === 0) return;
@@ -86,7 +150,7 @@ async function deliverLineAutoReply(params) {
86
150
  if (allowReplyToken && replyToken && !replyTokenUsed) {
87
151
  const replyBatch = remaining.slice(0, 5);
88
152
  try {
89
- await deps.replyMessageLine(replyToken, replyBatch, {
153
+ await replyVisible(replyToken, replyBatch, {
90
154
  cfg: params.cfg,
91
155
  accountId
92
156
  });
@@ -107,21 +171,38 @@ async function deliverLineAutoReply(params) {
107
171
  if (templateMsg) richMessages.push(templateMsg);
108
172
  }
109
173
  if (lineData.location) richMessages.push(deps.createLocationMessage(lineData.location));
110
- const processed = payload.text ? deps.processLineMessage(payload.text) : {
174
+ const visibleText = payload.text ? sanitizeAssistantVisibleText(payload.text) : "";
175
+ const processed = visibleText ? deps.processLineMessage(visibleText) : {
111
176
  text: "",
112
177
  flexMessages: []
113
178
  };
114
179
  for (const flexMsg of processed.flexMessages) richMessages.push(deps.createFlexMessage(truncateUtf16Safe(flexMsg.altText, 400), flexMsg.contents));
115
180
  const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
116
- const mediaMessages = resolveSendableOutboundReplyParts(payload).mediaUrls.map((url) => url?.trim()).filter((url) => Boolean(url)).map((url) => deps.createImageMessage(url));
181
+ const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
182
+ const mediaOpts = {
183
+ mediaKind: hasLineSpecificMediaOptions(lineData) ? lineData.mediaKind : "image",
184
+ previewImageUrl: lineData.previewImageUrl,
185
+ durationMs: lineData.durationMs,
186
+ trackingId: lineData.trackingId
187
+ };
188
+ const mediaMessages = [];
189
+ let richMediaError;
190
+ for (const rawUrl of mediaUrls) {
191
+ const url = rawUrl?.trim();
192
+ if (!url) continue;
193
+ try {
194
+ mediaMessages.push(await deps.buildMediaMessage(url, mediaOpts, to));
195
+ } catch (err) {
196
+ richMediaError ??= err;
197
+ }
198
+ }
117
199
  if (chunks.length > 0) {
118
200
  const hasRichOrMedia = richMessages.length > 0 || mediaMessages.length > 0;
119
201
  const sendRichBeforeText = hasQuickReplies && hasRichOrMedia;
120
- let richMediaError;
121
202
  if (sendRichBeforeText) try {
122
203
  await sendLineMessages([...richMessages, ...mediaMessages], false);
123
204
  } catch (err) {
124
- richMediaError = err;
205
+ richMediaError ??= err;
125
206
  }
126
207
  const { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({
127
208
  to,
@@ -131,23 +212,19 @@ async function deliverLineAutoReply(params) {
131
212
  replyTokenUsed,
132
213
  cfg: params.cfg,
133
214
  accountId,
134
- replyMessageLine: deps.replyMessageLine,
135
- pushMessageLine: deps.pushMessageLine,
136
- pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
137
- createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies
215
+ replyMessageLine: replyVisible,
216
+ pushMessageLine: pushTextVisible,
217
+ pushTextMessageWithQuickReplies: pushQuickRepliesVisible,
218
+ createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
219
+ onReplyError: deps.onReplyError
138
220
  });
139
221
  replyTokenUsed = nextReplyTokenUsed;
140
222
  if (!sendRichBeforeText) try {
141
223
  await sendLineMessages(richMessages, false);
142
224
  if (mediaMessages.length > 0) await sendLineMessages(mediaMessages, false);
143
225
  } catch (err) {
144
- richMediaError = err;
226
+ richMediaError ??= err;
145
227
  }
146
- if (richMediaError !== void 0) return {
147
- status: "partial",
148
- replyTokenUsed,
149
- error: markLineVisibleDeliveryError(richMediaError)
150
- };
151
228
  } else {
152
229
  const combined = [...richMessages, ...mediaMessages];
153
230
  if (hasQuickReplies && combined.length === 0) {
@@ -159,9 +236,9 @@ async function deliverLineAutoReply(params) {
159
236
  replyTokenUsed,
160
237
  cfg: params.cfg,
161
238
  accountId,
162
- replyMessageLine: deps.replyMessageLine,
163
- pushMessageLine: deps.pushMessageLine,
164
- pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
239
+ replyMessageLine: replyVisible,
240
+ pushMessageLine: pushTextVisible,
241
+ pushTextMessageWithQuickReplies: pushQuickRepliesVisible,
165
242
  createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
166
243
  onReplyError: deps.onReplyError
167
244
  });
@@ -178,9 +255,19 @@ async function deliverLineAutoReply(params) {
178
255
  await sendLineMessages(combined, true);
179
256
  }
180
257
  }
258
+ if (richMediaError !== void 0) {
259
+ if (!visibleReplySent) throw toLineDeliveryError(richMediaError);
260
+ return {
261
+ status: "partial",
262
+ replyTokenUsed,
263
+ visibleReplySent: true,
264
+ error: markLineVisibleDeliveryError(richMediaError)
265
+ };
266
+ }
181
267
  return {
182
268
  status: "delivered",
183
- replyTokenUsed
269
+ replyTokenUsed,
270
+ visibleReplySent
184
271
  };
185
272
  }
186
273
  //#endregion
@@ -370,6 +457,7 @@ async function finalizeLineInboundContext(params) {
370
457
  From: fromAddress,
371
458
  To: toAddress,
372
459
  SessionKey: params.route.sessionKey,
460
+ DmScope: params.route.dmScope,
373
461
  AccountId: params.route.accountId,
374
462
  ChatType: params.source.isGroup ? "group" : "direct",
375
463
  ConversationLabel: conversationLabel,
@@ -436,7 +524,7 @@ async function finalizeLineInboundContext(params) {
436
524
  };
437
525
  }
438
526
  async function buildLineMessageContext(params) {
439
- const { event, allMedia, mediaUnavailable, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
527
+ const { event, allMedia, mediaUnavailable, cfg, account, commandAuthorized, inboundHistory } = params;
440
528
  const source = event.source;
441
529
  const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
442
530
  source,
@@ -466,11 +554,6 @@ async function buildLineMessageContext(params) {
466
554
  address: loc.address
467
555
  });
468
556
  }
469
- const historyKey = isGroup ? peerId : void 0;
470
- const inboundHistory = historyKey && groupHistories && (historyLimit ?? 0) > 0 ? createChannelHistoryWindow({ historyMap: groupHistories }).buildInboundHistory({
471
- historyKey,
472
- limit: historyLimit ?? 0
473
- }) : void 0;
474
557
  const finalized = await finalizeLineInboundContext({
475
558
  cfg,
476
559
  account,
@@ -571,6 +654,41 @@ async function buildLinePostbackContext(params) {
571
654
  };
572
655
  }
573
656
  //#endregion
657
+ //#region extensions/line/src/group-history.ts
658
+ const reservedEntries = /* @__PURE__ */ new WeakSet();
659
+ function reserveLineGroupHistory(historyMap, historyKey, limit) {
660
+ if (!historyMap || !historyKey || limit <= 0) return {
661
+ commit: () => {},
662
+ release: () => {}
663
+ };
664
+ const consumedEntries = (historyMap.get(historyKey) ?? []).filter((entry) => !reservedEntries.has(entry));
665
+ for (const entry of consumedEntries) reservedEntries.add(entry);
666
+ const inboundHistory = createChannelHistoryWindow({ historyMap: /* @__PURE__ */ new Map([[historyKey, consumedEntries]]) }).buildInboundHistory({
667
+ historyKey,
668
+ limit
669
+ });
670
+ let settled = false;
671
+ const settle = () => {
672
+ if (settled) return false;
673
+ settled = true;
674
+ for (const entry of consumedEntries) reservedEntries.delete(entry);
675
+ return true;
676
+ };
677
+ return {
678
+ inboundHistory,
679
+ commit: () => {
680
+ if (!settle() || consumedEntries.length === 0) return;
681
+ const consumed = new Set(consumedEntries);
682
+ const kept = (historyMap.get(historyKey) ?? []).filter((entry) => !consumed.has(entry));
683
+ if (kept.length > 0) historyMap.set(historyKey, kept);
684
+ else historyMap.delete(historyKey);
685
+ },
686
+ release: () => {
687
+ settle();
688
+ }
689
+ };
690
+ }
691
+ //#endregion
574
692
  //#region extensions/line/src/bot-handlers.ts
575
693
  const LINE_DOWNLOADABLE_MESSAGE_TYPES = /* @__PURE__ */ new Set([
576
694
  "image",
@@ -581,57 +699,9 @@ const LINE_DOWNLOADABLE_MESSAGE_TYPES = /* @__PURE__ */ new Set([
581
699
  function isDownloadableLineMessageType(messageType) {
582
700
  return LINE_DOWNLOADABLE_MESSAGE_TYPES.has(messageType);
583
701
  }
584
- const LINE_WEBHOOK_REPLAY_WINDOW_MS = 600 * 1e3;
585
- const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
586
702
  function normalizeLineIngressEntry(value) {
587
703
  return normalizeLineAllowEntry(value) || null;
588
704
  }
589
- function createLineWebhookReplayCache() {
590
- return createClaimableDedupe({
591
- ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
592
- memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES
593
- });
594
- }
595
- function buildLineWebhookReplayKey(event, accountId) {
596
- if (event.type === "message") {
597
- const messageId = event.message?.id?.trim();
598
- if (messageId) return {
599
- key: `${accountId}|message:${messageId}`,
600
- eventId: `message:${messageId}`
601
- };
602
- }
603
- const eventId = event.webhookEventId?.trim();
604
- if (!eventId) return null;
605
- const source = event.source;
606
- const sourceId = source?.type === "group" ? `group:${source.groupId ?? ""}` : source?.type === "room" ? `room:${source.roomId ?? ""}` : `user:${source?.userId ?? ""}`;
607
- return {
608
- key: `${accountId}|${event.type}|${sourceId}|${eventId}`,
609
- eventId: `event:${eventId}`
610
- };
611
- }
612
- function getLineReplayCandidate(event, context) {
613
- const replay = buildLineWebhookReplayKey(event, context.account.accountId);
614
- const cache = context.replayCache;
615
- if (!replay || !cache) return null;
616
- return {
617
- key: replay.key,
618
- eventId: replay.eventId,
619
- cache
620
- };
621
- }
622
- async function claimLineReplayEvent(candidate) {
623
- const claim = await candidate.cache.claim(candidate.key);
624
- if (claim.kind === "claimed") return { skip: false };
625
- if (claim.kind === "inflight") {
626
- logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
627
- return {
628
- skip: true,
629
- inFlightResult: claim.pending.then(() => void 0)
630
- };
631
- }
632
- logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
633
- return { skip: true };
634
- }
635
705
  function resolveLineGroupConfig(params) {
636
706
  return resolveLineGroupConfigEntry(params.config.groups, {
637
707
  groupId: params.groupId,
@@ -771,7 +841,7 @@ async function shouldProcessLineEvent(event, context) {
771
841
  allowFrom: normalizeStringEntries(account.config.allowFrom),
772
842
  groupAllowFrom,
773
843
  command: {
774
- hasControlCommand: shouldComputeCommandAuthorized(rawText, cfg),
844
+ hasControlCommand: hasControlCommand(rawText, cfg),
775
845
  groupOwnerAllowFrom: "none"
776
846
  }
777
847
  });
@@ -869,42 +939,41 @@ async function handleMessageEvent(event, context) {
869
939
  });
870
940
  return;
871
941
  }
872
- const allMedia = [];
873
- let mediaUnavailable = false;
874
- if (isDownloadableLineMessageType(message.type)) try {
875
- const originalFilename = message.type === "file" ? normalizeOptionalString(message.fileName) : void 0;
876
- const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes, { originalFilename });
877
- allMedia.push({
878
- path: media.path,
879
- contentType: media.contentType
880
- });
881
- } catch (err) {
882
- mediaUnavailable = true;
883
- const errMsg = String(err);
884
- if (errMsg.includes("exceeds") && errMsg.includes("limit")) logVerbose(`line: media exceeds size limit for message ${message.id}`);
885
- else runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
886
- }
887
- const messageContext = await buildLineMessageContext({
888
- event,
889
- allMedia,
890
- mediaUnavailable,
891
- cfg,
892
- account,
893
- commandAuthorized: decision.commandAccess.authorized,
894
- groupHistories: context.groupHistories,
895
- historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
896
- });
897
- if (!messageContext) {
898
- logVerbose("line: skipping empty message");
899
- return;
900
- }
901
- await processMessage(messageContext);
902
- if (isGroup && context.groupHistories) {
903
- const historyKey = groupId ?? roomId;
904
- if (historyKey && context.groupHistories.has(historyKey)) createChannelHistoryWindow({ historyMap: context.groupHistories }).clear({
905
- historyKey,
906
- limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
942
+ const groupHistoryKey = isGroup ? groupId ?? roomId : void 0;
943
+ const historyReservation = reserveLineGroupHistory(context.groupHistories, groupHistoryKey, context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT);
944
+ try {
945
+ const allMedia = [];
946
+ let mediaUnavailable = false;
947
+ if (isDownloadableLineMessageType(message.type)) try {
948
+ const originalFilename = message.type === "file" ? normalizeOptionalString(message.fileName) : void 0;
949
+ const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes, { originalFilename });
950
+ allMedia.push({
951
+ path: media.path,
952
+ contentType: media.contentType
953
+ });
954
+ } catch (err) {
955
+ mediaUnavailable = true;
956
+ const errMsg = String(err);
957
+ if (errMsg.includes("exceeds") && errMsg.includes("limit")) logVerbose(`line: media exceeds size limit for message ${message.id}`);
958
+ else runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
959
+ }
960
+ const messageContext = await buildLineMessageContext({
961
+ event,
962
+ allMedia,
963
+ mediaUnavailable,
964
+ cfg,
965
+ account,
966
+ commandAuthorized: decision.commandAccess.authorized,
967
+ inboundHistory: historyReservation.inboundHistory
907
968
  });
969
+ if (!messageContext) {
970
+ logVerbose("line: skipping empty message");
971
+ return;
972
+ }
973
+ await processMessage(messageContext, context.turnAdoptionLifecycle ? { turnAdoptionLifecycle: context.turnAdoptionLifecycle } : {});
974
+ historyReservation.commit();
975
+ } finally {
976
+ historyReservation.release();
908
977
  }
909
978
  }
910
979
  async function handleFollowEvent(event, _context) {
@@ -935,53 +1004,41 @@ async function handlePostbackEvent(event, context) {
935
1004
  commandAuthorized: decision.commandAccess.authorized
936
1005
  });
937
1006
  if (!postbackContext) return;
938
- await context.processMessage(postbackContext);
1007
+ await context.processMessage(postbackContext, context.turnAdoptionLifecycle ? { turnAdoptionLifecycle: context.turnAdoptionLifecycle } : {});
939
1008
  }
940
1009
  async function handleLineWebhookEvents(events, context) {
941
1010
  let firstError;
942
- for (const event of events) {
943
- const replayCandidate = getLineReplayCandidate(event, context);
944
- const replaySkip = replayCandidate ? await claimLineReplayEvent(replayCandidate) : null;
945
- if (replaySkip?.skip) {
946
- if (replaySkip.inFlightResult) try {
947
- await replaySkip.inFlightResult;
948
- } catch (err) {
949
- context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
950
- firstError ??= err;
951
- }
952
- continue;
953
- }
954
- try {
955
- switch (event.type) {
956
- case "message":
957
- await handleMessageEvent(event, context);
958
- break;
959
- case "follow":
960
- await handleFollowEvent(event, context);
961
- break;
962
- case "unfollow":
963
- await handleUnfollowEvent(event, context);
964
- break;
965
- case "join":
966
- await handleJoinEvent(event, context);
967
- break;
968
- case "leave":
969
- await handleLeaveEvent(event, context);
970
- break;
971
- case "postback":
972
- await handlePostbackEvent(event, context);
973
- break;
974
- default: logVerbose(`line: unhandled event type: ${event.type}`);
975
- }
976
- if (replayCandidate) await replayCandidate.cache.commit(replayCandidate.key);
977
- } catch (err) {
978
- if (replayCandidate) await replayCandidate.cache.commit(replayCandidate.key);
979
- context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
980
- firstError ??= err;
981
- }
1011
+ for (const event of events) try {
1012
+ await handleLineWebhookEvent(event, context);
1013
+ } catch (err) {
1014
+ context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
1015
+ firstError ??= err;
982
1016
  }
983
1017
  if (firstError) throw toLintErrorObject(firstError, "Non-Error thrown");
984
1018
  }
1019
+ async function handleLineWebhookEvent(event, context) {
1020
+ switch (event.type) {
1021
+ case "message":
1022
+ await handleMessageEvent(event, context);
1023
+ break;
1024
+ case "follow":
1025
+ await handleFollowEvent(event, context);
1026
+ break;
1027
+ case "unfollow":
1028
+ await handleUnfollowEvent(event, context);
1029
+ break;
1030
+ case "join":
1031
+ await handleJoinEvent(event, context);
1032
+ break;
1033
+ case "leave":
1034
+ await handleLeaveEvent(event, context);
1035
+ break;
1036
+ case "postback":
1037
+ await handlePostbackEvent(event, context);
1038
+ break;
1039
+ default: logVerbose(`line: unhandled event type: ${event.type}`);
1040
+ }
1041
+ }
985
1042
  function toLintErrorObject(value, fallbackMessage) {
986
1043
  if (value instanceof Error) return value;
987
1044
  if (typeof value === "string") return new Error(value);
@@ -990,6 +1047,221 @@ function toLintErrorObject(value, fallbackMessage) {
990
1047
  return error;
991
1048
  }
992
1049
  //#endregion
1050
+ //#region extensions/line/src/webhook-spool.ts
1051
+ const LINE_WEBHOOK_SPOOL_VERSION = 1;
1052
+ const LINE_WEBHOOK_DRAIN_INTERVAL_MS = 500;
1053
+ const LINE_WEBHOOK_MAX_CONCURRENT_DELIVERIES = 8;
1054
+ const LINE_WEBHOOK_DRAIN_SCAN_LIMIT = 100;
1055
+ const LINE_WEBHOOK_TOMBSTONE_TTL_MS = 720 * 60 * 6e4;
1056
+ const LINE_WEBHOOK_TOMBSTONE_MAX_ENTRIES = 4096;
1057
+ var LineWebhookPayloadError = class extends Error {
1058
+ constructor(message, options) {
1059
+ super(message, options);
1060
+ this.name = "LineWebhookPayloadError";
1061
+ }
1062
+ };
1063
+ var LineWebhookTerminalDeliveryError = class extends Error {
1064
+ constructor(message, options) {
1065
+ super(message, options);
1066
+ this.reason = "delivery-side-effects-committed";
1067
+ this.name = "LineWebhookTerminalDeliveryError";
1068
+ }
1069
+ };
1070
+ function nonEmptyString(value) {
1071
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1072
+ }
1073
+ /** Message ids preserve the shipped replay-guard keyspace; other events use LINE's delivery id. */
1074
+ function eventIdFor(event) {
1075
+ if (!event || typeof event !== "object") throw new LineWebhookPayloadError("LINE webhook event must be an object.");
1076
+ const candidate = event;
1077
+ if (candidate.type === "message") {
1078
+ const messageId = nonEmptyString(candidate.message?.id);
1079
+ if (messageId) return `message:${messageId}`;
1080
+ }
1081
+ const webhookEventId = nonEmptyString(candidate.webhookEventId);
1082
+ if (webhookEventId) return `event:${webhookEventId}`;
1083
+ throw new LineWebhookPayloadError("LINE webhook event is missing a stable delivery id.");
1084
+ }
1085
+ function laneKeyFor(event, eventId) {
1086
+ if (!event || typeof event !== "object") return eventId;
1087
+ const source = event.source;
1088
+ if (source?.type === "group") {
1089
+ const groupId = nonEmptyString(source.groupId);
1090
+ if (groupId) return `group:${groupId}`;
1091
+ }
1092
+ if (source?.type === "room") {
1093
+ const roomId = nonEmptyString(source.roomId);
1094
+ if (roomId) return `room:${roomId}`;
1095
+ }
1096
+ if (source?.type === "user") {
1097
+ const userId = nonEmptyString(source.userId);
1098
+ if (userId) return `user:${userId}`;
1099
+ }
1100
+ return eventId;
1101
+ }
1102
+ function parseClaimedEvent(payload, claimedId) {
1103
+ if (payload.version !== LINE_WEBHOOK_SPOOL_VERSION || typeof payload.rawEvent !== "string" || typeof payload.destination !== "string") throw new LineWebhookPayloadError("LINE webhook spool payload is invalid.");
1104
+ let event;
1105
+ try {
1106
+ event = JSON.parse(payload.rawEvent);
1107
+ } catch (error) {
1108
+ throw new LineWebhookPayloadError("LINE webhook event JSON is invalid.", { cause: error });
1109
+ }
1110
+ if (eventIdFor(event) !== claimedId) throw new LineWebhookPayloadError("LINE webhook event id changed after durable admission.");
1111
+ return event;
1112
+ }
1113
+ function errorText(error) {
1114
+ return error instanceof Error ? error.message : String(error);
1115
+ }
1116
+ function isLineAuthenticationFailure(error) {
1117
+ if (!error || typeof error !== "object") return false;
1118
+ const status = error.status;
1119
+ return status === 401 || status === 403;
1120
+ }
1121
+ function createLineWebhookSpool(options) {
1122
+ const queue = options.queue ?? getLineRuntime().state.openChannelIngressQueue({ accountId: options.accountId });
1123
+ const shutdown = new AbortController();
1124
+ const activeDeliveries = /* @__PURE__ */ new Set();
1125
+ const deferredClaims = /* @__PURE__ */ new Map();
1126
+ const drain = createChannelIngressDrain({
1127
+ queue,
1128
+ abortSignal: shutdown.signal,
1129
+ adoptionStallTimeoutMs: DEFAULT_INGRESS_ADOPTION_STALL_MS,
1130
+ orderBy: "received",
1131
+ scanLimit: LINE_WEBHOOK_DRAIN_SCAN_LIMIT,
1132
+ startLimit: LINE_WEBHOOK_MAX_CONCURRENT_DELIVERIES,
1133
+ retryPolicy: {
1134
+ maxAttempts: DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS,
1135
+ deadLetterMinAgeMs: 0
1136
+ },
1137
+ resolveNonRetryableFailure: (error) => {
1138
+ if (error instanceof LineWebhookPayloadError) return {
1139
+ reason: "invalid-event",
1140
+ message: error.message
1141
+ };
1142
+ if (error instanceof LineWebhookTerminalDeliveryError) return {
1143
+ reason: error.reason,
1144
+ message: error.message
1145
+ };
1146
+ if (isLineAuthenticationFailure(error)) return {
1147
+ reason: "authentication-failed",
1148
+ message: errorText(error)
1149
+ };
1150
+ return null;
1151
+ },
1152
+ onLog: (message) => options.runtime.error?.(danger(`line: ${message}`)),
1153
+ dispatchClaimedEvent: async (claimed, lifecycle) => {
1154
+ const event = parseClaimedEvent(claimed.payload, claimed.id);
1155
+ const boundLifecycle = bindIngressLifecycleToReplyOptions(lifecycle).turnAdoptionLifecycle;
1156
+ let resolveDeferredClaim;
1157
+ const deferredClaim = new Promise((resolve) => {
1158
+ resolveDeferredClaim = resolve;
1159
+ });
1160
+ let deferredClaimSettled = false;
1161
+ const settleDeferredClaim = () => {
1162
+ if (deferredClaimSettled) return;
1163
+ deferredClaimSettled = true;
1164
+ if (deferredClaims.get(claimed.id) === deferredClaim) deferredClaims.delete(claimed.id);
1165
+ resolveDeferredClaim();
1166
+ };
1167
+ const delivery = options.deliver(event, claimed.payload.destination, { turnAdoptionLifecycle: {
1168
+ ...boundLifecycle,
1169
+ onAdopted: async () => {
1170
+ try {
1171
+ await boundLifecycle.onAdopted();
1172
+ } finally {
1173
+ settleDeferredClaim();
1174
+ }
1175
+ },
1176
+ onDeferred: () => {
1177
+ if (!deferredClaimSettled) deferredClaims.set(claimed.id, deferredClaim);
1178
+ boundLifecycle.onDeferred();
1179
+ },
1180
+ onAbandoned: async () => {
1181
+ try {
1182
+ await boundLifecycle.onAbandoned();
1183
+ } finally {
1184
+ settleDeferredClaim();
1185
+ }
1186
+ }
1187
+ } });
1188
+ activeDeliveries.add(delivery);
1189
+ try {
1190
+ await delivery;
1191
+ } finally {
1192
+ activeDeliveries.delete(delivery);
1193
+ }
1194
+ }
1195
+ });
1196
+ let running = false;
1197
+ let drainRequested = false;
1198
+ let drainTask;
1199
+ let drainTimer;
1200
+ const requestDrain = () => {
1201
+ if (!running || shutdown.signal.aborted) return;
1202
+ drainRequested = true;
1203
+ if (drainTask) return;
1204
+ drainTask = runDetachedWebhookWork(async () => {
1205
+ while (drainRequested && !shutdown.signal.aborted) {
1206
+ if (!running) break;
1207
+ drainRequested = false;
1208
+ await drain.drainOnce({ shouldStop: () => !running || shutdown.signal.aborted || activeDeliveries.size >= LINE_WEBHOOK_MAX_CONCURRENT_DELIVERIES });
1209
+ }
1210
+ }).catch((error) => {
1211
+ options.runtime.error?.(danger(`line: webhook spool drain failed: ${errorText(error)}`));
1212
+ }).finally(() => {
1213
+ drainTask = void 0;
1214
+ if (running && drainRequested && !shutdown.signal.aborted) requestDrain();
1215
+ });
1216
+ };
1217
+ return {
1218
+ accept: async (body) => {
1219
+ const events = body.events ?? [];
1220
+ if (events.length === 0) return;
1221
+ await queue.prune({
1222
+ completedTtlMs: LINE_WEBHOOK_TOMBSTONE_TTL_MS,
1223
+ completedMaxEntries: LINE_WEBHOOK_TOMBSTONE_MAX_ENTRIES,
1224
+ failedTtlMs: LINE_WEBHOOK_TOMBSTONE_TTL_MS,
1225
+ failedMaxEntries: LINE_WEBHOOK_TOMBSTONE_MAX_ENTRIES
1226
+ });
1227
+ const receivedAt = Date.now();
1228
+ for (const event of events) {
1229
+ const eventId = eventIdFor(event);
1230
+ await queue.enqueue(eventId, {
1231
+ version: LINE_WEBHOOK_SPOOL_VERSION,
1232
+ rawEvent: JSON.stringify(event),
1233
+ destination: body.destination ?? ""
1234
+ }, {
1235
+ receivedAt,
1236
+ laneKey: laneKeyFor(event, eventId)
1237
+ });
1238
+ }
1239
+ requestDrain();
1240
+ },
1241
+ start: () => {
1242
+ if (running) return;
1243
+ running = true;
1244
+ requestDrain();
1245
+ drainTimer = setInterval(requestDrain, LINE_WEBHOOK_DRAIN_INTERVAL_MS);
1246
+ drainTimer.unref?.();
1247
+ },
1248
+ stop: async () => {
1249
+ if (!running && shutdown.signal.aborted) return;
1250
+ running = false;
1251
+ if (drainTimer) {
1252
+ clearInterval(drainTimer);
1253
+ drainTimer = void 0;
1254
+ }
1255
+ shutdown.abort();
1256
+ await drainTask;
1257
+ await Promise.allSettled(activeDeliveries);
1258
+ await Promise.allSettled(deferredClaims.values());
1259
+ await drain.waitForIdle();
1260
+ drain.dispose();
1261
+ }
1262
+ };
1263
+ }
1264
+ //#endregion
993
1265
  //#region extensions/line/src/bot.ts
994
1266
  function createLineBot(opts) {
995
1267
  const runtime = opts.runtime ?? createNonExitingRuntime();
@@ -1002,24 +1274,26 @@ function createLineBot(opts) {
1002
1274
  const processMessage = opts.onMessage ?? (async () => {
1003
1275
  logVerbose("line: no message handler configured");
1004
1276
  });
1005
- const replayCache = createLineWebhookReplayCache();
1006
1277
  const groupHistories = /* @__PURE__ */ new Map();
1007
- const handleWebhook = async (body) => {
1008
- if (!body.events || body.events.length === 0) return;
1009
- await handleLineWebhookEvents(body.events, {
1278
+ const spool = createLineWebhookSpool({
1279
+ accountId: account.accountId,
1280
+ runtime,
1281
+ deliver: async (event, _destination, control) => await handleLineWebhookEvents([event], {
1010
1282
  cfg,
1011
1283
  account,
1012
1284
  runtime,
1013
1285
  mediaMaxBytes,
1014
1286
  processMessage,
1015
- replayCache,
1287
+ ...control.turnAdoptionLifecycle ? { turnAdoptionLifecycle: control.turnAdoptionLifecycle } : {},
1016
1288
  groupHistories,
1017
1289
  historyLimit: cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
1018
- });
1019
- };
1290
+ })
1291
+ });
1292
+ spool.start();
1020
1293
  return {
1021
- handleWebhook,
1022
- account
1294
+ handleWebhook: spool.accept,
1295
+ account,
1296
+ stop: spool.stop
1023
1297
  };
1024
1298
  }
1025
1299
  //#endregion
@@ -1042,34 +1316,39 @@ async function sendLineReplyChunks(params) {
1042
1316
  const quickReplies = params.quickReplies?.length ? params.quickReplies : void 0;
1043
1317
  let replyTokenUsed = Boolean(params.replyTokenUsed);
1044
1318
  if (params.chunks.length === 0) return { replyTokenUsed };
1045
- if (params.replyToken && !replyTokenUsed) try {
1046
- const replyBatch = params.chunks.slice(0, 5);
1047
- const remaining = params.chunks.slice(replyBatch.length);
1048
- const replyMessages = replyBatch.map((chunk) => ({
1049
- type: "text",
1050
- text: chunk
1051
- }));
1052
- if (quickReplies && remaining.length === 0 && replyMessages.length > 0) {
1053
- const lastIndex = replyMessages.length - 1;
1054
- replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(expectDefined(replyBatch[lastIndex], "last non-empty LINE reply batch chunk"), quickReplies);
1319
+ if (params.replyToken && !replyTokenUsed) {
1320
+ let replySucceeded = false;
1321
+ try {
1322
+ const replyBatch = params.chunks.slice(0, 5);
1323
+ const remaining = params.chunks.slice(replyBatch.length);
1324
+ const replyMessages = replyBatch.map((chunk) => ({
1325
+ type: "text",
1326
+ text: chunk
1327
+ }));
1328
+ if (quickReplies && remaining.length === 0 && replyMessages.length > 0) {
1329
+ const lastIndex = replyMessages.length - 1;
1330
+ replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(expectDefined(replyBatch[lastIndex], "last non-empty LINE reply batch chunk"), quickReplies);
1331
+ }
1332
+ await params.replyMessageLine(params.replyToken, replyMessages, {
1333
+ cfg: params.cfg,
1334
+ accountId: params.accountId
1335
+ });
1336
+ replyTokenUsed = true;
1337
+ replySucceeded = true;
1338
+ for (const [i, chunk] of remaining.entries()) if (i === remaining.length - 1 && quickReplies) await params.pushTextMessageWithQuickReplies(params.to, chunk, quickReplies, {
1339
+ cfg: params.cfg,
1340
+ accountId: params.accountId
1341
+ });
1342
+ else await params.pushMessageLine(params.to, chunk, {
1343
+ cfg: params.cfg,
1344
+ accountId: params.accountId
1345
+ });
1346
+ return { replyTokenUsed };
1347
+ } catch (err) {
1348
+ if (replySucceeded) throw err;
1349
+ params.onReplyError?.(err);
1350
+ replyTokenUsed = true;
1055
1351
  }
1056
- await params.replyMessageLine(params.replyToken, replyMessages, {
1057
- cfg: params.cfg,
1058
- accountId: params.accountId
1059
- });
1060
- replyTokenUsed = true;
1061
- for (const [i, chunk] of remaining.entries()) if (i === remaining.length - 1 && quickReplies) await params.pushTextMessageWithQuickReplies(params.to, chunk, quickReplies, {
1062
- cfg: params.cfg,
1063
- accountId: params.accountId
1064
- });
1065
- else await params.pushMessageLine(params.to, chunk, {
1066
- cfg: params.cfg,
1067
- accountId: params.accountId
1068
- });
1069
- return { replyTokenUsed };
1070
- } catch (err) {
1071
- params.onReplyError?.(err);
1072
- replyTokenUsed = true;
1073
1352
  }
1074
1353
  for (const [i, chunk] of params.chunks.entries()) if (i === params.chunks.length - 1 && quickReplies) await params.pushTextMessageWithQuickReplies(params.to, chunk, quickReplies, {
1075
1354
  cfg: params.cfg,
@@ -1106,9 +1385,6 @@ async function readLineWebhookRequestBody(req, maxBytes = LINE_WEBHOOK_MAX_BODY_
1106
1385
  timeoutMs
1107
1386
  });
1108
1387
  }
1109
- function logLineWebhookDispatchError(runtime, err) {
1110
- runtime?.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
1111
- }
1112
1388
  function createLineNodeWebhookHandler(params) {
1113
1389
  const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
1114
1390
  const readBody = params.readBody ?? readLineWebhookRequestBody;
@@ -1131,7 +1407,6 @@ function createLineNodeWebhookHandler(params) {
1131
1407
  res.end(JSON.stringify({ error: "Method Not Allowed" }));
1132
1408
  return;
1133
1409
  }
1134
- let receiveContext;
1135
1410
  try {
1136
1411
  const signatureHeader = req.headers["x-line-signature"];
1137
1412
  const signature = typeof signatureHeader === "string" ? signatureHeader.trim() : Array.isArray(signatureHeader) ? (signatureHeader[0] ?? "").trim() : "";
@@ -1158,24 +1433,14 @@ function createLineNodeWebhookHandler(params) {
1158
1433
  return;
1159
1434
  }
1160
1435
  params.onRequestAuthenticated?.();
1161
- receiveContext = createMessageReceiveContext({
1162
- id: `${Date.now()}:line:webhook`,
1163
- channel: "line",
1164
- message: body,
1165
- ackPolicy: "after_receive_record",
1166
- onAck: () => {
1167
- res.statusCode = 200;
1168
- res.setHeader("Content-Type", "application/json");
1169
- res.end(JSON.stringify({ status: "ok" }));
1170
- }
1171
- });
1172
- if (receiveContext.shouldAckAfter("receive_record")) await receiveContext.ack();
1173
1436
  if (body.events && body.events.length > 0) {
1174
1437
  logVerbose(`line: received ${body.events.length} webhook events`);
1175
- Promise.resolve().then(() => params.bot.handleWebhook(body)).catch((err) => logLineWebhookDispatchError(params.runtime, err));
1438
+ await params.bot.handleWebhook(body);
1176
1439
  }
1440
+ res.statusCode = 200;
1441
+ res.setHeader("Content-Type", "application/json");
1442
+ res.end(JSON.stringify({ status: "ok" }));
1177
1443
  } catch (err) {
1178
- await receiveContext?.nack(err);
1179
1444
  if (isRequestBodyLimitError$1(err, "PAYLOAD_TOO_LARGE")) {
1180
1445
  res.statusCode = 413;
1181
1446
  res.setHeader("Content-Type", "application/json");
@@ -1236,7 +1501,7 @@ async function monitorLineProvider(opts) {
1236
1501
  accountId,
1237
1502
  runtime,
1238
1503
  config,
1239
- onMessage: async (ctx) => {
1504
+ onMessage: async (ctx, deliveryControl) => {
1240
1505
  if (!ctx) return;
1241
1506
  const { ctxPayload, replyToken, route } = ctx;
1242
1507
  const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
@@ -1250,14 +1515,23 @@ async function monitorLineProvider(opts) {
1250
1515
  accountId: ctx.accountId
1251
1516
  }) : null;
1252
1517
  logVerbose(`line: received message from ${await displayNamePromise} (${ctxPayload.From})`);
1518
+ let replyTokenUsed = false;
1519
+ let turnAdopted = false;
1520
+ const ingressLifecycle = deliveryControl.turnAdoptionLifecycle;
1253
1521
  try {
1254
1522
  const textLimit = 5e3;
1255
- let replyTokenUsed = false;
1256
- const core = getLineRuntime();
1257
- const turnResult = await core.channel.inbound.run({
1523
+ const turnResult = await getLineRuntime().channel.inbound.run({
1258
1524
  channel: "line",
1259
1525
  accountId: route.accountId,
1260
1526
  raw: ctx,
1527
+ turnAdoptionLifecycle: {
1528
+ ...ingressLifecycle,
1529
+ admission: "exclusive",
1530
+ onAdopted: async () => {
1531
+ await ingressLifecycle?.onAdopted();
1532
+ turnAdopted = true;
1533
+ }
1534
+ },
1261
1535
  adapter: {
1262
1536
  ingest: () => ({
1263
1537
  id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
@@ -1267,14 +1541,14 @@ async function monitorLineProvider(opts) {
1267
1541
  cfg: config,
1268
1542
  channel: "line",
1269
1543
  accountId: route.accountId,
1270
- agentId: route.agentId,
1271
- routeSessionKey: route.sessionKey,
1272
- storePath: ctx.turn.storePath,
1544
+ route: {
1545
+ agentId: route.agentId,
1546
+ sessionKey: route.sessionKey
1547
+ },
1273
1548
  ctxPayload,
1274
- recordInboundSession: core.channel.session.recordInboundSession,
1275
- dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
1276
1549
  record: ctx.turn.record,
1277
1550
  replyPipeline: {},
1551
+ ...ingressLifecycle?.abortSignal ? { replyOptions: { abortSignal: ingressLifecycle.abortSignal } } : {},
1278
1552
  delivery: {
1279
1553
  durable: (payload, info) => resolveLineDurableReplyOptions({
1280
1554
  payload,
@@ -1310,7 +1584,7 @@ async function monitorLineProvider(opts) {
1310
1584
  createTextMessageWithQuickReplies,
1311
1585
  pushMessagesLine,
1312
1586
  createFlexMessage,
1313
- createImageMessage,
1587
+ buildMediaMessage: buildLineMediaMessage,
1314
1588
  createLocationMessage,
1315
1589
  onReplyError: (replyErr) => {
1316
1590
  logVerbose(`line: reply token failed, falling back to push: ${String(replyErr)}`);
@@ -1319,6 +1593,7 @@ async function monitorLineProvider(opts) {
1319
1593
  });
1320
1594
  replyTokenUsed = deliveryResult.replyTokenUsed;
1321
1595
  if (deliveryResult.status === "partial") throw deliveryResult.error;
1596
+ return { visibleReplySent: deliveryResult.visibleReplySent };
1322
1597
  },
1323
1598
  onError: (err, info) => {
1324
1599
  runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
@@ -1330,17 +1605,8 @@ async function monitorLineProvider(opts) {
1330
1605
  if (!hasFinalInboundReplyDispatch(turnResult.dispatched ? turnResult.dispatchResult : void 0)) logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
1331
1606
  } catch (err) {
1332
1607
  runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
1333
- if (replyToken) try {
1334
- await replyMessageLine(replyToken, [{
1335
- type: "text",
1336
- text: "Sorry, I encountered an error processing your message."
1337
- }], {
1338
- cfg: config,
1339
- accountId: ctx.accountId
1340
- });
1341
- } catch (replyErr) {
1342
- runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
1343
- }
1608
+ if (turnAdopted || replyTokenUsed) throw new LineWebhookTerminalDeliveryError("LINE delivery failed after consuming the event reply token.", { cause: err });
1609
+ throw err;
1344
1610
  } finally {
1345
1611
  stopLoading?.();
1346
1612
  }
@@ -1418,16 +1684,13 @@ async function monitorLineProvider(opts) {
1418
1684
  res.end(JSON.stringify({ error: "Invalid webhook payload" }));
1419
1685
  return;
1420
1686
  }
1421
- requestLifecycle.release();
1422
- res.statusCode = 200;
1423
- res.setHeader("Content-Type", "application/json");
1424
- res.end(JSON.stringify({ status: "ok" }));
1425
1687
  if (body.events && body.events.length > 0) {
1426
1688
  logVerbose(`line: received ${body.events.length} webhook events`);
1427
- Promise.resolve().then(() => match.target.bot.handleWebhook(body)).catch((err) => {
1428
- match.target.runtime.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
1429
- });
1689
+ await match.target.bot.handleWebhook(body);
1430
1690
  }
1691
+ res.statusCode = 200;
1692
+ res.setHeader("Content-Type", "application/json");
1693
+ res.end(JSON.stringify({ status: "ok" }));
1431
1694
  } catch (err) {
1432
1695
  if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
1433
1696
  res.statusCode = 413;
@@ -1455,23 +1718,29 @@ async function monitorLineProvider(opts) {
1455
1718
  });
1456
1719
  logVerbose(`line: registered webhook handler at ${normalizedPath}`);
1457
1720
  let stopped = false;
1721
+ let stopPromise;
1458
1722
  const stopHandler = () => {
1459
- if (stopped) return;
1723
+ if (stopPromise) return stopPromise;
1724
+ if (stopped) return Promise.resolve();
1460
1725
  stopped = true;
1461
1726
  logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
1462
1727
  unregisterHttp();
1728
+ stopPromise = bot.stop();
1729
+ return stopPromise;
1463
1730
  };
1464
- if (abortSignal?.aborted) stopHandler();
1731
+ const stopOnAbort = () => void stopHandler();
1732
+ if (abortSignal?.aborted) await stopHandler();
1465
1733
  else if (abortSignal) {
1466
- abortSignal.addEventListener("abort", stopHandler, { once: true });
1734
+ abortSignal.addEventListener("abort", stopOnAbort, { once: true });
1467
1735
  await waitForAbortSignal(abortSignal);
1736
+ await stopHandler();
1468
1737
  }
1469
1738
  return {
1470
1739
  account: bot.account,
1471
1740
  handleWebhook: bot.handleWebhook,
1472
- stop: () => {
1473
- stopHandler();
1474
- abortSignal?.removeEventListener("abort", stopHandler);
1741
+ stop: async () => {
1742
+ await stopHandler();
1743
+ abortSignal?.removeEventListener("abort", stopOnAbort);
1475
1744
  }
1476
1745
  };
1477
1746
  }