@zhin.js/adapter-line 1.1.18 → 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/lib/client.js CHANGED
@@ -1,54 +1,252 @@
1
- import { defineEndpointClient } from 'zhin.js/adapter';
2
- /** Direct LINE Messaging API client; it has no Endpoint lifecycle methods. */
3
- export class LineClient {
4
- config;
5
- fetch;
6
- constructor(config, fetch) {
7
- this.config = config;
8
- this.fetch = fetch;
9
- }
10
- async request(path, init = {}) {
11
- const response = await this.fetch(`${this.config.apiBaseUrl}${path}`, {
12
- method: init.method ?? 'GET',
1
+ import { LineApiClient } from './api-client.js';
2
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
3
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
4
+ import { formatInboundContent, formatOutboundMessages, generateMessageId, isLineLifecycleEvent, isMessageEvent, isValidLineRecipientId, lineInboundConversation, } from './protocol.js';
5
+ import { registerLineWebhookRoutes } from './webhook.js';
6
+ import { receiveLineSideEvent } from './side-event-dispatch.js';
7
+ /** LINE replyToken 有效期短,过期后 reply 必 400;缓存带时间戳,超时弃用改走 push。 */
8
+ const REPLY_TOKEN_TTL_MS = 60_000;
9
+ /** 出站 HTTP 调用统一 30s 超时。 */
10
+ const OUTBOUND_TIMEOUT_MS = 30_000;
11
+ export class LineClient extends LineApiClient {
12
+ #logger;
13
+ #options;
14
+ #fetch;
15
+ #routeReleases = [];
16
+ #replyTokenCache = new Map();
17
+ #admissions = new Map();
18
+ #open = false;
19
+ #started = false;
20
+ endpointManagement = createLineEndpointManagement(this);
21
+ configName() { return this.#options.config.id; }
22
+ get transportState() { return undefined; }
23
+ executionSignal;
24
+ setExecutionSignal(signal) { this.executionSignal = signal; this.transportSignal = signal; }
25
+ /** Native protocol work runs here; Endpoint attaches the framework receipt without replacing SDK APIs. */
26
+ receiveInboundCallback(receive) {
27
+ try {
28
+ return Promise.resolve(receive());
29
+ }
30
+ catch (error) {
31
+ return Promise.reject(error);
32
+ }
33
+ }
34
+ publishEvent(name, payload) { this.emit(name, payload); return Promise.resolve(); }
35
+ publishAccepted(name, payload) { return this.publishEvent(name, payload).then(() => undefined); }
36
+ publishPlatform(name, event) { this.emit(name, event); return Promise.resolve(); }
37
+ constructor(options) {
38
+ super({ config: options.config, fetch: options.fetch ?? globalThis.fetch });
39
+ this.#logger = getAdapterLogger('line', options.config.id);
40
+ this.#options = options;
41
+ const transportFetch = options.fetch ?? globalThis.fetch;
42
+ this.#fetch = (url, init) => {
43
+ this.executionSignal?.throwIfAborted();
44
+ return transportFetch(url, { ...init, ...(this.executionSignal ? { signal: init?.signal ? AbortSignal.any([this.executionSignal, init.signal]) : this.executionSignal } : {}) });
45
+ };
46
+ }
47
+ /** Used by webhook handler. */
48
+ get isOpen() {
49
+ return this.#open;
50
+ }
51
+ get config() {
52
+ return this.#options.config;
53
+ }
54
+ async connectTransport(signal) {
55
+ if (signal) {
56
+ this.setExecutionSignal(signal);
57
+ signal.throwIfAborted();
58
+ }
59
+ if (this.#started)
60
+ return;
61
+ this.#started = true;
62
+ try {
63
+ this.#routeReleases.push(...registerLineWebhookRoutes(this.#options.http, this));
64
+ this.#logger.debug(formatCompact({
65
+ endpoint: this.#options.config.id,
66
+ op: 'webhook',
67
+ path: this.#options.config.webhookPath,
68
+ }));
69
+ }
70
+ catch (error) {
71
+ await this.disconnectTransport();
72
+ this.#logger.error('Failed to connect LINE endpoint:', error);
73
+ throw error;
74
+ }
75
+ }
76
+ openInput() {
77
+ this.#open = true;
78
+ }
79
+ closeInput() {
80
+ this.#open = false;
81
+ }
82
+ async disconnectTransport() {
83
+ this.#open = false;
84
+ this.#replyTokenCache.clear();
85
+ this.#admissions.clear();
86
+ for (const release of this.#routeReleases.splice(0))
87
+ release();
88
+ this.#started = false;
89
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
90
+ }
91
+ async sendPayload({ conversation, payload, signal }) {
92
+ try {
93
+ const messages = formatOutboundMessages(payload);
94
+ if (messages.length === 0) {
95
+ throw new EndpointDeliveryError('invalid_payload', 'No valid LINE messages to send', 'not_sent');
96
+ }
97
+ // LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
98
+ const target = conversation.id;
99
+ const cached = this.#replyTokenCache.get(target);
100
+ if (cached) {
101
+ this.#replyTokenCache.delete(target);
102
+ if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
103
+ return await this.#replyMessage({ replyToken: cached.token, messages, signal });
104
+ }
105
+ }
106
+ if (!isValidLineRecipientId(target)) {
107
+ throw new EndpointDeliveryError('invalid_recipient', `Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`, 'not_sent');
108
+ }
109
+ return await this.#pushMessage({ to: target, messages, signal });
110
+ }
111
+ catch (error) {
112
+ if (error instanceof EndpointDeliveryError)
113
+ throw error;
114
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
115
+ }
116
+ }
117
+ /** Test / internal: admit a parsed event when open (non-webhook path). */
118
+ admit(event) {
119
+ if (!this.#open)
120
+ return;
121
+ void this.admitAccepted(event).catch((err) => {
122
+ this.#logger.warn(formatCompact({ op: 'line_gateway_receive_failed', error: err instanceof Error ? err.message : String(err) }));
123
+ });
124
+ }
125
+ /** Webhook acknowledgement follows actual runtime generation admission. */
126
+ async admitAccepted(event) {
127
+ if (!this.#open)
128
+ throw new Error('LINE endpoint is closed');
129
+ return this.receiveInboundCallback(() => this.#acceptMessage(event));
130
+ }
131
+ async #acceptMessage(event) {
132
+ if (!this.#open)
133
+ throw new Error('LINE endpoint is closed');
134
+ const id = event.webhookEventId;
135
+ if (typeof id !== 'string' || !id.trim())
136
+ return this.#dispatchAccepted(event);
137
+ const now = Date.now();
138
+ for (const [key, entry] of this.#admissions)
139
+ if (entry.expires <= now)
140
+ this.#admissions.delete(key);
141
+ const existing = this.#admissions.get(id);
142
+ if (existing)
143
+ return existing.promise;
144
+ // Never evict in-flight admissions: overload returns 503 and permits platform retry.
145
+ if (this.#admissions.size >= 10_000)
146
+ throw new Error('LINE admission dedup capacity exhausted');
147
+ const entry = { promise: Promise.resolve().then(() => this.#dispatchAccepted(event)), expires: Infinity };
148
+ this.#admissions.set(id, entry);
149
+ try {
150
+ await entry.promise;
151
+ if (this.#admissions.get(id) === entry)
152
+ entry.expires = Date.now() + 24 * 60 * 60 * 1000;
153
+ }
154
+ catch (error) {
155
+ if (this.#admissions.get(id) === entry)
156
+ this.#admissions.delete(id);
157
+ throw error;
158
+ }
159
+ }
160
+ inboundReceiptCurrent() { return this.#open && !this.executionSignal?.aborted; }
161
+ async #dispatchAccepted(event) {
162
+ if (!this.inboundReceiptCurrent())
163
+ throw new Error('LINE endpoint is closed');
164
+ void this.publishPlatform(event.type || 'event', event).catch((error) => {
165
+ this.#logger.warn(formatCompact({
166
+ op: 'line_platform_event_failed',
167
+ event: event.type,
168
+ error: error instanceof Error ? error.message : String(error),
169
+ }));
170
+ });
171
+ if (isLineLifecycleEvent(event)) {
172
+ await receiveLineSideEvent((name, payload) => this.publishAccepted(name, payload), String(this.#options.id), this.#options.config.id, event, this.#logger);
173
+ return;
174
+ }
175
+ const conversation = lineInboundConversation(String(this.#options.id), event.source);
176
+ if ('replyToken' in event && typeof event.replyToken === 'string') {
177
+ this.#replyTokenCache.set(conversation.id, { token: event.replyToken, timestamp: Date.now() });
178
+ }
179
+ await this.publishAccepted('message.receive', {
180
+ event,
181
+ conversation,
182
+ message: { conversation, id: generateMessageId(event) },
183
+ content: formatInboundContent(event),
184
+ ...(event.source.userId ? { sender: { id: event.source.userId } } : {}),
185
+ endpointId: this.#options.config.id,
186
+ metadata: Object.freeze({
187
+ eventType: event.type,
188
+ sourceType: event.source.type,
189
+ timestamp: event.timestamp,
190
+ ...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
191
+ }),
192
+ });
193
+ }
194
+ async #replyMessage({ replyToken, messages, signal }) {
195
+ const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/reply`;
196
+ const response = await this.#fetch(url, {
197
+ method: 'POST',
13
198
  headers: {
14
- Authorization: `Bearer ${this.config.channelAccessToken}`,
15
- ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
199
+ 'Content-Type': 'application/json',
200
+ Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
16
201
  },
17
- ...(init.body === undefined ? {} : { body: init.body }),
18
- signal: init.signal ?? AbortSignal.timeout(30_000),
202
+ body: JSON.stringify({ replyToken, messages }),
203
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(OUTBOUND_TIMEOUT_MS)]) : AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
19
204
  });
20
205
  if (!response.ok) {
21
- const text = await response.text();
22
- throw new Error(`LINE API error ${response.status}: ${text}`);
23
- }
24
- return await response.json();
25
- }
26
- getProfile(userId) {
27
- return this.request(`/v2/profile/${encodeURIComponent(userId)}`);
28
- }
29
- getGroupMemberIds(groupId, start) {
30
- const kind = groupId.startsWith('R') ? 'room' : 'group';
31
- const query = start ? `?start=${encodeURIComponent(start)}` : '';
32
- return this.request(`/v2/bot/${kind}/${encodeURIComponent(groupId)}/members/ids${query}`);
33
- }
34
- async getGroupMembers(groupId) {
35
- const kind = groupId.startsWith('R') ? 'room' : 'group';
36
- const ids = [];
37
- let next;
38
- do {
39
- const page = await this.getGroupMemberIds(groupId, next);
40
- ids.push(...(page.memberIds ?? []).filter((id) => typeof id === 'string' && id.length > 0));
41
- next = page.next || undefined;
42
- } while (next);
43
- return Promise.all(ids.map(async (userId) => {
44
- try {
45
- const profile = await this.request(`/v2/bot/${kind}/${encodeURIComponent(groupId)}/member/${encodeURIComponent(userId)}`);
46
- return { user_id: userId, nickname: String(profile.displayName ?? userId) };
47
- }
48
- catch {
49
- return { user_id: userId, nickname: userId };
50
- }
51
- }));
206
+ throw await lineResponseError(response);
207
+ }
208
+ const result = await response.json();
209
+ return requireMessageId(result.sentMessages?.[0]?.id);
52
210
  }
211
+ async #pushMessage({ to, messages, signal }) {
212
+ const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/push`;
213
+ const response = await this.#fetch(url, {
214
+ method: 'POST',
215
+ headers: {
216
+ 'Content-Type': 'application/json',
217
+ Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
218
+ },
219
+ body: JSON.stringify({ to, messages }),
220
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(OUTBOUND_TIMEOUT_MS)]) : AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
221
+ });
222
+ if (!response.ok) {
223
+ throw await lineResponseError(response);
224
+ }
225
+ const result = await response.json();
226
+ return requireMessageId(result.sentMessages?.[0]?.id);
227
+ }
228
+ }
229
+ function createLineEndpointManagement(endpoint) {
230
+ return Object.freeze({
231
+ // listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
232
+ listGroupMembers: ({ groupId, signal }) => endpoint.getGroupMembers(groupId, { signal }),
233
+ });
234
+ }
235
+ async function lineResponseError(response) {
236
+ // Error responses may carry sentMessages: a failed request can have partial side effects.
237
+ let body;
238
+ try {
239
+ body = await response.json();
240
+ }
241
+ catch {
242
+ return new EndpointDeliveryError('delivery_unconfirmed', `LINE HTTP ${response.status}`, 'unknown');
243
+ }
244
+ const disposition = Array.isArray(body?.sentMessages) && body.sentMessages.length > 0
245
+ ? 'unknown' : response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 409 ? 'rejected' : 'unknown';
246
+ return new EndpointDeliveryError(disposition === 'unknown' ? 'delivery_unconfirmed' : 'platform_rejected', `LINE HTTP ${response.status}`, disposition);
247
+ }
248
+ function requireMessageId(value) {
249
+ if (typeof value === 'string' && value.trim().length > 0)
250
+ return value;
251
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
53
252
  }
54
- export const lineClient = defineEndpointClient('line');
package/lib/console.d.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  */
6
6
  import { type AdapterConsoleResources } from 'zhin.js/adapter';
7
7
  import type { PluginConsoleRpc } from 'zhin.js';
8
- export declare function createLineConsoleRpc(resources: AdapterConsoleResources, getConfig: () => unknown): PluginConsoleRpc;
8
+ export declare function createLineConsoleRpc(_resources: AdapterConsoleResources, getConfig: () => unknown): PluginConsoleRpc;
package/lib/console.js CHANGED
@@ -4,13 +4,10 @@
4
4
  * 页面绑定 `pages/index`,读取 / 写入分别要求 `<adapter>:read` / `<adapter>:write`。
5
5
  */
6
6
  import { createAdapterConsoleRpc } from 'zhin.js/adapter';
7
- import { lineRuntimeStateToken } from './line-runtime-state.js';
8
7
  import { lineConsoleSpec } from './console-spec.js';
9
- export function createLineConsoleRpc(resources, getConfig) {
10
- const state = resources.use(lineRuntimeStateToken);
8
+ export function createLineConsoleRpc(_resources, getConfig) {
11
9
  return createAdapterConsoleRpc({
12
10
  ...lineConsoleSpec,
13
- running: () => state.endpoints.values(),
14
11
  getConfig,
15
- }, resources);
12
+ });
16
13
  }
package/lib/endpoint.d.ts CHANGED
@@ -1,43 +1,15 @@
1
- /**
2
- * LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
3
- */
4
- import { Endpoint, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
5
- import type { HttpHost } from '@zhin.js/host-http';
6
- import type { CapabilityId } from 'zhin.js';
7
- import { type LineEvent, type ResolvedLineConfig } from './protocol.js';
8
- import { LineClient } from './client.js';
9
- export type LineFetch = (url: string, init?: {
10
- readonly method?: string;
11
- readonly headers?: Record<string, string>;
12
- readonly body?: string;
13
- readonly signal?: AbortSignal;
14
- }) => Promise<{
15
- readonly ok: boolean;
16
- readonly status: number;
17
- text(): Promise<string>;
18
- json(): Promise<unknown>;
19
- }>;
20
- export interface LineEndpointOptions {
21
- readonly id: CapabilityId;
22
- readonly http: HttpHost;
23
- readonly config: ResolvedLineConfig;
24
- readonly fetch?: LineFetch;
25
- }
26
- export declare class LineEndpoint extends Endpoint<LineClient> {
1
+ import { 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
+ /** Actual protocol Client; framework lifecycle and admission stay in their symbol namespace. */
6
+ export declare class LineEndpoint extends LineClient implements Endpoint {
27
7
  #private;
28
- readonly client: LineClient;
29
- readonly management: EndpointManagement;
8
+ readonly [endpointSpec]: import("@zhin.js/adapter").EndpointSpecification;
30
9
  constructor(options: LineEndpointOptions);
31
- /** Used by webhook handler. */
32
- get isOpen(): boolean;
33
- get config(): ResolvedLineConfig;
34
- start(): Promise<void>;
35
- open(): void;
36
- close(): void;
37
- stop(): Promise<void>;
38
- send({ conversation, payload }: EndpointSendRequest): Promise<string>;
39
- /** Test / internal: admit a parsed event when open (non-webhook path). */
40
- admit(event: LineEvent): void;
41
- /** Webhook acknowledgement follows actual runtime generation admission. */
42
- admitAccepted(event: LineEvent): Promise<void>;
10
+ protected inboundReceiptCurrent(): boolean;
11
+ protected receiveInboundCallback<T>(receive: () => T | Promise<T>): Promise<T>;
12
+ protected publishEvent(name: string, payload: unknown): Promise<unknown>;
13
+ protected publishAccepted(name: string, payload: unknown): Promise<void>;
14
+ protected publishPlatform(name: string, event: unknown): Promise<unknown>;
43
15
  }
package/lib/endpoint.js CHANGED
@@ -1,227 +1,33 @@
1
- import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
- /**
3
- * LineEndpoint — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
4
- */
5
- import { Endpoint } from 'zhin.js/adapter';
6
- import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
7
- import { formatInboundContent, formatOutboundMessages, generateMessageId, isLineLifecycleEvent, isMessageEvent, isValidLineRecipientId, lineInboundConversation, } from './protocol.js';
8
- import { registerLineWebhookRoutes } from './webhook.js';
9
- import { receiveLineSideEvent } from './side-event-dispatch.js';
1
+ import { createEndpointSpecification, endpointSpec } from 'zhin.js/adapter';
10
2
  import { LineClient } from './client.js';
11
- /** LINE replyToken 有效期短,过期后 reply 必 400;缓存带时间戳,超时弃用改走 push。 */
12
- const REPLY_TOKEN_TTL_MS = 60_000;
13
- /** 出站 HTTP 调用统一 30s 超时。 */
14
- const OUTBOUND_TIMEOUT_MS = 30_000;
15
- export class LineEndpoint extends Endpoint {
16
- client;
17
- #logger;
18
- #options;
19
- #fetch;
20
- #routeReleases = [];
21
- #replyTokenCache = new Map();
22
- #admissions = new Map();
23
- #open = false;
24
- #started = false;
25
- management = createLineEndpointManagement(this);
26
- constructor(options) {
27
- super();
28
- this.#logger = getAdapterLogger('line', options.config.id);
29
- this.#options = options;
30
- this.#fetch = options.fetch ?? globalThis.fetch;
31
- this.client = new LineClient(options.config, this.#fetch);
3
+ /** Actual protocol Client; framework lifecycle and admission stay in their symbol namespace. */
4
+ export class LineEndpoint extends LineClient {
5
+ [endpointSpec] = this.#createSpecification();
6
+ constructor(options) { super(options); }
7
+ #createSpecification() {
8
+ const hooks = {
9
+ start: (signal) => this.#startFramework(signal),
10
+ open: () => this.openInput(), close: () => this.closeInput(), stop: () => this.disconnectTransport(),
11
+ send: (request) => this.sendPayload(request),
12
+ name: this.configName(),
13
+ management: this.endpointManagement,
14
+ };
15
+ Object.defineProperty(hooks, 'transportState', { get: () => this.transportState });
16
+ return createEndpointSpecification(this, hooks);
17
+ }
18
+ #startFramework(signal) { return this.connectTransport(signal); }
19
+ inboundReceiptCurrent() { return !this.executionSignal?.aborted; }
20
+ receiveInboundCallback(receive) {
21
+ return this[endpointSpec].events.run(receive);
22
+ }
23
+ publishEvent(name, payload) {
24
+ return this[endpointSpec].events.emit(name, payload);
25
+ }
26
+ publishAccepted(name, payload) {
27
+ return this[endpointSpec].events.emitAccepted(name, payload).then(() => undefined);
28
+ }
29
+ async publishPlatform(name, event) {
30
+ await super.publishPlatform(name, event);
31
+ return this[endpointSpec].events.platform(name, event);
32
32
  }
33
- /** Used by webhook handler. */
34
- get isOpen() {
35
- return this.#open;
36
- }
37
- get config() {
38
- return this.#options.config;
39
- }
40
- async start() {
41
- if (this.#started)
42
- return;
43
- this.#started = true;
44
- try {
45
- this.#routeReleases.push(...registerLineWebhookRoutes(this.#options.http, this));
46
- this.#logger.debug(formatCompact({
47
- endpoint: this.#options.config.id,
48
- op: 'webhook',
49
- path: this.#options.config.webhookPath,
50
- }));
51
- }
52
- catch (error) {
53
- await this.stop();
54
- this.#logger.error('Failed to connect LINE endpoint:', error);
55
- throw error;
56
- }
57
- }
58
- open() {
59
- this.#open = true;
60
- }
61
- close() {
62
- this.#open = false;
63
- }
64
- async stop() {
65
- this.#open = false;
66
- this.#replyTokenCache.clear();
67
- this.#admissions.clear();
68
- for (const release of this.#routeReleases.splice(0))
69
- release();
70
- this.#started = false;
71
- this.#logger.debug(formatCompact({ op: 'disconnect' }));
72
- }
73
- async send({ conversation, payload }) {
74
- try {
75
- const messages = formatOutboundMessages(payload);
76
- if (messages.length === 0) {
77
- throw new EndpointDeliveryError('invalid_payload', 'No valid LINE messages to send', 'not_sent');
78
- }
79
- // LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
80
- const target = conversation.id;
81
- const cached = this.#replyTokenCache.get(target);
82
- if (cached) {
83
- this.#replyTokenCache.delete(target);
84
- if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
85
- return await this.#replyMessage(cached.token, messages);
86
- }
87
- }
88
- if (!isValidLineRecipientId(target)) {
89
- throw new EndpointDeliveryError('invalid_recipient', `Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`, 'not_sent');
90
- }
91
- return await this.#pushMessage(target, messages);
92
- }
93
- catch (error) {
94
- if (error instanceof EndpointDeliveryError)
95
- throw error;
96
- throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
97
- }
98
- }
99
- /** Test / internal: admit a parsed event when open (non-webhook path). */
100
- admit(event) {
101
- if (!this.#open)
102
- return;
103
- void this.admitAccepted(event).catch((err) => {
104
- this.#logger.warn(formatCompact({ op: 'line_gateway_receive_failed', error: err instanceof Error ? err.message : String(err) }));
105
- });
106
- }
107
- /** Webhook acknowledgement follows actual runtime generation admission. */
108
- async admitAccepted(event) {
109
- if (!this.#open)
110
- throw new Error('LINE endpoint is closed');
111
- const id = event.webhookEventId;
112
- if (typeof id !== 'string' || !id.trim())
113
- return this.#dispatchAccepted(event);
114
- const now = Date.now();
115
- for (const [key, entry] of this.#admissions)
116
- if (entry.expires <= now)
117
- this.#admissions.delete(key);
118
- const existing = this.#admissions.get(id);
119
- if (existing)
120
- return existing.promise;
121
- // Never evict in-flight admissions: overload returns 503 and permits platform retry.
122
- if (this.#admissions.size >= 10_000)
123
- throw new Error('LINE admission dedup capacity exhausted');
124
- const entry = { promise: Promise.resolve().then(() => this.#dispatchAccepted(event)), expires: Infinity };
125
- this.#admissions.set(id, entry);
126
- try {
127
- await entry.promise;
128
- if (this.#admissions.get(id) === entry)
129
- entry.expires = Date.now() + 24 * 60 * 60 * 1000;
130
- }
131
- catch (error) {
132
- if (this.#admissions.get(id) === entry)
133
- this.#admissions.delete(id);
134
- throw error;
135
- }
136
- }
137
- async #dispatchAccepted(event) {
138
- if (!this.#open)
139
- throw new Error('LINE endpoint is closed');
140
- void this.emitPlatform(event.type || 'event', event).catch((error) => {
141
- this.#logger.warn(formatCompact({
142
- op: 'line_platform_event_failed',
143
- event: event.type,
144
- error: error instanceof Error ? error.message : String(error),
145
- }));
146
- });
147
- if (isLineLifecycleEvent(event)) {
148
- await receiveLineSideEvent((name, payload) => this.emitAccepted(name, payload), String(this.#options.id), this.#options.config.id, event, this.#logger);
149
- return;
150
- }
151
- const conversation = lineInboundConversation(String(this.#options.id), event.source);
152
- if ('replyToken' in event && typeof event.replyToken === 'string') {
153
- this.#replyTokenCache.set(conversation.id, { token: event.replyToken, timestamp: Date.now() });
154
- }
155
- await this.emitAccepted('message.receive', {
156
- conversation,
157
- message: { conversation, id: generateMessageId(event) },
158
- content: formatInboundContent(event),
159
- sender: { id: event.source.userId || conversation.id },
160
- endpointId: this.#options.config.id,
161
- metadata: Object.freeze({
162
- eventType: event.type,
163
- sourceType: event.source.type,
164
- timestamp: event.timestamp,
165
- ...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
166
- }),
167
- });
168
- }
169
- async #replyMessage(replyToken, messages) {
170
- const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/reply`;
171
- const response = await this.#fetch(url, {
172
- method: 'POST',
173
- headers: {
174
- 'Content-Type': 'application/json',
175
- Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
176
- },
177
- body: JSON.stringify({ replyToken, messages }),
178
- signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
179
- });
180
- if (!response.ok) {
181
- throw await lineResponseError(response);
182
- }
183
- const result = await response.json();
184
- return requireMessageId(result.sentMessages?.[0]?.id);
185
- }
186
- async #pushMessage(to, messages) {
187
- const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/push`;
188
- const response = await this.#fetch(url, {
189
- method: 'POST',
190
- headers: {
191
- 'Content-Type': 'application/json',
192
- Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
193
- },
194
- body: JSON.stringify({ to, messages }),
195
- signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
196
- });
197
- if (!response.ok) {
198
- throw await lineResponseError(response);
199
- }
200
- const result = await response.json();
201
- return requireMessageId(result.sentMessages?.[0]?.id);
202
- }
203
- }
204
- function createLineEndpointManagement(endpoint) {
205
- return Object.freeze({
206
- // listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
207
- listGroupMembers: (groupId) => endpoint.client.getGroupMembers(groupId),
208
- });
209
- }
210
- async function lineResponseError(response) {
211
- // Error responses may carry sentMessages: a failed request can have partial side effects.
212
- let body;
213
- try {
214
- body = await response.json();
215
- }
216
- catch {
217
- return new EndpointDeliveryError('delivery_unconfirmed', `LINE HTTP ${response.status}`, 'unknown');
218
- }
219
- const disposition = Array.isArray(body?.sentMessages) && body.sentMessages.length > 0
220
- ? 'unknown' : response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 409 ? 'rejected' : 'unknown';
221
- return new EndpointDeliveryError(disposition === 'unknown' ? 'delivery_unconfirmed' : 'platform_rejected', `LINE HTTP ${response.status}`, disposition);
222
- }
223
- function requireMessageId(value) {
224
- if (typeof value === 'string' && value.trim().length > 0)
225
- return value;
226
- throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
227
33
  }
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { formatInboundContent, formatOutboundMessages, generateMessageId, isMessageEvent, isPostbackEvent, isValidLineRecipientId, normalizeWebhookPath, readTextBody, resolveChannel, resolveLineConfig, verifySignature, type LineEndpointConfig, type LineApiResponse, type LineChannel, type LineEvent, type LineFollowEvent, type LineJoinEvent, type LineLeaveEvent, type LineMessage, type LineMessageEvent, type LinePostbackEvent, type LinePushRequest, type LineReplyMessage, type LineReplyRequest, type LineSource, type LineUnfollowEvent, type LineUser, type LineWebhookBody, type LineWireSegment, type ResolvedLineConfig, } from './protocol.js';
2
2
  export { LineEndpoint, type LineEndpointOptions, type LineFetch, } from './endpoint.js';
3
3
  export { registerLineWebhookRoutes, handleLineWebhookRequest, type LineWebhookHandler, } from './webhook.js';
4
- export { LineClient, lineClient, type LineClientEventMap, type LineGroupMember, } from './client.js';
4
+ export { LineClient, type LineClientEventMap, type LineGroupMember, } from './client.js';
5
+ export { LineAdapter } from './adapter.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { formatInboundContent, formatOutboundMessages, generateMessageId, isMessageEvent, isPostbackEvent, isValidLineRecipientId, normalizeWebhookPath, readTextBody, resolveChannel, resolveLineConfig, verifySignature, } from './protocol.js';
2
2
  export { LineEndpoint, } from './endpoint.js';
3
3
  export { registerLineWebhookRoutes, handleLineWebhookRequest, } from './webhook.js';
4
- export { LineClient, lineClient, } from './client.js';
4
+ export { LineClient, } from './client.js';
5
+ export { LineAdapter } from './adapter.js';