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