@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
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import type { webhook } from "@line/bot-sdk";
|
|
2
|
+
import { buildMentionRegexes, matchesMentionPatterns } from "autobot/plugin-sdk/channel-inbound";
|
|
3
|
+
import { resolveStableChannelMessageIngress } from "autobot/plugin-sdk/channel-ingress-runtime";
|
|
4
|
+
import { createChannelPairingChallengeIssuer } from "autobot/plugin-sdk/channel-pairing";
|
|
5
|
+
import { shouldComputeCommandAuthorized } from "autobot/plugin-sdk/command-auth-native";
|
|
6
|
+
import type { GroupPolicy, AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
7
|
+
import {
|
|
8
|
+
readChannelAllowFromStore,
|
|
9
|
+
resolvePairingIdLabel,
|
|
10
|
+
upsertChannelPairingRequest,
|
|
11
|
+
} from "autobot/plugin-sdk/conversation-runtime";
|
|
12
|
+
import { createClaimableDedupe, type ClaimableDedupe } from "autobot/plugin-sdk/persistent-dedupe";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
15
|
+
createChannelHistoryWindow,
|
|
16
|
+
type HistoryEntry,
|
|
17
|
+
} from "autobot/plugin-sdk/reply-history";
|
|
18
|
+
import { resolveAgentRoute } from "autobot/plugin-sdk/routing";
|
|
19
|
+
import type { RuntimeEnv } from "autobot/plugin-sdk/runtime";
|
|
20
|
+
import { danger, logVerbose } from "autobot/plugin-sdk/runtime-env";
|
|
21
|
+
import {
|
|
22
|
+
resolveAllowlistProviderRuntimeGroupPolicy,
|
|
23
|
+
resolveDefaultGroupPolicy,
|
|
24
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
25
|
+
} from "autobot/plugin-sdk/runtime-group-policy";
|
|
26
|
+
import { normalizeStringEntries } from "autobot/plugin-sdk/string-coerce-runtime";
|
|
27
|
+
import { firstDefined, normalizeLineAllowEntry } from "./bot-access.js";
|
|
28
|
+
import {
|
|
29
|
+
buildLineMessageContext,
|
|
30
|
+
buildLinePostbackContext,
|
|
31
|
+
getLineSourceInfo,
|
|
32
|
+
type LineInboundContext,
|
|
33
|
+
} from "./bot-message-context.js";
|
|
34
|
+
import { downloadLineMedia } from "./download.js";
|
|
35
|
+
import { resolveLineGroupConfigEntry } from "./group-keys.js";
|
|
36
|
+
import { pushMessageLine, replyMessageLine } from "./send.js";
|
|
37
|
+
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
|
|
38
|
+
|
|
39
|
+
type FollowEvent = webhook.FollowEvent;
|
|
40
|
+
type JoinEvent = webhook.JoinEvent;
|
|
41
|
+
type LeaveEvent = webhook.LeaveEvent;
|
|
42
|
+
type MessageEvent = webhook.MessageEvent;
|
|
43
|
+
type PostbackEvent = webhook.PostbackEvent;
|
|
44
|
+
type UnfollowEvent = webhook.UnfollowEvent;
|
|
45
|
+
type WebhookEvent = webhook.Event;
|
|
46
|
+
|
|
47
|
+
interface MediaRef {
|
|
48
|
+
path: string;
|
|
49
|
+
contentType?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LINE_DOWNLOADABLE_MESSAGE_TYPES: ReadonlySet<string> = new Set([
|
|
53
|
+
"image",
|
|
54
|
+
"video",
|
|
55
|
+
"audio",
|
|
56
|
+
"file",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
function isDownloadableLineMessageType(
|
|
60
|
+
messageType: MessageEvent["message"]["type"],
|
|
61
|
+
): messageType is "image" | "video" | "audio" | "file" {
|
|
62
|
+
return LINE_DOWNLOADABLE_MESSAGE_TYPES.has(messageType);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LineHandlerContext {
|
|
66
|
+
cfg: AutoBotConfig;
|
|
67
|
+
account: ResolvedLineAccount;
|
|
68
|
+
runtime: RuntimeEnv;
|
|
69
|
+
mediaMaxBytes: number;
|
|
70
|
+
processMessage: (ctx: LineInboundContext) => Promise<void>;
|
|
71
|
+
replayCache?: LineWebhookReplayCache;
|
|
72
|
+
groupHistories?: Map<string, HistoryEntry[]>;
|
|
73
|
+
historyLimit?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
|
77
|
+
const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
|
|
78
|
+
export type LineWebhookReplayCache = ClaimableDedupe;
|
|
79
|
+
|
|
80
|
+
function normalizeLineIngressEntry(value: string): string | null {
|
|
81
|
+
return normalizeLineAllowEntry(value) || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class LineRetryableWebhookError extends Error {
|
|
85
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
86
|
+
super(message, options);
|
|
87
|
+
this.name = "LineRetryableWebhookError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createLineWebhookReplayCache(): LineWebhookReplayCache {
|
|
92
|
+
return createClaimableDedupe({
|
|
93
|
+
ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
|
|
94
|
+
memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildLineWebhookReplayKey(
|
|
99
|
+
event: WebhookEvent,
|
|
100
|
+
accountId: string,
|
|
101
|
+
): { key: string; eventId: string } | null {
|
|
102
|
+
if (event.type === "message") {
|
|
103
|
+
const messageId = event.message?.id?.trim();
|
|
104
|
+
if (messageId) {
|
|
105
|
+
return {
|
|
106
|
+
key: `${accountId}|message:${messageId}`,
|
|
107
|
+
eventId: `message:${messageId}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const eventId = (event as { webhookEventId?: string }).webhookEventId?.trim();
|
|
112
|
+
if (!eventId) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const source = (
|
|
117
|
+
event as {
|
|
118
|
+
source?: { type?: string; userId?: string; groupId?: string; roomId?: string };
|
|
119
|
+
}
|
|
120
|
+
).source;
|
|
121
|
+
const sourceId =
|
|
122
|
+
source?.type === "group"
|
|
123
|
+
? `group:${source.groupId ?? ""}`
|
|
124
|
+
: source?.type === "room"
|
|
125
|
+
? `room:${source.roomId ?? ""}`
|
|
126
|
+
: `user:${source?.userId ?? ""}`;
|
|
127
|
+
return { key: `${accountId}|${event.type}|${sourceId}|${eventId}`, eventId: `event:${eventId}` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type LineReplayCandidate = {
|
|
131
|
+
key: string;
|
|
132
|
+
eventId: string;
|
|
133
|
+
cache: LineWebhookReplayCache;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function getLineReplayCandidate(
|
|
137
|
+
event: WebhookEvent,
|
|
138
|
+
context: LineHandlerContext,
|
|
139
|
+
): LineReplayCandidate | null {
|
|
140
|
+
const replay = buildLineWebhookReplayKey(event, context.account.accountId);
|
|
141
|
+
const cache = context.replayCache;
|
|
142
|
+
if (!replay || !cache) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return { key: replay.key, eventId: replay.eventId, cache };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function claimLineReplayEvent(
|
|
149
|
+
candidate: LineReplayCandidate,
|
|
150
|
+
): Promise<{ skip: true; inFlightResult?: Promise<void> } | { skip: false }> {
|
|
151
|
+
const claim = await candidate.cache.claim(candidate.key);
|
|
152
|
+
if (claim.kind === "claimed") {
|
|
153
|
+
return { skip: false };
|
|
154
|
+
}
|
|
155
|
+
if (claim.kind === "inflight") {
|
|
156
|
+
logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
|
|
157
|
+
return { skip: true, inFlightResult: claim.pending.then(() => undefined) };
|
|
158
|
+
}
|
|
159
|
+
logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
|
|
160
|
+
return { skip: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveLineGroupConfig(params: {
|
|
164
|
+
config: ResolvedLineAccount["config"];
|
|
165
|
+
groupId?: string;
|
|
166
|
+
roomId?: string;
|
|
167
|
+
}): LineGroupConfig | undefined {
|
|
168
|
+
return resolveLineGroupConfigEntry(params.config.groups, {
|
|
169
|
+
groupId: params.groupId,
|
|
170
|
+
roomId: params.roomId,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function sendLinePairingReply(params: {
|
|
175
|
+
senderId: string;
|
|
176
|
+
replyToken?: string;
|
|
177
|
+
context: LineHandlerContext;
|
|
178
|
+
}): Promise<void> {
|
|
179
|
+
const { senderId, replyToken, context } = params;
|
|
180
|
+
const idLabel = (() => {
|
|
181
|
+
try {
|
|
182
|
+
return resolvePairingIdLabel("line");
|
|
183
|
+
} catch {
|
|
184
|
+
return "lineUserId";
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
await createChannelPairingChallengeIssuer({
|
|
188
|
+
channel: "line",
|
|
189
|
+
upsertPairingRequest: async ({ id, meta }) =>
|
|
190
|
+
await upsertChannelPairingRequest({
|
|
191
|
+
channel: "line",
|
|
192
|
+
id,
|
|
193
|
+
accountId: context.account.accountId,
|
|
194
|
+
meta,
|
|
195
|
+
}),
|
|
196
|
+
})({
|
|
197
|
+
senderId,
|
|
198
|
+
senderIdLine: `Your ${idLabel}: ${senderId}`,
|
|
199
|
+
onCreated: () => {
|
|
200
|
+
logVerbose(`line pairing request sender=${senderId}`);
|
|
201
|
+
},
|
|
202
|
+
sendPairingReply: async (text) => {
|
|
203
|
+
if (replyToken) {
|
|
204
|
+
try {
|
|
205
|
+
await replyMessageLine(replyToken, [{ type: "text", text }], {
|
|
206
|
+
cfg: context.cfg,
|
|
207
|
+
accountId: context.account.accountId,
|
|
208
|
+
channelAccessToken: context.account.channelAccessToken,
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
} catch (err) {
|
|
212
|
+
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
await pushMessageLine(`line:${senderId}`, text, {
|
|
217
|
+
cfg: context.cfg,
|
|
218
|
+
accountId: context.account.accountId,
|
|
219
|
+
channelAccessToken: context.account.channelAccessToken,
|
|
220
|
+
});
|
|
221
|
+
} catch (err) {
|
|
222
|
+
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function shouldProcessLineEvent(
|
|
229
|
+
event: MessageEvent | PostbackEvent,
|
|
230
|
+
context: LineHandlerContext,
|
|
231
|
+
) {
|
|
232
|
+
const { cfg, account } = context;
|
|
233
|
+
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
|
|
234
|
+
const senderId = userId ?? "";
|
|
235
|
+
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
|
|
236
|
+
const rawText = resolveEventRawText(event);
|
|
237
|
+
const requireMention = isGroup ? groupConfig?.requireMention !== false : false;
|
|
238
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
239
|
+
const { groupPolicy: runtimeGroupPolicy, providerMissingFallbackApplied } =
|
|
240
|
+
resolveAllowlistProviderRuntimeGroupPolicy({
|
|
241
|
+
providerConfigPresent: cfg.channels?.line !== undefined,
|
|
242
|
+
groupPolicy: account.config.groupPolicy,
|
|
243
|
+
defaultGroupPolicy: resolveDefaultGroupPolicy(cfg),
|
|
244
|
+
});
|
|
245
|
+
const groupPolicy: GroupPolicy =
|
|
246
|
+
runtimeGroupPolicy === "disabled"
|
|
247
|
+
? "disabled"
|
|
248
|
+
: groupConfig?.allowFrom !== undefined
|
|
249
|
+
? "allowlist"
|
|
250
|
+
: runtimeGroupPolicy;
|
|
251
|
+
const groupAllowFrom = normalizeStringEntries(
|
|
252
|
+
firstDefined(
|
|
253
|
+
groupConfig?.allowFrom,
|
|
254
|
+
account.config.groupAllowFrom,
|
|
255
|
+
account.config.allowFrom?.length ? account.config.allowFrom : undefined,
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
const mentionFacts = (() => {
|
|
259
|
+
if (!isGroup || event.type !== "message") {
|
|
260
|
+
return { canDetectMention: false, wasMentioned: false, hasAnyMention: false };
|
|
261
|
+
}
|
|
262
|
+
const peerId = groupId ?? roomId ?? userId ?? "unknown";
|
|
263
|
+
const { agentId } = resolveAgentRoute({
|
|
264
|
+
cfg,
|
|
265
|
+
channel: "line",
|
|
266
|
+
accountId: account.accountId,
|
|
267
|
+
peer: { kind: "group", id: peerId },
|
|
268
|
+
});
|
|
269
|
+
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
|
270
|
+
const wasMentionedByNative = isLineBotMentioned(event.message);
|
|
271
|
+
const wasMentionedByPattern =
|
|
272
|
+
event.message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
|
|
273
|
+
return {
|
|
274
|
+
canDetectMention: event.message.type === "text",
|
|
275
|
+
wasMentioned: wasMentionedByNative || wasMentionedByPattern,
|
|
276
|
+
hasAnyMention: hasAnyLineMention(event.message),
|
|
277
|
+
};
|
|
278
|
+
})();
|
|
279
|
+
const access = await resolveStableChannelMessageIngress({
|
|
280
|
+
channelId: "line",
|
|
281
|
+
accountId: account.accountId,
|
|
282
|
+
identity: {
|
|
283
|
+
key: "line-user-id",
|
|
284
|
+
normalize: normalizeLineIngressEntry,
|
|
285
|
+
sensitivity: "pii",
|
|
286
|
+
entryIdPrefix: "line-entry",
|
|
287
|
+
},
|
|
288
|
+
cfg,
|
|
289
|
+
readStoreAllowFrom: async () =>
|
|
290
|
+
await readChannelAllowFromStore("line", undefined, account.accountId),
|
|
291
|
+
subject: { stableId: senderId },
|
|
292
|
+
conversation: {
|
|
293
|
+
kind: isGroup ? "group" : "direct",
|
|
294
|
+
id: (groupId ?? roomId ?? senderId) || "unknown",
|
|
295
|
+
},
|
|
296
|
+
...(isGroup && groupConfig?.enabled === false
|
|
297
|
+
? { route: { id: "line:group-config", enabled: false } }
|
|
298
|
+
: {}),
|
|
299
|
+
mentionFacts:
|
|
300
|
+
isGroup && event.type === "message"
|
|
301
|
+
? {
|
|
302
|
+
canDetectMention: mentionFacts.canDetectMention,
|
|
303
|
+
wasMentioned: mentionFacts.wasMentioned,
|
|
304
|
+
hasAnyMention: mentionFacts.hasAnyMention,
|
|
305
|
+
implicitMentionKinds: [],
|
|
306
|
+
}
|
|
307
|
+
: undefined,
|
|
308
|
+
event: { kind: event.type === "postback" ? "postback" : "message" },
|
|
309
|
+
dmPolicy,
|
|
310
|
+
groupPolicy,
|
|
311
|
+
policy: {
|
|
312
|
+
groupAllowFromFallbackToAllowFrom: false,
|
|
313
|
+
activation: {
|
|
314
|
+
requireMention: isGroup && event.type === "message" && requireMention,
|
|
315
|
+
allowTextCommands: true,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
allowFrom: normalizeStringEntries(account.config.allowFrom),
|
|
319
|
+
groupAllowFrom,
|
|
320
|
+
command: {
|
|
321
|
+
hasControlCommand: shouldComputeCommandAuthorized(rawText, cfg),
|
|
322
|
+
groupOwnerAllowFrom: "none",
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
warnMissingProviderGroupPolicyFallbackOnce({
|
|
326
|
+
providerMissingFallbackApplied,
|
|
327
|
+
providerKey: "line",
|
|
328
|
+
accountId: account.accountId,
|
|
329
|
+
log: (message) => logVerbose(message),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (
|
|
333
|
+
access.senderAccess.decision === "allow" &&
|
|
334
|
+
(access.ingress.admission === "dispatch" ||
|
|
335
|
+
access.ingress.admission === "observe" ||
|
|
336
|
+
access.ingress.admission === "skip")
|
|
337
|
+
) {
|
|
338
|
+
return access;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (access.senderAccess.decision === "allow") {
|
|
342
|
+
logVerbose(`Blocked line event (${access.ingress.reasonCode})`);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (isGroup) {
|
|
347
|
+
if (groupConfig?.enabled === false) {
|
|
348
|
+
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (groupConfig?.allowFrom !== undefined) {
|
|
352
|
+
if (!senderId) {
|
|
353
|
+
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
if (access.senderAccess.reasonCode !== "group_policy_allowed") {
|
|
357
|
+
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (access.senderAccess.reasonCode === "group_policy_disabled") {
|
|
362
|
+
logVerbose("Blocked line group message (groupPolicy: disabled)");
|
|
363
|
+
} else if (!senderId && groupPolicy === "allowlist") {
|
|
364
|
+
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
|
|
365
|
+
} else if (access.senderAccess.reasonCode === "group_policy_empty_allowlist") {
|
|
366
|
+
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
|
|
367
|
+
} else {
|
|
368
|
+
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (access.senderAccess.reasonCode === "dm_policy_disabled") {
|
|
374
|
+
logVerbose("Blocked line sender (dmPolicy: disabled)");
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (access.senderAccess.decision === "pairing") {
|
|
379
|
+
if (!senderId) {
|
|
380
|
+
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
await sendLinePairingReply({
|
|
384
|
+
senderId,
|
|
385
|
+
replyToken: "replyToken" in event ? event.replyToken : undefined,
|
|
386
|
+
context,
|
|
387
|
+
});
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
logVerbose(
|
|
392
|
+
`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${
|
|
393
|
+
account.config.dmPolicy ?? "pairing"
|
|
394
|
+
})`,
|
|
395
|
+
);
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function getLineMentionees(
|
|
400
|
+
message: MessageEvent["message"],
|
|
401
|
+
): Array<{ type?: string; isSelf?: boolean }> {
|
|
402
|
+
if (message.type !== "text") {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
const mentionees = (
|
|
406
|
+
message as Record<string, unknown> & {
|
|
407
|
+
mention?: { mentionees?: Array<{ type?: string; isSelf?: boolean }> };
|
|
408
|
+
}
|
|
409
|
+
).mention?.mentionees;
|
|
410
|
+
return Array.isArray(mentionees) ? mentionees : [];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function isLineBotMentioned(message: MessageEvent["message"]): boolean {
|
|
414
|
+
return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function hasAnyLineMention(message: MessageEvent["message"]): boolean {
|
|
418
|
+
return getLineMentionees(message).length > 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
|
|
422
|
+
if (event.type === "message") {
|
|
423
|
+
const msg = event.message;
|
|
424
|
+
if (msg.type === "text") {
|
|
425
|
+
return msg.text;
|
|
426
|
+
}
|
|
427
|
+
return "";
|
|
428
|
+
}
|
|
429
|
+
if (event.type === "postback") {
|
|
430
|
+
return event.postback?.data?.trim() ?? "";
|
|
431
|
+
}
|
|
432
|
+
return "";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
|
|
436
|
+
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
|
|
437
|
+
const message = event.message;
|
|
438
|
+
|
|
439
|
+
const decision = await shouldProcessLineEvent(event, context);
|
|
440
|
+
if (!decision) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
|
|
445
|
+
if (isGroup && decision.activationAccess.shouldSkip) {
|
|
446
|
+
const rawText = message.type === "text" ? message.text : "";
|
|
447
|
+
const sourceInfo = getLineSourceInfo(event.source);
|
|
448
|
+
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
|
|
449
|
+
const historyKey = groupId ?? roomId;
|
|
450
|
+
const senderId = sourceInfo.userId ?? "unknown";
|
|
451
|
+
if (historyKey && context.groupHistories) {
|
|
452
|
+
createChannelHistoryWindow({ historyMap: context.groupHistories }).record({
|
|
453
|
+
historyKey,
|
|
454
|
+
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
455
|
+
entry: {
|
|
456
|
+
sender: `user:${senderId}`,
|
|
457
|
+
body: rawText || `<${message.type}>`,
|
|
458
|
+
timestamp: event.timestamp,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const allMedia: MediaRef[] = [];
|
|
466
|
+
|
|
467
|
+
if (isDownloadableLineMessageType(message.type)) {
|
|
468
|
+
try {
|
|
469
|
+
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
|
|
470
|
+
allMedia.push({
|
|
471
|
+
path: media.path,
|
|
472
|
+
contentType: media.contentType,
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
const errMsg = String(err);
|
|
476
|
+
if (errMsg.includes("exceeds") && errMsg.includes("limit")) {
|
|
477
|
+
logVerbose(`line: media exceeds size limit for message ${message.id}`);
|
|
478
|
+
} else {
|
|
479
|
+
runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const messageContext = await buildLineMessageContext({
|
|
485
|
+
event,
|
|
486
|
+
allMedia,
|
|
487
|
+
cfg,
|
|
488
|
+
account,
|
|
489
|
+
commandAuthorized: decision.commandAccess.authorized,
|
|
490
|
+
groupHistories: context.groupHistories,
|
|
491
|
+
historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
if (!messageContext) {
|
|
495
|
+
logVerbose("line: skipping empty message");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
await processMessage(messageContext);
|
|
500
|
+
|
|
501
|
+
if (isGroup && context.groupHistories) {
|
|
502
|
+
const historyKey = groupId ?? roomId;
|
|
503
|
+
if (historyKey && context.groupHistories.has(historyKey)) {
|
|
504
|
+
createChannelHistoryWindow({ historyMap: context.groupHistories }).clear({
|
|
505
|
+
historyKey,
|
|
506
|
+
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {
|
|
513
|
+
const { userId } = getLineSourceInfo(event.source);
|
|
514
|
+
logVerbose(`line: user ${userId ?? "unknown"} followed`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function handleUnfollowEvent(
|
|
518
|
+
event: UnfollowEvent,
|
|
519
|
+
_context: LineHandlerContext,
|
|
520
|
+
): Promise<void> {
|
|
521
|
+
const { userId } = getLineSourceInfo(event.source);
|
|
522
|
+
logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise<void> {
|
|
526
|
+
const { groupId, roomId } = getLineSourceInfo(event.source);
|
|
527
|
+
logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext): Promise<void> {
|
|
531
|
+
const { groupId, roomId } = getLineSourceInfo(event.source);
|
|
532
|
+
logVerbose(`line: bot left ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function handlePostbackEvent(
|
|
536
|
+
event: PostbackEvent,
|
|
537
|
+
context: LineHandlerContext,
|
|
538
|
+
): Promise<void> {
|
|
539
|
+
const data = event.postback.data;
|
|
540
|
+
logVerbose(`line: received postback: ${data}`);
|
|
541
|
+
|
|
542
|
+
const decision = await shouldProcessLineEvent(event, context);
|
|
543
|
+
if (!decision) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const postbackContext = await buildLinePostbackContext({
|
|
548
|
+
event,
|
|
549
|
+
cfg: context.cfg,
|
|
550
|
+
account: context.account,
|
|
551
|
+
commandAuthorized: decision.commandAccess.authorized,
|
|
552
|
+
});
|
|
553
|
+
if (!postbackContext) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
await context.processMessage(postbackContext);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function handleLineWebhookEvents(
|
|
561
|
+
events: WebhookEvent[],
|
|
562
|
+
context: LineHandlerContext,
|
|
563
|
+
): Promise<void> {
|
|
564
|
+
let firstError: unknown;
|
|
565
|
+
for (const event of events) {
|
|
566
|
+
const replayCandidate = getLineReplayCandidate(event, context);
|
|
567
|
+
const replaySkip = replayCandidate ? await claimLineReplayEvent(replayCandidate) : null;
|
|
568
|
+
if (replaySkip?.skip) {
|
|
569
|
+
if (replaySkip.inFlightResult) {
|
|
570
|
+
try {
|
|
571
|
+
await replaySkip.inFlightResult;
|
|
572
|
+
} catch (err) {
|
|
573
|
+
context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
|
|
574
|
+
firstError ??= err;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
switch (event.type) {
|
|
581
|
+
case "message":
|
|
582
|
+
await handleMessageEvent(event, context);
|
|
583
|
+
break;
|
|
584
|
+
case "follow":
|
|
585
|
+
await handleFollowEvent(event, context);
|
|
586
|
+
break;
|
|
587
|
+
case "unfollow":
|
|
588
|
+
await handleUnfollowEvent(event, context);
|
|
589
|
+
break;
|
|
590
|
+
case "join":
|
|
591
|
+
await handleJoinEvent(event, context);
|
|
592
|
+
break;
|
|
593
|
+
case "leave":
|
|
594
|
+
await handleLeaveEvent(event, context);
|
|
595
|
+
break;
|
|
596
|
+
case "postback":
|
|
597
|
+
await handlePostbackEvent(event, context);
|
|
598
|
+
break;
|
|
599
|
+
default:
|
|
600
|
+
logVerbose(`line: unhandled event type: ${(event as WebhookEvent).type}`);
|
|
601
|
+
}
|
|
602
|
+
if (replayCandidate) {
|
|
603
|
+
await replayCandidate.cache.commit(replayCandidate.key);
|
|
604
|
+
}
|
|
605
|
+
} catch (err) {
|
|
606
|
+
if (replayCandidate) {
|
|
607
|
+
if (err instanceof LineRetryableWebhookError) {
|
|
608
|
+
replayCandidate.cache.release(replayCandidate.key, { error: err });
|
|
609
|
+
} else {
|
|
610
|
+
await replayCandidate.cache.commit(replayCandidate.key);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
|
|
614
|
+
firstError ??= err;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (firstError) {
|
|
618
|
+
throw firstError;
|
|
619
|
+
}
|
|
620
|
+
}
|