@zhin.js/adapter-line 1.1.18 → 1.1.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-line",
3
- "version": "1.1.18",
3
+ "version": "1.1.20",
4
4
  "description": "Zhin.js LINE Messaging API adapter for Plugin Runtime (HTTP webhook)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -31,26 +31,26 @@
31
31
  "directory": "plugins/adapters/line"
32
32
  },
33
33
  "dependencies": {
34
- "@zhin.js/adapter": "1.1.25",
35
- "@zhin.js/agent-feature": "1.1.10",
36
- "@zhin.js/client": "1.1.21",
34
+ "@zhin.js/adapter": "1.1.26",
35
+ "@zhin.js/agent-feature": "1.1.11",
36
+ "@zhin.js/client": "1.1.22",
37
37
  "@zhin.js/console-contract": "1.1.3",
38
- "@zhin.js/core": "1.1.53",
39
- "@zhin.js/feature-kit": "1.1.9",
40
- "@zhin.js/host-http": "1.1.11",
38
+ "@zhin.js/core": "1.1.54",
39
+ "@zhin.js/feature-kit": "1.1.10",
40
+ "@zhin.js/host-http": "1.1.12",
41
41
  "@zhin.js/im-contract": "1.1.9",
42
42
  "@zhin.js/logger": "1.1.2",
43
- "@zhin.js/page": "1.1.10",
44
- "@zhin.js/skill": "1.1.10",
45
- "@zhin.js/tool": "1.1.12"
43
+ "@zhin.js/page": "1.1.12",
44
+ "@zhin.js/skill": "1.1.11",
45
+ "@zhin.js/tool": "1.1.13"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "zod": "^4.0.0",
49
- "@zhin.js/adapter": "^1.1.25",
50
- "@zhin.js/command": "^1.1.11",
51
- "@zhin.js/core": "^1.1.53",
52
- "@zhin.js/host-http": "^1.1.11",
53
- "zhin.js": "^1.1.18"
49
+ "@zhin.js/adapter": "^1.1.26",
50
+ "@zhin.js/command": "^1.1.12",
51
+ "@zhin.js/core": "^1.1.54",
52
+ "@zhin.js/host-http": "^1.1.12",
53
+ "zhin.js": "^1.1.19"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@zhin.js/command": {
@@ -68,7 +68,7 @@
68
68
  "typescript": "^6.0.3",
69
69
  "vitest": "^4.1.10",
70
70
  "zod": "^4.4.3",
71
- "zhin.js": "1.1.18"
71
+ "zhin.js": "1.1.19"
72
72
  },
