@zhin.js/adapter-line 0.1.0 → 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.
Files changed (56) hide show
  1. package/CHANGELOG.md +596 -0
  2. package/README.md +54 -22
  3. package/adapters/line.js +31 -0
  4. package/adapters/line.ts +36 -0
  5. package/agent/tools/get_group_members.ts +16 -0
  6. package/agent/tools/get_profile.ts +16 -0
  7. package/commands/endpoint/add/[id].js +3 -0
  8. package/commands/endpoint/add/[id].ts +3 -0
  9. package/commands/endpoint/list.js +3 -0
  10. package/commands/endpoint/list.ts +3 -0
  11. package/commands/endpoint/remove/[id].js +3 -0
  12. package/commands/endpoint/remove/[id].ts +3 -0
  13. package/lib/client.d.ts +33 -0
  14. package/lib/client.js +54 -0
  15. package/lib/endpoint.d.ts +41 -37
  16. package/lib/endpoint.js +149 -500
  17. package/lib/index.d.ts +4 -15
  18. package/lib/index.js +4 -83
  19. package/lib/line-endpoint-commands.d.ts +1 -0
  20. package/lib/line-endpoint-commands.js +17 -0
  21. package/lib/line-runtime-state.d.ts +1 -0
  22. package/lib/line-runtime-state.js +6 -0
  23. package/lib/protocol.d.ts +158 -0
  24. package/lib/protocol.js +265 -0
  25. package/lib/side-event-dispatch.d.ts +4 -0
  26. package/lib/side-event-dispatch.js +42 -0
  27. package/lib/webhook.d.ts +13 -0
  28. package/lib/webhook.js +50 -0
  29. package/package.json +61 -21
  30. package/plugin.js +14 -0
  31. package/schema.json +86 -0
  32. package/src/client.ts +87 -0
  33. package/src/endpoint.ts +197 -551
  34. package/src/index.ts +51 -100
  35. package/src/line-endpoint-commands.ts +18 -0
  36. package/src/line-runtime-state.ts +7 -0
  37. package/src/protocol.ts +442 -0
  38. package/src/side-event-dispatch.ts +54 -0
  39. package/src/webhook.ts +79 -0
  40. package/lib/adapter.d.ts +0 -15
  41. package/lib/adapter.d.ts.map +0 -1
  42. package/lib/adapter.js +0 -20
  43. package/lib/adapter.js.map +0 -1
  44. package/lib/endpoint.d.ts.map +0 -1
  45. package/lib/endpoint.js.map +0 -1
  46. package/lib/index.d.ts.map +0 -1
  47. package/lib/index.js.map +0 -1
  48. package/lib/types.d.ts +0 -112
  49. package/lib/types.d.ts.map +0 -1
  50. package/lib/types.js +0 -5
  51. package/lib/types.js.map +0 -1
  52. package/plugin.yml +0 -3
  53. package/src/adapter.ts +0 -29
  54. package/src/types.ts +0 -130
  55. /package/{skills/line → agent}/PERMITS.md +0 -0
  56. /package/{skills/line/SKILL.md → agent/skills/line.md} +0 -0
package/lib/endpoint.js CHANGED
@@ -1,437 +1,169 @@
1
- /**
2
- * LINE Endpoint 实现
3
- *
4
- * 使用 Webhook 模式接收消息,通过 LINE Messaging API 发送消息。
5
- * HMAC-SHA256 签名验证确保请求来自 LINE 平台。
6
- */
7
- import { createHmac, timingSafeEqual } from "node:crypto";
8
- import { Message, segment, formatCompact, } from "zhin.js";
9
- import { registerFetchRoute } from "@zhin.js/host-router/router";
10
- /** Type guard: narrows a LineEvent to a message event */
11
- function isMessageEvent(e) {
12
- return e.type === "message" && "message" in e && e.message != null;
13
- }
14
- /** Type guard: narrows a LineEvent to a postback event */
15
- function isPostbackEvent(e) {
16
- return e.type === "postback" && "postback" in e;
17
- }
18
- export class LineEndpoint {
19
- adapter;
20
- router;
21
- $config;
22
- $connected = false;
23
- get pluginLogger() {
24
- return this.adapter.plugin.logger;
25
- }
26
- get $id() {
27
- return this.$config.name;
28
- }
29
- constructor(adapter, router, $config) {
30
- this.adapter = adapter;
31
- this.router = router;
32
- this.$config = $config;
33
- }
34
- async $connect() {
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
- const path = this.$config.webhookPath || "/line/webhook";
37
- const cleanPath = path.startsWith("/") ? path : `/${path}`;
38
- registerFetchRoute(this.router, "POST", cleanPath, async (ctx) => {
39
- await this.handleWebhook(ctx);
40
- });
41
- this.$connected = true;
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.pluginLogger.error("Failed to connect LINE endpoint:", error);
46
- this.$connected = false;
48
+ await this.stop();
49
+ this.#logger.error('Failed to connect LINE endpoint:', error);
47
50
  throw error;
48
51
  }
49
52
  }
