@openclaw/line 2026.9.1-beta.1 → 2026.9.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.
Files changed (46) hide show
  1. package/dist/account-helpers-BSUyZwLm.js +12 -0
  2. package/dist/{accounts-BqEVsDoB.js → accounts-BBEFMYbY.js} +2 -1
  3. package/dist/api.js +2 -2
  4. package/dist/card-command-pwvE48Wb.js +252 -0
  5. package/dist/{channel-CsQLcrsA.js → channel-BJvELOIg.js} +111 -380
  6. package/dist/channel-plugin-api.js +1 -1
  7. package/dist/channel.runtime-CZOszXXj.js +4 -0
  8. package/dist/contract-api.js +1 -1
  9. package/dist/doctor-contract-api.js +141 -0
  10. package/dist/index.js +5 -17
  11. package/dist/{markdown-to-line-BmZ4hhfC.js → markdown-to-line-B-sj-7II.js} +22 -23
  12. package/dist/{monitor-Bd9XDV1i.js → monitor-m9h5osFz.js} +339 -237
  13. package/dist/monitor.runtime-C90LL-P3.js +2 -0
  14. package/dist/outbound.runtime-CGqyJ-Ug.js +3 -0
  15. package/dist/probe.runtime-CbtMgU4s.js +2 -0
  16. package/dist/rich-messages-DY1AGKAJ.js +742 -0
  17. package/dist/runtime-api.js +14 -15
  18. package/dist/{send-Dce0m539.js → send-CQQxpxPp.js} +142 -112
  19. package/dist/{send-receipt-CCPTalf9.js → send-retry-ChpfJ-LM.js} +326 -86
  20. package/dist/setup-api.js +1 -1
  21. package/dist/{setup-surface-W0Dbgu58.js → setup-surface-CDlgkTKi.js} +47 -39
  22. package/dist/webhook-spool-contract-1rY8OJw8.js +59 -0
  23. package/node_modules/typebox/build/format/email.mjs +1 -1
  24. package/node_modules/typebox/build/format/idna/idn-hostname.mjs +19 -1
  25. package/node_modules/typebox/build/format/json_pointer.d.mts +2 -2
  26. package/node_modules/typebox/build/format/json_pointer.mjs +2 -2
  27. package/node_modules/typebox/build/format/json_pointer_uri_fragment.d.mts +2 -2
  28. package/node_modules/typebox/build/format/json_pointer_uri_fragment.mjs +2 -2
  29. package/node_modules/typebox/build/format/relative_json_pointer.d.mts +2 -2
  30. package/node_modules/typebox/build/format/relative_json_pointer.mjs +2 -2
  31. package/node_modules/typebox/build/system/memory/assign.mjs +2 -1
  32. package/node_modules/typebox/build/system/memory/create.mjs +3 -4
  33. package/node_modules/typebox/build/system/memory/discard.mjs +2 -2
  34. package/node_modules/typebox/build/system/memory/freeze.d.mts +4 -0
  35. package/node_modules/typebox/build/system/memory/freeze.mjs +6 -0
  36. package/node_modules/typebox/build/system/memory/update.mjs +3 -2
  37. package/node_modules/typebox/package.json +1 -1
  38. package/openclaw.plugin.json +16 -3
  39. package/package.json +5 -5
  40. package/dist/card-command-CmU47gD9.js +0 -230
  41. package/dist/channel.runtime-B83x7k_i.js +0 -4
  42. package/dist/media-control-cards-CksNB8Wk.js +0 -474
  43. package/dist/monitor.runtime-BCCa6qTX.js +0 -2
  44. package/dist/outbound.runtime-D4w0-Gkc.js +0 -3
  45. package/dist/probe-CM03wo6r.js +0 -28
  46. package/dist/probe.runtime-D13ZtXsd.js +0 -2
@@ -1,34 +1,37 @@
1
- import { i as resolveLineAccount, r as resolveDefaultLineAccountId } from "./accounts-BqEVsDoB.js";
2
- import { i as buildLineQuickReplyFallbackText, l as getLineRuntime, o as resolveLineGroupConfigEntry } from "./media-control-cards-CksNB8Wk.js";
3
- import { _ as hasLineSpecificMediaOptions, g as buildLineMediaMessage } from "./send-receipt-CCPTalf9.js";
4
- import { i as processLineMessage, s as buildTemplateMessageFromPayload } from "./markdown-to-line-BmZ4hhfC.js";
5
- import { _ as replyMessageLine, a as createQuickReplyItems, c as getUserDisplayName, i as createLocationMessage, m as pushMessagesLine, n as createFlexMessage, p as pushMessageLine, y as showLoadingAnimation } from "./send-Dce0m539.js";
1
+ import { i as resolveLineAccount, r as resolveDefaultLineAccountId } from "./accounts-BBEFMYbY.js";
2
+ import { f as resolveLineGroupConfigEntry, h as getLineRuntime, i as prepareLineReplyPayload, n as createLineQuickReply, u as buildLineQuickReplyFallbackText } from "./rich-messages-DY1AGKAJ.js";
3
+ import { n as findLineHttpError, r as resolveLineNonDispatchRetryable, t as explainLineRefusal, w as buildLineMediaMessage } from "./send-retry-ChpfJ-LM.js";
4
+ import { a as eventIdFor, n as LINE_WEBHOOK_SPOOL_INVALID_PAYLOAD_MESSAGE, o as laneKeyFor, r as LineWebhookPayloadError, t as LINE_WEBHOOK_SPOOL_INVALID_EVENT_REASON } from "./webhook-spool-contract-1rY8OJw8.js";
5
+ import { i as processLineMessage, o as buildTemplateMessageFromPayload } from "./markdown-to-line-B-sj-7II.js";
6
+ import { _ as showLoadingAnimation, a as getLineGroupName, d as pushMessageLine, f as pushMessagesLine, h as replyMessageLine, n as createLocationMessage, o as getUserDisplayName, s as getUserProfile, t as createFlexMessage, v as quotesLineBotMessage } from "./send-CQQxpxPp.js";
6
7
  import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
