@zhin.js/adapter-line 0.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +689 -0
  2. package/README.md +56 -24
  3. package/adapters/line/index.js +31 -0
  4. package/adapters/line/index.ts +36 -0
  5. package/agents/line/agent.json +20 -0
  6. package/agents/line/boundaries.md +3 -0
  7. package/agents/line/conventions.md +3 -0
  8. package/{skills → agents/line/skills}/line/SKILL.md +3 -0
  9. package/agents/line/skills/line/tools/get_group_members/index.js +16 -0
  10. package/agents/line/skills/line/tools/get_group_members/index.ts +16 -0
  11. package/agents/line/skills/line/tools/get_profile/index.js +16 -0
  12. package/agents/line/skills/line/tools/get_profile/index.ts +16 -0
  13. package/agents/line/system.md +3 -0
  14. package/commands/line/endpoint/add/[id]/index.js +3 -0
  15. package/commands/line/endpoint/add/[id]/index.ts +3 -0
  16. package/commands/line/endpoint/definition.js +18 -0
  17. package/commands/line/endpoint/definition.ts +18 -0
  18. package/commands/line/endpoint/list/index.js +3 -0
  19. package/commands/line/endpoint/list/index.ts +3 -0
  20. package/commands/line/endpoint/remove/[id]/index.js +3 -0
  21. package/commands/line/endpoint/remove/[id]/index.ts +3 -0
  22. package/lib/client.d.ts +33 -0
  23. package/lib/client.js +54 -0
  24. package/lib/endpoint.d.ts +41 -37
  25. package/lib/endpoint.js +149 -500
  26. package/lib/index.d.ts +4 -15
  27. package/lib/index.js +4 -83
  28. package/lib/line-runtime-state.d.ts +1 -0
  29. package/lib/line-runtime-state.js +6 -0
  30. package/lib/protocol.d.ts +154 -0
  31. package/lib/protocol.js +260 -0
  32. package/lib/side-event-dispatch.d.ts +4 -0
  33. package/lib/side-event-dispatch.js +42 -0
  34. package/lib/webhook.d.ts +13 -0
  35. package/lib/webhook.js +50 -0
  36. package/package.json +74 -22
  37. package/plugin.js +14 -0
  38. package/schema.json +96 -0
  39. package/src/client.ts +87 -0
  40. package/src/endpoint.ts +197 -551
  41. package/src/index.ts +51 -100
  42. package/src/line-runtime-state.ts +7 -0
  43. package/src/protocol.ts +435 -0
  44. package/src/side-event-dispatch.ts +54 -0
  45. package/src/webhook.ts +79 -0
  46. package/lib/adapter.d.ts +0 -15
  47. package/lib/adapter.d.ts.map +0 -1
  48. package/lib/adapter.js +0 -20
  49. package/lib/adapter.js.map +0 -1
  50. package/lib/endpoint.d.ts.map +0 -1
  51. package/lib/endpoint.js.map +0 -1
  52. package/lib/index.d.ts.map +0 -1
  53. package/lib/index.js.map +0 -1
  54. package/lib/types.d.ts +0 -112
  55. package/lib/types.d.ts.map +0 -1
  56. package/lib/types.js +0 -5
  57. package/lib/types.js.map +0 -1
  58. package/plugin.yml +0 -3
  59. package/src/adapter.ts +0 -29
  60. package/src/types.ts +0 -130
  61. /package/{skills/line/PERMITS.md → PERMITS.md} +0 -0
