@zhin.js/adapter-line 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/lib/adapter.d.ts +15 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/adapter.js +20 -0
- package/lib/adapter.js.map +1 -0
- package/lib/endpoint.d.ts +38 -0
- package/lib/endpoint.d.ts.map +1 -0
- package/lib/endpoint.js +532 -0
- package/lib/endpoint.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +83 -0
- package/lib/index.js.map +1 -0
- package/lib/types.d.ts +112 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +5 -0
- package/lib/types.js.map +1 -0
- package/package.json +74 -0
- package/plugin.yml +3 -0
- package/skills/line/PERMITS.md +17 -0
- package/skills/line/SKILL.md +39 -0
- package/src/adapter.ts +29 -0
- package/src/endpoint.ts +605 -0
- package/src/index.ts +100 -0
- package/src/types.ts +130 -0
package/src/endpoint.ts
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Endpoint 实现
|
|
3
|
+
*
|
|
4
|
+
* 使用 Webhook 模式接收消息,通过 LINE Messaging API 发送消息。
|
|
5
|
+
* HMAC-SHA256 签名验证确保请求来自 LINE 平台。
|
|
6
|
+
*/
|
|
7
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
8
|
+
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;
|
|
38
|
+
}
|
|
39
|
+
|
|
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
|
+
}
|
|
44
|
+
|
|
45
|
+
export class LineEndpoint implements Endpoint<LineEndpointConfig, LineEvent> {
|
|
46
|
+
$connected: boolean = false;
|
|
47
|
+
|
|
48
|
+
get pluginLogger() {
|
|
49
|
+
return this.adapter.plugin.logger;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get $id() {
|
|
53
|
+
return this.$config.name;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
public adapter: LineAdapter,
|
|
58
|
+
private router: Router,
|
|
59
|
+
public $config: LineEndpointConfig,
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
async $connect(): Promise<void> {
|
|
63
|
+
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 }));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.pluginLogger.error("Failed to connect LINE endpoint:", error);
|
|
73
|
+
this.$connected = false;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
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
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
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
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
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);
|
|
154
|
+
}
|
|
155
|
+
|
|
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
|
+
}));
|
|
201
|
+
}
|
|
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 } });
|
|
290
|
+
}
|
|
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: "" };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
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}]`;
|
|
328
|
+
}
|
|
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 } });
|
|
416
|
+
}
|
|
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;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
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",
|
|
467
|
+
headers: {
|
|
468
|
+
"Content-Type": "application/json",
|
|
469
|
+
"Authorization": `Bearer ${this.$config.channelAccessToken}`,
|
|
470
|
+
},
|
|
471
|
+
body: JSON.stringify(request),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
if (!response.ok) {
|
|
475
|
+
const errorText = await response.text();
|
|
476
|
+
throw new Error(`LINE Reply API error ${response.status}: ${errorText}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Reply API now returns sentMessages in the response body
|
|
480
|
+
const result: LineApiResponse = await response.json() as LineApiResponse;
|
|
481
|
+
return result.sentMessages?.[0]?.id || `reply-${Date.now()}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
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",
|
|
489
|
+
headers: {
|
|
490
|
+
"Content-Type": "application/json",
|
|
491
|
+
"Authorization": `Bearer ${this.$config.channelAccessToken}`,
|
|
492
|
+
},
|
|
493
|
+
body: JSON.stringify(request),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
if (!response.ok) {
|
|
497
|
+
const errorText = await response.text();
|
|
498
|
+
throw new Error(`LINE Push API error ${response.status}: ${errorText}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const result: LineApiResponse = await response.json() as LineApiResponse;
|
|
502
|
+
return result.sentMessages?.[0]?.id || `push-${Date.now()}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
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
|
+
// ── 消息撤回 ──────────────────────────────────────────────────────
|
|
598
|
+
|
|
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
|
+
}
|
|
605
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Messaging API 适配器入口:类型扩展、导出、注册
|
|
3
|
+
*/
|
|
4
|
+
import { usePlugin, type Plugin } from 'zhin.js';
|
|
5
|
+
import { LineAdapter } from './adapter.js';
|
|
6
|
+
|
|
7
|
+
declare module 'zhin.js' {
|
|
8
|
+
namespace Plugin {
|
|
9
|
+
interface Contexts {
|
|
10
|
+
router: import('@zhin.js/host-router').Router;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
interface Adapters {
|
|
14
|
+
line: LineAdapter;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export * from './types.js';
|
|
19
|
+
export { LineEndpoint } from './endpoint.js';
|
|
20
|
+
export { LineAdapter } from './adapter.js';
|
|
21
|
+
|
|
22
|
+
const plugin = usePlugin();
|
|
23
|
+
const { provide, useContext } = plugin;
|
|
24
|
+
|
|
25
|
+
useContext('router', (router: any) => {
|
|
26
|
+
provide({
|
|
27
|
+
name: 'line',
|
|
28
|
+
description: 'LINE Messaging API Endpoint Adapter',
|
|
29
|
+
mounted: async (p: Plugin) => {
|
|
30
|
+
const adapter = new LineAdapter(p, router);
|
|
31
|
+
await adapter.start();
|
|
32
|
+
return adapter;
|
|
33
|
+
},
|
|
34
|
+
dispose: async (adapter: LineAdapter) => {
|
|
35
|
+
await adapter.stop();
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// L11: Register LINE-specific tools
|
|
41
|
+
useContext('tool', 'line', (toolService: any, lineAdapter: LineAdapter) => {
|
|
42
|
+
const accessToken = lineAdapter.endpoints.values().next().value?.$config?.channelAccessToken;
|
|
43
|
+
const apiBaseUrl = lineAdapter.endpoints.values().next().value?.$config?.apiBaseUrl || 'https://api.line.me';
|
|
44
|
+
|
|
45
|
+
const disposers: (() => void)[] = [];
|
|
46
|
+
|
|
47
|
+
disposers.push(toolService.addTool({
|
|
48
|
+
name: 'line_get_profile',
|
|
49
|
+
description: 'Get LINE user profile by userId',
|
|
50
|
+
parameters: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
userId: { type: 'string', description: 'LINE user ID (starts with U)' },
|
|
54
|
+
},
|
|
55
|
+
required: ['userId'],
|
|
56
|
+
},
|
|
57
|
+
execute: async ({ userId }: { userId: string }) => {
|
|
58
|
+
if (!userId.startsWith('U')) {
|
|
59
|
+
throw new Error(`Invalid userId "${userId}": must start with U`);
|
|
60
|
+
}
|
|
61
|
+
const response = await fetch(`${apiBaseUrl}/v2/profile/${userId}`, {
|
|
62
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const errorText = await response.text();
|
|
66
|
+
throw new Error(`LINE Profile API error ${response.status}: ${errorText}`);
|
|
67
|
+
}
|
|
68
|
+
return await response.json();
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
disposers.push(toolService.addTool({
|
|
73
|
+
name: 'line_get_group_members',
|
|
74
|
+
description: 'Get LINE group member IDs',
|
|
75
|
+
parameters: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
groupId: { type: 'string', description: 'LINE group ID (starts with G)' },
|
|
79
|
+
},
|
|
80
|
+
required: ['groupId'],
|
|
81
|
+
},
|
|
82
|
+
execute: async ({ groupId }: { groupId: string }) => {
|
|
83
|
+
if (!groupId.startsWith('G')) {
|
|
84
|
+
throw new Error(`Invalid groupId "${groupId}": must start with G`);
|
|
85
|
+
}
|
|
86
|
+
const response = await fetch(`${apiBaseUrl}/v2/bot/group/${groupId}/members/ids`, {
|
|
87
|
+
headers: { 'Authorization': `Bearer ${accessToken}` },
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const errorText = await response.text();
|
|
91
|
+
throw new Error(`LINE Group Members API error ${response.status}: ${errorText}`);
|
|
92
|
+
}
|
|
93
|
+
return await response.json();
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
for (const disposer of disposers) disposer();
|
|
99
|
+
};
|
|
100
|
+
});
|