@zhin.js/adapter-line 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +50 -19
- package/adapters/line.ts +26 -0
- package/agent/tools/get_group_members.ts +24 -0
- package/agent/tools/get_profile.ts +24 -0
- package/lib/endpoint.d.ts +45 -37
- package/lib/endpoint.js +114 -506
- package/lib/index.d.ts +4 -15
- package/lib/index.js +4 -83
- package/lib/line-agent-deps.d.ts +24 -0
- package/lib/line-agent-deps.js +33 -0
- package/lib/protocol.d.ts +151 -0
- package/lib/protocol.js +212 -0
- package/lib/webhook.d.ts +13 -0
- package/lib/webhook.js +50 -0
- package/package.json +48 -21
- package/plugin.ts +8 -0
- package/schema.json +22 -0
- package/src/endpoint.ts +148 -554
- package/src/index.ts +53 -100
- package/src/line-agent-deps.ts +47 -0
- package/src/protocol.ts +384 -0
- package/src/webhook.ts +79 -0
- package/lib/adapter.d.ts +0 -17
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -22
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.js.map +0 -1
- package/lib/types.d.ts +0 -112
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -5
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -29
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -130
- /package/{skills/line → agent}/PERMITS.md +0 -0
- /package/{skills/line/SKILL.md → agent/skills/line.md} +0 -0
package/lib/endpoint.js
CHANGED
|
@@ -1,441 +1,138 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*/
|
|
7
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
8
|
-
import { Message, segment, formatCompact, expandInteractiveSegmentsInContent, } from 'zhin.js';
|
|
9
|
-
import { registerFetchRoute } from "@zhin.js/host-router/router";
|
|
10
|
-
import { fromCanonicalSegments, toCanonicalSegments } from './segment-mapper.js';
|
|
11
|
-
/** Type guard: narrows a LineEvent to a message event */
|
|
12
|
-
function isMessageEvent(e) {
|
|
13
|
-
return e.type === "message" && "message" in e && e.message != null;
|
|
14
|
-
}
|
|
15
|
-
/** Type guard: narrows a LineEvent to a postback event */
|
|
16
|
-
function isPostbackEvent(e) {
|
|
17
|
-
return e.type === "postback" && "postback" in e;
|
|
18
|
-
}
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import { registerLineAgentEndpoint } from './line-agent-deps.js';
|
|
3
|
+
import { formatInboundContent, formatOutboundMessages, generateMessageId, isMessageEvent, isValidLineRecipientId, resolveChannel, } from './protocol.js';
|
|
4
|
+
import { registerLineWebhookRoutes } from './webhook.js';
|
|
5
|
+
const logger = getLogger('line');
|
|
19
6
|
export class LineEndpoint {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
this
|
|
34
|
-
}
|
|
35
|
-
|
|
7
|
+
#options;
|
|
8
|
+
#fetch;
|
|
9
|
+
#routeReleases = [];
|
|
10
|
+
#replyTokenCache = new Map();
|
|
11
|
+
#open = false;
|
|
12
|
+
#started = false;
|
|
13
|
+
#unregisterAgent;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.#options = options;
|
|
16
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
17
|
+
}
|
|
18
|
+
/** Used by webhook handler. */
|
|
19
|
+
get isOpen() {
|
|
20
|
+
return this.#open;
|
|
21
|
+
}
|
|
22
|
+
get config() {
|
|
23
|
+
return this.#options.config;
|
|
24
|
+
}
|
|
25
|
+
getApiConfig() {
|
|
26
|
+
return {
|
|
27
|
+
accessToken: this.#options.config.channelAccessToken,
|
|
28
|
+
apiBaseUrl: this.#options.config.apiBaseUrl,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async start() {
|
|
32
|
+
if (this.#started)
|
|
33
|
+
return;
|
|
34
|
+
this.#started = true;
|
|
36
35
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
this.#unregisterAgent = registerLineAgentEndpoint(this.#options.config.name, this);
|
|
37
|
+
this.#routeReleases.push(...registerLineWebhookRoutes(this.#options.http, this));
|
|
38
|
+
logger.debug(formatCompact({
|
|
39
|
+
endpoint: this.#options.config.name,
|
|
40
|
+
op: 'webhook',
|
|
41
|
+
path: this.#options.config.webhookPath,
|
|
42
|
+
}));
|
|
44
43
|
}
|
|
45
44
|
catch (error) {
|
|
46
|
-
this.
|
|
47
|
-
|
|
45
|
+
await this.stop();
|
|
46
|
+
logger.error('Failed to connect LINE endpoint:', error);
|
|
48
47
|
throw error;
|
|
49
48
|
}
|
|
50
49
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// L07: Use timing-safe comparison for signature
|
|
112
|
-
verifySignature(body, signature) {
|
|
113
|
-
const channelSecret = this.$config.channelSecret;
|
|
114
|
-
const hmac = createHmac("sha256", channelSecret);
|
|
115
|
-
hmac.update(body, "utf-8");
|
|
116
|
-
const computedSignature = hmac.digest("base64");
|
|
117
|
-
const sigBuf = Buffer.from(signature);
|
|
118
|
-
const computedBuf = Buffer.from(computedSignature);
|
|
119
|
-
if (sigBuf.length !== computedBuf.length)
|
|
120
|
-
return false;
|
|
121
|
-
return timingSafeEqual(sigBuf, computedBuf);
|
|
122
|
-
}
|
|
123
|
-
// L04: Use type guards instead of "in" checks + `as any` casts
|
|
124
|
-
async handleEvent(event) {
|
|
125
|
-
switch (event.type) {
|
|
126
|
-
case "message":
|
|
127
|
-
if (isMessageEvent(event)) {
|
|
128
|
-
await this.handleMessageEvent(event);
|
|
129
|
-
}
|
|
130
|
-
break;
|
|
131
|
-
case "follow":
|
|
132
|
-
await this.handleFollowEvent(event);
|
|
133
|
-
break;
|
|
134
|
-
case "unfollow":
|
|
135
|
-
this.pluginLogger.debug(formatCompact({
|
|
136
|
-
op: "unfollow",
|
|
137
|
-
endpoint: this.$config.name,
|
|
138
|
-
userId: event.source.userId,
|
|
139
|
-
}));
|
|
140
|
-
break;
|
|
141
|
-
case "join":
|
|
142
|
-
await this.handleJoinEvent(event);
|
|
143
|
-
break;
|
|
144
|
-
case "leave":
|
|
145
|
-
this.pluginLogger.debug(formatCompact({
|
|
146
|
-
op: "leave",
|
|
147
|
-
endpoint: this.$config.name,
|
|
148
|
-
sourceType: event.source.type,
|
|
149
|
-
groupId: event.source.groupId,
|
|
150
|
-
roomId: event.source.roomId,
|
|
151
|
-
}));
|
|
152
|
-
break;
|
|
153
|
-
case "postback":
|
|
154
|
-
if (isPostbackEvent(event)) {
|
|
155
|
-
this.pluginLogger.debug(formatCompact({
|
|
156
|
-
op: "postback",
|
|
157
|
-
endpoint: this.$config.name,
|
|
158
|
-
data: event.postback.data,
|
|
159
|
-
}));
|
|
160
|
-
}
|
|
161
|
-
break;
|
|
162
|
-
default:
|
|
163
|
-
this.pluginLogger.debug(formatCompact({
|
|
164
|
-
op: "unknown_event",
|
|
165
|
-
endpoint: this.$config.name,
|
|
166
|
-
type: event.type,
|
|
167
|
-
}));
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
// L01: Cache replyToken from webhook events before emitting
|
|
171
|
-
async handleMessageEvent(event) {
|
|
172
|
-
const { channelId } = this.resolveChannel(event.source);
|
|
173
|
-
this.cacheReplyToken(channelId, event.replyToken);
|
|
174
|
-
const message = this.$formatMessage(event);
|
|
175
|
-
this.adapter.emit("message.receive", message);
|
|
176
|
-
this.pluginLogger.debug(formatCompact({
|
|
177
|
-
op: "recv",
|
|
178
|
-
endpoint: this.$config.name,
|
|
179
|
-
channel: message.$channel.type,
|
|
180
|
-
id: message.$channel.id,
|
|
181
|
-
len: segment.raw(message.$content).length,
|
|
182
|
-
}));
|
|
183
|
-
}
|
|
184
|
-
async handleFollowEvent(event) {
|
|
185
|
-
const { channelId } = this.resolveChannel(event.source);
|
|
186
|
-
this.cacheReplyToken(channelId, event.replyToken);
|
|
187
|
-
const message = this.$formatMessage(event);
|
|
188
|
-
this.adapter.emit("message.receive", message);
|
|
189
|
-
this.pluginLogger.debug(formatCompact({
|
|
190
|
-
op: "follow",
|
|
191
|
-
endpoint: this.$config.name,
|
|
192
|
-
userId: event.source.userId,
|
|
193
|
-
}));
|
|
194
|
-
}
|
|
195
|
-
async handleJoinEvent(event) {
|
|
196
|
-
const { channelId } = this.resolveChannel(event.source);
|
|
197
|
-
this.cacheReplyToken(channelId, event.replyToken);
|
|
198
|
-
const message = this.$formatMessage(event);
|
|
199
|
-
this.adapter.emit("message.receive", message);
|
|
200
|
-
this.pluginLogger.debug(formatCompact({
|
|
201
|
-
op: "join",
|
|
202
|
-
endpoint: this.$config.name,
|
|
203
|
-
sourceType: event.source.type,
|
|
204
|
-
groupId: event.source.groupId,
|
|
205
|
-
roomId: event.source.roomId,
|
|
206
|
-
}));
|
|
207
|
-
}
|
|
208
|
-
// ── 消息格式化 ────────────────────────────────────────────────────
|
|
209
|
-
$formatMessage(event) {
|
|
210
|
-
const { channelType, channelId } = this.resolveChannel(event.source);
|
|
211
|
-
const wire = this.parseMessageContent(event);
|
|
212
|
-
const quoteId = Message.quoteIdFromContent(wire);
|
|
213
|
-
Message.alignReplySegments(wire, quoteId);
|
|
214
|
-
const content = toCanonicalSegments(wire);
|
|
215
|
-
const userId = event.source.userId || "";
|
|
216
|
-
const timestamp = event.timestamp || Date.now();
|
|
217
|
-
const rawText = this.extractRawText(event);
|
|
218
|
-
return Message.from(event, {
|
|
219
|
-
$id: this.generateMessageId(event),
|
|
220
|
-
$adapter: "line",
|
|
221
|
-
$endpoint: this.$config.name,
|
|
222
|
-
$sender: {
|
|
223
|
-
id: userId,
|
|
224
|
-
name: userId,
|
|
225
|
-
},
|
|
226
|
-
$channel: {
|
|
227
|
-
id: channelId,
|
|
228
|
-
type: channelType,
|
|
229
|
-
},
|
|
230
|
-
$content: content,
|
|
231
|
-
$quote_id: quoteId,
|
|
232
|
-
$raw: rawText,
|
|
233
|
-
$timestamp: timestamp,
|
|
234
|
-
$recall: async () => {
|
|
235
|
-
// LINE 不支持消息撤回
|
|
236
|
-
this.pluginLogger.warn("LINE does not support message recall");
|
|
237
|
-
},
|
|
238
|
-
$reply: async (content, quote) => {
|
|
239
|
-
if (!Array.isArray(content))
|
|
240
|
-
content = [content];
|
|
241
|
-
if (quote) {
|
|
242
|
-
const replyToMessageId = typeof quote === "boolean"
|
|
243
|
-
? (isMessageEvent(event) && event.message?.id) || ""
|
|
244
|
-
: quote;
|
|
245
|
-
content.unshift({ type: "reply", data: { id: replyToMessageId } });
|
|
246
|
-
}
|
|
247
|
-
return await this.adapter.sendMessage({
|
|
248
|
-
context: "line",
|
|
249
|
-
endpoint: this.$config.name,
|
|
250
|
-
id: channelId,
|
|
251
|
-
type: channelType,
|
|
252
|
-
content: content,
|
|
253
|
-
});
|
|
254
|
-
},
|
|
50
|
+
open() {
|
|
51
|
+
this.#open = true;
|
|
52
|
+
}
|
|
53
|
+
close() {
|
|
54
|
+
this.#open = false;
|
|
55
|
+
}
|
|
56
|
+
async stop() {
|
|
57
|
+
this.#open = false;
|
|
58
|
+
this.#replyTokenCache.clear();
|
|
59
|
+
for (const release of this.#routeReleases.splice(0))
|
|
60
|
+
release();
|
|
61
|
+
this.#unregisterAgent?.();
|
|
62
|
+
this.#unregisterAgent = undefined;
|
|
63
|
+
this.#started = false;
|
|
64
|
+
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
65
|
+
}
|
|
66
|
+
async send({ target, payload }) {
|
|
67
|
+
const messages = formatOutboundMessages(payload);
|
|
68
|
+
if (messages.length === 0) {
|
|
69
|
+
throw new Error('No valid LINE messages to send');
|
|
70
|
+
}
|
|
71
|
+
const replyToken = this.#replyTokenCache.get(target);
|
|
72
|
+
if (replyToken) {
|
|
73
|
+
this.#replyTokenCache.delete(target);
|
|
74
|
+
return this.#replyMessage(replyToken, messages);
|
|
75
|
+
}
|
|
76
|
+
if (!isValidLineRecipientId(target)) {
|
|
77
|
+
throw new Error(`Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`);
|
|
78
|
+
}
|
|
79
|
+
return this.#pushMessage(target, messages);
|
|
80
|
+
}
|
|
81
|
+
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
82
|
+
admit(event) {
|
|
83
|
+
if (!this.#open)
|
|
84
|
+
return;
|
|
85
|
+
const { channelId } = resolveChannel(event.source);
|
|
86
|
+
if ('replyToken' in event && typeof event.replyToken === 'string') {
|
|
87
|
+
this.#replyTokenCache.set(channelId, event.replyToken);
|
|
88
|
+
}
|
|
89
|
+
void this.#options.gateway.receive({
|
|
90
|
+
adapter: this.#options.id,
|
|
91
|
+
target: channelId,
|
|
92
|
+
content: formatInboundContent(event),
|
|
93
|
+
sender: event.source.userId || channelId,
|
|
94
|
+
id: generateMessageId(event),
|
|
95
|
+
metadata: Object.freeze({
|
|
96
|
+
eventType: event.type,
|
|
97
|
+
sourceType: event.source.type,
|
|
98
|
+
endpoint: this.#options.config.name,
|
|
99
|
+
timestamp: event.timestamp,
|
|
100
|
+
...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
|
|
101
|
+
}),
|
|
102
|
+
}).catch((err) => {
|
|
103
|
+
logger.warn(formatCompact({
|
|
104
|
+
op: 'line_gateway_receive_failed',
|
|
105
|
+
target: channelId,
|
|
106
|
+
error: err instanceof Error ? err.message : String(err),
|
|
107
|
+
}));
|
|
255
108
|
});
|
|
256
109
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
return `${event.type}-${event.timestamp}`;
|
|
262
|
-
}
|
|
263
|
-
resolveChannel(source) {
|
|
264
|
-
switch (source.type) {
|
|
265
|
-
case "user":
|
|
266
|
-
return { channelType: "private", channelId: source.userId || "" };
|
|
267
|
-
case "group":
|
|
268
|
-
return { channelType: "group", channelId: source.groupId || "" };
|
|
269
|
-
case "room":
|
|
270
|
-
return { channelType: "channel", channelId: source.roomId || "" };
|
|
271
|
-
default:
|
|
272
|
-
return { channelType: "private", channelId: "" };
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
extractRawText(event) {
|
|
276
|
-
if (isMessageEvent(event)) {
|
|
277
|
-
const msg = event.message;
|
|
278
|
-
if (msg.type === "text" && msg.text)
|
|
279
|
-
return msg.text;
|
|
280
|
-
if (msg.type === "location" && msg.address)
|
|
281
|
-
return msg.address;
|
|
282
|
-
return `[${msg.type}]`;
|
|
283
|
-
}
|
|
284
|
-
if (event.type === "follow")
|
|
285
|
-
return "[follow]";
|
|
286
|
-
if (event.type === "join")
|
|
287
|
-
return "[join]";
|
|
288
|
-
return "";
|
|
289
|
-
}
|
|
290
|
-
parseMessageContent(event) {
|
|
291
|
-
const segments = [];
|
|
292
|
-
if (isMessageEvent(event)) {
|
|
293
|
-
const msg = event.message;
|
|
294
|
-
switch (msg.type) {
|
|
295
|
-
case "text":
|
|
296
|
-
if (msg.text) {
|
|
297
|
-
segments.push({ type: "text", data: { text: msg.text } });
|
|
298
|
-
}
|
|
299
|
-
break;
|
|
300
|
-
case "image":
|
|
301
|
-
segments.push({
|
|
302
|
-
type: "image",
|
|
303
|
-
data: {
|
|
304
|
-
message_id: msg.id,
|
|
305
|
-
platform: "line",
|
|
306
|
-
},
|
|
307
|
-
});
|
|
308
|
-
break;
|
|
309
|
-
case "video":
|
|
310
|
-
segments.push({
|
|
311
|
-
type: "video",
|
|
312
|
-
data: {
|
|
313
|
-
message_id: msg.id,
|
|
314
|
-
platform: "line",
|
|
315
|
-
},
|
|
316
|
-
});
|
|
317
|
-
break;
|
|
318
|
-
case "audio":
|
|
319
|
-
segments.push({
|
|
320
|
-
type: "audio",
|
|
321
|
-
data: {
|
|
322
|
-
message_id: msg.id,
|
|
323
|
-
duration: msg.duration || 0,
|
|
324
|
-
platform: "line",
|
|
325
|
-
},
|
|
326
|
-
});
|
|
327
|
-
break;
|
|
328
|
-
case "file":
|
|
329
|
-
segments.push({
|
|
330
|
-
type: "file",
|
|
331
|
-
data: {
|
|
332
|
-
message_id: msg.id,
|
|
333
|
-
file_name: msg.fileName,
|
|
334
|
-
file_size: msg.fileSize,
|
|
335
|
-
platform: "line",
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
break;
|
|
339
|
-
case "location":
|
|
340
|
-
segments.push({
|
|
341
|
-
type: "location",
|
|
342
|
-
data: {
|
|
343
|
-
title: msg.title,
|
|
344
|
-
address: msg.address,
|
|
345
|
-
latitude: msg.latitude,
|
|
346
|
-
longitude: msg.longitude,
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
break;
|
|
350
|
-
case "sticker":
|
|
351
|
-
segments.push({
|
|
352
|
-
type: "sticker",
|
|
353
|
-
data: {
|
|
354
|
-
package_id: msg.packageId,
|
|
355
|
-
sticker_id: msg.stickerId,
|
|
356
|
-
resource_type: msg.stickerResourceType,
|
|
357
|
-
},
|
|
358
|
-
});
|
|
359
|
-
break;
|
|
360
|
-
default:
|
|
361
|
-
segments.push({ type: "text", data: { text: `[unsupported message type]` } });
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
else {
|
|
365
|
-
// 系统事件(follow/unfollow/join/leave)
|
|
366
|
-
const text = event.type === "follow" ? "[follow event]"
|
|
367
|
-
: event.type === "join" ? "[join event]"
|
|
368
|
-
: event.type === "unfollow" ? "[unfollow event]"
|
|
369
|
-
: event.type === "leave" ? "[leave event]"
|
|
370
|
-
: `[${event.type} event]`;
|
|
371
|
-
segments.push({ type: "text", data: { text } });
|
|
372
|
-
}
|
|
373
|
-
return segments.length > 0 ? segments : [{ type: "text", data: { text: "" } }];
|
|
374
|
-
}
|
|
375
|
-
// ── 发送消息 ──────────────────────────────────────────────────────
|
|
376
|
-
async $sendMessage(options) {
|
|
377
|
-
try {
|
|
378
|
-
const canonical = expandInteractiveSegmentsInContent(options.content);
|
|
379
|
-
const wire = fromCanonicalSegments(canonical);
|
|
380
|
-
const messages = this.buildLineMessages(wire);
|
|
381
|
-
if (messages.length === 0) {
|
|
382
|
-
throw new Error("No valid LINE messages to send");
|
|
383
|
-
}
|
|
384
|
-
// 优先使用 Reply API(如果存在 replyToken)
|
|
385
|
-
const replyToken = this.replyTokenCache.get(options.id);
|
|
386
|
-
if (replyToken) {
|
|
387
|
-
this.replyTokenCache.delete(options.id);
|
|
388
|
-
return await this.replyMessage(replyToken, messages);
|
|
389
|
-
}
|
|
390
|
-
// L06: Validate Push API `to` field
|
|
391
|
-
if (!/^[UGR]/.test(options.id)) {
|
|
392
|
-
throw new Error(`Invalid LINE recipient ID "${options.id}": must start with U (user), G (group), or R (room)`);
|
|
393
|
-
}
|
|
394
|
-
// 使用 Push API
|
|
395
|
-
return await this.pushMessage(options.id, messages);
|
|
396
|
-
}
|
|
397
|
-
catch (error) {
|
|
398
|
-
this.pluginLogger.error("Failed to send LINE message:", error);
|
|
399
|
-
throw error;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
replyTokenCache = new Map();
|
|
403
|
-
/**
|
|
404
|
-
* 缓存 replyToken,用于后续发送回复消息
|
|
405
|
-
*/
|
|
406
|
-
cacheReplyToken(channelId, replyToken) {
|
|
407
|
-
this.replyTokenCache.set(channelId, replyToken);
|
|
408
|
-
}
|
|
409
|
-
// L15: Parse Reply API response for message ID
|
|
410
|
-
async replyMessage(replyToken, messages) {
|
|
411
|
-
const baseUrl = this.$config.apiBaseUrl || "https://api.line.me";
|
|
412
|
-
const request = { replyToken, messages };
|
|
413
|
-
const response = await fetch(`${baseUrl}/v2/bot/message/reply`, {
|
|
414
|
-
method: "POST",
|
|
110
|
+
async #replyMessage(replyToken, messages) {
|
|
111
|
+
const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/reply`;
|
|
112
|
+
const response = await this.#fetch(url, {
|
|
113
|
+
method: 'POST',
|
|
415
114
|
headers: {
|
|
416
|
-
|
|
417
|
-
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
418
117
|
},
|
|
419
|
-
body: JSON.stringify(
|
|
118
|
+
body: JSON.stringify({ replyToken, messages }),
|
|
420
119
|
});
|
|
421
120
|
if (!response.ok) {
|
|
422
121
|
const errorText = await response.text();
|
|
423
122
|
throw new Error(`LINE Reply API error ${response.status}: ${errorText}`);
|
|
424
123
|
}
|
|
425
|
-
// Reply API now returns sentMessages in the response body
|
|
426
124
|
const result = await response.json();
|
|
427
125
|
return result.sentMessages?.[0]?.id || `reply-${Date.now()}`;
|
|
428
126
|
}
|
|
429
|
-
async pushMessage(to, messages) {
|
|
430
|
-
const
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
method: "POST",
|
|
127
|
+
async #pushMessage(to, messages) {
|
|
128
|
+
const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/push`;
|
|
129
|
+
const response = await this.#fetch(url, {
|
|
130
|
+
method: 'POST',
|
|
434
131
|
headers: {
|
|
435
|
-
|
|
436
|
-
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
437
134
|
},
|
|
438
|
-
body: JSON.stringify(
|
|
135
|
+
body: JSON.stringify({ to, messages }),
|
|
439
136
|
});
|
|
440
137
|
if (!response.ok) {
|
|
441
138
|
const errorText = await response.text();
|
|
@@ -444,93 +141,4 @@ export class LineEndpoint {
|
|
|
444
141
|
const result = await response.json();
|
|
445
142
|
return result.sentMessages?.[0]?.id || `push-${Date.now()}`;
|
|
446
143
|
}
|
|
447
|
-
buildLineMessages(content) {
|
|
448
|
-
if (!Array.isArray(content))
|
|
449
|
-
content = [content];
|
|
450
|
-
const messages = [];
|
|
451
|
-
for (const item of content) {
|
|
452
|
-
if (typeof item === "string") {
|
|
453
|
-
messages.push(this.buildTextMessage(item));
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
-
const seg = item;
|
|
457
|
-
switch (seg.type) {
|
|
458
|
-
case "text":
|
|
459
|
-
messages.push(this.buildTextMessage(seg.data.text || ""));
|
|
460
|
-
break;
|
|
461
|
-
case "at":
|
|
462
|
-
// LINE 没有 @ 语法,转为文本
|
|
463
|
-
if (seg.data.id) {
|
|
464
|
-
messages.push(this.buildTextMessage(`@${seg.data.name || seg.data.id}`));
|
|
465
|
-
}
|
|
466
|
-
break;
|
|
467
|
-
case "image":
|
|
468
|
-
if (seg.data.url) {
|
|
469
|
-
messages.push({
|
|
470
|
-
type: "image",
|
|
471
|
-
originalContentUrl: seg.data.url,
|
|
472
|
-
previewImageUrl: seg.data.url,
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
break;
|
|
476
|
-
case "video":
|
|
477
|
-
if (seg.data.url) {
|
|
478
|
-
messages.push({
|
|
479
|
-
type: "video",
|
|
480
|
-
originalContentUrl: seg.data.url,
|
|
481
|
-
previewImageUrl: seg.data.previewUrl || seg.data.url,
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
break;
|
|
485
|
-
case "audio":
|
|
486
|
-
if (seg.data.url) {
|
|
487
|
-
messages.push({
|
|
488
|
-
type: "audio",
|
|
489
|
-
originalContentUrl: seg.data.url,
|
|
490
|
-
duration: seg.data.duration || 0,
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
break;
|
|
494
|
-
case "location":
|
|
495
|
-
messages.push({
|
|
496
|
-
type: "location",
|
|
497
|
-
title: seg.data.title || "Location",
|
|
498
|
-
address: seg.data.address || "",
|
|
499
|
-
latitude: seg.data.latitude || 0,
|
|
500
|
-
longitude: seg.data.longitude || 0,
|
|
501
|
-
});
|
|
502
|
-
break;
|
|
503
|
-
case "sticker":
|
|
504
|
-
messages.push({
|
|
505
|
-
type: "sticker",
|
|
506
|
-
packageId: seg.data.package_id || "1",
|
|
507
|
-
stickerId: seg.data.sticker_id || "1",
|
|
508
|
-
});
|
|
509
|
-
break;
|
|
510
|
-
default:
|
|
511
|
-
messages.push(this.buildTextMessage(`[${seg.type}]`));
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
// L14: Log warning when messages are sliced to 5 (LINE limit)
|
|
515
|
-
if (messages.length > 5) {
|
|
516
|
-
this.pluginLogger.warn(`LINE messages truncated from ${messages.length} to 5 (platform limit)`);
|
|
517
|
-
}
|
|
518
|
-
// LINE 单次最多发送 5 条消息
|
|
519
|
-
return messages.slice(0, 5);
|
|
520
|
-
}
|
|
521
|
-
buildTextMessage(text) {
|
|
522
|
-
// L13: Log warning on text truncation
|
|
523
|
-
if (text.length > 5000) {
|
|
524
|
-
this.pluginLogger.warn(`LINE text message truncated from ${text.length} to 5000 characters`);
|
|
525
|
-
}
|
|
526
|
-
// LINE 消息文本限制 5000 字符
|
|
527
|
-
const truncated = text.length > 5000 ? text.slice(0, 4997) + "..." : text;
|
|
528
|
-
return { type: "text", text: truncated };
|
|
529
|
-
}
|
|
530
|
-
// ── 消息撤回 ──────────────────────────────────────────────────────
|
|
531
|
-
// L16: Log warning and return gracefully (matching WeCom/DingTalk pattern)
|
|
532
|
-
async $recallMessage(_id) {
|
|
533
|
-
this.pluginLogger.warn(formatCompact({ op: "recall", ok: false, error: "LINE Messaging API does not support message recall" }));
|
|
534
|
-
}
|
|
535
144
|
}
|
|
536
|
-
//# sourceMappingURL=endpoint.js.map
|
package/lib/index.d.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
router: import('@zhin.js/host-router').Router;
|
|
6
|
-
}
|
|
7
|
-
}
|
|
8
|
-
interface Adapters {
|
|
9
|
-
line: LineAdapter;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
export * from './types.js';
|
|
13
|
-
export { LineEndpoint } from './endpoint.js';
|
|
14
|
-
export { LineAdapter } from './adapter.js';
|
|
15
|
-
//# sourceMappingURL=index.d.ts.map
|
|
1
|
+
export { formatInboundContent, formatOutboundMessages, generateMessageId, isMessageEvent, isPostbackEvent, isValidLineRecipientId, normalizeWebhookPath, readTextBody, resolveChannel, resolveLineConfig, verifySignature, type LineAdapterConfig, type LineApiResponse, type LineChannel, type LineEvent, type LineFollowEvent, type LineJoinEvent, type LineLeaveEvent, type LineMessage, type LineMessageEvent, type LinePostbackEvent, type LinePushRequest, type LineReplyMessage, type LineReplyRequest, type LineSource, type LineUnfollowEvent, type LineUser, type LineWebhookBody, type LineWireSegment, type ResolvedLineConfig, } from './protocol.js';
|
|
2
|
+
export { LineEndpoint, type LineEndpointOptions, type LineFetch, } from './endpoint.js';
|
|
3
|
+
export { registerLineWebhookRoutes, handleLineWebhookRequest, type LineWebhookHandler, } from './webhook.js';
|
|
4
|
+
export { getLineAgentDeps, getLineApiConfig, registerLineAgentEndpoint, setLineAgentDeps, type LineAgentDeps, type LineAgentEndpoint, } from './line-agent-deps.js';
|