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