7
- import { normalizeNullableString, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
8
- import { DEFAULT_INGRESS_ADOPTION_STALL_MS, DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS, bindIngressLifecycleToReplyOptions, createChannelIngressError, createChannelIngressMonitor } from "openclaw/plugin-sdk/channel-outbound";
9
- import { buildChannelInboundEventContext, buildMentionRegexes, formatInboundEnvelope, formatInboundMediaUnavailableText, formatLocationText, hasFinalInboundReplyDispatch, isChannelPartialDeliveryError, matchesMentionPatterns, resolveInboundSessionEnvelopeContext, toInboundMediaFactsWithMetadata, toLocationContext } from "openclaw/plugin-sdk/channel-inbound";
8
+ import { resolveChannelGroupsConfigPath } from "openclaw/plugin-sdk/channel-policy";
9
+ import { normalizeOptionalString, normalizeStringEntries, readNonEmptyStringPreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
10
+ import { DEFAULT_INGRESS_ADOPTION_STALL_MS, DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS, bindIngressLifecycleToReplyOptions, createChannelIngressMonitor } from "openclaw/plugin-sdk/channel-outbound";
11
+ import { buildChannelInboundEventContext, buildMentionRegexes, formatInboundEnvelope, formatInboundMediaUnavailableText, formatLocationText, hasFinalInboundReplyDispatch, implicitMentionKindWhen, isChannelPartialDeliveryError, logInboundDrop, matchesMentionPatterns, resolveInboundSessionEnvelopeContext, toInboundMediaFactsWithMetadata, toLocationContext } from "openclaw/plugin-sdk/channel-inbound";
12
+ import { collectErrorGraphCandidates, extractErrorCode, formatErrorMessage, readErrorName, toErrorObject } from "openclaw/plugin-sdk/error-runtime";
10
13
  import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
11
14
  import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
12
15
  import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
16
+ import { classifyTransientNetworkErrorCode } from "openclaw/plugin-sdk/retry-runtime";
17
+ import { fetchWithRuntimeDispatcherOrMockedGlobal } from "openclaw/plugin-sdk/runtime-fetch";
13
18
  import { firstDefined } from "openclaw/plugin-sdk/allow-from";
14
19
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
15
20
  import { MediaFetchError } from "openclaw/plugin-sdk/media-runtime";
16
21
  import { saveMediaStream } from "openclaw/plugin-sdk/media-store";
17
22
  import { createNonExitingRuntime, danger, logVerbose, shouldLogVerbose, waitForAbortSignal, warn } from "openclaw/plugin-sdk/runtime-env";
18
- import { fetchWithRuntimeDispatcherOrMockedGlobal } from "openclaw/plugin-sdk/runtime-fetch";
19
- import { HTTPFetchError } from "@line/bot-sdk";
20
- import { collectErrorGraphCandidates, extractErrorCode, formatErrorMessage, readErrorName, toErrorObject } from "openclaw/plugin-sdk/error-runtime";
21
23
  import crypto from "node:crypto";
22
24
  import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
23
- import { classifyTransientNetworkErrorCode } from "openclaw/plugin-sdk/retry-runtime";
25
+ import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
24
26
  import { channelReadyPatch, channelStoppedPatch } from "openclaw/plugin-sdk/gateway-runtime";
25
27
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
26
- import { canonicalizeWebhookRouteKey, isRequestBodyLimitError, normalizePluginHttpPath, normalizeWebhookPath, registerWebhookTargetWithPluginRoute, requestBodyErrorToText, resolveSingleWebhookTarget } from "openclaw/plugin-sdk/webhook-ingress";
27
- import { beginWebhookRequestPipelineOrReject, createWebhookInFlightLimiter, isRequestBodyLimitError as isRequestBodyLimitError$1, readRequestBodyWithLimit, requestBodyErrorToText as requestBodyErrorToText$1, runDetachedWebhookWork } from "openclaw/plugin-sdk/webhook-request-guards";
28
+ import { canonicalizeWebhookRouteKey, normalizePluginHttpPath, normalizeWebhookPath, registerWebhookTargetWithPluginRoute, resolveSingleWebhookTarget } from "openclaw/plugin-sdk/webhook-ingress";
29
+ import { beginWebhookRequestPipelineOrReject, createWebhookInFlightLimiter, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, runDetachedWebhookWork, sendHttpRequestRejection } from "openclaw/plugin-sdk/webhook-request-guards";
28
30
  import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
29
31
  import { DEFAULT_GROUP_HISTORY_LIMIT, createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
30
- import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
31
- import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
32
+ import { getRuntimeConfig, getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, selectApplicableRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
33
+ import { resolveChannelImplicitMentions, resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
34
+ import { reportChannelRoomJoin } from "openclaw/plugin-sdk/channel-join-intro-runtime";
32
35
  import { hasControlCommand } from "openclaw/plugin-sdk/command-auth-native";
33
36
  import { ensureConfiguredBindingRouteReady, readChannelAllowFromStore, resolveConfiguredBindingRoute, resolvePairingIdLabel, resolvePinnedMainDmOwnerFromAllowlist, resolveRuntimeConversationBindingRoute, upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
34
37
  import { resolveAgentRoute, resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
@@ -110,7 +113,7 @@ async function fetchLineContentWhenReady(messageId, channelAccessToken, signal)
110
113
  }
111
114
  await response.body?.cancel().catch(() => void 0);
112
115
  if (response.status !== 202) throw new MediaFetchError("http_error", `LINE media download failed for message ${messageId} (HTTP ${response.status})`, { status: response.status });
113
- if (attempt < CONTENT_READY_MAX_ATTEMPTS - 1) await setTimeout$1(contentBackoffDelayMs(attempt), void 0, { signal });
116
+ if (attempt < 5) await setTimeout$1(contentBackoffDelayMs(attempt), void 0, { signal });
114
117
  }
115
118
  } catch (err) {
116
119
  if (signal.aborted) throw signal.reason;
@@ -125,7 +128,7 @@ function isRetryableLineInboundMediaError(err) {
125
128
  if (err.code === "http_error") return err.status === 202 || err.status === 408 || err.status === 429 || typeof err.status === "number" && err.status >= 500;
126
129
  return false;
127
130
  }
128
- async function downloadLineMedia(messageId, channelAccessToken, maxBytes = 10 * 1024 * 1024, options) {
131
+ async function downloadLineMedia(messageId, channelAccessToken, maxBytes = 10485760, options) {
129
132
  options?.signal?.throwIfAborted();
130
133
  const deadlineAbort = new AbortController();
131
134
  const signal = options?.signal ? AbortSignal.any([options.signal, deadlineAbort.signal]) : deadlineAbort.signal;
@@ -159,11 +162,8 @@ async function downloadLineMedia(messageId, channelAccessToken, maxBytes = 10 *
159
162
  function toLineDeliveryError(error) {
160
163
  return error instanceof Error ? error : new Error("LINE message send failed", { cause: error });
161
164
  }
162
- function getLineHttpError(error) {
163
- return collectErrorGraphCandidates(error, (candidate) => [candidate.cause, candidate.error]).find((candidate) => candidate instanceof HTTPFetchError);
164
- }
165
165
  function canFallbackAfterLineReplyFailure(error) {
166
- const httpError = getLineHttpError(error);
166
+ const httpError = findLineHttpError(error);
167
167
  if (httpError) return httpError.status >= 400 && httpError.status < 500 && httpError.status !== 408;
168
168
  const candidates = collectErrorGraphCandidates(error, (candidate) => [candidate.cause, candidate.error]);
169
169
  if (candidates.some((candidate) => readErrorName(candidate) === "AbortError" || classifyTransientNetworkErrorCode(extractErrorCode(candidate)) === "ambiguous")) return false;
@@ -235,7 +235,7 @@ async function deliverLineAutoReply(params) {
235
235
  } catch (err) {
236
236
  if (isChannelPartialDeliveryError(err) || !canFallbackAfterLineReplyFailure(err)) throw err;
237
237
  deps.onReplyError?.(err);
238
- await pushLineMessages(replyBatch, getLineHttpError(err)?.status === 400, remaining.slice(replyBatch.length));
238
+ await pushLineMessages(replyBatch, findLineHttpError(err)?.status === 400, remaining.slice(replyBatch.length));
239
239
  } finally {
240
240
  replyTokenUsed = true;
241
241
  }
@@ -244,7 +244,15 @@ async function deliverLineAutoReply(params) {
244
244
  if (remaining.length > 0) await pushLineMessages(remaining, true);
245
245
  };
246
246
  const richMessages = [];
247
- const hasQuickReplies = Boolean(lineData.quickReplies?.length);
247
+ const quickReplyItems = lineData.quickReplyItems?.length ? lineData.quickReplyItems : (lineData.quickReplies ?? []).map((label) => ({
248
+ label,
249
+ action: {
250
+ type: "command",
251
+ command: label
252
+ }
253
+ }));
254
+ const quickReplyLabels = quickReplyItems.map((item) => item.label);
255
+ const hasQuickReplies = quickReplyItems.length > 0;
248
256
  if (lineData.flexMessage) richMessages.push(deps.createFlexMessage(lineData.flexMessage.altText, lineData.flexMessage.contents));
249
257
  if (lineData.templateMessage) {
250
258
  const templateMsg = deps.buildTemplateMessageFromPayload(lineData.templateMessage);
@@ -264,7 +272,7 @@ async function deliverLineAutoReply(params) {
264
272
  const chunks = orderedMessages ? orderedMessages.flatMap((message) => message.type === "text" ? [message.text] : []) : processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : [];
265
273
  const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls;
266
274
  const mediaOpts = {
267
- mediaKind: hasLineSpecificMediaOptions(lineData) ? lineData.mediaKind : "image",
275
+ mediaKind: lineData.mediaKind,
268
276
  previewImageUrl: lineData.previewImageUrl,
269
277
  durationMs: lineData.durationMs,
270
278
  trackingId: lineData.trackingId
@@ -292,14 +300,14 @@ async function deliverLineAutoReply(params) {
292
300
  ];
293
301
  if (hasQuickReplies && textMessages.length === 0 && richMediaMessages.length === 0) textMessages.push({
294
302
  type: "text",
295
- text: buildLineQuickReplyFallbackText(lineData.quickReplies)
303
+ text: buildLineQuickReplyFallbackText(quickReplyLabels)
296
304
  });
297
305
  if (hasQuickReplies) {
298
306
  const targetMessages = orderedDeliveryMessages?.length ? orderedDeliveryMessages : textMessages.length > 0 ? textMessages : richMediaMessages;
299
307
  const lastIndex = targetMessages.length - 1;
300
308
  targetMessages[lastIndex] = {
301
309
  ...expectDefined(targetMessages[lastIndex], "last LINE auto-reply message"),
302
- quickReply: deps.createQuickReplyItems(lineData.quickReplies)
310
+ quickReply: createLineQuickReply(quickReplyItems)
303
311
  };
304
312
  }
305
313
  const messages = hasQuickReplies ? [...richMediaMessages, ...orderedDeliveryMessages ?? textMessages] : [...orderedDeliveryMessages ?? textMessages, ...richMediaMessages];
@@ -308,25 +316,33 @@ async function deliverLineAutoReply(params) {
308
316
  } catch (err) {
309
317
  deliveryError ??= err;
310
318
  const failedSegment = typeof err === "object" && err !== null ? failedPushSegments.get(err) : void 0;
311
- const httpError = getLineHttpError(err);
319
+ const httpError = findLineHttpError(err);
312
320
  const retryCandidates = failedSegment ? [...failedSegment.allowFailedBatchTextRecovery ? failedSegment.failedBatch : [], ...failedSegment.unattemptedTail] : [];
313
321
  const retryTextMessages = retryCandidates.filter((message) => message.type === "text");
314
322
  const quickRepliesNeedCarrier = hasQuickReplies && retryCandidates.some((message) => "quickReply" in message);
315
323
  const retryMessages = retryTextMessages.length > 0 ? retryTextMessages : quickRepliesNeedCarrier ? [{
316
324
  type: "text",
317
- text: buildLineQuickReplyFallbackText(lineData.quickReplies),
318
- quickReply: deps.createQuickReplyItems(lineData.quickReplies)
325
+ text: buildLineQuickReplyFallbackText(quickReplyLabels),
326
+ quickReply: createLineQuickReply(quickReplyItems)
319
327
  }] : [];
320
- if (retryMessages.length > 0 && failedSegment?.failedBatch.some((message) => message.type !== "text") && httpError?.status === 400) {
328
+ if (retryMessages.length > 0 && failedSegment?.failedBatch.some((message) => message.type !== "text") && httpError?.status === 400 && resolveLineNonDispatchRetryable(err) !== void 0) {
321
329
  const lastRetryMessage = retryMessages.at(-1);
322
- if (quickRepliesNeedCarrier && lastRetryMessage && !lastRetryMessage.quickReply) lastRetryMessage.quickReply = deps.createQuickReplyItems(lineData.quickReplies);
330
+ if (quickRepliesNeedCarrier && lastRetryMessage && !lastRetryMessage.quickReply) lastRetryMessage.quickReply = createLineQuickReply(quickReplyItems);
323
331
  try {
324
332
  await sendLineMessages(retryMessages, false);
325
333
  } catch {}
326
334
  }
327
335
  }
328
336
  if (deliveryError !== void 0) {
329
- if (!visibleReplySent) throw toLineDeliveryError(deliveryError);
337
+ if (!visibleReplySent) {
338
+ const named = toLineDeliveryError(deliveryError);
339
+ const refusal = await explainLineRefusal({
340
+ error: named,
341
+ cfg: params.cfg,
342
+ accountId
343
+ });
344
+ throw refusal.reason !== named.message ? new Error(refusal.reason, { cause: deliveryError }) : named;
345
+ }
330
346
  return {
331
347
  status: "partial",
332
348
  replyTokenUsed,
@@ -341,6 +357,44 @@ async function deliverLineAutoReply(params) {
341
357
  };
342
358
  }
343
359
  //#endregion
360
+ //#region extensions/line/src/mentions.ts
361
+ function getLineMentionees(message) {
362
+ return message.type === "text" ? message.mention?.mentionees ?? [] : [];
363
+ }
364
+ function addressesLineBot(mentionee) {
365
+ return mentionee.type === "all" || mentionee.isSelf === true;
366
+ }
367
+ function isLineBotMentioned(message) {
368
+ return getLineMentionees(message).some(addressesLineBot);
369
+ }
370
+ function hasAnyLineMention(message) {
371
+ return getLineMentionees(message).length > 0;
372
+ }
373
+ /**
374
+ * Text to interpret as a command, with the mention that addressed the bot removed.
375
+ *
376
+ * LINE writes a mention as the plain channel display name, so no pattern can
377
+ * tell it apart from a member's name; `mentionees[].index`/`length` (UTF-16
378
+ * code units, the unit LINE counts text in) is the only authoritative marker.
379
+ * Leaving it in place makes a group `@<bot> /status` parse as prose, and group
380
+ * chats require the mention to reach the bot at all.
381
+ */
382
+ function resolveLineMentionStrippedText(message) {
383
+ const text = message.type === "text" ? message.text : "";
384
+ const spans = getLineMentionees(message).filter(addressesLineBot).map((mentionee) => ({
385
+ start: mentionee.index,
386
+ end: mentionee.index + mentionee.length
387
+ })).toSorted((left, right) => left.start - right.start);
388
+ let stripped = "";
389
+ let cursor = 0;
390
+ for (const span of spans) {
391
+ stripped += text.slice(cursor, span.start);
392
+ if (/\S$/u.test(stripped) && /^\S/u.test(text.slice(span.end))) stripped += " ";
393
+ cursor = Math.max(cursor, span.end);
394
+ }
395
+ return `${stripped}${text.slice(cursor)}`.trim();
396
+ }
397
+ //#endregion
344
398
  //#region extensions/line/src/bot-message-context.ts
345
399
  function getLineSourceInfo(source) {
346
400
  if (!source) return {
@@ -425,30 +479,23 @@ async function resolveLineInboundRoute(params) {
425
479
  route
426
480
  };
427
481
  }
428
- const STICKER_PACKAGES = {
429
- "1": "Moon & James",
430
- "2": "Cony & Brown",
431
- "3": "Brown & Friends",
432
- "4": "Moon Special",
433
- "789": "LINE Characters",
434
- "6136": "Cony's Happy Life",
435
- "6325": "Brown's Life",
436
- "6359": "Choco",
437
- "6362": "Sally",
438
- "6370": "Edward",
439
- "11537": "Cony",
440
- "11538": "Brown",
441
- "11539": "Moon"
442
- };
443
- function describeStickerKeywords(sticker) {
444
- const keywords = sticker.keywords;
445
- if (keywords && keywords.length > 0) return keywords.slice(0, 3).join(", ");
446
- const stickerText = sticker.text;
447
- if (stickerText) return stickerText;
448
- return "";
482
+ /**
483
+ * Describe a sticker from what its webhook actually carries: LINE sends up to
484
+ * 15 keywords for the sticker, and a message sticker also carries the sender's
485
+ * own text. The package name is not among those facts and cannot be derived
486
+ * from the package id, so it is not part of the description.
487
+ */
488
+ function describeLineSticker(sticker) {
489
+ const description = readNonEmptyStringPreservingWhitespace(sticker.text) ?? normalizeStringEntries(sticker.keywords ?? []).slice(0, 3).join(", ");
490
+ return description ? `[Sent a sticker: ${description}]` : "[Sent a sticker]";
491
+ }
492
+ function readLineTextMessageBody(message) {
493
+ let text = message.text;
494
+ for (const { index, length } of (message.emojis ?? []).toSorted((a, b) => b.index - a.index)) if (index >= 0 && length === 2 && text.slice(index, index + length) === "()") text = `${text.slice(0, index)}[emoji]${text.slice(index + length)}`;
495
+ return text;
449
496
  }
450
497
  function extractMessageText(message) {
451
- if (message.type === "text") return message.text;
498
+ if (message.type === "text") return readLineTextMessageBody(message);
452
499
  if (message.type === "location") {
453
500
  const loc = message;
454
501
  return formatLocationText({
@@ -458,13 +505,7 @@ function extractMessageText(message) {
458
505
  address: loc.address
459
506
  }) ?? "";
460
507
  }
461
- if (message.type === "sticker") {
462
- const sticker = message;
463
- const packageName = STICKER_PACKAGES[sticker.packageId] ?? "sticker";
464
- const keywords = describeStickerKeywords(sticker);
465
- if (keywords) return `[Sent a ${packageName} sticker: ${keywords}]`;
466
- return `[Sent a ${packageName} sticker]`;
467
- }
508
+ if (message.type === "sticker") return describeLineSticker(message);
468
509
  return "";
469
510
  }
470
511
  function extractNativeMediaKind(message) {
@@ -478,9 +519,23 @@ function extractNativeMediaKind(message) {
478
519
  }
479
520
  async function finalizeLineInboundContext(params) {
480
521
  const senderId = params.source.userId ?? "unknown";
481
- const senderLabel = params.source.userId ? `user:${params.source.userId}` : "unknown";
482
- const conversationLabel = params.source.isGroup ? params.source.groupId ? `group:${params.source.groupId}` : params.source.roomId ? `room:${params.source.roomId}` : "unknown-group" : senderLabel;
522
+ const clientOpts = {
523
+ cfg: params.cfg,
524
+ accountId: params.account.accountId,
525
+ channelAccessToken: params.account.channelAccessToken
526
+ };
527
+ const [senderName, groupName] = await Promise.all([params.source.userId ? getUserProfile(params.source.userId, {
528
+ ...clientOpts,
529
+ groupId: params.source.groupId,
530
+ roomId: params.source.roomId
531
+ }).then((profile) => profile?.displayName) : void 0, params.source.groupId ? getLineGroupName(params.source.groupId, clientOpts) : void 0]);
532
+ const senderLabel = senderName ?? (params.source.userId ? `user:${params.source.userId}` : "unknown");
533
+ const conversationLabel = params.source.isGroup ? groupName ?? (params.source.groupId ? `group:${params.source.groupId}` : params.source.roomId ? `room:${params.source.roomId}` : "unknown-group") : senderLabel;
483
534
  const address = params.source.groupId ? `line:group:${params.source.groupId}` : params.source.roomId ? `line:room:${params.source.roomId}` : `line:${params.source.userId ?? params.source.peerId}`;
535
+ const groupConfig = params.source.isGroup ? resolveLineGroupConfigEntry(params.account.config.groups, {
536
+ groupId: params.source.groupId,
537
+ roomId: params.source.roomId
538
+ }) : void 0;
484
539
  const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
485
540
  cfg: params.cfg,
486
541
  agentId: params.route.agentId,
@@ -494,7 +549,10 @@ async function finalizeLineInboundContext(params) {
494
549
  timestamp: params.timestamp,
495
550
  body: agentBody,
496
551
  chatType: params.source.isGroup ? "group" : "direct",
497
- sender: { id: senderId },
552
+ sender: {
553
+ id: senderId,
554
+ name: senderName
555
+ },
498
556
  previousTimestamp,
499
557
  envelope: envelopeOptions
500
558
  });
@@ -505,7 +563,10 @@ async function finalizeLineInboundContext(params) {
505
563
  messageId: params.messageSid,
506
564
  timestamp: params.timestamp,
507
565
  from: address,
508
- sender: { id: senderId },
566
+ sender: {
567
+ id: senderId,
568
+ name: senderName
569
+ },
509
570
  conversation: {
510
571
  kind: params.source.isGroup ? "group" : "direct",
511
572
  id: params.source.peerId,
@@ -525,18 +586,18 @@ async function finalizeLineInboundContext(params) {
525
586
  body,
526
587
  bodyForAgent: agentBody,
527
588
  rawBody: params.rawBody,
528
- commandBody: params.rawBody,
589
+ commandBody: params.commandBody ?? params.rawBody,
529
590
  inboundHistory: params.inboundHistory
530
591
  },
531
- access: { commands: { authorized: params.commandAuthorized } },
592
+ access: {
593
+ commands: { authorized: params.commandAuthorized },
594
+ mentions: params.mentions
595
+ },
532
596
  media,
533
597
  extra: {
534
598
  ...params.locationContext,
535
- GroupSubject: params.source.isGroup ? params.source.groupId ?? params.source.roomId : void 0,
536
- GroupSystemPrompt: params.source.isGroup ? normalizeOptionalString(resolveLineGroupConfigEntry(params.account.config.groups, {
537
- groupId: params.source.groupId,
538
- roomId: params.source.roomId
539
- })?.systemPrompt) : void 0
599
+ GroupSubject: params.source.isGroup ? groupName ?? params.source.groupId ?? params.source.roomId : void 0,
600
+ GroupSystemPrompt: normalizeOptionalString(groupConfig?.systemPrompt)
540
601
  }
541
602
  });
542
603
  const pinnedMainDmOwner = !params.source.isGroup ? resolvePinnedMainDmOwnerFromAllowlist({
@@ -551,11 +612,13 @@ async function finalizeLineInboundContext(params) {
551
612
  if (shouldLogVerbose()) {
552
613
  const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
553
614
  const mediaInfo = params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1 ? ` mediaCount=${params.verboseLog.mediaCount}` : "";
554
- logVerbose(`${params.verboseLog.kind === "inbound" ? "line inbound" : "line postback"}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`);
615
+ const label = params.verboseLog.kind === "inbound" ? "line inbound" : "line postback";
616
+ logVerbose(`${label}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`);
555
617
  }
556
618
  return {
557
619
  ctxPayload,
558
620
  replyToken: params.event.replyToken,
621
+ skillFilter: groupConfig?.skills,
559
622
  turn: {
560
623
  storePath,
561
624
  record: {
@@ -626,15 +689,17 @@ async function buildLineMessageContext(params) {
626
689
  },
627
690
  rawBody,
628
691
  agentBody,
692
+ commandBody: resolveLineMentionStrippedText(message) || rawBody,
693
+ mentions: params.mentions,
629
694
  timestamp,
630
695
  messageSid: messageId,
631
696
  commandAuthorized,
632
- channelIngress: params.resolveChannelIngress ? await params.resolveChannelIngress({
697
+ channelIngress: await params.resolveChannelIngress?.({
633
698
  agentId: route.agentId,
634
699
  sessionKey: route.sessionKey,
635
700
  messageId,
636
701
  inboundEventKind: "user_request"
637
- }) : params.channelIngress,
702
+ }),
638
703
  buildContext: params.buildContext,
639
704
  media: mediaFacts,
640
705
  locationContext,
@@ -647,6 +712,7 @@ async function buildLineMessageContext(params) {
647
712
  return {
648
713
  ctxPayload: finalized.ctxPayload,
649
714
  turn: finalized.turn,
715
+ skillFilter: finalized.skillFilter,
650
716
  event,
651
717
  userId,
652
718
  groupId,
@@ -666,14 +732,18 @@ async function buildLinePostbackContext(params) {
666
732
  account
667
733
  });
668
734
  const timestamp = event.timestamp;
669
- const rawData = event.postback?.data?.trim() ?? "";
670
- if (!rawData) return null;
671
- let rawBody = rawData;
672
- if (rawData.includes("line.action=")) {
673
- const searchParams = new URLSearchParams(rawData);
735
+ const rawBody = event.postback?.data?.trim() ?? "";
736
+ if (!rawBody) return null;
737
+ let agentBody = rawBody;
738
+ if (rawBody.includes("line.action=")) {
739
+ const searchParams = new URLSearchParams(rawBody);
674
740
  const action = searchParams.get("line.action") ?? "";
675
741
  const device = searchParams.get("line.device");
676
- rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
742
+ agentBody = device ? `line action ${action} device ${device}` : `line action ${action}`;
743
+ }
744
+ for (const [key, value] of Object.entries(event.postback.params ?? {}).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
745
+ const picked = normalizeOptionalString(value);
746
+ if (picked) agentBody += ` ${key}=${picked}`;
677
747
  }
678
748
  const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`;
679
749
  const finalized = await finalizeLineInboundContext({
@@ -689,15 +759,16 @@ async function buildLinePostbackContext(params) {
689
759
  peerId
690
760
  },
691
761
  rawBody,
762
+ agentBody,
692
763
  timestamp,
693
764
  messageSid,
694
765
  commandAuthorized,
695
- channelIngress: params.resolveChannelIngress ? await params.resolveChannelIngress({
766
+ channelIngress: await params.resolveChannelIngress?.({
696
767
  agentId: route.agentId,
697
768
  sessionKey: route.sessionKey,
698
769
  messageId: messageSid,
699
770
  inboundEventKind: "user_request"
700
- }) : params.channelIngress,
771
+ }),
701
772
  buildContext: params.buildContext,
702
773
  media: [],
703
774
  verboseLog: { kind: "postback" }
@@ -705,6 +776,7 @@ async function buildLinePostbackContext(params) {
705
776
  return {
706
777
  ctxPayload: finalized.ctxPayload,
707
778
  turn: finalized.turn,
779
+ skillFilter: finalized.skillFilter,
708
780
  event,
709
781
  userId,
710
782
  groupId,
@@ -764,12 +836,6 @@ function isDownloadableLineMessageType(messageType) {
764
836
  function normalizeLineIngressEntry(value) {
765
837
  return normalizeLineAllowEntry(value) || null;
766
838
  }
767
- function resolveLineGroupConfig(params) {
768
- return resolveLineGroupConfigEntry(params.config.groups, {
769
- groupId: params.groupId,
770
- roomId: params.roomId
771
- });
772
- }
773
839
  async function sendLinePairingReply(params) {
774
840
  const { senderId, replyToken, context } = params;
775
841
  const idLabel = (() => {
@@ -821,12 +887,11 @@ async function sendLinePairingReply(params) {
821
887
  }
822
888
  });
823
889
  }
824
- async function shouldProcessLineEvent(event, context) {
890
+ async function resolveLineEventAdmission(event, context) {
825
891
  const { cfg, account } = context;
826
892
  const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
827
893
  const senderId = userId ?? "";
828
- const groupConfig = resolveLineGroupConfig({
829
- config: account.config,
894
+ const groupConfig = resolveLineGroupConfigEntry(account.config.groups, {
830
895
  groupId,
831
896
  roomId
832
897
  });
@@ -841,11 +906,7 @@ async function shouldProcessLineEvent(event, context) {
841
906
  const groupPolicy = runtimeGroupPolicy === "disabled" ? "disabled" : groupConfig?.allowFrom !== void 0 ? "allowlist" : runtimeGroupPolicy;
842
907
  const groupAllowFrom = normalizeStringEntries(firstDefined(groupConfig?.allowFrom, account.config.groupAllowFrom));
843
908
  const mentionFacts = (() => {
844
- if (!isGroup || event.type !== "message") return {
845
- canDetectMention: false,
846
- wasMentioned: false,
847
- hasAnyMention: false
848
- };
909
+ if (!isGroup || event.type !== "message") return;
849
910
  const peerId = groupId ?? roomId ?? userId ?? "unknown";
850
911
  const { agentId } = resolveAgentRoute({
851
912
  cfg,
@@ -862,7 +923,9 @@ async function shouldProcessLineEvent(event, context) {
862
923
  return {
863
924
  canDetectMention: event.message.type === "text",
864
925
  wasMentioned: wasMentionedByNative || wasMentionedByPattern,
865
- hasAnyMention: hasAnyLineMention(event.message)
926
+ explicitlyMentionedBot: wasMentionedByNative,
927
+ hasAnyMention: hasAnyLineMention(event.message),
928
+ implicitMentionKinds: implicitMentionKindWhen("quoted_bot", quotesLineBotMessage(account.accountId, resolveLineQuotedMessageId(event.message)))
866
929
  };
867
930
  })();
868
931
  const resolveAccess = async (contextBinding) => await resolveStableChannelMessageIngress({
@@ -876,7 +939,7 @@ async function shouldProcessLineEvent(event, context) {
876
939
  },
877
940
  cfg,
878
941
  readStoreAllowFrom: async () => await readChannelAllowFromStore("line", void 0, account.accountId),
879
- subject: { stableId: senderId },
942
+ subject: event.type === "join" ? {} : { stableId: senderId },
880
943
  conversation: {
881
944
  kind: isGroup ? "group" : "direct",
882
945
  id: (groupId ?? roomId ?? senderId) || "unknown"
@@ -886,20 +949,20 @@ async function shouldProcessLineEvent(event, context) {
886
949
  id: "line:group-config",
887
950
  enabled: false
888
951
  } } : {},
889
- mentionFacts: isGroup && event.type === "message" ? {
890
- canDetectMention: mentionFacts.canDetectMention,
891
- wasMentioned: mentionFacts.wasMentioned,
892
- hasAnyMention: mentionFacts.hasAnyMention,
893
- implicitMentionKinds: []
894
- } : void 0,
895
- event: { kind: event.type === "postback" ? "postback" : "message" },
952
+ mentionFacts,
953
+ event: { kind: event.type === "join" ? "system" : event.type },
896
954
  dmPolicy,
897
955
  groupPolicy,
898
956
  policy: {
899
957
  groupAllowFromFallbackToAllowFrom: false,
900
958
  activation: {
901
959
  requireMention: isGroup && event.type === "message" && requireMention,
902
- allowTextCommands: true
960
+ allowTextCommands: true,
961
+ implicitMentions: resolveChannelImplicitMentions({
962
+ cfg,
963
+ channel: "line",
964
+ accountId: account.accountId
965
+ })
903
966
  }
904
967
  },
905
968
  allowFrom: normalizeStringEntries(account.config.allowFrom),
@@ -916,9 +979,18 @@ async function shouldProcessLineEvent(event, context) {
916
979
  accountId: account.accountId,
917
980
  log: (message) => logVerbose(message)
918
981
  });
919
- if (access.senderAccess.decision === "allow" && (access.ingress.admission === "dispatch" || access.ingress.admission === "observe" || access.ingress.admission === "skip")) return {
982
+ if (event.type === "join") return groupConfig?.enabled !== false && groupPolicy !== "disabled" && (groupPolicy !== "allowlist" || access.state.allowlists.group.hasMatchableEntries) ? {
920
983
  access,
921
984
  resolveBoundAccess: resolveAccess
985
+ } : null;
986
+ if (access.senderAccess.decision === "allow" && (access.ingress.admission === "dispatch" || access.ingress.admission === "observe" || access.ingress.admission === "skip")) return {
987
+ access,
988
+ resolveBoundAccess: resolveAccess,
989
+ mentions: mentionFacts ? {
990
+ ...mentionFacts,
991
+ wasMentioned: access.activationAccess.effectiveWasMentioned ?? mentionFacts.wasMentioned,
992
+ requireMention
993
+ } : void 0
922
994
  };
923
995
  if (access.senderAccess.decision === "allow") {
924
996
  logVerbose(`Blocked line event (${access.ingress.reasonCode})`);
@@ -964,21 +1036,13 @@ async function shouldProcessLineEvent(event, context) {
964
1036
  logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${account.config.dmPolicy ?? "pairing"})`);
965
1037
  return null;
966
1038
  }
967
- function getLineMentionees(message) {
968
- if (message.type !== "text") return [];
969
- const mentionees = message.mention?.mentionees;
970
- return Array.isArray(mentionees) ? mentionees : [];
971
- }
972
- function isLineBotMentioned(message) {
973
- return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all");
974
- }
975
- function hasAnyLineMention(message) {
976
- return getLineMentionees(message).length > 0;
1039
+ function resolveLineQuotedMessageId(message) {
1040
+ return message.type === "text" || message.type === "sticker" ? message.quotedMessageId : void 0;
977
1041
  }
978
1042
  function resolveEventRawText(event) {
979
1043
  if (event.type === "message") {
980
1044
  const msg = event.message;
981
- if (msg.type === "text") return msg.text;
1045
+ if (msg.type === "text") return readLineTextMessageBody(msg);
982
1046
  return "";
983
1047
  }
984
1048
  if (event.type === "postback") return event.postback?.data?.trim() ?? "";
@@ -987,24 +1051,46 @@ function resolveEventRawText(event) {
987
1051
  async function handleMessageEvent(event, context) {
988
1052
  const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
989
1053
  const message = event.message;
990
- const decision = await shouldProcessLineEvent(event, context);
1054
+ const decision = await resolveLineEventAdmission(event, context);
991
1055
  if (!decision) return;
992
- const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
1056
+ const { isGroup, groupId, roomId, userId } = getLineSourceInfo(event.source);
993
1057
  if (isGroup && decision.access.activationAccess.shouldSkip) {
994
- const rawText = message.type === "text" ? message.text : "";
995
- const sourceInfo = getLineSourceInfo(event.source);
996
- logVerbose(`line: skipping group message (requireMention, not mentioned)`);
1058
+ const rawText = message.type === "text" ? readLineTextMessageBody(message) : "";
997
1059
  const historyKey = groupId ?? roomId;
998
- const senderId = sourceInfo.userId ?? "unknown";
999
- if (historyKey && context.groupHistories) createChannelHistoryWindow({ historyMap: context.groupHistories }).record({
1000
- historyKey,
1001
- limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
1002
- entry: {
1003
- sender: `user:${senderId}`,
1004
- body: rawText || `<${message.type}>`,
1005
- timestamp: event.timestamp
1006
- }
1060
+ const groupsConfigPath = resolveChannelGroupsConfigPath({
1061
+ cfg,
1062
+ channel: "line",
1063
+ accountId: account.accountId,
1064
+ groups: account.config.groups
1065
+ });
1066
+ logInboundDrop({
1067
+ log: runtime.log,
1068
+ channel: "line",
1069
+ reason: "no mention",
1070
+ target: historyKey,
1071
+ onceKey: JSON.stringify([account.accountId, historyKey]),
1072
+ hint: `Mention patterns can be derived from the agent identity name. Set ${groupsConfigPath}[${JSON.stringify(historyKey)}].requireMention=false to process messages without a mention. Preserve existing groups entries; when adding the first groups map, include "*": {} to keep other chats admitted.`
1007
1073
  });
1074
+ const senderId = userId ?? "unknown";
1075
+ if (historyKey && context.groupHistories) {
1076
+ const displayName = userId ? await getUserDisplayName(userId, {
1077
+ cfg,
1078
+ accountId: account.accountId,
1079
+ channelAccessToken: account.channelAccessToken,
1080
+ groupId,
1081
+ roomId
1082
+ }) : senderId;
1083
+ const sender = displayName === senderId ? senderId : `${displayName} (${senderId})`;
1084
+ createChannelHistoryWindow({ historyMap: context.groupHistories }).record({
1085
+ historyKey,
1086
+ limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
1087
+ entry: {
1088
+ sender,
1089
+ body: rawText || `<${message.type}>`,
1090
+ timestamp: event.timestamp
1091
+ }
1092
+ });
1093
+ }
1008
1094
  return;
1009
1095
  }
1010
1096
  const groupHistoryKey = isGroup ? groupId ?? roomId : void 0;
@@ -1023,7 +1109,8 @@ async function handleMessageEvent(event, context) {
1023
1109
  abortSignal?.throwIfAborted();
1024
1110
  allMedia.push({
1025
1111
  path: media.path,
1026
- contentType: media.contentType
1112
+ contentType: media.contentType,
1113
+ ...originalFilename ? { fileName: originalFilename } : {}
1027
1114
  });
1028
1115
  } catch (err) {
1029
1116
  if (abortSignal?.aborted) throw abortSignal.reason;
@@ -1043,13 +1130,17 @@ async function handleMessageEvent(event, context) {
1043
1130
  commandAuthorized: decision.access.commandAccess.authorized,
1044
1131
  resolveChannelIngress: decision.resolveBoundAccess,
1045
1132
  inboundHistory: historyReservation.inboundHistory,
1133
+ mentions: decision.mentions,
1046
1134
  buildContext: context.buildContext
1047
1135
  });
1048
1136
  if (!messageContext) {
1049
1137
  logVerbose("line: skipping empty message");
1050
1138
  return;
1051
1139
  }
1052
- await processMessage(messageContext, context.turnAdoptionLifecycle ? { turnAdoptionLifecycle: context.turnAdoptionLifecycle } : {});
1140
+ await processMessage(messageContext, {
1141
+ cfg,
1142
+ ...context.turnAdoptionLifecycle ? { turnAdoptionLifecycle: context.turnAdoptionLifecycle } : {}
1143
+ });
1053
1144
  historyReservation.commit();
1054
1145
  } finally {
1055
1146
  historyReservation.release();
@@ -1063,9 +1154,42 @@ async function handleUnfollowEvent(event, _context) {
1063
1154
  const { userId } = getLineSourceInfo(event.source);
1064
1155
  logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
1065
1156
  }
1066
- async function handleJoinEvent(event, _context) {
1067
- const { groupId, roomId } = getLineSourceInfo(event.source);
1157
+ async function handleJoinEvent(event, context) {
1158
+ const { groupId, roomId, isGroup } = getLineSourceInfo(event.source);
1159
+ const conversationId = groupId ?? roomId;
1160
+ if (!isGroup || !conversationId) return;
1068
1161
  logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
1162
+ const { cfg, account } = context;
1163
+ const roomAllowed = Boolean(await resolveLineEventAdmission(event, context));
1164
+ await reportChannelRoomJoin({
1165
+ cfg,
1166
+ channel: "line",
1167
+ accountId: account.accountId,
1168
+ conversationId,
1169
+ deliverTo: conversationId,
1170
+ route: resolveAgentRoute({
1171
+ cfg,
1172
+ channel: "line",
1173
+ accountId: account.accountId,
1174
+ peer: {
1175
+ kind: "group",
1176
+ id: conversationId
1177
+ }
1178
+ }),
1179
+ roomAllowed,
1180
+ resolveRoomContext: async () => {
1181
+ const roomContext = { historyUnavailable: true };
1182
+ const title = groupId ? await getLineGroupName(groupId, {
1183
+ cfg,
1184
+ accountId: account.accountId,
1185
+ channelAccessToken: account.channelAccessToken
1186
+ }) : void 0;
1187
+ return title ? {
1188
+ ...roomContext,
1189
+ title
1190
+ } : roomContext;
1191
+ }
1192
+ });
1069
1193
  }
1070
1194
  async function handleLeaveEvent(event, _context) {
1071
1195
  const { groupId, roomId } = getLineSourceInfo(event.source);
@@ -1074,7 +1198,7 @@ async function handleLeaveEvent(event, _context) {
1074
1198
  async function handlePostbackEvent(event, context) {
1075
1199
  const data = event.postback.data;
1076
1200
  logVerbose(`line: received postback: ${data}`);
1077
- const decision = await shouldProcessLineEvent(event, context);
1201
+ const decision = await resolveLineEventAdmission(event, context);
1078
1202
  if (!decision) return;
1079
1203
  const postbackContext = await buildLinePostbackContext({
1080
1204
  event,
@@ -1085,7 +1209,10 @@ async function handlePostbackEvent(event, context) {
1085
1209
  buildContext: context.buildContext
1086
1210
  });
1087
1211
  if (!postbackContext) return;
1088
- await context.processMessage(postbackContext, context.turnAdoptionLifecycle ? { turnAdoptionLifecycle: context.turnAdoptionLifecycle } : {});
1212
+ await context.processMessage(postbackContext, {
1213
+ cfg: context.cfg,
1214
+ ...context.turnAdoptionLifecycle ? { turnAdoptionLifecycle: context.turnAdoptionLifecycle } : {}
1215
+ });
1089
1216
  }
1090
1217
  async function handleLineWebhookEvents(events, context) {
1091
1218
  let firstError;
@@ -1122,12 +1249,10 @@ async function handleLineWebhookEvent(event, context) {
1122
1249
  }
1123
1250
  //#endregion
1124
1251
  //#region extensions/line/src/webhook-spool.ts
1125
- const LINE_WEBHOOK_SPOOL_VERSION = 1;
1126
1252
  const LINE_WEBHOOK_DRAIN_INTERVAL_MS = 500;
1127
1253
  const LINE_WEBHOOK_MAX_CONCURRENT_DELIVERIES = 8;
1128
1254
  const LINE_WEBHOOK_DRAIN_SCAN_LIMIT = 100;
1129
1255
  const LINE_WEBHOOK_ACTIVE_DELIVERY_STOP_GRACE_MS = 5e3;
1130
- const LineWebhookPayloadError = createChannelIngressError("LineWebhookPayloadError");
1131
1256
  var LineWebhookTerminalDeliveryError = class extends Error {
1132
1257
  constructor(message, options) {
1133
1258
  super(message, options);
@@ -1135,35 +1260,6 @@ var LineWebhookTerminalDeliveryError = class extends Error {
1135
1260
  this.name = "LineWebhookTerminalDeliveryError";
1136
1261
  }
1137
1262
  };
1138
- /** Message ids preserve the shipped replay-guard keyspace; other events use LINE's delivery id. */
1139
- function eventIdFor(event) {
1140
- if (!event || typeof event !== "object") throw new LineWebhookPayloadError("LINE webhook event must be an object.");
1141
- const candidate = event;
1142
- if (candidate.type === "message") {
1143
- const messageId = normalizeNullableString(candidate.message?.id);
1144
- if (messageId) return `message:${messageId}`;
1145
- }
1146
- const webhookEventId = normalizeNullableString(candidate.webhookEventId);
1147
- if (webhookEventId) return `event:${webhookEventId}`;
1148
- throw new LineWebhookPayloadError("LINE webhook event is missing a stable delivery id.");
1149
- }
1150
- function laneKeyFor(event, eventId) {
1151
- if (!event || typeof event !== "object") return eventId;
1152
- const source = event.source;
1153
- if (source?.type === "group") {
1154
- const groupId = normalizeNullableString(source.groupId);
1155
- if (groupId) return `group:${groupId}`;
1156
- }
1157
- if (source?.type === "room") {
1158
- const roomId = normalizeNullableString(source.roomId);
1159
- if (roomId) return `room:${roomId}`;
1160
- }
1161
- if (source?.type === "user") {
1162
- const userId = normalizeNullableString(source.userId);
1163
- if (userId) return `user:${userId}`;
1164
- }
1165
- return eventId;
1166
- }
1167
1263
  function parseStoredEvent(rawEvent) {
1168
1264
  let event;
1169
1265
  try {
@@ -1203,7 +1299,7 @@ function createLineWebhookSpool(options) {
1203
1299
  };
1204
1300
  },
1205
1301
  payload: {
1206
- version: LINE_WEBHOOK_SPOOL_VERSION,
1302
+ version: 1,
1207
1303
  serialize: ({ event, destination }) => ({
1208
1304
  rawEvent: JSON.stringify(event),
1209
1305
  destination
@@ -1218,7 +1314,7 @@ function createLineWebhookSpool(options) {
1218
1314
  destination: body.destination
1219
1315
  }),
1220
1316
  decode: (payload) => {
1221
- if (typeof payload.rawEvent !== "string" || typeof payload.destination !== "string") throw new LineWebhookPayloadError("LINE webhook spool payload is invalid.");
1317
+ if (typeof payload.rawEvent !== "string" || typeof payload.destination !== "string") throw new LineWebhookPayloadError(LINE_WEBHOOK_SPOOL_INVALID_PAYLOAD_MESSAGE);
1222
1318
  return {
1223
1319
  version: payload.version,
1224
1320
  body: {
@@ -1227,7 +1323,7 @@ function createLineWebhookSpool(options) {
1227
1323
  }
1228
1324
  };
1229
1325
  },
1230
- createClaimError: (kind) => new LineWebhookPayloadError(kind === "invalid-version" ? "LINE webhook spool payload is invalid." : "LINE webhook event identity changed after durable admission.")
1326
+ createClaimError: (kind) => new LineWebhookPayloadError(kind === "invalid-version" ? LINE_WEBHOOK_SPOOL_INVALID_PAYLOAD_MESSAGE : "LINE webhook event identity changed after durable admission.")
1231
1327
  },
1232
1328
  deliver: async ({ event, destination }, lifecycle) => {
1233
1329
  const boundLifecycle = bindIngressLifecycleToReplyOptions(lifecycle).turnAdoptionLifecycle;
@@ -1287,7 +1383,7 @@ function createLineWebhookSpool(options) {
1287
1383
  },
1288
1384
  resolveNonRetryableFailure: (error) => {
1289
1385
  if (error instanceof LineWebhookPayloadError) return {
1290
- reason: "invalid-event",
1386
+ reason: LINE_WEBHOOK_SPOOL_INVALID_EVENT_REASON,
1291
1387
  message: error.message
1292
1388
  };
1293
1389
  if (error instanceof LineWebhookTerminalDeliveryError) return {
@@ -1308,12 +1404,12 @@ function createLineWebhookSpool(options) {
1308
1404
  let stopTask;
1309
1405
  return {
1310
1406
  accept: async (body) => {
1311
- const events = body.events ?? [];
1312
- if (events.length === 0) return;
1313
- await monitor.admitBatch(events.map((event) => ({
1407
+ const events = (body.events ?? []).filter((event) => event.mode !== "standby");
1408
+ if (events.length === 0) return "ignored";
1409
+ return (await monitor.admitBatch(events.map((event) => ({
1314
1410
  event,
1315
1411
  destination: body.destination ?? ""
1316
- })), { receivedAt: Date.now() });
1412
+ })), { receivedAt: Date.now() })).some((admission) => admission.kind === "durable") ? "durable" : "ignored";
1317
1413
  },
1318
1414
  start: () => {
1319
1415
  if (!stopTask) monitor.start();
@@ -1340,9 +1436,17 @@ function createLineWebhookSpool(options) {
1340
1436
  const DEFAULT_MEDIA_MAX_MB = 10;
1341
1437
  function createLineBot(opts) {
1342
1438
  const runtime = opts.runtime ?? createNonExitingRuntime();
1343
- const cfg = opts.config ?? getRuntimeConfig();
1439
+ const startupConfig = opts.config ?? getRuntimeConfig();
1440
+ const startupRuntimeConfig = getRuntimeConfigSnapshot();
1441
+ const startupRuntimeSourceConfig = getRuntimeConfigSourceSnapshot();
1442
+ const followsRuntimeConfig = opts.config === void 0 || startupRuntimeConfig === startupConfig || startupRuntimeSourceConfig !== null && selectApplicableRuntimeConfig({
1443
+ inputConfig: startupConfig,
1444
+ runtimeConfig: startupRuntimeConfig,
1445
+ runtimeSourceConfig: startupRuntimeSourceConfig
1446
+ }) === startupRuntimeConfig;
1447
+ const resolveTurnConfig = () => (followsRuntimeConfig ? getRuntimeConfigSnapshot() : void 0) ?? startupConfig;
1344
1448
  const account = resolveLineAccount({
1345
- cfg,
1449
+ cfg: startupConfig,
1346
1450
  accountId: opts.accountId
1347
1451
  });
1348
1452
  const mediaMaxBytes = ([opts.mediaMaxMb, account.config.mediaMaxMb].find((value) => typeof value === "number" && value > 0) ?? DEFAULT_MEDIA_MAX_MB) * 1024 * 1024;
@@ -1353,17 +1457,20 @@ function createLineBot(opts) {
1353
1457
  const spool = createLineWebhookSpool({
1354
1458
  accountId: account.accountId,
1355
1459
  runtime,
1356
- deliver: async (event, _destination, control) => await handleLineWebhookEvents([event], {
1357
- cfg,
1358
- account,
1359
- runtime,
1360
- buildContext: opts.buildContext,
1361
- mediaMaxBytes,
1362
- processMessage,
1363
- ...control.turnAdoptionLifecycle ? { turnAdoptionLifecycle: control.turnAdoptionLifecycle } : {},
1364
- groupHistories,
1365
- historyLimit: account.config.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
1366
- })
1460
+ deliver: async (event, _destination, control) => {
1461
+ const cfg = resolveTurnConfig();
1462
+ await handleLineWebhookEvents([event], {
1463
+ cfg,
1464
+ account,
1465
+ runtime,
1466
+ buildContext: opts.buildContext,
1467
+ mediaMaxBytes,
1468
+ processMessage,
1469
+ ...control.turnAdoptionLifecycle ? { turnAdoptionLifecycle: control.turnAdoptionLifecycle } : {},
1470
+ groupHistories,
1471
+ historyLimit: account.config.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT
1472
+ });
1473
+ }
1367
1474
  });
1368
1475
  spool.start();
1369
1476
  return {
@@ -1389,7 +1496,8 @@ function resolveLineDurableReplyOptions(params) {
1389
1496
  //#endregion
1390
1497
  //#region extensions/line/src/signature.ts
1391
1498
  function validateLineSignature(body, signature, channelSecret) {
1392
- return safeEqualSecret(signature, crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"));
1499
+ const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
1500
+ return safeEqualSecret(signature, hash);
1393
1501
  }
1394
1502
  //#endregion
1395
1503
  //#region extensions/line/src/webhook-utils.ts
@@ -1402,15 +1510,28 @@ function parseLineWebhookBody(rawBody) {
1402
1510
  }
1403
1511
  //#endregion
1404
1512
  //#region extensions/line/src/webhook-node.ts
1405
- const LINE_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
1406
- const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES$1 = 64 * 1024;
1513
+ const LINE_WEBHOOK_MAX_BODY_BYTES = 1048576;
1514
+ const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES$1 = 65536;
1407
1515
  const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS$1 = 5e3;
1408
1516
  async function readLineWebhookRequestBody(req, maxBytes = LINE_WEBHOOK_MAX_BODY_BYTES, timeoutMs = LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS$1) {
1409
1517
  return await readRequestBodyWithLimit(req, {
1410
1518
  maxBytes,
1411
- timeoutMs
1519
+ timeoutMs,
1520
+ destroyOnLimit: false
1412
1521
  });
1413
1522
  }
1523
+ /**
1524
+ * Answer a body-limit failure through the connection owner.
1525
+ *
1526
+ * The reader defers destruction for these two codes, so the connection is already fenced
1527
+ * and only the owner can still write: responding directly would race the teardown and LINE
1528
+ * would see a reset instead of the status.
1529
+ */
1530
+ async function rejectLineWebhookRequest(req, res, error) {
1531
+ if (!isRequestBodyLimitError(error, "PAYLOAD_TOO_LARGE") && !isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) return false;
1532
+ await sendHttpRequestRejection(req, res, error.statusCode, JSON.stringify({ error: requestBodyErrorToText(error.code) }), "application/json");
1533
+ return true;
1534
+ }
1414
1535
  function createLineNodeWebhookHandler(params) {
1415
1536
  const maxBodyBytes = params.maxBodyBytes ?? LINE_WEBHOOK_MAX_BODY_BYTES;
1416
1537
  const readBody = params.readBody ?? readLineWebhookRequestBody;
@@ -1467,18 +1588,7 @@ function createLineNodeWebhookHandler(params) {
1467
1588
  res.setHeader("Content-Type", "application/json");
1468
1589
  res.end(JSON.stringify({ status: "ok" }));
1469
1590
  } catch (err) {
1470
- if (isRequestBodyLimitError$1(err, "PAYLOAD_TOO_LARGE")) {
1471
- res.statusCode = 413;
1472
- res.setHeader("Content-Type", "application/json");
1473
- res.end(JSON.stringify({ error: "Payload too large" }));
1474
- return;
1475
- }
1476
- if (isRequestBodyLimitError$1(err, "REQUEST_BODY_TIMEOUT")) {
1477
- res.statusCode = 408;
1478
- res.setHeader("Content-Type", "application/json");
1479
- res.end(JSON.stringify({ error: requestBodyErrorToText$1("REQUEST_BODY_TIMEOUT") }));
1480
- return;
1481
- }
1591
+ if (await rejectLineWebhookRequest(req, res, err)) return;
1482
1592
  params.runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
1483
1593
  if (!res.headersSent) {
1484
1594
  res.statusCode = 500;
@@ -1491,7 +1601,7 @@ function createLineNodeWebhookHandler(params) {
1491
1601
  //#endregion
1492
1602
  //#region extensions/line/src/monitor.ts
1493
1603
  const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
1494
- const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
1604
+ const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 65536;
1495
1605
  const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5e3;
1496
1606
  async function registerLineWebhookTarget(params, bot) {
1497
1607
  try {
@@ -1539,20 +1649,22 @@ async function monitorLineProvider(opts) {
1539
1649
  onMessage: async (ctx, deliveryControl) => {
1540
1650
  if (!ctx) return;
1541
1651
  const { ctxPayload, replyToken, route } = ctx;
1542
- const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
1543
- const displayNamePromise = ctx.userId ? getUserDisplayName(ctx.userId, {
1544
- cfg: config,
1545
- accountId: ctx.accountId
1546
- }) : Promise.resolve(ctxPayload.From);
1547
- const stopLoading = shouldShowLoading ? startLineLoadingKeepalive({
1548
- cfg: config,
1652
+ const turnConfig = deliveryControl.cfg;
1653
+ const stopLoading = Boolean(ctx.userId && !ctx.isGroup) ? startLineLoadingKeepalive({
1654
+ cfg: turnConfig,
1549
1655
  userId: ctx.userId,
1550
1656
  accountId: ctx.accountId
1551
1657
  }) : null;
1552
- logVerbose(`line: received message from ${await displayNamePromise} (${ctxPayload.From})`);
1658
+ logVerbose(`line: received message from ${ctxPayload.SenderName ?? ctx.userId ?? ctxPayload.From} (${ctxPayload.From})`);
1553
1659
  let replyTokenUsed = false;
1554
1660
  let turnAdopted = false;
1555
1661
  const ingressLifecycle = deliveryControl.turnAdoptionLifecycle;
1662
+ const turnAbortSignal = ingressLifecycle?.abortSignal;
1663
+ const skillFilter = ctx.skillFilter;
1664
+ const replyOptions = turnAbortSignal || skillFilter ? {
1665
+ ...turnAbortSignal ? { abortSignal: turnAbortSignal } : {},
1666
+ ...skillFilter ? { skillFilter } : {}
1667
+ } : void 0;
1556
1668
  try {
1557
1669
  const textLimit = 5e3;
1558
1670
  const turnResult = await getLineRuntime().channel.inbound.run({
@@ -1573,7 +1685,7 @@ async function monitorLineProvider(opts) {
1573
1685
  rawText: ctxPayload.RawBody ?? ctxPayload.BodyForAgent ?? ""
1574
1686
  }),
1575
1687
  resolveTurn: () => ({
1576
- cfg: config,
1688
+ cfg: turnConfig,
1577
1689
  channel: "line",
1578
1690
  accountId: route.accountId,
1579
1691
  route: {
@@ -1583,8 +1695,10 @@ async function monitorLineProvider(opts) {
1583
1695
  ctxPayload,
1584
1696
  record: ctx.turn.record,
1585
1697
  replyPipeline: {},
1586
- ...ingressLifecycle?.abortSignal ? { replyOptions: { abortSignal: ingressLifecycle.abortSignal } } : {},
1698
+ dispatcherOptions: { humanDelay: resolveHumanDelayConfig(turnConfig, route.agentId) },
1699
+ ...replyOptions ? { replyOptions } : {},
1587
1700
  delivery: {
1701
+ preparePayload: prepareLineReplyPayload,
1588
1702
  durable: (payload, info) => resolveLineDurableReplyOptions({
1589
1703
  payload,
1590
1704
  infoKind: info.kind,
@@ -1595,7 +1709,7 @@ async function monitorLineProvider(opts) {
1595
1709
  deliver: async (payload) => {
1596
1710
  const lineData = payload.channelData?.line ?? {};
1597
1711
  if (ctx.userId && !ctx.isGroup) showLoadingAnimation(ctx.userId, {
1598
- cfg: config,
1712
+ cfg: turnConfig,
1599
1713
  accountId: ctx.accountId
1600
1714
  }).catch(() => {});
1601
1715
  const deliveryResult = await deliverLineAutoReply({
@@ -1605,14 +1719,13 @@ async function monitorLineProvider(opts) {
1605
1719
  replyToken,
1606
1720
  replyTokenUsed,
1607
1721
  accountId: ctx.accountId,
1608
- cfg: config,
1722
+ cfg: turnConfig,
1609
1723
  textLimit,
1610
1724
  deps: {
1611
1725
  buildTemplateMessageFromPayload,
1612
1726
  processLineMessage,
1613
1727
  chunkMarkdownText,
1614
1728
  replyMessageLine,
1615
- createQuickReplyItems,
1616
1729
  pushMessagesLine,
1617
1730
  createFlexMessage,
1618
1731
  buildMediaMessage: buildLineMediaMessage,
@@ -1633,7 +1746,8 @@ async function monitorLineProvider(opts) {
1633
1746
  })
1634
1747
  }
1635
1748
  });
1636
- if (!hasFinalInboundReplyDispatch(turnResult.dispatched ? turnResult.dispatchResult : void 0)) logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
1749
+ const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : void 0;
1750
+ if (!hasFinalInboundReplyDispatch(dispatchResult)) logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
1637
1751
  } catch (err) {
1638
1752
  runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
1639
1753
  if (turnAdopted || replyTokenUsed) throw new LineWebhookTerminalDeliveryError("LINE delivery failed after consuming the event reply token.", { cause: err });
@@ -1720,25 +1834,13 @@ async function monitorLineProvider(opts) {
1720
1834
  }
1721
1835
  if (body.events && body.events.length > 0) {
1722
1836
  logVerbose(`line: received ${body.events.length} webhook events`);
1723
- await match.target.bot.handleWebhook(body);
1724
- res.setHeader("x-openclaw-delivery-accepted", "durable");
1837
+ if (await match.target.bot.handleWebhook(body) === "durable") res.setHeader("x-openclaw-delivery-accepted", "durable");
1725
1838
  }
1726
1839
  res.statusCode = 200;
1727
1840
  res.setHeader("Content-Type", "application/json");
1728
1841
  res.end(JSON.stringify({ status: "ok" }));
1729
1842
  } catch (err) {
1730
- if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
1731
- res.statusCode = 413;
1732
- res.setHeader("Content-Type", "application/json");
1733
- res.end(JSON.stringify({ error: "Payload too large" }));
1734
- return;
1735
- }
1736
- if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
1737
- res.statusCode = 408;
1738
- res.setHeader("Content-Type", "application/json");
1739
- res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
1740
- return;
1741
- }
1843
+ if (await rejectLineWebhookRequest(req, res, err)) return;
1742
1844
  runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
1743
1845
  if (!res.headersSent) {
1744
1846
  res.statusCode = 500;