@zhin.js/adapter-line 2.0.2 → 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 +33 -0
- package/README.md +42 -21
- package/adapters/line.ts +26 -0
- package/agent/tools/get_group_members.ts +2 -2
- package/agent/tools/get_profile.ts +2 -2
- package/lib/endpoint.d.ts +46 -0
- package/lib/endpoint.js +144 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- 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 +42 -25
- package/plugin.ts +8 -0
- package/schema.json +22 -0
- package/src/endpoint.ts +148 -554
- package/src/index.ts +50 -41
- package/src/line-agent-deps.ts +32 -23
- package/src/protocol.ts +384 -0
- package/src/webhook.ts +79 -0
- package/lib/agent/tools/get_group_members.js +0 -24
- package/lib/agent/tools/get_group_members.js.map +0 -1
- package/lib/agent/tools/get_profile.js +0 -24
- package/lib/agent/tools/get_profile.js.map +0 -1
- package/lib/src/adapter.js +0 -22
- package/lib/src/adapter.js.map +0 -1
- package/lib/src/endpoint.js +0 -536
- package/lib/src/endpoint.js.map +0 -1
- package/lib/src/index.js +0 -30
- package/lib/src/index.js.map +0 -1
- package/lib/src/line-agent-deps.js +0 -26
- package/lib/src/line-agent-deps.js.map +0 -1
- package/lib/src/segment-mapper.js +0 -2
- package/lib/src/segment-mapper.js.map +0 -1
- package/lib/src/types.js +0 -5
- package/lib/src/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/src/endpoint.ts
CHANGED
|
@@ -1,609 +1,203 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* 使用 Webhook 模式接收消息,通过 LINE Messaging API 发送消息。
|
|
5
|
-
* HMAC-SHA256 签名验证确保请求来自 LINE 平台。
|
|
2
|
+
* LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
|
|
6
3
|
*/
|
|
7
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
56
|
+
constructor(options: LineEndpointOptions) {
|
|
57
|
+
this.#options = options;
|
|
58
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
/** Used by webhook handler. */
|
|
62
|
+
get isOpen(): boolean {
|
|
63
|
+
return this.#open;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
this
|
|
82
|
-
this.
|
|
83
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
77
|
+
async start(): Promise<void> {
|
|
78
|
+
if (this.#started) return;
|
|
79
|
+
this.#started = true;
|
|
93
80
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
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 (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
473
|
-
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
474
172
|
},
|
|
475
|
-
body: JSON.stringify(
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
191
|
+
'Content-Type': 'application/json',
|
|
192
|
+
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
496
193
|
},
|
|
497
|
-
body: JSON.stringify(
|
|
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
|
}
|