@openclaw/line 2026.7.1 → 2026.7.2-beta.2

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,30 +1,36 @@
1
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-C7r5npxP.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-DmpZY78n.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-XceqeKRP.js";
4
4
  import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
5
5
  import { normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
6
6
  import { createMessageReceiveContext } 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";
10
+ import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
9
11
  import { firstDefined } from "openclaw/plugin-sdk/allow-from";
10
- 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";
11
15
  import { saveMediaStream } from "openclaw/plugin-sdk/media-store";
12
16
  import { createNonExitingRuntime, danger, logVerbose, shouldLogVerbose, waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
17
+ import { fetchWithRuntimeDispatcherOrMockedGlobal } from "openclaw/plugin-sdk/runtime-fetch";
13
18
  import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
14
19
  import { buildMentionRegexes, formatInboundEnvelope, formatInboundMediaUnavailableText, formatLocationText, hasFinalInboundReplyDispatch, matchesMentionPatterns, resolveInboundSessionEnvelopeContext, toLocationContext } from "openclaw/plugin-sdk/channel-inbound";
15
20
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
16
21
  import { isRequestBodyLimitError, normalizePluginHttpPath, normalizeWebhookPath, registerWebhookTargetWithPluginRoute, requestBodyErrorToText, resolveSingleWebhookTarget } from "openclaw/plugin-sdk/webhook-ingress";
17
- 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";
18
23
  import { DEFAULT_GROUP_HISTORY_LIMIT, createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
19
24
  import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
20
25
  import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
21
- import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth-native";
26
+ import { hasControlCommand } from "openclaw/plugin-sdk/command-auth-native";
22
27
  import { ensureConfiguredBindingRouteReady, readChannelAllowFromStore, resolveConfiguredBindingRoute, resolvePairingIdLabel, resolvePinnedMainDmOwnerFromAllowlist, resolveRuntimeConversationBindingRoute, upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
23
- import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
28
+ import { createChannelReplayGuard } from "openclaw/plugin-sdk/persistent-dedupe";
24
29
  import { resolveAgentRoute, resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
25
30
  import { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";
26
31
  import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
27
32
  import crypto from "node:crypto";
33
+ import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
28
34
  //#region extensions/line/src/bot-access.ts
29
35
  function normalizeLineAllowEntry(value) {
30
36
  const trimmed = String(value).trim();
@@ -42,8 +48,51 @@ const normalizeAllowFrom = (list) => {
42
48
  };
43
49
  //#endregion
44
50
  //#region extensions/line/src/download.ts
51
+ const CONTENT_READY_MAX_ATTEMPTS = 6;
52
+ const CONTENT_READY_BASE_DELAY_MS = 500;
53
+ const CONTENT_READY_MAX_DELAY_MS = 4e3;
54
+ const CONTENT_READY_TIMEOUT_MS = 15e3;
55
+ const LINE_CONTENT_BASE_URL = "https://api-data.line.me/v2/bot/message";
56
+ function contentBackoffDelayMs(attempt) {
57
+ return Math.min(CONTENT_READY_BASE_DELAY_MS * 2 ** attempt, CONTENT_READY_MAX_DELAY_MS);
58
+ }
59
+ async function fetchLineContentWhenReady(messageId, channelAccessToken) {
60
+ const controller = new AbortController();
61
+ const deadline = setTimeout(() => controller.abort(), CONTENT_READY_TIMEOUT_MS);
62
+ deadline.unref();
63
+ try {
64
+ for (let attempt = 0; attempt < CONTENT_READY_MAX_ATTEMPTS; attempt++) {
65
+ const response = await fetchWithRuntimeDispatcherOrMockedGlobal(`${LINE_CONTENT_BASE_URL}/${encodeURIComponent(messageId)}/content`, {
66
+ headers: { Authorization: `Bearer ${channelAccessToken}` },
67
+ redirect: "error",
68
+ signal: controller.signal
69
+ });
70
+ if (response.status === 200) {
71
+ if (!response.body) throw new Error(`LINE media response for message ${messageId} had no body`);
72
+ return Readable.fromWeb(response.body);
73
+ }
74
+ await response.body?.cancel();
75
+ if (response.status !== 202) throw new Error(`LINE media download failed for message ${messageId} (HTTP ${response.status})`);
76
+ if (attempt < CONTENT_READY_MAX_ATTEMPTS - 1) await setTimeout$1(contentBackoffDelayMs(attempt), void 0, { signal: controller.signal });
77
+ }
78
+ } catch (err) {
79
+ 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 });
80
+ throw err;
81
+ } finally {
82
+ clearTimeout(deadline);
83
+ }
84
+ throw new Error(`LINE media for message ${messageId} was still preparing (HTTP 202) after ${CONTENT_READY_MAX_ATTEMPTS} attempts`);
85
+ }
45
86
  async function downloadLineMedia(messageId, channelAccessToken, maxBytes = 10 * 1024 * 1024, options) {
46
- const saved = await saveMediaStream(await new messagingApi.MessagingApiBlobClient({ channelAccessToken }).getMessageContent(messageId), void 0, "inbound", maxBytes, options?.originalFilename);
87
+ const content = await fetchLineContentWhenReady(messageId, channelAccessToken);
88
+ let saved;
89
+ try {
90
+ saved = await saveMediaStream(content, void 0, "inbound", maxBytes, options?.originalFilename);
91
+ } catch (err) {
92
+ content.destroy();
93
+ await finished(content).catch(() => void 0);
94
+ throw err;
95
+ }
47
96
  logVerbose(`line: persisted media ${messageId} to ${saved.path} (${saved.size} bytes)`);
48
97
  return {
49
98
  path: saved.path,
@@ -53,15 +102,19 @@ async function downloadLineMedia(messageId, channelAccessToken, maxBytes = 10 *
53
102
  }
54
103
  //#endregion
55
104
  //#region extensions/line/src/auto-reply-delivery.ts
105
+ function toLineDeliveryError(error) {
106
+ return error instanceof Error ? error : new Error("LINE rich or media message send failed", { cause: error });
107
+ }
56
108
  function markLineVisibleDeliveryError(error) {
57
- if (error instanceof Error && Object.isExtensible(error)) {
58
- Object.assign(error, {
109
+ const deliveryError = toLineDeliveryError(error);
110
+ if (Object.isExtensible(deliveryError)) {
111
+ Object.assign(deliveryError, {
59
112
  sentBeforeError: true,
60
113
  visibleReplySent: true
61
114
  });
62
- return error;
115
+ return deliveryError;
63
116
  }
64
- const visibleError = new Error("LINE rich or media message send failed", { cause: error });
117
+ const visibleError = new Error("LINE rich or media message send failed", { cause: deliveryError });
65
118
  Object.assign(visibleError, {
66
119
  sentBeforeError: true,
67
120
  visibleReplySent: true
@@ -71,12 +124,26 @@ function markLineVisibleDeliveryError(error) {
71
124
  async function deliverLineAutoReply(params) {
72
125
  const { payload, lineData, replyToken, accountId, to, textLimit, deps } = params;
73
126
  let replyTokenUsed = params.replyTokenUsed;
127
+ let visibleReplySent = false;
128
+ const sendVisible = async (send) => {
129
+ try {
130
+ const result = await send();
131
+ visibleReplySent = true;
132
+ return result;
133
+ } catch (error) {
134
+ if (visibleReplySent) throw markLineVisibleDeliveryError(error);
135
+ throw error;
136
+ }
137
+ };
138
+ const replyVisible = (...args) => sendVisible(() => deps.replyMessageLine(...args));
139
+ const pushTextVisible = (...args) => sendVisible(() => deps.pushMessageLine(...args));
140
+ const pushQuickRepliesVisible = (...args) => sendVisible(() => deps.pushTextMessageWithQuickReplies(...args));
74
141
  const pushLineMessages = async (messages) => {
75
142
  if (messages.length === 0) return;
76
- for (let i = 0; i < messages.length; i += 5) await deps.pushMessagesLine(to, messages.slice(i, i + 5), {
143
+ for (let i = 0; i < messages.length; i += 5) await sendVisible(() => deps.pushMessagesLine(to, messages.slice(i, i + 5), {
77
144
  cfg: params.cfg,
78
145
  accountId
79
- });
146
+ }));
80
147
  };
81
148
  const sendLineMessages = async (messages, allowReplyToken) => {
82
149
  if (messages.length === 0) return;
@@ -84,7 +151,7 @@ async function deliverLineAutoReply(params) {
84
151
  if (allowReplyToken && replyToken && !replyTokenUsed) {
85
152
  const replyBatch = remaining.slice(0, 5);
86
153
  try {
87
- await deps.replyMessageLine(replyToken, replyBatch, {
154
+ await replyVisible(replyToken, replyBatch, {
88
155
  cfg: params.cfg,
89
156
  accountId
90
157
  });
@@ -105,21 +172,38 @@ async function deliverLineAutoReply(params) {
105
172
  if (templateMsg) richMessages.push(templateMsg);
106
173
  }
107
174
  if (lineData.location) richMessages.push(deps.createLocationMessage(lineData.location));
108
- const processed = payload.text ? deps.processLineMessage(payload.text) : {
175
+ const visibleText = payload.text ? sanitizeAssistantVisibleText(payload.text) : "";
176
+ const processed = visibleText ? deps.processLineMessage(visibleText) : {
109
177
  text: "",
110
178
  flexMessages: []
111
179
  };
112
180
  for (const flexMsg of processed.flexMessages) richMessages.push(deps.createFlexMessage(truncateUtf16Safe(flexMsg.altText, 400), flexMsg.contents));
113
181
  const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
114
- const mediaMessages = resolveSendableOutboundReplyParts(payload).mediaUrls.map((url) => url?.trim()).filter((url) => Boolean(url)).map((url) => deps.createImageMessage(url));
182
+ const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
183
+ const mediaOpts = {
184
+ mediaKind: hasLineSpecificMediaOptions(lineData) ? lineData.mediaKind : "image",
185
+ previewImageUrl: lineData.previewImageUrl,
186
+ durationMs: lineData.durationMs,
187
+ trackingId: lineData.trackingId
188
+ };
189
+ const mediaMessages = [];
190
+ let richMediaError;
191
+ for (const rawUrl of mediaUrls) {
192
+ const url = rawUrl?.trim();
193
+ if (!url) continue;
194
+ try {
195
+ mediaMessages.push(await deps.buildMediaMessage(url, mediaOpts, to));
196
+ } catch (err) {
197
+ richMediaError ??= err;
198
+ }
199
+ }
115
200
  if (chunks.length > 0) {
116
201
  const hasRichOrMedia = richMessages.length > 0 || mediaMessages.length > 0;
117
202
  const sendRichBeforeText = hasQuickReplies && hasRichOrMedia;
118
- let richMediaError;
119
203
  if (sendRichBeforeText) try {
120
204
  await sendLineMessages([...richMessages, ...mediaMessages], false);
121
205
  } catch (err) {
122
- richMediaError = err;
206
+ richMediaError ??= err;
123
207
  }
124
208
  const { replyTokenUsed: nextReplyTokenUsed } = await deps.sendLineReplyChunks({
125
209
  to,
@@ -129,23 +213,19 @@ async function deliverLineAutoReply(params) {
129
213
  replyTokenUsed,
130
214
  cfg: params.cfg,
131
215
  accountId,
132
- replyMessageLine: deps.replyMessageLine,
133
- pushMessageLine: deps.pushMessageLine,
134
- pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
135
- createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies
216
+ replyMessageLine: replyVisible,
217
+ pushMessageLine: pushTextVisible,
218
+ pushTextMessageWithQuickReplies: pushQuickRepliesVisible,
219
+ createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
220
+ onReplyError: deps.onReplyError
136
221
  });
137
222
  replyTokenUsed = nextReplyTokenUsed;
138
223
  if (!sendRichBeforeText) try {
139
224
  await sendLineMessages(richMessages, false);
140
225
  if (mediaMessages.length > 0) await sendLineMessages(mediaMessages, false);
141
226
  } catch (err) {
142
- richMediaError = err;
227
+ richMediaError ??= err;
143
228
  }
144
- if (richMediaError !== void 0) return {
145
- status: "partial",
146
- replyTokenUsed,
147
- error: markLineVisibleDeliveryError(richMediaError)
148
- };
149
229
  } else {
150
230
  const combined = [...richMessages, ...mediaMessages];
151
231
  if (hasQuickReplies && combined.length === 0) {
@@ -157,9 +237,9 @@ async function deliverLineAutoReply(params) {
157
237
  replyTokenUsed,
158
238
  cfg: params.cfg,
159
239
  accountId,
160
- replyMessageLine: deps.replyMessageLine,
161
- pushMessageLine: deps.pushMessageLine,
162
- pushTextMessageWithQuickReplies: deps.pushTextMessageWithQuickReplies,
240
+ replyMessageLine: replyVisible,
241
+ pushMessageLine: pushTextVisible,
242
+ pushTextMessageWithQuickReplies: pushQuickRepliesVisible,
163
243
  createTextMessageWithQuickReplies: deps.createTextMessageWithQuickReplies,
164
244
  onReplyError: deps.onReplyError
165
245
  });
@@ -176,9 +256,19 @@ async function deliverLineAutoReply(params) {
176
256
  await sendLineMessages(combined, true);
177
257
  }
178
258
  }
259
+ if (richMediaError !== void 0) {
260
+ if (!visibleReplySent) throw toLineDeliveryError(richMediaError);
261
+ return {
262
+ status: "partial",
263
+ replyTokenUsed,
264
+ visibleReplySent: true,
265
+ error: markLineVisibleDeliveryError(richMediaError)
266
+ };
267
+ }
179
268
  return {
180
269
  status: "delivered",
181
- replyTokenUsed
270
+ replyTokenUsed,
271
+ visibleReplySent
182
272
  };
183
273
  }
184
274
  //#endregion
@@ -403,7 +493,7 @@ async function finalizeLineInboundContext(params) {
403
493
  sessionKey: params.route.sessionKey
404
494
  });
405
495
  if (shouldLogVerbose()) {
406
- const preview = body.slice(0, 200).replace(/\n/g, "\\n");
496
+ const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
407
497
  const mediaInfo = params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1 ? ` mediaCount=${params.verboseLog.mediaCount}` : "";
408
498
  logVerbose(`${params.verboseLog.kind === "inbound" ? "line inbound" : "line postback"}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`);
409
499
  }
@@ -434,7 +524,7 @@ async function finalizeLineInboundContext(params) {
434
524
  };
435
525
  }
436
526
  async function buildLineMessageContext(params) {
437
- const { event, allMedia, mediaUnavailable, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
527
+ const { event, allMedia, mediaUnavailable, cfg, account, commandAuthorized, inboundHistory } = params;
438
528
  const source = event.source;
439
529
  const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
440
530
  source,
@@ -464,11 +554,6 @@ async function buildLineMessageContext(params) {
464
554
  address: loc.address
465
555
  });
466
556
  }
467
- const historyKey = isGroup ? peerId : void 0;
468
- const inboundHistory = historyKey && groupHistories && (historyLimit ?? 0) > 0 ? createChannelHistoryWindow({ historyMap: groupHistories }).buildInboundHistory({
469
- historyKey,
470
- limit: historyLimit ?? 0
471
- }) : void 0;
472
557
  const finalized = await finalizeLineInboundContext({
473
558
  cfg,
474
559
  account,
@@ -569,6 +654,41 @@ async function buildLinePostbackContext(params) {
569
654
  };
570
655
  }
571
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
572
692
  //#region extensions/line/src/bot-handlers.ts
573
693
  const LINE_DOWNLOADABLE_MESSAGE_TYPES = /* @__PURE__ */ new Set([
574
694
  "image",
@@ -584,16 +704,13 @@ const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
584
704
  function normalizeLineIngressEntry(value) {
585
705
  return normalizeLineAllowEntry(value) || null;
586
706
  }
587
- var LineRetryableWebhookError = class extends Error {
588
- constructor(message, options) {
589
- super(message, options);
590
- this.name = "LineRetryableWebhookError";
591
- }
592
- };
593
707
  function createLineWebhookReplayCache() {
594
- return createClaimableDedupe({
595
- ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
596
- memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES
708
+ return createChannelReplayGuard({
709
+ dedupe: {
710
+ ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
711
+ memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES
712
+ },
713
+ buildReplayKey: ({ event, accountId }) => buildLineWebhookReplayKey(event, accountId)?.key
597
714
  });
598
715
  }
599
716
  function buildLineWebhookReplayKey(event, accountId) {
@@ -613,29 +730,6 @@ function buildLineWebhookReplayKey(event, accountId) {
613
730
  eventId: `event:${eventId}`
614
731
  };
615
732
  }
616
- function getLineReplayCandidate(event, context) {
617
- const replay = buildLineWebhookReplayKey(event, context.account.accountId);
618
- const cache = context.replayCache;
619
- if (!replay || !cache) return null;
620
- return {
621
- key: replay.key,
622
- eventId: replay.eventId,
623
- cache
624
- };
625
- }
626
- async function claimLineReplayEvent(candidate) {
627
- const claim = await candidate.cache.claim(candidate.key);
628
- if (claim.kind === "claimed") return { skip: false };
629
- if (claim.kind === "inflight") {
630
- logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
631
- return {
632
- skip: true,
633
- inFlightResult: claim.pending.then(() => void 0)
634
- };
635
- }
636
- logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
637
- return { skip: true };
638
- }
639
733
  function resolveLineGroupConfig(params) {
640
734
  return resolveLineGroupConfigEntry(params.config.groups, {
641
735
  groupId: params.groupId,
@@ -710,7 +804,7 @@ async function shouldProcessLineEvent(event, context) {
710
804
  defaultGroupPolicy: resolveDefaultGroupPolicy(cfg)
711
805
  });
712
806
  const groupPolicy = runtimeGroupPolicy === "disabled" ? "disabled" : groupConfig?.allowFrom !== void 0 ? "allowlist" : runtimeGroupPolicy;
713
- const groupAllowFrom = normalizeStringEntries(firstDefined(groupConfig?.allowFrom, account.config.groupAllowFrom, account.config.allowFrom?.length ? account.config.allowFrom : void 0));
807
+ const groupAllowFrom = normalizeStringEntries(firstDefined(groupConfig?.allowFrom, account.config.groupAllowFrom));
714
808
  const mentionFacts = (() => {
715
809
  if (!isGroup || event.type !== "message") return {
716
810
  canDetectMention: false,
@@ -775,7 +869,7 @@ async function shouldProcessLineEvent(event, context) {
775
869
  allowFrom: normalizeStringEntries(account.config.allowFrom),
776
870
  groupAllowFrom,
777
871
  command: {
778
- hasControlCommand: shouldComputeCommandAuthorized(rawText, cfg),
872
+ hasControlCommand: hasControlCommand(rawText, cfg),
779
873
  groupOwnerAllowFrom: "none"
780
874
  }
781
875
  });
@@ -873,42 +967,41 @@ async function handleMessageEvent(event, context) {
873
967
  });
874
968
  return;
875
969
  }
876
- const allMedia = [];
877
- let mediaUnavailable = false;
878
- if (isDownloadableLineMessageType(message.type)) try {
879
- const originalFilename = message.type === "file" ? normalizeOptionalString(message.fileName) : void 0;
880
- const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes, { originalFilename });
881
- allMedia.push({
882
- path: media.path,
883
- contentType: media.contentType
884
- });
885
- } catch (err) {
886
- mediaUnavailable = true;
887
- const errMsg = String(err);
888
- if (errMsg.includes("exceeds") && errMsg.includes("limit")) logVerbose(`line: media exceeds size limit for message ${message.id}`);
889
- else runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
890
- }
891
- const messageContext = await buildLineMessageContext({
892
- event,
893
- allMedia,
894
- mediaUnavailable,
895
- cfg,
896
- account,
897
- commandAuthorized: decision.commandAccess.authorized,
898
- groupHistories: context.groupHistories,
899
- historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
900
- });
901
- if (!messageContext) {
902
- logVerbose("line: skipping empty message");
903
- return;
904
- }
905
- await processMessage(messageContext);
906
- if (isGroup && context.groupHistories) {
907
- const historyKey = groupId ?? roomId;
908
- if (historyKey && context.groupHistories.has(historyKey)) createChannelHistoryWindow({ historyMap: context.groupHistories }).clear({
909
- historyKey,
910
- limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
970
+ const groupHistoryKey = isGroup ? groupId ?? roomId : void 0;
971
+ const historyReservation = reserveLineGroupHistory(context.groupHistories, groupHistoryKey, context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT);
972
+ try {
973
+ const allMedia = [];
974
+ let mediaUnavailable = false;
975
+ if (isDownloadableLineMessageType(message.type)) try {
976
+ const originalFilename = message.type === "file" ? normalizeOptionalString(message.fileName) : void 0;
977
+ const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes, { originalFilename });
978
+ allMedia.push({
979
+ path: media.path,
980
+ contentType: media.contentType
981
+ });
982
+ } catch (err) {
983
+ mediaUnavailable = true;
984
+ const errMsg = String(err);
985
+ if (errMsg.includes("exceeds") && errMsg.includes("limit")) logVerbose(`line: media exceeds size limit for message ${message.id}`);
986
+ else runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
987
+ }
988
+ const messageContext = await buildLineMessageContext({
989
+ event,
990
+ allMedia,
991
+ mediaUnavailable,
992
+ cfg,
993
+ account,
994
+ commandAuthorized: decision.commandAccess.authorized,
995
+ inboundHistory: historyReservation.inboundHistory
911
996
  });
997
+ if (!messageContext) {
998
+ logVerbose("line: skipping empty message");
999
+ return;
1000
+ }
1001
+ await processMessage(messageContext);
1002
+ historyReservation.commit();
1003
+ } finally {
1004
+ historyReservation.release();
912
1005
  }
913
1006
  }
914
1007
  async function handleFollowEvent(event, _context) {
@@ -943,50 +1036,55 @@ async function handlePostbackEvent(event, context) {
943
1036
  }
944
1037
  async function handleLineWebhookEvents(events, context) {
945
1038
  let firstError;
946
- for (const event of events) {
947
- const replayCandidate = getLineReplayCandidate(event, context);
948
- const replaySkip = replayCandidate ? await claimLineReplayEvent(replayCandidate) : null;
949
- if (replaySkip?.skip) {
950
- if (replaySkip.inFlightResult) try {
951
- await replaySkip.inFlightResult;
1039
+ for (const event of events) try {
1040
+ if (!context.replayCache) {
1041
+ await handleLineWebhookEvent(event, context);
1042
+ continue;
1043
+ }
1044
+ const replayEvent = {
1045
+ event,
1046
+ accountId: context.account.accountId
1047
+ };
1048
+ const result = await context.replayCache.processGuarded(replayEvent, async () => await handleLineWebhookEvent(event, context), { onError: "commit" });
1049
+ const replayId = buildLineWebhookReplayKey(event, context.account.accountId)?.eventId;
1050
+ if (result.kind === "inflight") {
1051
+ logVerbose(`line: skipped in-flight replayed webhook event ${replayId ?? "unknown"}`);
1052
+ try {
1053
+ await result.pending;
952
1054
  } catch (err) {
953
1055
  context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
954
1056
  firstError ??= err;
955
1057
  }
956
- continue;
957
- }
958
- try {
959
- switch (event.type) {
960
- case "message":
961
- await handleMessageEvent(event, context);
962
- break;
963
- case "follow":
964
- await handleFollowEvent(event, context);
965
- break;
966
- case "unfollow":
967
- await handleUnfollowEvent(event, context);
968
- break;
969
- case "join":
970
- await handleJoinEvent(event, context);
971
- break;
972
- case "leave":
973
- await handleLeaveEvent(event, context);
974
- break;
975
- case "postback":
976
- await handlePostbackEvent(event, context);
977
- break;
978
- default: logVerbose(`line: unhandled event type: ${event.type}`);
979
- }
980
- if (replayCandidate) await replayCandidate.cache.commit(replayCandidate.key);
981
- } catch (err) {
982
- if (replayCandidate) if (err instanceof LineRetryableWebhookError) replayCandidate.cache.release(replayCandidate.key, { error: err });
983
- else await replayCandidate.cache.commit(replayCandidate.key);
984
- context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
985
- firstError ??= err;
986
- }
1058
+ } else if (result.kind === "duplicate") logVerbose(`line: skipped replayed webhook event ${replayId ?? "unknown"}`);
1059
+ } catch (err) {
1060
+ context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
1061
+ firstError ??= err;
987
1062
  }
988
1063
  if (firstError) throw toLintErrorObject(firstError, "Non-Error thrown");
989
1064
  }
1065
+ async function handleLineWebhookEvent(event, context) {
1066
+ switch (event.type) {
1067
+ case "message":
1068
+ await handleMessageEvent(event, context);
1069
+ break;
1070
+ case "follow":
1071
+ await handleFollowEvent(event, context);
1072
+ break;
1073
+ case "unfollow":
1074
+ await handleUnfollowEvent(event, context);
1075
+ break;
1076
+ case "join":
1077
+ await handleJoinEvent(event, context);
1078
+ break;
1079
+ case "leave":
1080
+ await handleLeaveEvent(event, context);
1081
+ break;
1082
+ case "postback":
1083
+ await handlePostbackEvent(event, context);
1084
+ break;
1085
+ default: logVerbose(`line: unhandled event type: ${event.type}`);
1086
+ }
1087
+ }
990
1088
  function toLintErrorObject(value, fallbackMessage) {
991
1089
  if (value instanceof Error) return value;
992
1090
  if (typeof value === "string") return new Error(value);
@@ -1044,43 +1142,48 @@ function resolveLineDurableReplyOptions(params) {
1044
1142
  //#endregion
1045
1143
  //#region extensions/line/src/reply-chunks.ts
1046
1144
  async function sendLineReplyChunks(params) {
1047
- const hasQuickReplies = Boolean(params.quickReplies?.length);
1145
+ const quickReplies = params.quickReplies?.length ? params.quickReplies : void 0;
1048
1146
  let replyTokenUsed = Boolean(params.replyTokenUsed);
1049
1147
  if (params.chunks.length === 0) return { replyTokenUsed };
1050
- if (params.replyToken && !replyTokenUsed) try {
1051
- const replyBatch = params.chunks.slice(0, 5);
1052
- const remaining = params.chunks.slice(replyBatch.length);
1053
- const replyMessages = replyBatch.map((chunk) => ({
1054
- type: "text",
1055
- text: chunk
1056
- }));
1057
- if (hasQuickReplies && remaining.length === 0 && replyMessages.length > 0) {
1058
- const lastIndex = replyMessages.length - 1;
1059
- replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(replyBatch[lastIndex], params.quickReplies);
1148
+ if (params.replyToken && !replyTokenUsed) {
1149
+ let replySucceeded = false;
1150
+ try {
1151
+ const replyBatch = params.chunks.slice(0, 5);
1152
+ const remaining = params.chunks.slice(replyBatch.length);
1153
+ const replyMessages = replyBatch.map((chunk) => ({
1154
+ type: "text",
1155
+ text: chunk
1156
+ }));
1157
+ if (quickReplies && remaining.length === 0 && replyMessages.length > 0) {
1158
+ const lastIndex = replyMessages.length - 1;
1159
+ replyMessages[lastIndex] = params.createTextMessageWithQuickReplies(expectDefined(replyBatch[lastIndex], "last non-empty LINE reply batch chunk"), quickReplies);
1160
+ }
1161
+ await params.replyMessageLine(params.replyToken, replyMessages, {
1162
+ cfg: params.cfg,
1163
+ accountId: params.accountId
1164
+ });
1165
+ replyTokenUsed = true;
1166
+ replySucceeded = true;
1167
+ for (const [i, chunk] of remaining.entries()) if (i === remaining.length - 1 && quickReplies) await params.pushTextMessageWithQuickReplies(params.to, chunk, quickReplies, {
1168
+ cfg: params.cfg,
1169
+ accountId: params.accountId
1170
+ });
1171
+ else await params.pushMessageLine(params.to, chunk, {
1172
+ cfg: params.cfg,
1173
+ accountId: params.accountId
1174
+ });
1175
+ return { replyTokenUsed };
1176
+ } catch (err) {
1177
+ if (replySucceeded) throw err;
1178
+ params.onReplyError?.(err);
1179
+ replyTokenUsed = true;
1060
1180
  }
1061
- await params.replyMessageLine(params.replyToken, replyMessages, {
1062
- cfg: params.cfg,
1063
- accountId: params.accountId
1064
- });
1065
- replyTokenUsed = true;
1066
- for (let i = 0; i < remaining.length; i += 1) if (i === remaining.length - 1 && hasQuickReplies) await params.pushTextMessageWithQuickReplies(params.to, remaining[i], params.quickReplies, {
1067
- cfg: params.cfg,
1068
- accountId: params.accountId
1069
- });
1070
- else await params.pushMessageLine(params.to, remaining[i], {
1071
- cfg: params.cfg,
1072
- accountId: params.accountId
1073
- });
1074
- return { replyTokenUsed };
1075
- } catch (err) {
1076
- params.onReplyError?.(err);
1077
- replyTokenUsed = true;
1078
1181
  }
1079
- for (let i = 0; i < params.chunks.length; i += 1) if (i === params.chunks.length - 1 && hasQuickReplies) await params.pushTextMessageWithQuickReplies(params.to, params.chunks[i], params.quickReplies, {
1182
+ for (const [i, chunk] of params.chunks.entries()) if (i === params.chunks.length - 1 && quickReplies) await params.pushTextMessageWithQuickReplies(params.to, chunk, quickReplies, {
1080
1183
  cfg: params.cfg,
1081
1184
  accountId: params.accountId
1082
1185
  });
1083
- else await params.pushMessageLine(params.to, params.chunks[i], {
1186
+ else await params.pushMessageLine(params.to, chunk, {
1084
1187
  cfg: params.cfg,
1085
1188
  accountId: params.accountId
1086
1189
  });
@@ -1089,16 +1192,7 @@ async function sendLineReplyChunks(params) {
1089
1192
  //#endregion
1090
1193
  //#region extensions/line/src/signature.ts
1091
1194
  function validateLineSignature(body, signature, channelSecret) {
1092
- const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
1093
- const hashBuffer = Buffer.from(hash);
1094
- const signatureBuffer = Buffer.from(signature);
1095
- const maxLen = Math.max(hashBuffer.length, signatureBuffer.length);
1096
- const paddedHash = Buffer.alloc(maxLen);
1097
- const paddedSig = Buffer.alloc(maxLen);
1098
- hashBuffer.copy(paddedHash);
1099
- signatureBuffer.copy(paddedSig);
1100
- const timingResult = crypto.timingSafeEqual(paddedHash, paddedSig);
1101
- return hashBuffer.length === signatureBuffer.length && timingResult;
1195
+ return safeEqualSecret(signature, crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"));
1102
1196
  }
1103
1197
  //#endregion
1104
1198
  //#region extensions/line/src/webhook-utils.ts
@@ -1186,7 +1280,7 @@ function createLineNodeWebhookHandler(params) {
1186
1280
  if (receiveContext.shouldAckAfter("receive_record")) await receiveContext.ack();
1187
1281
  if (body.events && body.events.length > 0) {
1188
1282
  logVerbose(`line: received ${body.events.length} webhook events`);
1189
- Promise.resolve().then(() => params.bot.handleWebhook(body)).catch((err) => logLineWebhookDispatchError(params.runtime, err));
1283
+ runDetachedWebhookWork(() => params.bot.handleWebhook(body)).catch((err) => logLineWebhookDispatchError(params.runtime, err));
1190
1284
  }
1191
1285
  } catch (err) {
1192
1286
  await receiveContext?.nack(err);
@@ -1213,24 +1307,10 @@ function createLineNodeWebhookHandler(params) {
1213
1307
  }
1214
1308
  //#endregion
1215
1309
  //#region extensions/line/src/monitor.ts
1216
- const runtimeState = /* @__PURE__ */ new Map();
1217
1310
  const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
1218
1311
  const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
1219
1312
  const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5e3;
1220
1313
  const lineWebhookTargets = /* @__PURE__ */ new Map();
1221
- function recordChannelRuntimeState(params) {
1222
- const key = `${params.channel}:${params.accountId}`;
1223
- const existing = runtimeState.get(key) ?? {
1224
- running: false,
1225
- lastStartAt: null,
1226
- lastStopAt: null,
1227
- lastError: null
1228
- };
1229
- runtimeState.set(key, {
1230
- ...existing,
1231
- ...params.state
1232
- });
1233
- }
1234
1314
  function startLineLoadingKeepalive(params) {
1235
1315
  const intervalMs = params.intervalMs ?? 18e3;
1236
1316
  const loadingSeconds = params.loadingSeconds ?? 20;
@@ -1267,11 +1347,6 @@ async function monitorLineProvider(opts) {
1267
1347
  onMessage: async (ctx) => {
1268
1348
  if (!ctx) return;
1269
1349
  const { ctxPayload, replyToken, route } = ctx;
1270
- recordChannelRuntimeState({
1271
- channel: "line",
1272
- accountId: resolvedAccountId,
1273
- state: { lastInboundAt: Date.now() }
1274
- });
1275
1350
  const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
1276
1351
  const displayNamePromise = ctx.userId ? getUserDisplayName(ctx.userId, {
1277
1352
  cfg: config,
@@ -1343,7 +1418,7 @@ async function monitorLineProvider(opts) {
1343
1418
  createTextMessageWithQuickReplies,
1344
1419
  pushMessagesLine,
1345
1420
  createFlexMessage,
1346
- createImageMessage,
1421
+ buildMediaMessage: buildLineMediaMessage,
1347
1422
  createLocationMessage,
1348
1423
  onReplyError: (replyErr) => {
1349
1424
  logVerbose(`line: reply token failed, falling back to push: ${String(replyErr)}`);
@@ -1352,11 +1427,7 @@ async function monitorLineProvider(opts) {
1352
1427
  });
1353
1428
  replyTokenUsed = deliveryResult.replyTokenUsed;
1354
1429
  if (deliveryResult.status === "partial") throw deliveryResult.error;
1355
- recordChannelRuntimeState({
1356
- channel: "line",
1357
- accountId: resolvedAccountId,
1358
- state: { lastOutboundAt: Date.now() }
1359
- });
1430
+ return { visibleReplySent: deliveryResult.visibleReplySent };
1360
1431
  },
1361
1432
  onError: (err, info) => {
1362
1433
  runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
@@ -1462,7 +1533,7 @@ async function monitorLineProvider(opts) {
1462
1533
  res.end(JSON.stringify({ status: "ok" }));
1463
1534
  if (body.events && body.events.length > 0) {
1464
1535
  logVerbose(`line: received ${body.events.length} webhook events`);
1465
- Promise.resolve().then(() => match.target.bot.handleWebhook(body)).catch((err) => {
1536
+ runDetachedWebhookWork(() => match.target.bot.handleWebhook(body)).catch((err) => {
1466
1537
  match.target.runtime.error?.(danger(`line webhook dispatch failed: ${String(err)}`));
1467
1538
  });
1468
1539
  }
@@ -1491,14 +1562,6 @@ async function monitorLineProvider(opts) {
1491
1562
  }
1492
1563
  }
1493
1564
  });
1494
- recordChannelRuntimeState({
1495
- channel: "line",
1496
- accountId: resolvedAccountId,
1497
- state: {
1498
- running: true,
1499
- lastStartAt: Date.now()
1500
- }
1501
- });
1502
1565
  logVerbose(`line: registered webhook handler at ${normalizedPath}`);
1503
1566
  let stopped = false;
1504
1567
  const stopHandler = () => {
@@ -1506,14 +1569,6 @@ async function monitorLineProvider(opts) {
1506
1569
  stopped = true;
1507
1570
  logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
1508
1571
  unregisterHttp();
1509
- recordChannelRuntimeState({
1510
- channel: "line",
1511
- accountId: resolvedAccountId,
1512
- state: {
1513
- running: false,
1514
- lastStopAt: Date.now()
1515
- }
1516
- });
1517
1572
  };
1518
1573
  if (abortSignal?.aborted) stopHandler();
1519
1574
  else if (abortSignal) {