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