50
- async $disconnect() {
51
- try {
52
- this.$connected = false;
53
- this.replyTokenCache.clear();
54
- this.pluginLogger.info(`LINE endpoint ${this.$config.name} disconnected`);
55
- }
56
- catch (error) {
57
- this.pluginLogger.error("Error disconnecting LINE endpoint:", error);
58
- // L02: Log and swallow instead of re-throwing
59
- }
60
- }
61
- // ── Webhook 处理 ────────────────────────────────────────────────────
62
- async handleWebhook(ctx) {
63
- try {
64
- // 1. 签名验证
65
- const signature = ctx.get("x-line-signature");
66
- if (!signature) {
67
- ctx.status = 403;
68
- ctx.body = { message: "Missing signature" };
69
- return;
70
- }
71
- // L05: 获取原始请求体用于签名验证
72
- // Koa ctx.req 是 Node.js IncomingMessage,koa-body 可能已经消费了流
73
- // 因此优先使用已解析的 body 并序列化,记录警告说明可能不精确
74
- let rawBody;
75
- if (typeof ctx.request.body === "string") {
76
- rawBody = ctx.request.body;
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
- break;
130
- case "follow":
131
- await this.handleFollowEvent(event);
132
- break;
133
- case "unfollow":
134
- this.pluginLogger.debug(formatCompact({
135
- op: "unfollow",
136
- endpoint: this.$config.name,
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
- else {
363
- // 系统事件(follow/unfollow/join/leave)
364
- const text = event.type === "follow" ? "[follow event]"
365
- : event.type === "join" ? "[join event]"
366
- : event.type === "unfollow" ? "[unfollow event]"
367
- : event.type === "leave" ? "[leave event]"
368
- : `[${event.type} event]`;
369
- segments.push({ type: "text", data: { text } });
370
- }
371
- return segments.length > 0 ? segments : [{ type: "text", data: { text: "" } }];
372
- }
373
- // ── 发送消息 ──────────────────────────────────────────────────────
374
- async $sendMessage(options) {
375
- try {
376
- const messages = this.buildLineMessages(options.content);
377
- if (messages.length === 0) {
378
- throw new Error("No valid LINE messages to send");
379
- }
380
- // 优先使用 Reply API(如果存在 replyToken)
381
- const replyToken = this.replyTokenCache.get(options.id);
382
- if (replyToken) {
383
- this.replyTokenCache.delete(options.id);
384
- return await this.replyMessage(replyToken, messages);
385
- }
386
- // L06: Validate Push API `to` field
387
- if (!/^[UGR]/.test(options.id)) {
388
- throw new Error(`Invalid LINE recipient ID "${options.id}": must start with U (user), G (group), or R (room)`);
389
- }
390
- // 使用 Push API
391
- return await this.pushMessage(options.id, messages);
392
- }
393
- catch (error) {
394
- this.pluginLogger.error("Failed to send LINE message:", error);
395
- throw error;
396
- }
397
- }
398
- replyTokenCache = new Map();
399
- /**
400
- * 缓存 replyToken,用于后续发送回复消息
401
- */
402
- cacheReplyToken(channelId, replyToken) {
403
- this.replyTokenCache.set(channelId, replyToken);
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
- // L15: Parse Reply API response for message ID
406
- async replyMessage(replyToken, messages) {
407
- const baseUrl = this.$config.apiBaseUrl || "https://api.line.me";
408
- const request = { replyToken, messages };
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
- "Content-Type": "application/json",
413
- "Authorization": `Bearer ${this.$config.channelAccessToken}`,
142
+ 'Content-Type': 'application/json',
143
+ Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
414
144
  },
415
- body: JSON.stringify(request),
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
- throw new Error(`LINE Reply API error ${response.status}: ${errorText}`);
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 baseUrl = this.$config.apiBaseUrl || "https://api.line.me";
427
- const request = { to, messages };
428
- const response = await fetch(`${baseUrl}/v2/bot/message/push`, {
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
- "Content-Type": "application/json",
432
- "Authorization": `Bearer ${this.$config.channelAccessToken}`,
162
+ 'Content-Type': 'application/json',
163
+ Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
433
164
  },
434
- body: JSON.stringify(request),
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
- //# sourceMappingURL=endpoint.js.map
176
+ function createLineEndpointManagement(endpoint) {
177
+ return Object.freeze({
178
+ // listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
179
+ listGroupMembers: (groupId) => endpoint.client.getGroupMembers(groupId),
180
+ });
181
+ }