package/src/index.ts CHANGED
@@ -1,100 +1,51 @@
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
- });
1
+ export {
2
+ formatInboundContent,
3
+ formatOutboundMessages,
4
+ generateMessageId,
5
+ isMessageEvent,
6
+ isPostbackEvent,
7
+ isValidLineRecipientId,
8
+ normalizeWebhookPath,
9
+ readTextBody,
10
+ resolveChannel,
11
+ resolveLineConfig,
12
+ verifySignature,
13
+ type LineEndpointConfig,
14
+ type LineApiResponse,
15
+ type LineChannel,
16
+ type LineEvent,
17
+ type LineFollowEvent,
18
+ type LineJoinEvent,
19
+ type LineLeaveEvent,
20
+ type LineMessage,
21
+ type LineMessageEvent,
22
+ type LinePostbackEvent,
23
+ type LinePushRequest,
24
+ type LineReplyMessage,
25
+ type LineReplyRequest,
26
+ type LineSource,
27
+ type LineUnfollowEvent,
28
+ type LineUser,
29
+ type LineWebhookBody,
30
+ type LineWireSegment,
31
+ type ResolvedLineConfig,
32
+ } from './protocol.js';
33
+
34
+ export {
35
+ LineEndpoint,
36
+ type LineEndpointOptions,
37
+ type LineFetch,
38
+ } from './endpoint.js';
39
+
40
+ export {
41
+ registerLineWebhookRoutes,
42
+ handleLineWebhookRequest,
43
+ type LineWebhookHandler,
44
+ } from './webhook.js';
45
+
46
+ export {
47
+ LineClient,
48
+ lineClient,
49
+ type LineClientEventMap,
50
+ type LineGroupMember,
51
+ } from './client.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * LINE 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `line endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from 'zhin.js/adapter';
6
+
7
+ export const lineRuntimeStateToken = defineEndpointRuntimeStateToken('line');
@@ -0,0 +1,435 @@
1
+ /**
2
+ * LINE Messaging API protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+
6
+ import { createHmac, timingSafeEqual } from 'node:crypto';
7
+ import type { IncomingMessage } from 'node:http';
8
+ import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
9
+ import { formatCompact, getLogger } from '@zhin.js/logger';
10
+
11
+ const logger = getLogger('line');
12
+
13
+ /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
14
+ export interface LineEndpointConfig {
15
+ readonly id: string;
16
+ readonly channelSecret: string;
17
+ readonly channelAccessToken: string;
18
+ readonly webhookPath?: string;
19
+ readonly apiBaseUrl?: string;
20
+ }
21
+
22
+ export interface ResolvedLineConfig {
23
+ readonly context: 'line';
24
+ readonly id: string;
25
+ readonly channelSecret: string;
26
+ readonly channelAccessToken: string;
27
+ readonly webhookPath: string;
28
+ readonly apiBaseUrl: string;
29
+ }
30
+
31
+ export interface LineUser {
32
+ readonly userId: string;
33
+ readonly displayName?: string;
34
+ readonly pictureUrl?: string;
35
+ readonly statusMessage?: string;
36
+ }
37
+
38
+ export interface LineMessageEvent {
39
+ readonly type: 'message';
40
+ readonly replyToken: string;
41
+ readonly source: LineSource;
42
+ readonly timestamp: number;
43
+ readonly message: LineMessage;
44
+ }
45
+
46
+ export interface LineFollowEvent {
47
+ readonly type: 'follow';
48
+ readonly replyToken: string;
49
+ readonly source: LineSource;
50
+ readonly timestamp: number;
51
+ }
52
+
53
+ export interface LineUnfollowEvent {
54
+ readonly type: 'unfollow';
55
+ readonly source: LineSource;
56
+ readonly timestamp: number;
57
+ }
58
+
59
+ export interface LineJoinEvent {
60
+ readonly type: 'join';
61
+ readonly replyToken: string;
62
+ readonly source: LineSource;
63
+ readonly timestamp: number;
64
+ }
65
+
66
+ export interface LineLeaveEvent {
67
+ readonly type: 'leave';
68
+ readonly source: LineSource;
69
+ readonly timestamp: number;
70
+ }
71
+
72
+ export interface LinePostbackEvent {
73
+ readonly type: 'postback';
74
+ readonly replyToken: string;
75
+ readonly source: LineSource;
76
+ readonly timestamp: number;
77
+ readonly postback: {
78
+ readonly data: string;
79
+ readonly params?: Record<string, string>;
80
+ };
81
+ }
82
+
83
+ export type LineEvent =
84
+ | LineMessageEvent
85
+ | LineFollowEvent
86
+ | LineUnfollowEvent
87
+ | LineJoinEvent
88
+ | LineLeaveEvent
89
+ | LinePostbackEvent;
90
+
91
+ export interface LineSource {
92
+ readonly type: 'user' | 'group' | 'room';
93
+ readonly userId?: string;
94
+ readonly groupId?: string;
95
+ readonly roomId?: string;
96
+ }
97
+
98
+ export interface LineMessage {
99
+ readonly id: string;
100
+ readonly type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'location' | 'sticker';
101
+ readonly text?: string;
102
+ readonly fileName?: string;
103
+ readonly fileSize?: number;
104
+ readonly title?: string;
105
+ readonly address?: string;
106
+ readonly latitude?: number;
107
+ readonly longitude?: number;
108
+ readonly packageId?: string;
109
+ readonly stickerId?: string;
110
+ readonly stickerResourceType?: string;
111
+ readonly duration?: number;
112
+ }
113
+
114
+ export interface LineWebhookBody {
115
+ readonly destination: string;
116
+ readonly events: readonly LineEvent[];
117
+ }
118
+
119
+ export interface LineReplyMessage {
120
+ readonly type: 'text' | 'image' | 'video' | 'audio' | 'location' | 'sticker' | 'flex';
121
+ readonly text?: string;
122
+ readonly originalContentUrl?: string;
123
+ readonly previewImageUrl?: string;
124
+ readonly packageId?: string;
125
+ readonly stickerId?: string;
126
+ readonly title?: string;
127
+ readonly address?: string;
128
+ readonly latitude?: number;
129
+ readonly longitude?: number;
130
+ readonly duration?: number;
131
+ readonly altText?: string;
132
+ readonly contents?: Record<string, unknown>;
133
+ }
134
+
135
+ export interface LineReplyRequest {
136
+ readonly replyToken: string;
137
+ readonly messages: readonly LineReplyMessage[];
138
+ }
139
+
140
+ export interface LinePushRequest {
141
+ readonly to: string;
142
+ readonly messages: readonly LineReplyMessage[];
143
+ }
144
+
145
+ export interface LineApiResponse {
146
+ readonly sentMessages?: ReadonlyArray<{ readonly id: string; readonly quoteToken?: string }>;
147
+ }
148
+
149
+ export interface LineWireSegment {
150
+ readonly type: string;
151
+ readonly data?: Record<string, unknown>;
152
+ }
153
+
154
+ export interface LineChannel {
155
+ readonly channelType: 'private' | 'group' | 'channel';
156
+ readonly channelId: string;
157
+ }
158
+
159
+ export function resolveLineConfig(config: LineEndpointConfig): ResolvedLineConfig {
160
+ const id = requiredEndpointField(config.id, 'id');
161
+ const channelSecret = requiredEndpointField(config.channelSecret, 'channelSecret');
162
+ const channelAccessToken = requiredEndpointField(
163
+ config.channelAccessToken,
164
+ 'channelAccessToken',
165
+ );
166
+ const webhookPath = normalizeWebhookPath(config.webhookPath ?? '/line/webhook');
167
+ const apiBaseUrl = (config.apiBaseUrl ?? 'https://api.line.me').replace(/\/$/, '');
168
+ return {
169
+ context: 'line',
170
+ id,
171
+ channelSecret,
172
+ channelAccessToken,
173
+ webhookPath,
174
+ apiBaseUrl,
175
+ };
176
+ }
177
+
178
+ function requiredEndpointField(
179
+ value: unknown,
180
+ field: 'id' | 'channelSecret' | 'channelAccessToken',
181
+ ): string {
182
+ if (typeof value !== 'string' || !value.trim()) {
183
+ throw new TypeError(`LINE endpoint requires a non-empty ${field}`);
184
+ }
185
+ return value.trim();
186
+ }
187
+
188
+ export function normalizeWebhookPath(path: string): string {
189
+ const trimmed = path.trim() || '/line/webhook';
190
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
191
+ }
192
+
193
+ export function isMessageEvent(event: LineEvent): event is LineMessageEvent {
194
+ return event.type === 'message' && 'message' in event && (event as LineMessageEvent).message != null;
195
+ }
196
+
197
+ export function isLineLifecycleEvent(event: LineEvent): boolean {
198
+ return event.type === 'follow'
199
+ || event.type === 'unfollow'
200
+ || event.type === 'join'
201
+ || event.type === 'leave';
202
+ }
203
+
204
+ export function isPostbackEvent(event: LineEvent): event is LinePostbackEvent {
205
+ return event.type === 'postback' && 'postback' in event;
206
+ }
207
+
208
+ export function resolveChannel(source: LineSource): LineChannel {
209
+ switch (source.type) {
210
+ case 'user':
211
+ return { channelType: 'private', channelId: source.userId || '' };
212
+ case 'group':
213
+ return { channelType: 'group', channelId: source.groupId || '' };
214
+ case 'room':
215
+ return { channelType: 'channel', channelId: source.roomId || '' };
216
+ default:
217
+ return { channelType: 'private', channelId: '' };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * 入站归一化 → ConversationRef:LINE user → private、group → group、room → channel。
223
+ * LINE 没有 guild/频道容器概念,无 parent;recipient id 前缀(U/G/R)自带场景信息。
224
+ */
225
+ export function lineInboundConversation(endpointKey: string, source: LineSource): ConversationRef {
226
+ const channel = resolveChannel(source);
227
+ return {
228
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
229
+ kind: channel.channelType,
230
+ id: channel.channelId,
231
+ };
232
+ }
233
+
234
+ export function generateMessageId(event: LineEvent): string {
235
+ if (isMessageEvent(event) && event.message?.id) return event.message.id;
236
+ return `${event.type}-${event.timestamp}`;
237
+ }
238
+
239
+ /** Build inbound text for OutboundMessageService.receive. */
240
+ export function formatInboundContent(event: LineEvent): string {
241
+ if (isMessageEvent(event)) {
242
+ const msg = event.message;
243
+ switch (msg.type) {
244
+ case 'text':
245
+ return msg.text || '';
246
+ case 'location':
247
+ return msg.address || `[location: ${msg.latitude},${msg.longitude}]`;
248
+ case 'image':
249
+ return '[image]';
250
+ case 'video':
251
+ return '[video]';
252
+ case 'audio':
253
+ return '[audio]';
254
+ case 'file':
255
+ return msg.fileName ? `[file: ${msg.fileName}]` : '[file]';
256
+ case 'sticker':
257
+ return `[sticker: ${msg.packageId}/${msg.stickerId}]`;
258
+ default:
259
+ return `[${(msg as LineMessage).type}]`;
260
+ }
261
+ }
262
+ if (event.type === 'follow') return '[follow]';
263
+ if (event.type === 'join') return '[join]';
264
+ if (event.type === 'unfollow') return '[unfollow]';
265
+ if (event.type === 'leave') return '[leave]';
266
+ if (event.type === 'postback') return `[postback: ${event.postback.data}]`;
267
+ return '';
268
+ }
269
+
270
+ export function verifySignature(
271
+ channelSecret: string,
272
+ body: string,
273
+ signature: string,
274
+ ): boolean {
275
+ const hmac = createHmac('sha256', channelSecret);
276
+ hmac.update(body, 'utf-8');
277
+ const computedSignature = hmac.digest('base64');
278
+ const sigBuf = Buffer.from(signature);
279
+ const computedBuf = Buffer.from(computedSignature);
280
+ if (sigBuf.length !== computedBuf.length) return false;
281
+ return timingSafeEqual(sigBuf, computedBuf);
282
+ }
283
+
284
+ export function isValidLineRecipientId(id: string): boolean {
285
+ return /^[UGR]/.test(id);
286
+ }
287
+
288
+ function buildTextMessage(text: string): LineReplyMessage {
289
+ const truncated = text.length > 5000 ? `${text.slice(0, 4997)}...` : text;
290
+ return { type: 'text', text: truncated };
291
+ }
292
+
293
+ /**
294
+ * 解析 image/audio/video 段的投递源(canonical MediaRef 唯一来源)。
295
+ * LINE Messaging API 媒体消息仅消费远程 HTTPS URL(无上传通道),
296
+ * 因此只有 kind=url 可投递;base64 / path / file 一律 warn + 丢弃。
297
+ */
298
+ function resolveMediaUrl(seg: LineWireSegment): string | undefined {
299
+ const media = (seg.data ?? {}).media;
300
+ if (!isMediaRef(media)) {
301
+ logger.warn(formatCompact({
302
+ op: 'line_outbound_media_dropped',
303
+ type: seg.type,
304
+ reason: 'missing_media_ref',
305
+ }));
306
+ return undefined;
307
+ }
308
+ if (media.kind !== 'url') {
309
+ logger.warn(formatCompact({
310
+ op: 'line_outbound_media_dropped',
311
+ type: seg.type,
312
+ reason: 'unsupported_media_kind',
313
+ kind: media.kind,
314
+ }));
315
+ return undefined;
316
+ }
317
+ return media.value;
318
+ }
319
+
320
+ /**
321
+ * Wire-encode an already-rendered outbound payload into LINE Reply/Push messages.
322
+ * Segment canonicalization is intentionally not done here.
323
+ */
324
+ export function formatOutboundMessages(payload: unknown): LineReplyMessage[] {
325
+ if (typeof payload === 'string') {
326
+ return [buildTextMessage(payload)];
327
+ }
328
+
329
+ const items: Array<string | LineWireSegment> = Array.isArray(payload)
330
+ ? payload as Array<string | LineWireSegment>
331
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
332
+ ? [payload as LineWireSegment]
333
+ : [];
334
+
335
+ if (items.length === 0) {
336
+ const text = payload == null
337
+ ? ''
338
+ : typeof payload === 'object'
339
+ ? JSON.stringify(payload)
340
+ : String(payload);
341
+ return [buildTextMessage(text)];
342
+ }
343
+
344
+ const messages: LineReplyMessage[] = [];
345
+ for (const item of items) {
346
+ if (typeof item === 'string') {
347
+ messages.push(buildTextMessage(item));
348
+ continue;
349
+ }
350
+ const data = item.data ?? {};
351
+ switch (item.type) {
352
+ case 'text':
353
+ messages.push(buildTextMessage(String(data.text ?? data.content ?? '')));
354
+ break;
355
+ case 'at':
356
+ if (data.id) {
357
+ messages.push(buildTextMessage(`@${String(data.name || data.id)}`));
358
+ }
359
+ break;
360
+ case 'image': {
361
+ const url = resolveMediaUrl(item);
362
+ if (url) {
363
+ messages.push({
364
+ type: 'image',
365
+ originalContentUrl: url,
366
+ previewImageUrl: url,
367
+ });
368
+ }
369
+ break;
370
+ }
371
+ case 'video': {
372
+ const url = resolveMediaUrl(item);
373
+ if (url) {
374
+ messages.push({
375
+ type: 'video',
376
+ originalContentUrl: url,
377
+ previewImageUrl: typeof data.previewUrl === 'string' ? data.previewUrl : url,
378
+ });
379
+ }
380
+ break;
381
+ }
382
+ case 'audio': {
383
+ const url = resolveMediaUrl(item);
384
+ if (url) {
385
+ messages.push({
386
+ type: 'audio',
387
+ originalContentUrl: url,
388
+ duration: typeof data.duration === 'number' ? data.duration : 0,
389
+ });
390
+ }
391
+ break;
392
+ }
393
+ case 'location':
394
+ messages.push({
395
+ type: 'location',
396
+ title: String(data.title || 'Location'),
397
+ address: String(data.address || ''),
398
+ latitude: typeof data.latitude === 'number' ? data.latitude : 0,
399
+ longitude: typeof data.longitude === 'number' ? data.longitude : 0,
400
+ });
401
+ break;
402
+ case 'sticker':
403
+ messages.push({
404
+ type: 'sticker',
405
+ packageId: String(data.package_id || '1'),
406
+ stickerId: String(data.sticker_id || '1'),
407
+ });
408
+ break;
409
+ default:
410
+ messages.push(buildTextMessage(`[${item.type}]`));
411
+ }
412
+ }
413
+
414
+ // LINE allows at most 5 messages per Reply/Push request.
415
+ return messages.slice(0, 5);
416
+ }
417
+
418
+ export async function readTextBody(
419
+ request: IncomingMessage,
420
+ options: { readonly limit?: number } = {},
421
+ ): Promise<string> {
422
+ const limit = options.limit ?? 1_048_576;
423
+ const chunks: Buffer[] = [];
424
+ let size = 0;
425
+ for await (const chunk of request) {
426
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
427
+ size += buffer.length;
428
+ if (size > limit) {
429
+ request.destroy();
430
+ throw new Error(`Request body exceeds ${limit} bytes`);
431
+ }
432
+ chunks.push(buffer);
433
+ }
434
+ return Buffer.concat(chunks).toString('utf8');
435
+ }
@@ -0,0 +1,54 @@
1
+ import { buildNotice, senderFromId } from '@zhin.js/core';
2
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
3
+ import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
4
+ import {
5
+ isLineLifecycleEvent,
6
+ lineInboundConversation,
7
+ type LineEvent,
8
+ } from './protocol.js';
9
+
10
+ function mapLineLifecycleParts(type: LineEvent['type']): { scene_type: string; sub_type: string } {
11
+ switch (type) {
12
+ case 'follow':
13
+ return { scene_type: 'friend', sub_type: 'increase' };
14
+ case 'unfollow':
15
+ return { scene_type: 'friend', sub_type: 'decrease' };
16
+ case 'join':
17
+ return { scene_type: 'group', sub_type: 'member_increase' };
18
+ case 'leave':
19
+ return { scene_type: 'group', sub_type: 'member_decrease' };
20
+ default:
21
+ return { scene_type: 'line', sub_type: type };
22
+ }
23
+ }
24
+
25
+ export function receiveLineSideEvent(
26
+ emit: EndpointEventEmitter,
27
+ endpointKey: string,
28
+ configId: string,
29
+ event: LineEvent,
30
+ logger: ReturnType<typeof getAdapterLogger>,
31
+ ): void {
32
+ if (!isLineLifecycleEvent(event)) return;
33
+ const parts = mapLineLifecycleParts(event.type);
34
+ const conversation = lineInboundConversation(endpointKey, event.source);
35
+ const userId = event.source.userId || conversation.id;
36
+ void emit('notice.receive', buildNotice(event, {
37
+ $id: `line:${event.type}:${event.timestamp}:${userId}`,
38
+ $adapter: 'line' as never,
39
+ $endpoint: configId,
40
+ $type: 'notice',
41
+ $scene_id: conversation.id,
42
+ $scene_type: parts.scene_type,
43
+ $sub_type: parts.sub_type,
44
+ $actor: senderFromId(userId),
45
+ $timestamp: event.timestamp,
46
+ })).catch((err) => {
47
+ logger.warn(formatCompact({
48
+ op: 'line_side_event_failed',
49
+ endpoint: configId,
50
+ event: event.type,
51
+ error: err instanceof Error ? err.message : String(err),
52
+ }));
53
+ });
54
+ }