@zhin.js/adapter-line 0.1.1 → 1.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/CHANGELOG.md +596 -0
- package/README.md +54 -22
- package/adapters/line.js +31 -0
- package/adapters/line.ts +36 -0
- package/agent/tools/get_group_members.ts +16 -0
- package/agent/tools/get_profile.ts +16 -0
- package/commands/endpoint/add/[id].js +3 -0
- package/commands/endpoint/add/[id].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].ts +3 -0
- package/lib/client.d.ts +33 -0
- package/lib/client.js +54 -0
- package/lib/endpoint.d.ts +41 -37
- package/lib/endpoint.js +149 -500
- package/lib/index.d.ts +4 -15
- package/lib/index.js +4 -83
- package/lib/line-endpoint-commands.d.ts +1 -0
- package/lib/line-endpoint-commands.js +17 -0
- package/lib/line-runtime-state.d.ts +1 -0
- package/lib/line-runtime-state.js +6 -0
- package/lib/protocol.d.ts +158 -0
- package/lib/protocol.js +265 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +42 -0
- package/lib/webhook.d.ts +13 -0
- package/lib/webhook.js +50 -0
- package/package.json +61 -21
- package/plugin.js +14 -0
- package/schema.json +86 -0
- package/src/client.ts +87 -0
- package/src/endpoint.ts +197 -551
- package/src/index.ts +51 -100
- package/src/line-endpoint-commands.ts +18 -0
- package/src/line-runtime-state.ts +7 -0
- package/src/protocol.ts +442 -0
- package/src/side-event-dispatch.ts +54 -0
- package/src/webhook.ts +79 -0
- package/lib/adapter.d.ts +0 -15
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -20
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -112
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -5
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -29
- package/src/types.ts +0 -130
- /package/{skills/line → agent}/PERMITS.md +0 -0
- /package/{skills/line/SKILL.md → agent/skills/line.md} +0 -0
package/lib/endpoint.js
CHANGED
|
@@ -1,437 +1,169 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
this
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
3
|
+
import { formatInboundContent, formatOutboundMessages, generateMessageId, isLineLifecycleEvent, isMessageEvent, isValidLineRecipientId, lineInboundConversation, } from './protocol.js';
|
|
4
|
+
import { registerLineWebhookRoutes } from './webhook.js';
|
|
5
|
+
import { receiveLineSideEvent } from './side-event-dispatch.js';
|
|
6
|
+
import { LineClient } from './client.js';
|
|
7
|
+
/** LINE replyToken 有效期短,过期后 reply 必 400;缓存带时间戳,超时弃用改走 push。 */
|
|
8
|
+
const REPLY_TOKEN_TTL_MS = 60_000;
|
|
9
|
+
/** 出站 HTTP 调用统一 30s 超时。 */
|
|
10
|
+
const OUTBOUND_TIMEOUT_MS = 30_000;
|
|
11
|
+
export class LineEndpoint extends Endpoint {
|
|
12
|
+
client;
|
|
13
|
+
#logger;
|
|
14
|
+
#options;
|
|
15
|
+
#fetch;
|
|
16
|
+
#routeReleases = [];
|
|
17
|
+
#replyTokenCache = new Map();
|
|
18
|
+
#open = false;
|
|
19
|
+
#started = false;
|
|
20
|
+
management = createLineEndpointManagement(this);
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super();
|
|
23
|
+
this.#logger = getAdapterLogger('line', options.config.id);
|
|
24
|
+
this.#options = options;
|
|
25
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
26
|
+
this.client = new LineClient(options.config, this.#fetch);
|
|
27
|
+
}
|
|
28
|
+
/** Used by webhook handler. */
|
|
29
|
+
get isOpen() {
|
|
30
|
+
return this.#open;
|
|
31
|
+
}
|
|
32
|
+
get config() {
|
|
33
|
+
return this.#options.config;
|
|
34
|
+
}
|
|
35
|
+
async start() {
|
|
36
|
+
if (this.#started)
|
|
37
|
+
return;
|
|
38
|
+
this.#started = true;
|
|
35
39
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
this.pluginLogger.info(formatCompact({ op: "webhook", path: cleanPath }));
|
|
40
|
+
this.#routeReleases.push(...registerLineWebhookRoutes(this.#options.http, this));
|
|
41
|
+
this.#logger.debug(formatCompact({
|
|
42
|
+
endpoint: this.#options.config.id,
|
|
43
|
+
op: 'webhook',
|
|
44
|
+
path: this.#options.config.webhookPath,
|
|
45
|
+
}));
|
|
43
46
|
}
|
|
44
47
|
catch (error) {
|
|
45
|
-
this.
|
|
46
|
-
this
|
|
48
|
+
await this.stop();
|
|
49
|
+
this.#logger.error('Failed to connect LINE endpoint:', error);
|
|
47
50
|
throw error;
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
rawBody = JSON.stringify(ctx.request.body);
|
|
80
|
-
this.pluginLogger.debug("Signature verification using JSON.stringify(body) — may differ from original raw body");
|
|
81
|
-
}
|
|
82
|
-
if (!this.verifySignature(rawBody, signature)) {
|
|
83
|
-
this.pluginLogger.warn(formatCompact({ op: "webhook", ok: false, error: "invalid signature" }));
|
|
84
|
-
ctx.status = 403;
|
|
85
|
-
ctx.body = { message: "Invalid signature" };
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
// 2. 解析事件
|
|
89
|
-
const body = typeof ctx.request.body === "string"
|
|
90
|
-
? JSON.parse(ctx.request.body)
|
|
91
|
-
: ctx.request.body;
|
|
92
|
-
if (!body.events || !Array.isArray(body.events)) {
|
|
93
|
-
ctx.status = 200;
|
|
94
|
-
ctx.body = { message: "OK" };
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
// 3. 处理每个事件
|
|
98
|
-
for (const event of body.events) {
|
|
99
|
-
await this.handleEvent(event);
|
|
100
|
-
}
|
|
101
|
-
ctx.status = 200;
|
|
102
|
-
ctx.body = { message: "OK" };
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
this.pluginLogger.error("LINE webhook error:", error);
|
|
106
|
-
ctx.status = 200;
|
|
107
|
-
ctx.body = { message: "OK" };
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
// L07: Use timing-safe comparison for signature
|
|
111
|
-
verifySignature(body, signature) {
|
|
112
|
-
const channelSecret = this.$config.channelSecret;
|
|
113
|
-
const hmac = createHmac("sha256", channelSecret);
|
|
114
|
-
hmac.update(body, "utf-8");
|
|
115
|
-
const computedSignature = hmac.digest("base64");
|
|
116
|
-
const sigBuf = Buffer.from(signature);
|
|
117
|
-
const computedBuf = Buffer.from(computedSignature);
|
|
118
|
-
if (sigBuf.length !== computedBuf.length)
|
|
119
|
-
return false;
|
|
120
|
-
return timingSafeEqual(sigBuf, computedBuf);
|
|
121
|
-
}
|
|
122
|
-
// L04: Use type guards instead of "in" checks + `as any` casts
|
|
123
|
-
async handleEvent(event) {
|
|
124
|
-
switch (event.type) {
|
|
125
|
-
case "message":
|
|
126
|
-
if (isMessageEvent(event)) {
|
|
127
|
-
await this.handleMessageEvent(event);
|
|
53
|
+
open() {
|
|
54
|
+
this.#open = true;
|
|
55
|
+
}
|
|
56
|
+
close() {
|
|
57
|
+
this.#open = false;
|
|
58
|
+
}
|
|
59
|
+
async stop() {
|
|
60
|
+
this.#open = false;
|
|
61
|
+
this.#replyTokenCache.clear();
|
|
62
|
+
for (const release of this.#routeReleases.splice(0))
|
|
63
|
+
release();
|
|
64
|
+
this.#started = false;
|
|
65
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
66
|
+
}
|
|
67
|
+
async send({ conversation, payload }) {
|
|
68
|
+
const messages = formatOutboundMessages(payload);
|
|
69
|
+
if (messages.length === 0) {
|
|
70
|
+
throw new Error('No valid LINE messages to send');
|
|
71
|
+
}
|
|
72
|
+
// LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
|
|
73
|
+
const target = conversation.id;
|
|
74
|
+
const cached = this.#replyTokenCache.get(target);
|
|
75
|
+
if (cached) {
|
|
76
|
+
this.#replyTokenCache.delete(target);
|
|
77
|
+
if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
|
|
78
|
+
try {
|
|
79
|
+
return await this.#replyMessage(cached.token, messages);
|
|
128
80
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
userId: event.source.userId,
|
|
138
|
-
}));
|
|
139
|
-
break;
|
|
140
|
-
case "join":
|
|
141
|
-
await this.handleJoinEvent(event);
|
|
142
|
-
break;
|
|
143
|
-
case "leave":
|
|
144
|
-
this.pluginLogger.debug(formatCompact({
|
|
145
|
-
op: "leave",
|
|
146
|
-
endpoint: this.$config.name,
|
|
147
|
-
sourceType: event.source.type,
|
|
148
|
-
groupId: event.source.groupId,
|
|
149
|
-
roomId: event.source.roomId,
|
|
150
|
-
}));
|
|
151
|
-
break;
|
|
152
|
-
case "postback":
|
|
153
|
-
if (isPostbackEvent(event)) {
|
|
154
|
-
this.pluginLogger.debug(formatCompact({
|
|
155
|
-
op: "postback",
|
|
156
|
-
endpoint: this.$config.name,
|
|
157
|
-
data: event.postback.data,
|
|
81
|
+
catch (error) {
|
|
82
|
+
// replyToken 过期/失效时 LINE 返回 400,回退 push 保证消息不丢
|
|
83
|
+
if (error.status !== 400)
|
|
84
|
+
throw error;
|
|
85
|
+
this.#logger.warn(formatCompact({
|
|
86
|
+
op: 'line_reply_fallback_push',
|
|
87
|
+
endpoint: this.#options.config.id,
|
|
88
|
+
target,
|
|
158
89
|
}));
|
|
159
90
|
}
|
|
160
|
-
break;
|
|
161
|
-
default:
|
|
162
|
-
this.pluginLogger.debug(formatCompact({
|
|
163
|
-
op: "unknown_event",
|
|
164
|
-
endpoint: this.$config.name,
|
|
165
|
-
type: event.type,
|
|
166
|
-
}));
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// L01: Cache replyToken from webhook events before emitting
|
|
170
|
-
async handleMessageEvent(event) {
|
|
171
|
-
const { channelId } = this.resolveChannel(event.source);
|
|
172
|
-
this.cacheReplyToken(channelId, event.replyToken);
|
|
173
|
-
const message = this.$formatMessage(event);
|
|
174
|
-
this.adapter.emit("message.receive", message);
|
|
175
|
-
this.pluginLogger.debug(formatCompact({
|
|
176
|
-
op: "recv",
|
|
177
|
-
endpoint: this.$config.name,
|
|
178
|
-
channel: message.$channel.type,
|
|
179
|
-
id: message.$channel.id,
|
|
180
|
-
len: segment.raw(message.$content).length,
|
|
181
|
-
}));
|
|
182
|
-
}
|
|
183
|
-
async handleFollowEvent(event) {
|
|
184
|
-
const { channelId } = this.resolveChannel(event.source);
|
|
185
|
-
this.cacheReplyToken(channelId, event.replyToken);
|
|
186
|
-
const message = this.$formatMessage(event);
|
|
187
|
-
this.adapter.emit("message.receive", message);
|
|
188
|
-
this.pluginLogger.debug(formatCompact({
|
|
189
|
-
op: "follow",
|
|
190
|
-
endpoint: this.$config.name,
|
|
191
|
-
userId: event.source.userId,
|
|
192
|
-
}));
|
|
193
|
-
}
|
|
194
|
-
async handleJoinEvent(event) {
|
|
195
|
-
const { channelId } = this.resolveChannel(event.source);
|
|
196
|
-
this.cacheReplyToken(channelId, event.replyToken);
|
|
197
|
-
const message = this.$formatMessage(event);
|
|
198
|
-
this.adapter.emit("message.receive", message);
|
|
199
|
-
this.pluginLogger.debug(formatCompact({
|
|
200
|
-
op: "join",
|
|
201
|
-
endpoint: this.$config.name,
|
|
202
|
-
sourceType: event.source.type,
|
|
203
|
-
groupId: event.source.groupId,
|
|
204
|
-
roomId: event.source.roomId,
|
|
205
|
-
}));
|
|
206
|
-
}
|
|
207
|
-
// ── 消息格式化 ────────────────────────────────────────────────────
|
|
208
|
-
$formatMessage(event) {
|
|
209
|
-
const { channelType, channelId } = this.resolveChannel(event.source);
|
|
210
|
-
const content = this.parseMessageContent(event);
|
|
211
|
-
const quoteId = Message.quoteIdFromContent(content);
|
|
212
|
-
Message.alignReplySegments(content, quoteId);
|
|
213
|
-
const userId = event.source.userId || "";
|
|
214
|
-
const timestamp = event.timestamp || Date.now();
|
|
215
|
-
const rawText = this.extractRawText(event);
|
|
216
|
-
return Message.from(event, {
|
|
217
|
-
$id: this.generateMessageId(event),
|
|
218
|
-
$adapter: "line",
|
|
219
|
-
$endpoint: this.$config.name,
|
|
220
|
-
$sender: {
|
|
221
|
-
id: userId,
|
|
222
|
-
name: userId,
|
|
223
|
-
},
|
|
224
|
-
$channel: {
|
|
225
|
-
id: channelId,
|
|
226
|
-
type: channelType,
|
|
227
|
-
},
|
|
228
|
-
$content: content,
|
|
229
|
-
$quote_id: quoteId,
|
|
230
|
-
$raw: rawText,
|
|
231
|
-
$timestamp: timestamp,
|
|
232
|
-
$recall: async () => {
|
|
233
|
-
// LINE 不支持消息撤回
|
|
234
|
-
this.pluginLogger.warn("LINE does not support message recall");
|
|
235
|
-
},
|
|
236
|
-
$reply: async (content, quote) => {
|
|
237
|
-
if (!Array.isArray(content))
|
|
238
|
-
content = [content];
|
|
239
|
-
if (quote) {
|
|
240
|
-
const replyToMessageId = typeof quote === "boolean"
|
|
241
|
-
? (isMessageEvent(event) && event.message?.id) || ""
|
|
242
|
-
: quote;
|
|
243
|
-
content.unshift({ type: "reply", data: { id: replyToMessageId } });
|
|
244
|
-
}
|
|
245
|
-
return await this.adapter.sendMessage({
|
|
246
|
-
context: "line",
|
|
247
|
-
endpoint: this.$config.name,
|
|
248
|
-
id: channelId,
|
|
249
|
-
type: channelType,
|
|
250
|
-
content: content,
|
|
251
|
-
});
|
|
252
|
-
},
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
generateMessageId(event) {
|
|
256
|
-
if (isMessageEvent(event) && event.message?.id) {
|
|
257
|
-
return event.message.id;
|
|
258
|
-
}
|
|
259
|
-
return `${event.type}-${event.timestamp}`;
|
|
260
|
-
}
|
|
261
|
-
resolveChannel(source) {
|
|
262
|
-
switch (source.type) {
|
|
263
|
-
case "user":
|
|
264
|
-
return { channelType: "private", channelId: source.userId || "" };
|
|
265
|
-
case "group":
|
|
266
|
-
return { channelType: "group", channelId: source.groupId || "" };
|
|
267
|
-
case "room":
|
|
268
|
-
return { channelType: "channel", channelId: source.roomId || "" };
|
|
269
|
-
default:
|
|
270
|
-
return { channelType: "private", channelId: "" };
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
extractRawText(event) {
|
|
274
|
-
if (isMessageEvent(event)) {
|
|
275
|
-
const msg = event.message;
|
|
276
|
-
if (msg.type === "text" && msg.text)
|
|
277
|
-
return msg.text;
|
|
278
|
-
if (msg.type === "location" && msg.address)
|
|
279
|
-
return msg.address;
|
|
280
|
-
return `[${msg.type}]`;
|
|
281
|
-
}
|
|
282
|
-
if (event.type === "follow")
|
|
283
|
-
return "[follow]";
|
|
284
|
-
if (event.type === "join")
|
|
285
|
-
return "[join]";
|
|
286
|
-
return "";
|
|
287
|
-
}
|
|
288
|
-
parseMessageContent(event) {
|
|
289
|
-
const segments = [];
|
|
290
|
-
if (isMessageEvent(event)) {
|
|
291
|
-
const msg = event.message;
|
|
292
|
-
switch (msg.type) {
|
|
293
|
-
case "text":
|
|
294
|
-
if (msg.text) {
|
|
295
|
-
segments.push({ type: "text", data: { text: msg.text } });
|
|
296
|
-
}
|
|
297
|
-
break;
|
|
298
|
-
case "image":
|
|
299
|
-
segments.push({
|
|
300
|
-
type: "image",
|
|
301
|
-
data: {
|
|
302
|
-
message_id: msg.id,
|
|
303
|
-
platform: "line",
|
|
304
|
-
},
|
|
305
|
-
});
|
|
306
|
-
break;
|
|
307
|
-
case "video":
|
|
308
|
-
segments.push({
|
|
309
|
-
type: "video",
|
|
310
|
-
data: {
|
|
311
|
-
message_id: msg.id,
|
|
312
|
-
platform: "line",
|
|
313
|
-
},
|
|
314
|
-
});
|
|
315
|
-
break;
|
|
316
|
-
case "audio":
|
|
317
|
-
segments.push({
|
|
318
|
-
type: "audio",
|
|
319
|
-
data: {
|
|
320
|
-
message_id: msg.id,
|
|
321
|
-
duration: msg.duration || 0,
|
|
322
|
-
platform: "line",
|
|
323
|
-
},
|
|
324
|
-
});
|
|
325
|
-
break;
|
|
326
|
-
case "file":
|
|
327
|
-
segments.push({
|
|
328
|
-
type: "file",
|
|
329
|
-
data: {
|
|
330
|
-
message_id: msg.id,
|
|
331
|
-
file_name: msg.fileName,
|
|
332
|
-
file_size: msg.fileSize,
|
|
333
|
-
platform: "line",
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
break;
|
|
337
|
-
case "location":
|
|
338
|
-
segments.push({
|
|
339
|
-
type: "location",
|
|
340
|
-
data: {
|
|
341
|
-
title: msg.title,
|
|
342
|
-
address: msg.address,
|
|
343
|
-
latitude: msg.latitude,
|
|
344
|
-
longitude: msg.longitude,
|
|
345
|
-
},
|
|
346
|
-
});
|
|
347
|
-
break;
|
|
348
|
-
case "sticker":
|
|
349
|
-
segments.push({
|
|
350
|
-
type: "sticker",
|
|
351
|
-
data: {
|
|
352
|
-
package_id: msg.packageId,
|
|
353
|
-
sticker_id: msg.stickerId,
|
|
354
|
-
resource_type: msg.stickerResourceType,
|
|
355
|
-
},
|
|
356
|
-
});
|
|
357
|
-
break;
|
|
358
|
-
default:
|
|
359
|
-
segments.push({ type: "text", data: { text: `[unsupported message type]` } });
|
|
360
91
|
}
|
|
361
92
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
93
|
+
if (!isValidLineRecipientId(target)) {
|
|
94
|
+
throw new Error(`Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`);
|
|
95
|
+
}
|
|
96
|
+
return this.#pushMessage(target, messages);
|
|
97
|
+
}
|
|
98
|
+
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
99
|
+
admit(event) {
|
|
100
|
+
if (!this.#open)
|
|
101
|
+
return;
|
|
102
|
+
void this.emitPlatform(event.type || 'event', event).catch((error) => {
|
|
103
|
+
this.#logger.warn(formatCompact({
|
|
104
|
+
op: 'line_platform_event_failed',
|
|
105
|
+
event: event.type,
|
|
106
|
+
error: error instanceof Error ? error.message : String(error),
|
|
107
|
+
}));
|
|
108
|
+
});
|
|
109
|
+
if (isLineLifecycleEvent(event)) {
|
|
110
|
+
receiveLineSideEvent((name, payload) => this.emit(name, payload), String(this.#options.id), this.#options.config.id, event, this.#logger);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const conversation = lineInboundConversation(String(this.#options.id), event.source);
|
|
114
|
+
if ('replyToken' in event && typeof event.replyToken === 'string') {
|
|
115
|
+
this.#replyTokenCache.set(conversation.id, { token: event.replyToken, timestamp: Date.now() });
|
|
116
|
+
}
|
|
117
|
+
void this.emit('message.receive', {
|
|
118
|
+
conversation,
|
|
119
|
+
message: { conversation, id: generateMessageId(event) },
|
|
120
|
+
content: formatInboundContent(event),
|
|
121
|
+
sender: { id: event.source.userId || conversation.id },
|
|
122
|
+
endpointId: this.#options.config.id,
|
|
123
|
+
metadata: Object.freeze({
|
|
124
|
+
eventType: event.type,
|
|
125
|
+
sourceType: event.source.type,
|
|
126
|
+
timestamp: event.timestamp,
|
|
127
|
+
...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
|
|
128
|
+
}),
|
|
129
|
+
}).catch((err) => {
|
|
130
|
+
this.#logger.warn(formatCompact({
|
|
131
|
+
op: 'line_gateway_receive_failed',
|
|
132
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
133
|
+
error: err instanceof Error ? err.message : String(err),
|
|
134
|
+
}));
|
|
135
|
+
});
|
|
404
136
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
const response = await fetch(`${baseUrl}/v2/bot/message/reply`, {
|
|
410
|
-
method: "POST",
|
|
137
|
+
async #replyMessage(replyToken, messages) {
|
|
138
|
+
const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/reply`;
|
|
139
|
+
const response = await this.#fetch(url, {
|
|
140
|
+
method: 'POST',
|
|
411
141
|
headers: {
|
|
412
|
-
|
|
413
|
-
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
414
144
|
},
|
|
415
|
-
body: JSON.stringify(
|
|
145
|
+
body: JSON.stringify({ replyToken, messages }),
|
|
146
|
+
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
416
147
|
});
|
|
417
148
|
if (!response.ok) {
|
|
418
149
|
const errorText = await response.text();
|
|
419
|
-
|
|
150
|
+
const error = new Error(`LINE Reply API error ${response.status}: ${errorText}`);
|
|
151
|
+
error.status = response.status;
|
|
152
|
+
throw error;
|
|
420
153
|
}
|
|
421
|
-
// Reply API now returns sentMessages in the response body
|
|
422
154
|
const result = await response.json();
|
|
423
155
|
return result.sentMessages?.[0]?.id || `reply-${Date.now()}`;
|
|
424
156
|
}
|
|
425
|
-
async pushMessage(to, messages) {
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
method: "POST",
|
|
157
|
+
async #pushMessage(to, messages) {
|
|
158
|
+
const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/push`;
|
|
159
|
+
const response = await this.#fetch(url, {
|
|
160
|
+
method: 'POST',
|
|
430
161
|
headers: {
|
|
431
|
-
|
|
432
|
-
|
|
162
|
+
'Content-Type': 'application/json',
|
|
163
|
+
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
433
164
|
},
|
|
434
|
-
body: JSON.stringify(
|
|
165
|
+
body: JSON.stringify({ to, messages }),
|
|
166
|
+
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
435
167
|
});
|
|
436
168
|
if (!response.ok) {
|
|
437
169
|
const errorText = await response.text();
|
|
@@ -440,93 +172,10 @@ export class LineEndpoint {
|
|
|
440
172
|
const result = await response.json();
|
|
441
173
|
return result.sentMessages?.[0]?.id || `push-${Date.now()}`;
|
|
442
174
|
}
|
|
443
|
-
buildLineMessages(content) {
|
|
444
|
-
if (!Array.isArray(content))
|
|
445
|
-
content = [content];
|
|
446
|
-
const messages = [];
|
|
447
|
-
for (const item of content) {
|
|
448
|
-
if (typeof item === "string") {
|
|
449
|
-
messages.push(this.buildTextMessage(item));
|
|
450
|
-
continue;
|
|
451
|
-
}
|
|
452
|
-
const seg = item;
|
|
453
|
-
switch (seg.type) {
|
|
454
|
-
case "text":
|
|
455
|
-
messages.push(this.buildTextMessage(seg.data.text || ""));
|
|
456
|
-
break;
|
|
457
|
-
case "at":
|
|
458
|
-
// LINE 没有 @ 语法,转为文本
|
|
459
|
-
if (seg.data.id) {
|
|
460
|
-
messages.push(this.buildTextMessage(`@${seg.data.name || seg.data.id}`));
|
|
461
|
-
}
|
|
462
|
-
break;
|
|
463
|
-
case "image":
|
|
464
|
-
if (seg.data.url) {
|
|
465
|
-
messages.push({
|
|
466
|
-
type: "image",
|
|
467
|
-
originalContentUrl: seg.data.url,
|
|
468
|
-
previewImageUrl: seg.data.url,
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
break;
|
|
472
|
-
case "video":
|
|
473
|
-
if (seg.data.url) {
|
|
474
|
-
messages.push({
|
|
475
|
-
type: "video",
|
|
476
|
-
originalContentUrl: seg.data.url,
|
|
477
|
-
previewImageUrl: seg.data.previewUrl || seg.data.url,
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
break;
|
|
481
|
-
case "audio":
|
|
482
|
-
if (seg.data.url) {
|
|
483
|
-
messages.push({
|
|
484
|
-
type: "audio",
|
|
485
|
-
originalContentUrl: seg.data.url,
|
|
486
|
-
duration: seg.data.duration || 0,
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
break;
|
|
490
|
-
case "location":
|
|
491
|
-
messages.push({
|
|
492
|
-
type: "location",
|
|
493
|
-
title: seg.data.title || "Location",
|
|
494
|
-
address: seg.data.address || "",
|
|
495
|
-
latitude: seg.data.latitude || 0,
|
|
496
|
-
longitude: seg.data.longitude || 0,
|
|
497
|
-
});
|
|
498
|
-
break;
|
|
499
|
-
case "sticker":
|
|
500
|
-
messages.push({
|
|
501
|
-
type: "sticker",
|
|
502
|
-
packageId: seg.data.package_id || "1",
|
|
503
|
-
stickerId: seg.data.sticker_id || "1",
|
|
504
|
-
});
|
|
505
|
-
break;
|
|
506
|
-
default:
|
|
507
|
-
messages.push(this.buildTextMessage(`[${seg.type}]`));
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
// L14: Log warning when messages are sliced to 5 (LINE limit)
|
|
511
|
-
if (messages.length > 5) {
|
|
512
|
-
this.pluginLogger.warn(`LINE messages truncated from ${messages.length} to 5 (platform limit)`);
|
|
513
|
-
}
|
|
514
|
-
// LINE 单次最多发送 5 条消息
|
|
515
|
-
return messages.slice(0, 5);
|
|
516
|
-
}
|
|
517
|
-
buildTextMessage(text) {
|
|
518
|
-
// L13: Log warning on text truncation
|
|
519
|
-
if (text.length > 5000) {
|
|
520
|
-
this.pluginLogger.warn(`LINE text message truncated from ${text.length} to 5000 characters`);
|
|
521
|
-
}
|
|
522
|
-
// LINE 消息文本限制 5000 字符
|
|
523
|
-
const truncated = text.length > 5000 ? text.slice(0, 4997) + "..." : text;
|
|
524
|
-
return { type: "text", text: truncated };
|
|
525
|
-
}
|
|
526
|
-
// ── 消息撤回 ──────────────────────────────────────────────────────
|
|
527
|
-
// L16: Log warning and return gracefully (matching WeCom/DingTalk pattern)
|
|
528
|
-
async $recallMessage(_id) {
|
|
529
|
-
this.pluginLogger.warn(formatCompact({ op: "recall", ok: false, error: "LINE Messaging API does not support message recall" }));
|
|
530
|
-
}
|
|
531
175
|
}
|
|
532
|
-
|
|
176
|
+
function createLineEndpointManagement(endpoint) {
|
|
177
|
+
return Object.freeze({
|
|
178
|
+
// listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
|
|
179
|
+
listGroupMembers: (groupId) => endpoint.client.getGroupMembers(groupId),
|
|
180
|
+
});
|
|
181
|
+
}
|