@zhin.js/adapter-line 1.1.17 → 1.1.19
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 +65 -0
- package/README.md +13 -2
- package/adapters/line/index.js +3 -17
- package/adapters/line/index.ts +2 -17
- package/commands/line/endpoint/definition.js +0 -2
- package/commands/line/endpoint/definition.ts +0 -2
- package/lib/adapter.d.ts +19 -0
- package/lib/adapter.js +24 -0
- package/lib/api-client.d.ts +34 -0
- package/lib/api-client.js +65 -0
- package/lib/client.d.ts +51 -30
- package/lib/client.js +246 -48
- package/lib/console-spec.d.ts +12 -0
- package/lib/console-spec.js +11 -0
- package/lib/console.d.ts +8 -0
- package/lib/console.js +13 -0
- package/lib/endpoint.d.ts +12 -40
- package/lib/endpoint.js +30 -224
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/package.json +24 -16
- package/pages/index/index.js +15 -0
- package/pages/index/index.ts +16 -0
- package/plugin.js +3 -4
- package/src/adapter.ts +29 -0
- package/src/api-client.ts +96 -0
- package/src/client.ts +295 -67
- package/src/console-spec.ts +16 -0
- package/src/console.ts +18 -0
- package/src/endpoint.ts +34 -280
- package/src/index.ts +1 -1
- package/lib/line-runtime-state.d.ts +0 -1
- package/lib/line-runtime-state.js +0 -6
- package/src/line-runtime-state.ts +0 -7
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Console 页面元数据(浏览器与服务端共享;不要在这里 import 服务端代码)。
|
|
3
|
+
*/
|
|
4
|
+
import type { AdapterConsoleField } from 'zhin.js/adapter';
|
|
5
|
+
|
|
6
|
+
export const lineConsoleSpec = {
|
|
7
|
+
adapterKey: 'line',
|
|
8
|
+
adapterDisplayName: 'LINE',
|
|
9
|
+
fields: [
|
|
10
|
+
{ key: 'channelSecret', required: true, env: true, description: 'LINE channel secret' },
|
|
11
|
+
{ key: 'channelAccessToken', required: true, env: true, description: 'LINE channel access token' },
|
|
12
|
+
] as readonly AdapterConsoleField[],
|
|
13
|
+
docsUrl: 'https://zhin.js.org/adapters/line',
|
|
14
|
+
readPermission: 'line:read',
|
|
15
|
+
writePermission: 'line:write',
|
|
16
|
+
} as const;
|
package/src/console.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Console RPC:运行状态 + endpoint 增删(原 `line endpoint` 聊天命令)。
|
|
3
|
+
*
|
|
4
|
+
* 页面绑定 `pages/index`,读取 / 写入分别要求 `<adapter>:read` / `<adapter>:write`。
|
|
5
|
+
*/
|
|
6
|
+
import { createAdapterConsoleRpc, type AdapterConsoleResources } from 'zhin.js/adapter';
|
|
7
|
+
import type { PluginConsoleRpc } from 'zhin.js';
|
|
8
|
+
import { lineConsoleSpec } from './console-spec.js';
|
|
9
|
+
|
|
10
|
+
export function createLineConsoleRpc(
|
|
11
|
+
_resources: AdapterConsoleResources,
|
|
12
|
+
getConfig: () => unknown,
|
|
13
|
+
): PluginConsoleRpc {
|
|
14
|
+
return createAdapterConsoleRpc({
|
|
15
|
+
...lineConsoleSpec,
|
|
16
|
+
getConfig,
|
|
17
|
+
});
|
|
18
|
+
}
|
package/src/endpoint.ts
CHANGED
|
@@ -1,282 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export class LineEndpoint extends Endpoint<LineClient> {
|
|
53
|
-
readonly client: LineClient;
|
|
54
|
-
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
55
|
-
|
|
56
|
-
readonly #options: LineEndpointOptions;
|
|
57
|
-
readonly #fetch: LineFetch;
|
|
58
|
-
#routeReleases: HttpRouteRegistration[] = [];
|
|
59
|
-
#replyTokenCache = new Map<string, { token: string; timestamp: number }>();
|
|
60
|
-
readonly #admissions = new Map<string, { readonly promise: Promise<void>; expires: number }>();
|
|
61
|
-
#open = false;
|
|
62
|
-
#started = false;
|
|
63
|
-
readonly management: EndpointManagement = createLineEndpointManagement(this);
|
|
64
|
-
|
|
65
|
-
constructor(options: LineEndpointOptions) {
|
|
66
|
-
super();
|
|
67
|
-
this.#logger = getAdapterLogger('line', options.config.id);
|
|
68
|
-
this.#options = options;
|
|
69
|
-
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
70
|
-
this.client = new LineClient(options.config, this.#fetch);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Used by webhook handler. */
|
|
74
|
-
get isOpen(): boolean {
|
|
75
|
-
return this.#open;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
get config(): ResolvedLineConfig {
|
|
79
|
-
return this.#options.config;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async start(): Promise<void> {
|
|
83
|
-
if (this.#started) return;
|
|
84
|
-
this.#started = true;
|
|
85
|
-
try {
|
|
86
|
-
this.#routeReleases.push(...registerLineWebhookRoutes(this.#options.http, this));
|
|
87
|
-
this.#logger.debug(formatCompact({
|
|
88
|
-
endpoint: this.#options.config.id,
|
|
89
|
-
op: 'webhook',
|
|
90
|
-
path: this.#options.config.webhookPath,
|
|
91
|
-
}));
|
|
92
|
-
} catch (error) {
|
|
93
|
-
await this.stop();
|
|
94
|
-
this.#logger.error('Failed to connect LINE endpoint:', error);
|
|
95
|
-
throw error;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
open(): void {
|
|
100
|
-
this.#open = true;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
close(): void {
|
|
104
|
-
this.#open = false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async stop(): Promise<void> {
|
|
108
|
-
this.#open = false;
|
|
109
|
-
this.#replyTokenCache.clear();
|
|
110
|
-
this.#admissions.clear();
|
|
111
|
-
for (const release of this.#routeReleases.splice(0)) release();
|
|
112
|
-
this.#started = false;
|
|
113
|
-
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
117
|
-
try {
|
|
118
|
-
const messages = formatOutboundMessages(payload);
|
|
119
|
-
if (messages.length === 0) {
|
|
120
|
-
throw new EndpointDeliveryError('invalid_payload', 'No valid LINE messages to send', 'not_sent');
|
|
121
|
-
}
|
|
122
|
-
// LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
|
|
123
|
-
const target = conversation.id;
|
|
124
|
-
|
|
125
|
-
const cached = this.#replyTokenCache.get(target);
|
|
126
|
-
if (cached) {
|
|
127
|
-
this.#replyTokenCache.delete(target);
|
|
128
|
-
if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
|
|
129
|
-
return await this.#replyMessage(cached.token, messages);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (!isValidLineRecipientId(target)) {
|
|
134
|
-
throw new EndpointDeliveryError('invalid_recipient',
|
|
135
|
-
`Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`, 'not_sent',
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
return await this.#pushMessage(target, messages);
|
|
139
|
-
} catch (error) {
|
|
140
|
-
if (error instanceof EndpointDeliveryError) throw error;
|
|
141
|
-
throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Test / internal: admit a parsed event when open (non-webhook path). */
|
|
146
|
-
admit(event: LineEvent): void {
|
|
147
|
-
if (!this.#open) return;
|
|
148
|
-
void this.admitAccepted(event).catch((err) => {
|
|
149
|
-
this.#logger.warn(formatCompact({ op: 'line_gateway_receive_failed', error: err instanceof Error ? err.message : String(err) }));
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Webhook acknowledgement follows actual runtime generation admission. */
|
|
154
|
-
async admitAccepted(event: LineEvent): Promise<void> {
|
|
155
|
-
if (!this.#open) throw new Error('LINE endpoint is closed');
|
|
156
|
-
const id = event.webhookEventId;
|
|
157
|
-
if (typeof id !== 'string' || !id.trim()) return this.#dispatchAccepted(event);
|
|
158
|
-
const now = Date.now();
|
|
159
|
-
for (const [key, entry] of this.#admissions) if (entry.expires <= now) this.#admissions.delete(key);
|
|
160
|
-
const existing = this.#admissions.get(id);
|
|
161
|
-
if (existing) return existing.promise;
|
|
162
|
-
// Never evict in-flight admissions: overload returns 503 and permits platform retry.
|
|
163
|
-
if (this.#admissions.size >= 10_000) throw new Error('LINE admission dedup capacity exhausted');
|
|
164
|
-
const entry = { promise: Promise.resolve().then(() => this.#dispatchAccepted(event)), expires: Infinity };
|
|
165
|
-
this.#admissions.set(id, entry);
|
|
166
|
-
try {
|
|
167
|
-
await entry.promise;
|
|
168
|
-
if (this.#admissions.get(id) === entry) entry.expires = Date.now() + 24 * 60 * 60 * 1000;
|
|
169
|
-
} catch (error) {
|
|
170
|
-
if (this.#admissions.get(id) === entry) this.#admissions.delete(id);
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async #dispatchAccepted(event: LineEvent): Promise<void> {
|
|
176
|
-
if (!this.#open) throw new Error('LINE endpoint is closed');
|
|
177
|
-
void this.emitPlatform(event.type || 'event', event).catch((error) => {
|
|
178
|
-
this.#logger.warn(formatCompact({
|
|
179
|
-
op: 'line_platform_event_failed',
|
|
180
|
-
event: event.type,
|
|
181
|
-
error: error instanceof Error ? error.message : String(error),
|
|
182
|
-
}));
|
|
183
|
-
});
|
|
184
|
-
if (isLineLifecycleEvent(event)) {
|
|
185
|
-
await receiveLineSideEvent(
|
|
186
|
-
(name, payload) => this.emitAccepted(name, payload),
|
|
187
|
-
String(this.#options.id),
|
|
188
|
-
this.#options.config.id,
|
|
189
|
-
event,
|
|
190
|
-
this.#logger,
|
|
191
|
-
);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
const conversation = lineInboundConversation(String(this.#options.id), event.source);
|
|
195
|
-
if ('replyToken' in event && typeof event.replyToken === 'string') {
|
|
196
|
-
this.#replyTokenCache.set(conversation.id, { token: event.replyToken, timestamp: Date.now() });
|
|
197
|
-
}
|
|
198
|
-
await this.emitAccepted('message.receive', {
|
|
199
|
-
conversation,
|
|
200
|
-
message: { conversation, id: generateMessageId(event) },
|
|
201
|
-
content: formatInboundContent(event),
|
|
202
|
-
sender: { id: event.source.userId || conversation.id },
|
|
203
|
-
endpointId: this.#options.config.id,
|
|
204
|
-
metadata: Object.freeze({
|
|
205
|
-
eventType: event.type,
|
|
206
|
-
sourceType: event.source.type,
|
|
207
|
-
timestamp: event.timestamp,
|
|
208
|
-
...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
|
|
209
|
-
}),
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async #replyMessage(
|
|
214
|
-
replyToken: string,
|
|
215
|
-
messages: ReturnType<typeof formatOutboundMessages>,
|
|
216
|
-
): Promise<string> {
|
|
217
|
-
const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/reply`;
|
|
218
|
-
const response = await this.#fetch(url, {
|
|
219
|
-
method: 'POST',
|
|
220
|
-
headers: {
|
|
221
|
-
'Content-Type': 'application/json',
|
|
222
|
-
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
223
|
-
},
|
|
224
|
-
body: JSON.stringify({ replyToken, messages }),
|
|
225
|
-
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
226
|
-
});
|
|
227
|
-
if (!response.ok) {
|
|
228
|
-
throw await lineResponseError(response);
|
|
229
|
-
}
|
|
230
|
-
const result = await response.json() as LineApiResponse;
|
|
231
|
-
return requireMessageId(result.sentMessages?.[0]?.id);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async #pushMessage(
|
|
235
|
-
to: string,
|
|
236
|
-
messages: ReturnType<typeof formatOutboundMessages>,
|
|
237
|
-
): Promise<string> {
|
|
238
|
-
const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/push`;
|
|
239
|
-
const response = await this.#fetch(url, {
|
|
240
|
-
method: 'POST',
|
|
241
|
-
headers: {
|
|
242
|
-
'Content-Type': 'application/json',
|
|
243
|
-
Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
|
|
244
|
-
},
|
|
245
|
-
body: JSON.stringify({ to, messages }),
|
|
246
|
-
signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
|
|
247
|
-
});
|
|
248
|
-
if (!response.ok) {
|
|
249
|
-
throw await lineResponseError(response);
|
|
250
|
-
}
|
|
251
|
-
const result = await response.json() as LineApiResponse;
|
|
252
|
-
return requireMessageId(result.sentMessages?.[0]?.id);
|
|
1
|
+
import { createEndpointSpecification, endpointSpec, type Endpoint } from 'zhin.js/adapter';
|
|
2
|
+
import { LineClient, type LineEndpointOptions } from './client.js';
|
|
3
|
+
export type { LineEndpointOptions } from './client.js';
|
|
4
|
+
export type { LineFetch } from './client.js';
|
|
5
|
+
|
|
6
|
+
/** Actual protocol Client; framework lifecycle and admission stay in their symbol namespace. */
|
|
7
|
+
export class LineEndpoint extends LineClient implements Endpoint {
|
|
8
|
+
readonly [endpointSpec] = this.#createSpecification();
|
|
9
|
+
constructor(options: LineEndpointOptions) { super(options); }
|
|
10
|
+
#createSpecification() {
|
|
11
|
+
const hooks = {
|
|
12
|
+
start: (signal: AbortSignal) => this.#startFramework(signal),
|
|
13
|
+
open: () => this.openInput(), close: () => this.closeInput(), stop: () => this.disconnectTransport(),
|
|
14
|
+
send: (request: import('zhin.js/adapter').EndpointSendRequest) => this.sendPayload(request),
|
|
15
|
+
name: this.configName(),
|
|
16
|
+
management: this.endpointManagement,
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(hooks, 'transportState', { get: () => this.transportState });
|
|
19
|
+
return createEndpointSpecification(this, hooks);
|
|
20
|
+
}
|
|
21
|
+
#startFramework(signal: AbortSignal) { return this.connectTransport(signal); }
|
|
22
|
+
protected override inboundReceiptCurrent(): boolean { return !this.executionSignal?.aborted; }
|
|
23
|
+
protected override receiveInboundCallback<T>(receive: () => T | Promise<T>): Promise<T> {
|
|
24
|
+
return this[endpointSpec].events.run(receive);
|
|
25
|
+
}
|
|
26
|
+
protected override publishEvent(name: string, payload: unknown): Promise<unknown> {
|
|
27
|
+
return this[endpointSpec].events.emit(name, payload);
|
|
28
|
+
}
|
|
29
|
+
protected override publishAccepted(name: string, payload: unknown): Promise<void> {
|
|
30
|
+
return this[endpointSpec].events.emitAccepted(name, payload).then(() => undefined);
|
|
31
|
+
}
|
|
32
|
+
protected override async publishPlatform(name: string, event: unknown): Promise<unknown> {
|
|
33
|
+
await super.publishPlatform(name, event);
|
|
34
|
+
return this[endpointSpec].events.platform(name, event);
|
|
253
35
|
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* group/room 成员列表:Bot API 无群列表,只能按已知 groupId/roomId 拉成员。
|
|
257
|
-
* members/ids 分页(next continuation token)后逐个取 profile 归一 nickname;
|
|
258
|
-
* 单个 profile 失败(用户已退群等)时回退 userId 占位,不拖垮整批。
|
|
259
|
-
*/
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function createLineEndpointManagement(endpoint: LineEndpoint): EndpointManagement {
|
|
263
|
-
return Object.freeze<EndpointManagement>({
|
|
264
|
-
// listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
|
|
265
|
-
listGroupMembers: (groupId) => endpoint.client.getGroupMembers(groupId),
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async function lineResponseError(response: Awaited<ReturnType<LineFetch>>): Promise<EndpointDeliveryError> {
|
|
270
|
-
// Error responses may carry sentMessages: a failed request can have partial side effects.
|
|
271
|
-
let body: { sentMessages?: unknown[] };
|
|
272
|
-
try { body = await response.json() as { sentMessages?: unknown[] }; }
|
|
273
|
-
catch { return new EndpointDeliveryError('delivery_unconfirmed', `LINE HTTP ${response.status}`, 'unknown'); }
|
|
274
|
-
const disposition = Array.isArray(body?.sentMessages) && body.sentMessages.length > 0
|
|
275
|
-
? 'unknown' : response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 409 ? 'rejected' : 'unknown';
|
|
276
|
-
return new EndpointDeliveryError(disposition === 'unknown' ? 'delivery_unconfirmed' : 'platform_rejected', `LINE HTTP ${response.status}`, disposition);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function requireMessageId(value: unknown): string {
|
|
280
|
-
if (typeof value === 'string' && value.trim().length > 0) return value;
|
|
281
|
-
throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
|
|
282
36
|
}
|
package/src/index.ts
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const lineRuntimeStateToken: import("zhin.js").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -1,6 +0,0 @@
|
|
|
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
|
-
export const lineRuntimeStateToken = defineEndpointRuntimeStateToken('line');
|
|
@@ -1,7 +0,0 @@
|
|
|
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');
|