@gakr-gakr/line 0.1.0
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/api.ts +11 -0
- package/autobot.plugin.json +15 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/index.ts +54 -0
- package/package.json +60 -0
- package/runtime-api.ts +182 -0
- package/secret-contract-api.ts +4 -0
- package/setup-api.ts +2 -0
- package/setup-entry.ts +9 -0
- package/src/account-helpers.ts +16 -0
- package/src/accounts.ts +187 -0
- package/src/actions.ts +61 -0
- package/src/auto-reply-delivery.ts +200 -0
- package/src/bindings.ts +65 -0
- package/src/bot-access.ts +30 -0
- package/src/bot-handlers.ts +620 -0
- package/src/bot-message-context.ts +586 -0
- package/src/bot.ts +70 -0
- package/src/card-command.ts +347 -0
- package/src/channel-access-token.ts +14 -0
- package/src/channel-api.ts +17 -0
- package/src/channel-shared.ts +48 -0
- package/src/channel.runtime.ts +3 -0
- package/src/channel.setup.ts +11 -0
- package/src/channel.ts +155 -0
- package/src/config-adapter.ts +29 -0
- package/src/config-schema.ts +81 -0
- package/src/download.ts +34 -0
- package/src/flex-templates/basic-cards.ts +395 -0
- package/src/flex-templates/common.ts +20 -0
- package/src/flex-templates/media-control-cards.ts +555 -0
- package/src/flex-templates/message.ts +13 -0
- package/src/flex-templates/schedule-cards.ts +467 -0
- package/src/flex-templates/types.ts +22 -0
- package/src/flex-templates.ts +32 -0
- package/src/gateway.ts +129 -0
- package/src/group-keys.ts +65 -0
- package/src/group-policy.ts +22 -0
- package/src/markdown-to-line.ts +416 -0
- package/src/monitor-durable.ts +37 -0
- package/src/monitor.runtime.ts +1 -0
- package/src/monitor.ts +507 -0
- package/src/outbound-media.ts +120 -0
- package/src/outbound.runtime.ts +12 -0
- package/src/outbound.ts +427 -0
- package/src/probe.runtime.ts +1 -0
- package/src/probe.ts +34 -0
- package/src/quick-reply-fallback.ts +10 -0
- package/src/reply-chunks.ts +110 -0
- package/src/reply-payload-transform.ts +317 -0
- package/src/rich-menu.ts +326 -0
- package/src/runtime.ts +32 -0
- package/src/send-receipt.ts +32 -0
- package/src/send.ts +531 -0
- package/src/setup-core.ts +149 -0
- package/src/setup-runtime-api.ts +9 -0
- package/src/setup-surface.ts +229 -0
- package/src/signature.ts +24 -0
- package/src/status.ts +37 -0
- package/src/template-messages.ts +333 -0
- package/src/types.ts +130 -0
- package/src/webhook-node.ts +155 -0
- package/src/webhook-utils.ts +10 -0
- package/src/webhook.ts +135 -0
- package/tsconfig.json +16 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import type { webhook } from "@line/bot-sdk";
|
|
2
|
+
import { hasFinalChannelTurnDispatch } from "autobot/plugin-sdk/channel-message";
|
|
3
|
+
import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
4
|
+
import { chunkMarkdownText } from "autobot/plugin-sdk/reply-runtime";
|
|
5
|
+
import {
|
|
6
|
+
danger,
|
|
7
|
+
logVerbose,
|
|
8
|
+
waitForAbortSignal,
|
|
9
|
+
type RuntimeEnv,
|
|
10
|
+
} from "autobot/plugin-sdk/runtime-env";
|
|
11
|
+
import {
|
|
12
|
+
isRequestBodyLimitError,
|
|
13
|
+
normalizePluginHttpPath,
|
|
14
|
+
registerWebhookTargetWithPluginRoute,
|
|
15
|
+
requestBodyErrorToText,
|
|
16
|
+
resolveSingleWebhookTarget,
|
|
17
|
+
} from "autobot/plugin-sdk/webhook-ingress";
|
|
18
|
+
import {
|
|
19
|
+
beginWebhookRequestPipelineOrReject,
|
|
20
|
+
createWebhookInFlightLimiter,
|
|
21
|
+
} from "autobot/plugin-sdk/webhook-request-guards";
|
|
22
|
+
import { resolveDefaultLineAccountId } from "./accounts.js";
|
|
23
|
+
import { deliverLineAutoReply } from "./auto-reply-delivery.js";
|
|
24
|
+
import { createLineBot } from "./bot.js";
|
|
25
|
+
import { processLineMessage } from "./markdown-to-line.js";
|
|
26
|
+
import { resolveLineDurableReplyOptions } from "./monitor-durable.js";
|
|
27
|
+
import { sendLineReplyChunks } from "./reply-chunks.js";
|
|
28
|
+
import { getLineRuntime } from "./runtime.js";
|
|
29
|
+
import {
|
|
30
|
+
createFlexMessage,
|
|
31
|
+
createImageMessage,
|
|
32
|
+
createLocationMessage,
|
|
33
|
+
createQuickReplyItems,
|
|
34
|
+
createTextMessageWithQuickReplies,
|
|
35
|
+
getUserDisplayName,
|
|
36
|
+
pushMessageLine,
|
|
37
|
+
pushMessagesLine,
|
|
38
|
+
pushTextMessageWithQuickReplies,
|
|
39
|
+
replyMessageLine,
|
|
40
|
+
showLoadingAnimation,
|
|
41
|
+
} from "./send.js";
|
|
42
|
+
import { buildTemplateMessageFromPayload } from "./template-messages.js";
|
|
43
|
+
import type { LineChannelData, ResolvedLineAccount } from "./types.js";
|
|
44
|
+
import { createLineNodeWebhookHandler, readLineWebhookRequestBody } from "./webhook-node.js";
|
|
45
|
+
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
|
|
46
|
+
|
|
47
|
+
export interface MonitorLineProviderOptions {
|
|
48
|
+
channelAccessToken: string;
|
|
49
|
+
channelSecret: string;
|
|
50
|
+
accountId?: string;
|
|
51
|
+
config: AutoBotConfig;
|
|
52
|
+
runtime: RuntimeEnv;
|
|
53
|
+
abortSignal?: AbortSignal;
|
|
54
|
+
webhookUrl?: string;
|
|
55
|
+
webhookPath?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LineProviderMonitor {
|
|
59
|
+
account: ResolvedLineAccount;
|
|
60
|
+
handleWebhook: (body: webhook.CallbackRequest) => Promise<void>;
|
|
61
|
+
stop: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const runtimeState = new Map<
|
|
65
|
+
string,
|
|
66
|
+
{
|
|
67
|
+
running: boolean;
|
|
68
|
+
lastStartAt: number | null;
|
|
69
|
+
lastStopAt: number | null;
|
|
70
|
+
lastError: string | null;
|
|
71
|
+
lastInboundAt?: number | null;
|
|
72
|
+
lastOutboundAt?: number | null;
|
|
73
|
+
}
|
|
74
|
+
>();
|
|
75
|
+
const lineWebhookInFlightLimiter = createWebhookInFlightLimiter();
|
|
76
|
+
const LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES = 64 * 1024;
|
|
77
|
+
const LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS = 5_000;
|
|
78
|
+
|
|
79
|
+
type LineWebhookTarget = {
|
|
80
|
+
accountId: string;
|
|
81
|
+
bot: ReturnType<typeof createLineBot>;
|
|
82
|
+
channelSecret: string;
|
|
83
|
+
path: string;
|
|
84
|
+
runtime: RuntimeEnv;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const lineWebhookTargets = new Map<string, LineWebhookTarget[]>();
|
|
88
|
+
|
|
89
|
+
function recordChannelRuntimeState(params: {
|
|
90
|
+
channel: string;
|
|
91
|
+
accountId: string;
|
|
92
|
+
state: Partial<{
|
|
93
|
+
running: boolean;
|
|
94
|
+
lastStartAt: number | null;
|
|
95
|
+
lastStopAt: number | null;
|
|
96
|
+
lastError: string | null;
|
|
97
|
+
lastInboundAt: number | null;
|
|
98
|
+
lastOutboundAt: number | null;
|
|
99
|
+
}>;
|
|
100
|
+
}): void {
|
|
101
|
+
const key = `${params.channel}:${params.accountId}`;
|
|
102
|
+
const existing = runtimeState.get(key) ?? {
|
|
103
|
+
running: false,
|
|
104
|
+
lastStartAt: null,
|
|
105
|
+
lastStopAt: null,
|
|
106
|
+
lastError: null,
|
|
107
|
+
};
|
|
108
|
+
runtimeState.set(key, { ...existing, ...params.state });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getLineRuntimeState(accountId: string) {
|
|
112
|
+
return runtimeState.get(`line:${accountId}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function clearLineRuntimeStateForTests() {
|
|
116
|
+
runtimeState.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function startLineLoadingKeepalive(params: {
|
|
120
|
+
cfg: AutoBotConfig;
|
|
121
|
+
userId: string;
|
|
122
|
+
accountId?: string;
|
|
123
|
+
intervalMs?: number;
|
|
124
|
+
loadingSeconds?: number;
|
|
125
|
+
}): () => void {
|
|
126
|
+
const intervalMs = params.intervalMs ?? 18_000;
|
|
127
|
+
const loadingSeconds = params.loadingSeconds ?? 20;
|
|
128
|
+
let stopped = false;
|
|
129
|
+
|
|
130
|
+
const trigger = () => {
|
|
131
|
+
if (stopped) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
void showLoadingAnimation(params.userId, {
|
|
135
|
+
cfg: params.cfg,
|
|
136
|
+
accountId: params.accountId,
|
|
137
|
+
loadingSeconds,
|
|
138
|
+
}).catch(() => {});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
trigger();
|
|
142
|
+
const timer = setInterval(trigger, intervalMs);
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
if (stopped) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
stopped = true;
|
|
149
|
+
clearInterval(timer);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function monitorLineProvider(
|
|
154
|
+
opts: MonitorLineProviderOptions,
|
|
155
|
+
): Promise<LineProviderMonitor> {
|
|
156
|
+
const {
|
|
157
|
+
channelAccessToken,
|
|
158
|
+
channelSecret,
|
|
159
|
+
accountId,
|
|
160
|
+
config,
|
|
161
|
+
runtime,
|
|
162
|
+
abortSignal,
|
|
163
|
+
webhookPath,
|
|
164
|
+
} = opts;
|
|
165
|
+
const resolvedAccountId = accountId ?? resolveDefaultLineAccountId(config);
|
|
166
|
+
const token = channelAccessToken.trim();
|
|
167
|
+
const secret = channelSecret.trim();
|
|
168
|
+
|
|
169
|
+
if (!token) {
|
|
170
|
+
throw new Error("LINE webhook mode requires a non-empty channel access token.");
|
|
171
|
+
}
|
|
172
|
+
if (!secret) {
|
|
173
|
+
throw new Error("LINE webhook mode requires a non-empty channel secret.");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
recordChannelRuntimeState({
|
|
177
|
+
channel: "line",
|
|
178
|
+
accountId: resolvedAccountId,
|
|
179
|
+
state: {
|
|
180
|
+
running: true,
|
|
181
|
+
lastStartAt: Date.now(),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const bot = createLineBot({
|
|
186
|
+
channelAccessToken: token,
|
|
187
|
+
channelSecret: secret,
|
|
188
|
+
accountId,
|
|
189
|
+
runtime,
|
|
190
|
+
config,
|
|
191
|
+
onMessage: async (ctx) => {
|
|
192
|
+
if (!ctx) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { ctxPayload, replyToken, route } = ctx;
|
|
197
|
+
|
|
198
|
+
recordChannelRuntimeState({
|
|
199
|
+
channel: "line",
|
|
200
|
+
accountId: resolvedAccountId,
|
|
201
|
+
state: {
|
|
202
|
+
lastInboundAt: Date.now(),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const shouldShowLoading = Boolean(ctx.userId && !ctx.isGroup);
|
|
207
|
+
|
|
208
|
+
const displayNamePromise = ctx.userId
|
|
209
|
+
? getUserDisplayName(ctx.userId, { cfg: config, accountId: ctx.accountId })
|
|
210
|
+
: Promise.resolve(ctxPayload.From);
|
|
211
|
+
|
|
212
|
+
const stopLoading = shouldShowLoading
|
|
213
|
+
? startLineLoadingKeepalive({
|
|
214
|
+
cfg: config,
|
|
215
|
+
userId: ctx.userId!,
|
|
216
|
+
accountId: ctx.accountId,
|
|
217
|
+
})
|
|
218
|
+
: null;
|
|
219
|
+
|
|
220
|
+
const displayName = await displayNamePromise;
|
|
221
|
+
logVerbose(`line: received message from ${displayName} (${ctxPayload.From})`);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const textLimit = 5000;
|
|
225
|
+
let replyTokenUsed = false;
|
|
226
|
+
const core = getLineRuntime();
|
|
227
|
+
const turnResult = await core.channel.turn.run({
|
|
228
|
+
channel: "line",
|
|
229
|
+
accountId: route.accountId,
|
|
230
|
+
raw: ctx,
|
|
231
|
+
adapter: {
|
|
232
|
+
ingest: () => ({
|
|
233
|
+
id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`,
|
|
234
|
+
rawText: ctxPayload.RawBody ?? ctxPayload.BodyForAgent ?? "",
|
|
235
|
+
}),
|
|
236
|
+
resolveTurn: () => ({
|
|
237
|
+
cfg: config,
|
|
238
|
+
channel: "line",
|
|
239
|
+
accountId: route.accountId,
|
|
240
|
+
agentId: route.agentId,
|
|
241
|
+
routeSessionKey: route.sessionKey,
|
|
242
|
+
storePath: ctx.turn.storePath,
|
|
243
|
+
ctxPayload,
|
|
244
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
245
|
+
dispatchReplyWithBufferedBlockDispatcher:
|
|
246
|
+
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
247
|
+
record: ctx.turn.record,
|
|
248
|
+
replyPipeline: {},
|
|
249
|
+
delivery: {
|
|
250
|
+
durable: (payload, info) =>
|
|
251
|
+
resolveLineDurableReplyOptions({
|
|
252
|
+
payload,
|
|
253
|
+
infoKind: info.kind,
|
|
254
|
+
to: ctxPayload.From,
|
|
255
|
+
replyToken,
|
|
256
|
+
replyTokenUsed,
|
|
257
|
+
}),
|
|
258
|
+
deliver: async (payload) => {
|
|
259
|
+
const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {};
|
|
260
|
+
|
|
261
|
+
if (ctx.userId && !ctx.isGroup) {
|
|
262
|
+
void showLoadingAnimation(ctx.userId, {
|
|
263
|
+
cfg: config,
|
|
264
|
+
accountId: ctx.accountId,
|
|
265
|
+
}).catch(() => {});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { replyTokenUsed: nextReplyTokenUsed } = await deliverLineAutoReply({
|
|
269
|
+
payload,
|
|
270
|
+
lineData,
|
|
271
|
+
to: ctxPayload.From,
|
|
272
|
+
replyToken,
|
|
273
|
+
replyTokenUsed,
|
|
274
|
+
accountId: ctx.accountId,
|
|
275
|
+
cfg: config,
|
|
276
|
+
textLimit,
|
|
277
|
+
deps: {
|
|
278
|
+
buildTemplateMessageFromPayload,
|
|
279
|
+
processLineMessage,
|
|
280
|
+
chunkMarkdownText,
|
|
281
|
+
sendLineReplyChunks,
|
|
282
|
+
replyMessageLine,
|
|
283
|
+
pushMessageLine,
|
|
284
|
+
pushTextMessageWithQuickReplies,
|
|
285
|
+
createQuickReplyItems,
|
|
286
|
+
createTextMessageWithQuickReplies,
|
|
287
|
+
pushMessagesLine,
|
|
288
|
+
createFlexMessage,
|
|
289
|
+
createImageMessage,
|
|
290
|
+
createLocationMessage,
|
|
291
|
+
onReplyError: (replyErr) => {
|
|
292
|
+
logVerbose(
|
|
293
|
+
`line: reply token failed, falling back to push: ${String(replyErr)}`,
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
replyTokenUsed = nextReplyTokenUsed;
|
|
299
|
+
|
|
300
|
+
recordChannelRuntimeState({
|
|
301
|
+
channel: "line",
|
|
302
|
+
accountId: resolvedAccountId,
|
|
303
|
+
state: {
|
|
304
|
+
lastOutboundAt: Date.now(),
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
onError: (err, info) => {
|
|
309
|
+
runtime.error?.(danger(`line ${info.kind} reply failed: ${String(err)}`));
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const dispatchResult = turnResult.dispatched ? turnResult.dispatchResult : undefined;
|
|
316
|
+
if (!hasFinalChannelTurnDispatch(dispatchResult)) {
|
|
317
|
+
logVerbose(`line: no response generated for message from ${ctxPayload.From}`);
|
|
318
|
+
}
|
|
319
|
+
} catch (err) {
|
|
320
|
+
runtime.error?.(danger(`line: auto-reply failed: ${String(err)}`));
|
|
321
|
+
|
|
322
|
+
if (replyToken) {
|
|
323
|
+
try {
|
|
324
|
+
await replyMessageLine(
|
|
325
|
+
replyToken,
|
|
326
|
+
[{ type: "text", text: "Sorry, I encountered an error processing your message." }],
|
|
327
|
+
{ cfg: config, accountId: ctx.accountId },
|
|
328
|
+
);
|
|
329
|
+
} catch (replyErr) {
|
|
330
|
+
runtime.error?.(danger(`line: error reply failed: ${String(replyErr)}`));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
stopLoading?.();
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const normalizedPath = normalizePluginHttpPath(webhookPath, "/line/webhook") ?? "/line/webhook";
|
|
340
|
+
const createScopedLineWebhookHandler = (target: LineWebhookTarget) =>
|
|
341
|
+
createLineNodeWebhookHandler({
|
|
342
|
+
channelSecret: target.channelSecret,
|
|
343
|
+
bot: target.bot,
|
|
344
|
+
runtime: target.runtime,
|
|
345
|
+
});
|
|
346
|
+
const { unregister: unregisterHttp } = registerWebhookTargetWithPluginRoute({
|
|
347
|
+
targetsByPath: lineWebhookTargets,
|
|
348
|
+
target: {
|
|
349
|
+
accountId: resolvedAccountId,
|
|
350
|
+
bot,
|
|
351
|
+
channelSecret: secret,
|
|
352
|
+
path: normalizedPath,
|
|
353
|
+
runtime,
|
|
354
|
+
},
|
|
355
|
+
route: {
|
|
356
|
+
auth: "plugin",
|
|
357
|
+
pluginId: "line",
|
|
358
|
+
accountId: resolvedAccountId,
|
|
359
|
+
log: (msg) => logVerbose(msg),
|
|
360
|
+
handler: async (req, res) => {
|
|
361
|
+
const targets = lineWebhookTargets.get(normalizedPath) ?? [];
|
|
362
|
+
const firstTarget = targets[0];
|
|
363
|
+
if (req.method !== "POST") {
|
|
364
|
+
if (!firstTarget) {
|
|
365
|
+
res.statusCode = 404;
|
|
366
|
+
res.end("Not Found");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
await createScopedLineWebhookHandler(firstTarget)(req, res);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const requestLifecycle = beginWebhookRequestPipelineOrReject({
|
|
374
|
+
req,
|
|
375
|
+
res,
|
|
376
|
+
inFlightLimiter: lineWebhookInFlightLimiter,
|
|
377
|
+
inFlightKey: `line:${normalizedPath}`,
|
|
378
|
+
});
|
|
379
|
+
if (!requestLifecycle.ok) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const signatureHeader = req.headers["x-line-signature"];
|
|
385
|
+
const signature =
|
|
386
|
+
typeof signatureHeader === "string"
|
|
387
|
+
? signatureHeader.trim()
|
|
388
|
+
: Array.isArray(signatureHeader)
|
|
389
|
+
? (signatureHeader[0] ?? "").trim()
|
|
390
|
+
: "";
|
|
391
|
+
|
|
392
|
+
if (!signature) {
|
|
393
|
+
logVerbose("line: webhook missing X-Line-Signature header");
|
|
394
|
+
res.statusCode = 400;
|
|
395
|
+
res.setHeader("Content-Type", "application/json");
|
|
396
|
+
res.end(JSON.stringify({ error: "Missing X-Line-Signature header" }));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const rawBody = await readLineWebhookRequestBody(
|
|
401
|
+
req,
|
|
402
|
+
LINE_WEBHOOK_PREAUTH_MAX_BODY_BYTES,
|
|
403
|
+
LINE_WEBHOOK_PREAUTH_BODY_TIMEOUT_MS,
|
|
404
|
+
);
|
|
405
|
+
const match = resolveSingleWebhookTarget(targets, (target) =>
|
|
406
|
+
validateLineSignature(rawBody, signature, target.channelSecret),
|
|
407
|
+
);
|
|
408
|
+
if (match.kind === "none") {
|
|
409
|
+
logVerbose("line: webhook signature validation failed");
|
|
410
|
+
res.statusCode = 401;
|
|
411
|
+
res.setHeader("Content-Type", "application/json");
|
|
412
|
+
res.end(JSON.stringify({ error: "Invalid signature" }));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (match.kind === "ambiguous") {
|
|
416
|
+
logVerbose("line: webhook signature matched multiple accounts");
|
|
417
|
+
res.statusCode = 401;
|
|
418
|
+
res.setHeader("Content-Type", "application/json");
|
|
419
|
+
res.end(JSON.stringify({ error: "Ambiguous webhook target" }));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const body = parseLineWebhookBody(rawBody);
|
|
424
|
+
if (!body) {
|
|
425
|
+
res.statusCode = 400;
|
|
426
|
+
res.setHeader("Content-Type", "application/json");
|
|
427
|
+
res.end(JSON.stringify({ error: "Invalid webhook payload" }));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
requestLifecycle.release();
|
|
432
|
+
res.statusCode = 200;
|
|
433
|
+
res.setHeader("Content-Type", "application/json");
|
|
434
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
435
|
+
|
|
436
|
+
if (body.events && body.events.length > 0) {
|
|
437
|
+
logVerbose(`line: received ${body.events.length} webhook events`);
|
|
438
|
+
void Promise.resolve()
|
|
439
|
+
.then(() => match.target.bot.handleWebhook(body))
|
|
440
|
+
.catch((err) => {
|
|
441
|
+
match.target.runtime.error?.(
|
|
442
|
+
danger(`line webhook dispatch failed: ${String(err)}`),
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
} catch (err) {
|
|
447
|
+
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
448
|
+
res.statusCode = 413;
|
|
449
|
+
res.setHeader("Content-Type", "application/json");
|
|
450
|
+
res.end(JSON.stringify({ error: "Payload too large" }));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
454
|
+
res.statusCode = 408;
|
|
455
|
+
res.setHeader("Content-Type", "application/json");
|
|
456
|
+
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
runtime.error?.(danger(`line webhook error: ${String(err)}`));
|
|
460
|
+
if (!res.headersSent) {
|
|
461
|
+
res.statusCode = 500;
|
|
462
|
+
res.setHeader("Content-Type", "application/json");
|
|
463
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
464
|
+
}
|
|
465
|
+
} finally {
|
|
466
|
+
requestLifecycle.release();
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
logVerbose(`line: registered webhook handler at ${normalizedPath}`);
|
|
473
|
+
|
|
474
|
+
let stopped = false;
|
|
475
|
+
const stopHandler = () => {
|
|
476
|
+
if (stopped) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
stopped = true;
|
|
480
|
+
logVerbose(`line: stopping provider for account ${resolvedAccountId}`);
|
|
481
|
+
unregisterHttp();
|
|
482
|
+
recordChannelRuntimeState({
|
|
483
|
+
channel: "line",
|
|
484
|
+
accountId: resolvedAccountId,
|
|
485
|
+
state: {
|
|
486
|
+
running: false,
|
|
487
|
+
lastStopAt: Date.now(),
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
if (abortSignal?.aborted) {
|
|
493
|
+
stopHandler();
|
|
494
|
+
} else if (abortSignal) {
|
|
495
|
+
abortSignal.addEventListener("abort", stopHandler, { once: true });
|
|
496
|
+
await waitForAbortSignal(abortSignal);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
account: bot.account,
|
|
501
|
+
handleWebhook: bot.handleWebhook,
|
|
502
|
+
stop: () => {
|
|
503
|
+
stopHandler();
|
|
504
|
+
abortSignal?.removeEventListener("abort", stopHandler);
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "autobot/plugin-sdk/ssrf-runtime";
|
|
2
|
+
import { normalizeLowercaseStringOrEmpty } from "autobot/plugin-sdk/string-coerce-runtime";
|
|
3
|
+
|
|
4
|
+
type LineOutboundMediaKind = "image" | "video" | "audio";
|
|
5
|
+
|
|
6
|
+
export type LineOutboundMediaResolved = {
|
|
7
|
+
mediaUrl: string;
|
|
8
|
+
mediaKind: LineOutboundMediaKind;
|
|
9
|
+
previewImageUrl?: string;
|
|
10
|
+
durationMs?: number;
|
|
11
|
+
trackingId?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type ResolveLineOutboundMediaOpts = {
|
|
15
|
+
mediaKind?: LineOutboundMediaKind;
|
|
16
|
+
previewImageUrl?: string;
|
|
17
|
+
durationMs?: number;
|
|
18
|
+
trackingId?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const LINE_OUTBOUND_MEDIA_SSRF_POLICY: SsrFPolicy = {
|
|
22
|
+
allowPrivateNetwork: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function validateLineMediaUrl(url: string): Promise<void> {
|
|
26
|
+
let parsed: URL;
|
|
27
|
+
try {
|
|
28
|
+
parsed = new URL(url);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`LINE outbound media URL must be a valid URL: ${url}`);
|
|
31
|
+
}
|
|
32
|
+
if (parsed.protocol !== "https:") {
|
|
33
|
+
throw new Error(`LINE outbound media URL must use HTTPS: ${url}`);
|
|
34
|
+
}
|
|
35
|
+
if (url.length > 2000) {
|
|
36
|
+
throw new Error(`LINE outbound media URL must be 2000 chars or less (got ${url.length})`);
|
|
37
|
+
}
|
|
38
|
+
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
|
39
|
+
policy: LINE_OUTBOUND_MEDIA_SSRF_POLICY,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function detectLineMediaKind(mimeType: string): LineOutboundMediaKind {
|
|
44
|
+
const normalized = normalizeLowercaseStringOrEmpty(mimeType);
|
|
45
|
+
if (normalized.startsWith("image/")) {
|
|
46
|
+
return "image";
|
|
47
|
+
}
|
|
48
|
+
if (normalized.startsWith("video/")) {
|
|
49
|
+
return "video";
|
|
50
|
+
}
|
|
51
|
+
if (normalized.startsWith("audio/")) {
|
|
52
|
+
return "audio";
|
|
53
|
+
}
|
|
54
|
+
return "image";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isHttpsUrl(url: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
return new URL(url).protocol === "https:";
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function detectLineMediaKindFromUrl(url: string): LineOutboundMediaKind | undefined {
|
|
66
|
+
try {
|
|
67
|
+
const pathname = normalizeLowercaseStringOrEmpty(new URL(url).pathname);
|
|
68
|
+
if (/\.(png|jpe?g|gif|webp|bmp|heic|heif|avif)$/i.test(pathname)) {
|
|
69
|
+
return "image";
|
|
70
|
+
}
|
|
71
|
+
if (/\.(mp4|mov|m4v|webm)$/i.test(pathname)) {
|
|
72
|
+
return "video";
|
|
73
|
+
}
|
|
74
|
+
if (/\.(mp3|m4a|aac|wav|ogg|oga)$/i.test(pathname)) {
|
|
75
|
+
return "audio";
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function resolveLineOutboundMedia(
|
|
84
|
+
mediaUrl: string,
|
|
85
|
+
opts: ResolveLineOutboundMediaOpts = {},
|
|
86
|
+
): Promise<LineOutboundMediaResolved> {
|
|
87
|
+
const trimmedUrl = mediaUrl.trim();
|
|
88
|
+
if (isHttpsUrl(trimmedUrl)) {
|
|
89
|
+
await validateLineMediaUrl(trimmedUrl);
|
|
90
|
+
const previewImageUrl = opts.previewImageUrl?.trim();
|
|
91
|
+
if (previewImageUrl) {
|
|
92
|
+
await validateLineMediaUrl(previewImageUrl);
|
|
93
|
+
}
|
|
94
|
+
const mediaKind =
|
|
95
|
+
opts.mediaKind ??
|
|
96
|
+
(typeof opts.durationMs === "number" ? "audio" : undefined) ??
|
|
97
|
+
(opts.trackingId?.trim() ? "video" : undefined) ??
|
|
98
|
+
detectLineMediaKindFromUrl(trimmedUrl) ??
|
|
99
|
+
"image";
|
|
100
|
+
return {
|
|
101
|
+
mediaUrl: trimmedUrl,
|
|
102
|
+
mediaKind,
|
|
103
|
+
...(previewImageUrl ? { previewImageUrl } : {}),
|
|
104
|
+
...(typeof opts.durationMs === "number" ? { durationMs: opts.durationMs } : {}),
|
|
105
|
+
...(opts.trackingId ? { trackingId: opts.trackingId } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const parsed = new URL(trimmedUrl);
|
|
111
|
+
if (parsed.protocol !== "https:") {
|
|
112
|
+
throw new Error(`LINE outbound media URL must use HTTPS: ${trimmedUrl}`);
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e instanceof Error && e.message.startsWith("LINE outbound")) {
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new Error("LINE outbound media currently requires a public HTTPS URL");
|
|
120
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { processLineMessage } from "./markdown-to-line.js";
|
|
2
|
+
export {
|
|
3
|
+
createQuickReplyItems,
|
|
4
|
+
pushFlexMessage,
|
|
5
|
+
pushLocationMessage,
|
|
6
|
+
pushMessageLine,
|
|
7
|
+
pushMessagesLine,
|
|
8
|
+
pushTemplateMessage,
|
|
9
|
+
pushTextMessageWithQuickReplies,
|
|
10
|
+
sendMessageLine,
|
|
11
|
+
} from "./send.js";
|
|
12
|
+
export { buildTemplateMessageFromPayload } from "./template-messages.js";
|