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