@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/lib/protocol.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
export function resolveLineConfig(config = {}) {
|
|
7
|
+
const entry = config.endpoints?.find((item) => item.context === 'line');
|
|
8
|
+
const channelSecret = config.channelSecret
|
|
9
|
+
?? entry?.channelSecret
|
|
10
|
+
?? process.env.LINE_CHANNEL_SECRET;
|
|
11
|
+
const channelAccessToken = config.channelAccessToken
|
|
12
|
+
?? entry?.channelAccessToken
|
|
13
|
+
?? process.env.LINE_CHANNEL_ACCESS_TOKEN;
|
|
14
|
+
if (!channelSecret || !channelAccessToken) {
|
|
15
|
+
throw new TypeError('LINE adapter requires channelSecret + channelAccessToken (plugins.<key> or endpoints with context: line)');
|
|
16
|
+
}
|
|
17
|
+
const name = (typeof config.name === 'string' && config.name)
|
|
18
|
+
|| (typeof entry?.name === 'string' && entry.name)
|
|
19
|
+
|| process.env.LINE_BOT_NAME
|
|
20
|
+
|| 'line-bot';
|
|
21
|
+
const webhookPath = normalizeWebhookPath(config.webhookPath ?? entry?.webhookPath ?? '/line/webhook');
|
|
22
|
+
const apiBaseUrl = (config.apiBaseUrl ?? entry?.apiBaseUrl ?? 'https://api.line.me').replace(/\/$/, '');
|
|
23
|
+
return {
|
|
24
|
+
context: 'line',
|
|
25
|
+
name,
|
|
26
|
+
channelSecret,
|
|
27
|
+
channelAccessToken,
|
|
28
|
+
webhookPath,
|
|
29
|
+
apiBaseUrl,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function normalizeWebhookPath(path) {
|
|
33
|
+
const trimmed = path.trim() || '/line/webhook';
|
|
34
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
35
|
+
}
|
|
36
|
+
export function isMessageEvent(event) {
|
|
37
|
+
return event.type === 'message' && 'message' in event && event.message != null;
|
|
38
|
+
}
|
|
39
|
+
export function isPostbackEvent(event) {
|
|
40
|
+
return event.type === 'postback' && 'postback' in event;
|
|
41
|
+
}
|
|
42
|
+
export function resolveChannel(source) {
|
|
43
|
+
switch (source.type) {
|
|
44
|
+
case 'user':
|
|
45
|
+
return { channelType: 'private', channelId: source.userId || '' };
|
|
46
|
+
case 'group':
|
|
47
|
+
return { channelType: 'group', channelId: source.groupId || '' };
|
|
48
|
+
case 'room':
|
|
49
|
+
return { channelType: 'channel', channelId: source.roomId || '' };
|
|
50
|
+
default:
|
|
51
|
+
return { channelType: 'private', channelId: '' };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function generateMessageId(event) {
|
|
55
|
+
if (isMessageEvent(event) && event.message?.id)
|
|
56
|
+
return event.message.id;
|
|
57
|
+
return `${event.type}-${event.timestamp}`;
|
|
58
|
+
}
|
|
59
|
+
/** Build inbound text for MessageGateway.receive. */
|
|
60
|
+
export function formatInboundContent(event) {
|
|
61
|
+
if (isMessageEvent(event)) {
|
|
62
|
+
const msg = event.message;
|
|
63
|
+
switch (msg.type) {
|
|
64
|
+
case 'text':
|
|
65
|
+
return msg.text || '';
|
|
66
|
+
case 'location':
|
|
67
|
+
return msg.address || `[location: ${msg.latitude},${msg.longitude}]`;
|
|
68
|
+
case 'image':
|
|
69
|
+
return '[image]';
|
|
70
|
+
case 'video':
|
|
71
|
+
return '[video]';
|
|
72
|
+
case 'audio':
|
|
73
|
+
return '[audio]';
|
|
74
|
+
case 'file':
|
|
75
|
+
return msg.fileName ? `[file: ${msg.fileName}]` : '[file]';
|
|
76
|
+
case 'sticker':
|
|
77
|
+
return `[sticker: ${msg.packageId}/${msg.stickerId}]`;
|
|
78
|
+
default:
|
|
79
|
+
return `[${msg.type}]`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (event.type === 'follow')
|
|
83
|
+
return '[follow]';
|
|
84
|
+
if (event.type === 'join')
|
|
85
|
+
return '[join]';
|
|
86
|
+
if (event.type === 'unfollow')
|
|
87
|
+
return '[unfollow]';
|
|
88
|
+
if (event.type === 'leave')
|
|
89
|
+
return '[leave]';
|
|
90
|
+
if (event.type === 'postback')
|
|
91
|
+
return `[postback: ${event.postback.data}]`;
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
export function verifySignature(channelSecret, body, signature) {
|
|
95
|
+
const hmac = createHmac('sha256', channelSecret);
|
|
96
|
+
hmac.update(body, 'utf-8');
|
|
97
|
+
const computedSignature = hmac.digest('base64');
|
|
98
|
+
const sigBuf = Buffer.from(signature);
|
|
99
|
+
const computedBuf = Buffer.from(computedSignature);
|
|
100
|
+
if (sigBuf.length !== computedBuf.length)
|
|
101
|
+
return false;
|
|
102
|
+
return timingSafeEqual(sigBuf, computedBuf);
|
|
103
|
+
}
|
|
104
|
+
export function isValidLineRecipientId(id) {
|
|
105
|
+
return /^[UGR]/.test(id);
|
|
106
|
+
}
|
|
107
|
+
function buildTextMessage(text) {
|
|
108
|
+
const truncated = text.length > 5000 ? `${text.slice(0, 4997)}...` : text;
|
|
109
|
+
return { type: 'text', text: truncated };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Wire-encode an already-rendered outbound payload into LINE Reply/Push messages.
|
|
113
|
+
* Segment canonicalization is intentionally not done here.
|
|
114
|
+
*/
|
|
115
|
+
export function formatOutboundMessages(payload) {
|
|
116
|
+
if (typeof payload === 'string') {
|
|
117
|
+
return [buildTextMessage(payload)];
|
|
118
|
+
}
|
|
119
|
+
const items = Array.isArray(payload)
|
|
120
|
+
? payload
|
|
121
|
+
: payload && typeof payload === 'object' && 'type' in payload
|
|
122
|
+
? [payload]
|
|
123
|
+
: [];
|
|
124
|
+
if (items.length === 0) {
|
|
125
|
+
const text = payload == null
|
|
126
|
+
? ''
|
|
127
|
+
: typeof payload === 'object'
|
|
128
|
+
? JSON.stringify(payload)
|
|
129
|
+
: String(payload);
|
|
130
|
+
return [buildTextMessage(text)];
|
|
131
|
+
}
|
|
132
|
+
const messages = [];
|
|
133
|
+
for (const item of items) {
|
|
134
|
+
if (typeof item === 'string') {
|
|
135
|
+
messages.push(buildTextMessage(item));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const data = item.data ?? {};
|
|
139
|
+
switch (item.type) {
|
|
140
|
+
case 'text':
|
|
141
|
+
messages.push(buildTextMessage(String(data.text ?? data.content ?? '')));
|
|
142
|
+
break;
|
|
143
|
+
case 'at':
|
|
144
|
+
if (data.id) {
|
|
145
|
+
messages.push(buildTextMessage(`@${String(data.name || data.id)}`));
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
case 'image':
|
|
149
|
+
if (typeof data.url === 'string' && data.url) {
|
|
150
|
+
messages.push({
|
|
151
|
+
type: 'image',
|
|
152
|
+
originalContentUrl: data.url,
|
|
153
|
+
previewImageUrl: data.url,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
case 'video':
|
|
158
|
+
if (typeof data.url === 'string' && data.url) {
|
|
159
|
+
messages.push({
|
|
160
|
+
type: 'video',
|
|
161
|
+
originalContentUrl: data.url,
|
|
162
|
+
previewImageUrl: typeof data.previewUrl === 'string' ? data.previewUrl : data.url,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
case 'audio':
|
|
167
|
+
if (typeof data.url === 'string' && data.url) {
|
|
168
|
+
messages.push({
|
|
169
|
+
type: 'audio',
|
|
170
|
+
originalContentUrl: data.url,
|
|
171
|
+
duration: typeof data.duration === 'number' ? data.duration : 0,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
case 'location':
|
|
176
|
+
messages.push({
|
|
177
|
+
type: 'location',
|
|
178
|
+
title: String(data.title || 'Location'),
|
|
179
|
+
address: String(data.address || ''),
|
|
180
|
+
latitude: typeof data.latitude === 'number' ? data.latitude : 0,
|
|
181
|
+
longitude: typeof data.longitude === 'number' ? data.longitude : 0,
|
|
182
|
+
});
|
|
183
|
+
break;
|
|
184
|
+
case 'sticker':
|
|
185
|
+
messages.push({
|
|
186
|
+
type: 'sticker',
|
|
187
|
+
packageId: String(data.package_id || '1'),
|
|
188
|
+
stickerId: String(data.sticker_id || '1'),
|
|
189
|
+
});
|
|
190
|
+
break;
|
|
191
|
+
default:
|
|
192
|
+
messages.push(buildTextMessage(`[${item.type}]`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// LINE allows at most 5 messages per Reply/Push request.
|
|
196
|
+
return messages.slice(0, 5);
|
|
197
|
+
}
|
|
198
|
+
export async function readTextBody(request, options = {}) {
|
|
199
|
+
const limit = options.limit ?? 1_048_576;
|
|
200
|
+
const chunks = [];
|
|
201
|
+
let size = 0;
|
|
202
|
+
for await (const chunk of request) {
|
|
203
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
204
|
+
size += buffer.length;
|
|
205
|
+
if (size > limit) {
|
|
206
|
+
request.destroy();
|
|
207
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
208
|
+
}
|
|
209
|
+
chunks.push(buffer);
|
|
210
|
+
}
|
|
211
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
212
|
+
}
|
package/lib/webhook.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE webhook HTTP: signature → parse → admit.
|
|
3
|
+
*/
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
6
|
+
import { type LineEvent, type ResolvedLineConfig } from './protocol.js';
|
|
7
|
+
export interface LineWebhookHandler {
|
|
8
|
+
readonly config: ResolvedLineConfig;
|
|
9
|
+
readonly isOpen: boolean;
|
|
10
|
+
admit(event: LineEvent): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function registerLineWebhookRoutes(http: HttpHost, handler: LineWebhookHandler): HttpRouteRegistration[];
|
|
13
|
+
export declare function handleLineWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: LineWebhookHandler): Promise<void>;
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
|
+
import { readTextBody, verifySignature, } from './protocol.js';
|
|
3
|
+
const logger = getLogger('line');
|
|
4
|
+
export function registerLineWebhookRoutes(http, handler) {
|
|
5
|
+
const path = handler.config.webhookPath;
|
|
6
|
+
return [
|
|
7
|
+
http.route('POST', path, async (request, response) => {
|
|
8
|
+
await handleLineWebhookRequest(request, response, handler);
|
|
9
|
+
}, { summary: 'LINE Messaging API webhook', tags: ['line'] }),
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
export async function handleLineWebhookRequest(request, response, handler) {
|
|
13
|
+
try {
|
|
14
|
+
const signature = request.headers['x-line-signature'];
|
|
15
|
+
const signatureValue = Array.isArray(signature) ? signature[0] : signature;
|
|
16
|
+
if (!signatureValue) {
|
|
17
|
+
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
18
|
+
response.end(JSON.stringify({ message: 'Missing signature' }));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const rawBody = await readTextBody(request);
|
|
22
|
+
if (!verifySignature(handler.config.channelSecret, rawBody, signatureValue)) {
|
|
23
|
+
logger.warn(formatCompact({ op: 'webhook', ok: false, error: 'invalid signature' }));
|
|
24
|
+
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
25
|
+
response.end(JSON.stringify({ message: 'Invalid signature' }));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let body;
|
|
29
|
+
try {
|
|
30
|
+
body = JSON.parse(rawBody);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
34
|
+
response.end(JSON.stringify({ message: 'OK' }));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (handler.isOpen && Array.isArray(body.events)) {
|
|
38
|
+
for (const event of body.events) {
|
|
39
|
+
handler.admit(event);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
43
|
+
response.end(JSON.stringify({ message: 'OK' }));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
logger.error('LINE webhook error:', error);
|
|
47
|
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
48
|
+
response.end(JSON.stringify({ message: 'OK' }));
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-line",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"description": "Zhin.js adapter for
|
|
3
|
+
"version": "2.0.3",
|
|
4
|
+
"description": "Zhin.js LINE Messaging API adapter for Plugin Runtime (HTTP webhook)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
@@ -31,30 +31,24 @@
|
|
|
31
31
|
"type": "git",
|
|
32
32
|
"directory": "plugins/adapters/line"
|
|
33
33
|
},
|
|
34
|
-
"
|
|
35
|
-
"@
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"@zhin.js/
|
|
39
|
-
"@zhin.js/
|
|
40
|
-
"@zhin.js/host-api": "2.0.5",
|
|
41
|
-
"@zhin.js/host-router": "2.0.3",
|
|
42
|
-
"@zhin.js/logger": "1.0.74",
|
|
43
|
-
"zhin.js": "4.1.2"
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@zhin.js/adapter": "1.0.1",
|
|
36
|
+
"@zhin.js/core": "1.3.5",
|
|
37
|
+
"@zhin.js/host-http": "1.0.1",
|
|
38
|
+
"@zhin.js/logger": "1.0.75",
|
|
39
|
+
"@zhin.js/plugin-runtime": "1.0.1"
|
|
44
40
|
},
|
|
45
41
|
"peerDependencies": {
|
|
46
42
|
"zod": "^4.0.0",
|
|
47
|
-
"@zhin.js/
|
|
48
|
-
"@zhin.js/
|
|
49
|
-
"@zhin.js/
|
|
50
|
-
"@zhin.js/
|
|
51
|
-
"zhin.js": "
|
|
43
|
+
"@zhin.js/adapter": "1.0.1",
|
|
44
|
+
"@zhin.js/agent": "1.0.4",
|
|
45
|
+
"@zhin.js/core": "1.3.5",
|
|
46
|
+
"@zhin.js/host-http": "1.0.1",
|
|
47
|
+
"@zhin.js/plugin-runtime": "1.0.1",
|
|
48
|
+
"zhin.js": "4.1.3"
|
|
52
49
|
},
|
|
53
50
|
"peerDependenciesMeta": {
|
|
54
|
-
"
|
|
55
|
-
"optional": true
|
|
56
|
-
},
|
|
57
|
-
"@zhin.js/host-api": {
|
|
51
|
+
"zhin.js": {
|
|
58
52
|
"optional": true
|
|
59
53
|
},
|
|
60
54
|
"@zhin.js/agent": {
|
|
@@ -64,11 +58,20 @@
|
|
|
64
58
|
"optional": true
|
|
65
59
|
}
|
|
66
60
|
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^26.1.0",
|
|
63
|
+
"typescript": "^6.0.3",
|
|
64
|
+
"vitest": "^4.1.10",
|
|
65
|
+
"zod": "^4.4.3",
|
|
66
|
+
"@zhin.js/agent": "1.0.4"
|
|
67
|
+
},
|
|
67
68
|
"files": [
|
|
69
|
+
"adapters",
|
|
70
|
+
"plugin.ts",
|
|
71
|
+
"schema.json",
|
|
68
72
|
"src",
|
|
69
73
|
"lib",
|
|
70
74
|
"agent",
|
|
71
|
-
"plugin.yml",
|
|
72
75
|
"README.md",
|
|
73
76
|
"CHANGELOG.md"
|
|
74
77
|
],
|
|
@@ -79,9 +82,23 @@
|
|
|
79
82
|
"engines": {
|
|
80
83
|
"node": "^20.19.0 || >=22.12.0"
|
|
81
84
|
},
|
|
85
|
+
"zhin": {
|
|
86
|
+
"protocol": 1,
|
|
87
|
+
"type": "plugin",
|
|
88
|
+
"entry": "./plugin.ts",
|
|
89
|
+
"engine": "^1.0.0",
|
|
90
|
+
"runtime": "trusted",
|
|
91
|
+
"features": [
|
|
92
|
+
{
|
|
93
|
+
"package": "@zhin.js/adapter",
|
|
94
|
+
"api": "^1.0.0"
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
"plugins": []
|
|
98
|
+
},
|
|
82
99
|
"scripts": {
|
|
83
|
-
"build": "
|
|
84
|
-
"clean": "rimraf lib
|
|
85
|
-
"
|
|
100
|
+
"build": "tsc",
|
|
101
|
+
"clean": "rimraf lib",
|
|
102
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/line/tests"
|
|
86
103
|
}
|
|
87
104
|
}
|
package/plugin.ts
ADDED
package/schema.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"additionalProperties": false,
|
|
5
|
+
"properties": {
|
|
6
|
+
"name": {
|
|
7
|
+
"type": "string",
|
|
8
|
+
"default": "line-bot"
|
|
9
|
+
},
|
|
10
|
+
"channelSecret": { "type": "string" },
|
|
11
|
+
"channelAccessToken": { "type": "string" },
|
|
12
|
+
"webhookPath": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"default": "/line/webhook"
|
|
15
|
+
},
|
|
16
|
+
"apiBaseUrl": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"default": "https://api.line.me"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"required": ["channelSecret", "channelAccessToken"]
|
|
22
|
+
}
|