73
73
  "files": [
74
74
  "adapters",
@@ -100,7 +100,7 @@
100
100
  "features": [
101
101
  {
102
102
  "package": "@zhin.js/adapter",
103
- "api": "^1.0.0"
103
+ "api": "^2.0.0"
104
104
  },
105
105
  {
106
106
  "package": "@zhin.js/command",
package/plugin.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
- import { createEndpointRuntimeState } from 'zhin.js/adapter';
3
2
  import { definePlugin, pluginConsoleRpcToken } from 'zhin.js';
4
- import { lineRuntimeStateToken } from "./lib/line-runtime-state.js";
5
3
  import { createLineConsoleRpc } from "./lib/console.js";
6
4
  export default definePlugin({
7
5
  name: 'line',
@@ -10,7 +8,6 @@ export default definePlugin({
10
8
  },
11
9
  setup(context) {
12
10
  // 运行中 endpoint 注册表(line endpoint list 的"运行中"数据源)
13
- context.resources.provide(lineRuntimeStateToken, createEndpointRuntimeState());
14
11
  context.resources.provide(pluginConsoleRpcToken, createLineConsoleRpc(context.resources, () => context.config.get()));
15
12
  },
16
13
  });
package/src/adapter.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { LineApiClient } from './api-client.js';
2
+ import { Adapter, type AdapterContext, type EndpointCreationContext } from 'zhin.js/adapter';
3
+ import { LineEndpoint } from './endpoint.js';
4
+ import { resolveLineConfig, type LineEndpointConfig } from './protocol.js';
5
+ import type { LineClientEventMap } from './client.js';
6
+ import { httpHostToken } from '@zhin.js/host-http';
7
+
8
+ export class LineAdapter extends Adapter<LineEndpoint, LineEndpointConfig, LineClientEventMap> {
9
+ readonly #context: AdapterContext<LineEndpointConfig>;
10
+ constructor(context: AdapterContext<LineEndpointConfig>) { super(); this.#context = context; }
11
+ override get configurationIdentityFields(): readonly string[] { return ['id']; }
12
+ override get configurationUpdateFields(): readonly string[] { return ['channelSecret', 'channelAccessToken', 'webhookPath', 'apiBaseUrl']; }
13
+ override get configurationIdentityProbeFields(): readonly string[] { return ['channelAccessToken', 'apiBaseUrl']; }
14
+ protected override async identifyConfiguration(input: LineEndpointConfig, signal: AbortSignal, _changed: readonly string[]): Promise<string | undefined> {
15
+ const config = resolveLineConfig(input);
16
+ const response = await new LineApiClient({ config, fetch: globalThis.fetch, signal }).request<{ userId?: unknown }>('/v2/bot/info');
17
+ if (typeof response.userId !== 'string' || !response.userId) throw new Error('LINE identity verification returned no userId');
18
+ return JSON.stringify(['line', new URL(config.apiBaseUrl).origin, response.userId]);
19
+ }
20
+ protected override validateConfiguration(config: LineEndpointConfig): void { resolveLineConfig(config); }
21
+ protected createEndpoint(input: LineEndpointConfig, execution: EndpointCreationContext): LineEndpoint {
22
+ const config = resolveLineConfig(input);
23
+ return new LineEndpoint({ id: execution.canonicalId, config, http: this.#context.use(httpHostToken) });
24
+ }
25
+ }
26
+
27
+ declare module '@zhin.js/feature-kit' {
28
+ interface AdapterRegistry { readonly 'line': LineAdapter; }
29
+ }
@@ -0,0 +1,96 @@
1
+ import { type EndpointManagementRequest } from 'zhin.js/adapter';
2
+ import { EventEmitter } from 'node:events';
3
+ import type { LineFetch } from './endpoint.js';
4
+ import type { LineEvent, ResolvedLineConfig } from './protocol.js';
5
+
6
+ export interface LineGroupMember {
7
+ readonly user_id: string;
8
+ readonly nickname: string;
9
+ }
10
+
11
+ interface LineGroupMemberIds { readonly memberIds?: string[]; readonly next?: string; }
12
+ interface LineGroupMemberPageRequest extends EndpointManagementRequest {
13
+ readonly groupId: string;
14
+ readonly start?: string;
15
+ }
16
+
17
+ export interface LineApiClientOptions {
18
+ readonly config: ResolvedLineConfig;
19
+ readonly fetch: LineFetch;
20
+ readonly signal?: AbortSignal;
21
+ }
22
+
23
+ /** Direct LINE Messaging API client; it has no Endpoint lifecycle methods. */
24
+ export class LineApiClient extends EventEmitter {
25
+ readonly apiConfig: ResolvedLineConfig;
26
+ constructor({ config, fetch, signal }: LineApiClientOptions) {
27
+ super();
28
+ this.apiConfig = config;
29
+ this.transportSignal = signal;
30
+ this.fetch = (url, init) => {
31
+ this.transportSignal?.throwIfAborted();
32
+ return fetch(url, { ...init, ...(this.transportSignal ? { signal: init?.signal ? AbortSignal.any([this.transportSignal, init.signal]) : this.transportSignal } : {}) });
33
+ };
34
+ }
35
+ readonly fetch: LineFetch;
36
+ protected transportSignal?: AbortSignal;
37
+
38
+ async request<T = unknown>(path: string, init: {
39
+ readonly method?: string;
40
+ readonly body?: string;
41
+ readonly signal?: AbortSignal;
42
+ } = {}): Promise<T> {
43
+ init.signal?.throwIfAborted();
44
+ const response = await this.fetch(`${this.apiConfig.apiBaseUrl}${path}`, {
45
+ method: init.method ?? 'GET',
46
+ headers: {
47
+ Authorization: `Bearer ${this.apiConfig.channelAccessToken}`,
48
+ ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
49
+ },
50
+ ...(init.body === undefined ? {} : { body: init.body }),
51
+ signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
52
+ });
53
+ if (!response.ok) {
54
+ const text = await response.text();
55
+ throw new Error(`LINE API error ${response.status}: ${text}`);
56
+ }
57
+ return await response.json() as T;
58
+ }
59
+
60
+ getProfile(userId: string): Promise<unknown> {
61
+ return this.request(`/v2/profile/${encodeURIComponent(userId)}`);
62
+ }
63
+
64
+ getGroupMemberIds(groupId: string, start?: string): Promise<LineGroupMemberIds> {
65
+ return this.#groupMemberIds({ groupId, start });
66
+ }
67
+
68
+ #groupMemberIds({ groupId, start, signal }: LineGroupMemberPageRequest): Promise<LineGroupMemberIds> {
69
+ const kind = groupId.startsWith('R') ? 'room' : 'group';
70
+ const query = start ? `?start=${encodeURIComponent(start)}` : '';
71
+ return this.request(`/v2/bot/${kind}/${encodeURIComponent(groupId)}/members/ids${query}`, { signal });
72
+ }
73
+
74
+ async getGroupMembers(groupId: string, request: EndpointManagementRequest = {}): Promise<LineGroupMember[]> {
75
+ const kind = groupId.startsWith('R') ? 'room' : 'group';
76
+ const ids: string[] = [];
77
+ let next: string | undefined;
78
+ do {
79
+ const page = await this.#groupMemberIds({ groupId, start: next, signal: request.signal });
80
+ ids.push(...(page.memberIds ?? []).filter((id) => typeof id === 'string' && id.length > 0));
81
+ next = page.next || undefined;
82
+ } while (next);
83
+
84
+ return Promise.all(ids.map(async (userId) => {
85
+ try {
86
+ const profile = await this.request<{ displayName?: string }>(
87
+ `/v2/bot/${kind}/${encodeURIComponent(groupId)}/member/${encodeURIComponent(userId)}`, { signal: request.signal },
88
+ );
89
+ return { user_id: userId, nickname: String(profile.displayName ?? userId) };
90
+ } catch (error) {
91
+ if (request.signal?.aborted) throw error;
92
+ return { user_id: userId, nickname: userId };
93
+ }
94
+ }));
95
+ }
96
+ }
package/src/client.ts CHANGED
@@ -1,87 +1,315 @@
1
- import type { LineFetch } from './endpoint.js';
2
- import { defineEndpointClient } from 'zhin.js/adapter';
3
- import type { LineEvent, ResolvedLineConfig } from './protocol.js';
1
+ import { LineApiClient } from './api-client.js';
2
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
3
+ /**
4
+ * LineClient — lifecycle, outbound, admit, OpenAPI helpers for agent tools.
5
+ */
6
+ import {
7
+ type EndpointManagement,
8
+ type EndpointSendRequest,
9
+ type EndpointGroupRequest,
10
+ } from 'zhin.js/adapter';
11
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
12
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
13
+ import type { CapabilityId } from 'zhin.js';
14
+ import {
15
+ formatInboundContent,
16
+ formatOutboundMessages,
17
+ generateMessageId,
18
+ isLineLifecycleEvent,
19
+ isMessageEvent,
20
+ isValidLineRecipientId,
21
+ lineInboundConversation,
22
+ type LineApiResponse,
23
+ type LineEvent,
24
+ type ResolvedLineConfig,
25
+ } from './protocol.js';
26
+ import { registerLineWebhookRoutes } from './webhook.js';
27
+ import { receiveLineSideEvent } from './side-event-dispatch.js';
4
28
 
5
- export interface LineGroupMember {
6
- readonly user_id: string;
7
- readonly nickname: string;
8
- }
9
-
10
- /** Direct LINE Messaging API client; it has no Endpoint lifecycle methods. */
11
- export class LineClient {
12
- constructor(
13
- readonly config: ResolvedLineConfig,
14
- readonly fetch: LineFetch,
15
- ) {}
29
+ /** LINE replyToken 有效期短,过期后 reply 必 400;缓存带时间戳,超时弃用改走 push。 */
30
+ const REPLY_TOKEN_TTL_MS = 60_000;
31
+ /** 出站 HTTP 调用统一 30s 超时。 */
32
+ const OUTBOUND_TIMEOUT_MS = 30_000;
16
33
 
17
- async request<T = unknown>(path: string, init: {
34
+ export type LineFetch = (
35
+ url: string,
36
+ init?: {
18
37
  readonly method?: string;
38
+ readonly headers?: Record<string, string>;
19
39
  readonly body?: string;
20
40
  readonly signal?: AbortSignal;
21
- } = {}): Promise<T> {
22
- const response = await this.fetch(`${this.config.apiBaseUrl}${path}`, {
23
- method: init.method ?? 'GET',
24
- headers: {
25
- Authorization: `Bearer ${this.config.channelAccessToken}`,
26
- ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
27
- },
28
- ...(init.body === undefined ? {} : { body: init.body }),
29
- signal: init.signal ?? AbortSignal.timeout(30_000),
30
- });
31
- if (!response.ok) {
32
- const text = await response.text();
33
- throw new Error(`LINE API error ${response.status}: ${text}`);
41
+ },
42
+ ) => Promise<{
43
+ readonly ok: boolean;
44
+ readonly status: number;
45
+ text(): Promise<string>;
46
+ json(): Promise<unknown>;
47
+ }>;
48
+
49
+ export interface LineEndpointOptions {
50
+ readonly id: CapabilityId;
51
+ readonly http: HttpHost;
52
+ readonly config: ResolvedLineConfig;
53
+ readonly fetch?: LineFetch;
54
+ }
55
+
56
+ export class LineClient extends LineApiClient {
57
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
58
+
59
+ readonly #options: LineEndpointOptions;
60
+ readonly #fetch: LineFetch;
61
+ #routeReleases: HttpRouteRegistration[] = [];
62
+ #replyTokenCache = new Map<string, { token: string; timestamp: number }>();
63
+ readonly #admissions = new Map<string, { readonly promise: Promise<void>; expires: number }>();
64
+ #open = false;
65
+ #started = false;
66
+ protected readonly endpointManagement: EndpointManagement = createLineEndpointManagement(this);
67
+
68
+ protected configName(): string { return this.#options.config.id; }
69
+ get transportState(): import('zhin.js/adapter').EndpointTransportState | undefined { return undefined; }
70
+ protected executionSignal?: AbortSignal;
71
+ protected setExecutionSignal(signal: AbortSignal): void { this.executionSignal = signal; this.transportSignal = signal; }
72
+ /** Native protocol work runs here; Endpoint attaches the framework receipt without replacing SDK APIs. */
73
+ protected receiveInboundCallback<T>(receive: () => T | Promise<T>): Promise<T> {
74
+ try { return Promise.resolve(receive()); } catch (error) { return Promise.reject(error); }
75
+ }
76
+ protected publishEvent(name: string, payload: unknown): Promise<unknown> { this.emit(name, payload); return Promise.resolve(); }
77
+ protected publishAccepted(name: string, payload: unknown): Promise<void> { return this.publishEvent(name, payload).then(() => undefined); }
78
+ protected publishPlatform(name: string, event: unknown): Promise<unknown> { this.emit(name, event); return Promise.resolve(); }
79
+
80
+ constructor(options: LineEndpointOptions) {
81
+ super({ config: options.config, fetch: options.fetch ?? globalThis.fetch });
82
+ this.#logger = getAdapterLogger('line', options.config.id);
83
+ this.#options = options;
84
+ const transportFetch = options.fetch ?? globalThis.fetch;
85
+ this.#fetch = (url, init) => {
86
+ this.executionSignal?.throwIfAborted();
87
+ return transportFetch(url, { ...init, ...(this.executionSignal ? { signal: init?.signal ? AbortSignal.any([this.executionSignal, init.signal]) : this.executionSignal } : {}) });
88
+ };
89
+ }
90
+
91
+ /** Used by webhook handler. */
92
+ get isOpen(): boolean {
93
+ return this.#open;
94
+ }
95
+
96
+ get config(): ResolvedLineConfig {
97
+ return this.#options.config;
98
+ }
99
+
100
+ protected async connectTransport(signal?: AbortSignal): Promise<void> {
101
+ if (signal) { this.setExecutionSignal(signal); signal.throwIfAborted(); }
102
+ if (this.#started) return;
103
+ this.#started = true;
104
+ try {
105
+ this.#routeReleases.push(...registerLineWebhookRoutes(this.#options.http, this));
106
+ this.#logger.debug(formatCompact({
107
+ endpoint: this.#options.config.id,
108
+ op: 'webhook',
109
+ path: this.#options.config.webhookPath,
110
+ }));
111
+ } catch (error) {
112
+ await this.disconnectTransport();
113
+ this.#logger.error('Failed to connect LINE endpoint:', error);
114
+ throw error;
34
115
  }
35
- return await response.json() as T;
36
116
  }
37
117
 
38
- getProfile(userId: string): Promise<unknown> {
39
- return this.request(`/v2/profile/${encodeURIComponent(userId)}`);
118
+ protected openInput(): void {
119
+ this.#open = true;
120
+ }
121
+
122
+ protected closeInput(): void {
123
+ this.#open = false;
40
124
  }
41
125
 
42
- getGroupMemberIds(groupId: string, start?: string): Promise<{
43
- readonly memberIds?: string[];
44
- readonly next?: string;
45
- }> {
46
- const kind = groupId.startsWith('R') ? 'room' : 'group';
47
- const query = start ? `?start=${encodeURIComponent(start)}` : '';
48
- return this.request(
49
- `/v2/bot/${kind}/${encodeURIComponent(groupId)}/members/ids${query}`,
50
- );
126
+ protected async disconnectTransport(): Promise<void> {
127
+ this.#open = false;
128
+ this.#replyTokenCache.clear();
129
+ this.#admissions.clear();
130
+ for (const release of this.#routeReleases.splice(0)) release();
131
+ this.#started = false;
132
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
51
133
  }
52
134
 
53
- async getGroupMembers(groupId: string): Promise<LineGroupMember[]> {
54
- const kind = groupId.startsWith('R') ? 'room' : 'group';
55
- const ids: string[] = [];
56
- let next: string | undefined;
57
- do {
58
- const page = await this.getGroupMemberIds(groupId, next);
59
- ids.push(...(page.memberIds ?? []).filter((id) => typeof id === 'string' && id.length > 0));
60
- next = page.next || undefined;
61
- } while (next);
62
-
63
- return Promise.all(ids.map(async (userId) => {
64
- try {
65
- const profile = await this.request<{ displayName?: string }>(
66
- `/v2/bot/${kind}/${encodeURIComponent(groupId)}/member/${encodeURIComponent(userId)}`,
135
+ protected async sendPayload({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
136
+ try {
137
+ const messages = formatOutboundMessages(payload);
138
+ if (messages.length === 0) {
139
+ throw new EndpointDeliveryError('invalid_payload', 'No valid LINE messages to send', 'not_sent');
140
+ }
141
+ // LINE recipient id 前缀(U/G/R)自带场景信息,原生 id 即投递地址。
142
+ const target = conversation.id;
143
+
144
+ const cached = this.#replyTokenCache.get(target);
145
+ if (cached) {
146
+ this.#replyTokenCache.delete(target);
147
+ if (Date.now() - cached.timestamp <= REPLY_TOKEN_TTL_MS) {
148
+ return await this.#replyMessage({ replyToken: cached.token, messages, signal });
149
+ }
150
+ }
151
+
152
+ if (!isValidLineRecipientId(target)) {
153
+ throw new EndpointDeliveryError('invalid_recipient',
154
+ `Invalid LINE recipient ID "${target}": must start with U (user), G (group), or R (room)`, 'not_sent',
67
155
  );
68
- return { user_id: userId, nickname: String(profile.displayName ?? userId) };
69
- } catch {
70
- return { user_id: userId, nickname: userId };
71
156
  }
72
- }));
157
+ return await this.#pushMessage({ to: target, messages, signal });
158
+ } catch (error) {
159
+ if (error instanceof EndpointDeliveryError) throw error;
160
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Outbound request outcome is unknown', 'unknown', { cause: error });
161
+ }
73
162
  }
74
- }
75
163
 
76
- export type LineClientEventMap = Record<string, LineEvent>;
164
+ /** Test / internal: admit a parsed event when open (non-webhook path). */
165
+ admit(event: LineEvent): void {
166
+ if (!this.#open) return;
167
+ void this.admitAccepted(event).catch((err) => {
168
+ this.#logger.warn(formatCompact({ op: 'line_gateway_receive_failed', error: err instanceof Error ? err.message : String(err) }));
169
+ });
170
+ }
77
171
 
78
- declare module '@zhin.js/feature-kit' {
79
- interface AdapterClientRegistry {
80
- readonly line: {
81
- readonly client: LineClient;
82
- readonly events: LineClientEventMap;
83
- };
172
+ /** Webhook acknowledgement follows actual runtime generation admission. */
173
+ async admitAccepted(event: LineEvent): Promise<void> {
174
+ if (!this.#open) throw new Error('LINE endpoint is closed');
175
+ return this.receiveInboundCallback(() => this.#acceptMessage(event));
176
+ }
177
+
178
+ async #acceptMessage(event: LineEvent): Promise<void> {
179
+ if (!this.#open) throw new Error('LINE endpoint is closed');
180
+ const id = event.webhookEventId;
181
+ if (typeof id !== 'string' || !id.trim()) return this.#dispatchAccepted(event);
182
+ const now = Date.now();
183
+ for (const [key, entry] of this.#admissions) if (entry.expires <= now) this.#admissions.delete(key);
184
+ const existing = this.#admissions.get(id);
185
+ if (existing) return existing.promise;
186
+ // Never evict in-flight admissions: overload returns 503 and permits platform retry.
187
+ if (this.#admissions.size >= 10_000) throw new Error('LINE admission dedup capacity exhausted');
188
+ const entry = { promise: Promise.resolve().then(() => this.#dispatchAccepted(event)), expires: Infinity };
189
+ this.#admissions.set(id, entry);
190
+ try {
191
+ await entry.promise;
192
+ if (this.#admissions.get(id) === entry) entry.expires = Date.now() + 24 * 60 * 60 * 1000;
193
+ } catch (error) {
194
+ if (this.#admissions.get(id) === entry) this.#admissions.delete(id);
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ protected inboundReceiptCurrent(): boolean { return this.#open && !this.executionSignal?.aborted; }
200
+
201
+ async #dispatchAccepted(event: LineEvent): Promise<void> {
202
+ if (!this.inboundReceiptCurrent()) throw new Error('LINE endpoint is closed');
203
+ void this.publishPlatform(event.type || 'event', event).catch((error) => {
204
+ this.#logger.warn(formatCompact({
205
+ op: 'line_platform_event_failed',
206
+ event: event.type,
207
+ error: error instanceof Error ? error.message : String(error),
208
+ }));
209
+ });
210
+ if (isLineLifecycleEvent(event)) {
211
+ await receiveLineSideEvent(
212
+ (name, payload) => this.publishAccepted(name, payload),
213
+ String(this.#options.id),
214
+ this.#options.config.id,
215
+ event,
216
+ this.#logger,
217
+ );
218
+ return;
219
+ }
220
+ const conversation = lineInboundConversation(String(this.#options.id), event.source);
221
+ if ('replyToken' in event && typeof event.replyToken === 'string') {
222
+ this.#replyTokenCache.set(conversation.id, { token: event.replyToken, timestamp: Date.now() });
223
+ }
224
+ await this.publishAccepted('message.receive', {
225
+ event,
226
+ conversation,
227
+ message: { conversation, id: generateMessageId(event) },
228
+ content: formatInboundContent(event),
229
+ ...(event.source.userId ? { sender: { id: event.source.userId } } : {}),
230
+ endpointId: this.#options.config.id,
231
+ metadata: Object.freeze({
232
+ eventType: event.type,
233
+ sourceType: event.source.type,
234
+ timestamp: event.timestamp,
235
+ ...(isMessageEvent(event) ? { messageType: event.message.type } : {}),
236
+ }),
237
+ });
84
238
  }
239
+
240
+ async #replyMessage(
241
+ { replyToken, messages, signal }: LineReplyRequest,
242
+ ): Promise<string> {
243
+ const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/reply`;
244
+ const response = await this.#fetch(url, {
245
+ method: 'POST',
246
+ headers: {
247
+ 'Content-Type': 'application/json',
248
+ Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
249
+ },
250
+ body: JSON.stringify({ replyToken, messages }),
251
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(OUTBOUND_TIMEOUT_MS)]) : AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
252
+ });
253
+ if (!response.ok) {
254
+ throw await lineResponseError(response);
255
+ }
256
+ const result = await response.json() as LineApiResponse;
257
+ return requireMessageId(result.sentMessages?.[0]?.id);
258
+ }
259
+
260
+ async #pushMessage(
261
+ { to, messages, signal }: LinePushRequest,
262
+ ): Promise<string> {
263
+ const url = `${this.#options.config.apiBaseUrl}/v2/bot/message/push`;
264
+ const response = await this.#fetch(url, {
265
+ method: 'POST',
266
+ headers: {
267
+ 'Content-Type': 'application/json',
268
+ Authorization: `Bearer ${this.#options.config.channelAccessToken}`,
269
+ },
270
+ body: JSON.stringify({ to, messages }),
271
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(OUTBOUND_TIMEOUT_MS)]) : AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
272
+ });
273
+ if (!response.ok) {
274
+ throw await lineResponseError(response);
275
+ }
276
+ const result = await response.json() as LineApiResponse;
277
+ return requireMessageId(result.sentMessages?.[0]?.id);
278
+ }
279
+
280
+ /**
281
+ * group/room 成员列表:Bot API 无群列表,只能按已知 groupId/roomId 拉成员。
282
+ * members/ids 分页(next continuation token)后逐个取 profile 归一 nickname;
283
+ * 单个 profile 失败(用户已退群等)时回退 userId 占位,不拖垮整批。
284
+ */
285
+ }
286
+
287
+ function createLineEndpointManagement(endpoint: LineClient): EndpointManagement {
288
+ return Object.freeze<EndpointManagement>({
289
+ // listGroups 不接:LINE Bot API 没有"我加入了哪些群"的接口,群 id 只能来自入站事件。
290
+ listGroupMembers: ({ groupId, signal }: EndpointGroupRequest) => endpoint.getGroupMembers(groupId, { signal }),
291
+ });
292
+ }
293
+
294
+ async function lineResponseError(response: Awaited<ReturnType<LineFetch>>): Promise<EndpointDeliveryError> {
295
+ // Error responses may carry sentMessages: a failed request can have partial side effects.
296
+ let body: { sentMessages?: unknown[] };
297
+ try { body = await response.json() as { sentMessages?: unknown[] }; }
298
+ catch { return new EndpointDeliveryError('delivery_unconfirmed', `LINE HTTP ${response.status}`, 'unknown'); }
299
+ const disposition = Array.isArray(body?.sentMessages) && body.sentMessages.length > 0
300
+ ? 'unknown' : response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 409 ? 'rejected' : 'unknown';
301
+ return new EndpointDeliveryError(disposition === 'unknown' ? 'delivery_unconfirmed' : 'platform_rejected', `LINE HTTP ${response.status}`, disposition);
302
+ }
303
+
304
+ function requireMessageId(value: unknown): string {
305
+ if (typeof value === 'string' && value.trim().length > 0) return value;
306
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Platform did not return a real message ID', 'unknown');
85
307
  }
86
308
 
87
- export const lineClient = defineEndpointClient<LineClient, LineClientEventMap>('line');
309
+ export type LineClientEventMap = Record<string, LineEvent>;
310
+
311
+ export type { LineGroupMember } from './api-client.js';
312
+
313
+ interface LineMessageRequest { readonly messages: ReturnType<typeof formatOutboundMessages>; readonly signal?: AbortSignal; }
314
+ interface LineReplyRequest extends LineMessageRequest { readonly replyToken: string; }
315
+ interface LinePushRequest extends LineMessageRequest { readonly to: string; }
package/src/console.ts CHANGED
@@ -5,17 +5,14 @@
5
5
  */
6
6
  import { createAdapterConsoleRpc, type AdapterConsoleResources } from 'zhin.js/adapter';
7
7
  import type { PluginConsoleRpc } from 'zhin.js';
8
- import { lineRuntimeStateToken } from './line-runtime-state.js';
9
8
  import { lineConsoleSpec } from './console-spec.js';
10
9
 
11
10
  export function createLineConsoleRpc(
12
- resources: AdapterConsoleResources,
11
+ _resources: AdapterConsoleResources,
13
12
  getConfig: () => unknown,
14
13
  ): PluginConsoleRpc {
15
- const state = resources.use(lineRuntimeStateToken);
16
14
  return createAdapterConsoleRpc({
17
15
  ...lineConsoleSpec,
18
- running: () => state.endpoints.values(),
19
16
  getConfig,
20
- }, resources);
17
+ });
21
18
  }