@zhin.js/adapter-napcat 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +17 -4
  3. package/adapters/napcat/index.js +2 -29
  4. package/adapters/napcat/index.ts +3 -35
  5. package/agents/napcat/skills/napcat-account/tools/set_profile/index.js +1 -1
  6. package/agents/napcat/skills/napcat-account/tools/set_profile/index.ts +1 -1
  7. package/agents/napcat/skills/napcat-group-files-history/tools/upload_group_file/index.js +1 -1
  8. package/agents/napcat/skills/napcat-group-files-history/tools/upload_group_file/index.ts +1 -1
  9. package/agents/napcat/skills/napcat-group-settings/tools/set_title/index.js +1 -1
  10. package/agents/napcat/skills/napcat-group-settings/tools/set_title/index.ts +1 -1
  11. package/agents/napcat/skills/napcat-media/tools/get_mini_app_ark/index.js +1 -1
  12. package/agents/napcat/skills/napcat-media/tools/get_mini_app_ark/index.ts +1 -1
  13. package/commands/napcat/endpoint/definition.js +0 -2
  14. package/commands/napcat/endpoint/definition.ts +0 -2
  15. package/lib/adapter.d.ts +23 -0
  16. package/lib/adapter.js +42 -0
  17. package/lib/client.d.ts +53 -16
  18. package/lib/client.js +53 -10
  19. package/lib/console-spec.d.ts +12 -0
  20. package/lib/console-spec.js +14 -0
  21. package/lib/console.d.ts +8 -0
  22. package/lib/console.js +13 -0
  23. package/lib/endpoint-management.d.ts +2 -2
  24. package/lib/endpoint-management.js +13 -13
  25. package/lib/http-endpoint.d.ts +5 -15
  26. package/lib/http-endpoint.js +44 -36
  27. package/lib/identity.d.ts +3 -0
  28. package/lib/identity.js +32 -0
  29. package/lib/index.d.ts +3 -2
  30. package/lib/index.js +2 -2
  31. package/lib/onebot-get-msg.d.ts +2 -2
  32. package/lib/onebot-get-msg.js +2 -2
  33. package/lib/protocol.d.ts +5 -0
  34. package/lib/protocol.js +23 -11
  35. package/lib/ws-endpoint.d.ts +6 -16
  36. package/lib/ws-endpoint.js +57 -48
  37. package/lib/ws-transport.d.ts +18 -12
  38. package/lib/ws-transport.js +28 -72
  39. package/lib/ws-types.d.ts +0 -5
  40. package/lib/wss-endpoint.d.ts +7 -15
  41. package/lib/wss-endpoint.js +56 -47
  42. package/package.json +25 -17
  43. package/pages/index/index.js +15 -0
  44. package/pages/index/index.ts +16 -0
  45. package/plugin.js +3 -4
  46. package/src/adapter.ts +45 -0
  47. package/src/client.ts +91 -19
  48. package/src/console-spec.ts +19 -0
  49. package/src/console.ts +18 -0
  50. package/src/endpoint-management.ts +19 -15
  51. package/src/http-endpoint.ts +47 -45
  52. package/src/identity.ts +29 -0
  53. package/src/index.ts +9 -5
  54. package/src/onebot-get-msg.ts +4 -4
  55. package/src/protocol.ts +26 -14
  56. package/src/ws-endpoint.ts +61 -54
  57. package/src/ws-transport.ts +40 -101
  58. package/src/ws-types.ts +0 -6
  59. package/src/wss-endpoint.ts +60 -54
  60. package/lib/napcat-runtime-state.d.ts +0 -1
  61. package/lib/napcat-runtime-state.js +0 -6
  62. package/src/napcat-runtime-state.ts +0 -7
@@ -0,0 +1,29 @@
1
+ import WebSocket from 'ws';
2
+ import { buildWsConnectOptions, readNapCatActionResponse, type NapCatWsConfig } from './protocol.js';
3
+ import { createNapCatRpc } from './ws-transport.js';
4
+
5
+ /** Separate read-only connection: never changes credentials or state of the running Endpoint. */
6
+ export async function probeNapcatIdentity(config: NapCatWsConfig, signal: AbortSignal): Promise<string | undefined> {
7
+ signal.throwIfAborted();
8
+ const { url, headers } = buildWsConnectOptions(config);
9
+ const socket = new WebSocket(url, { headers });
10
+ const rpc = createNapCatRpc({ endpointId: config.id, signal, socket: () => socket });
11
+ let rejectOpen: (error: unknown) => void = () => undefined;
12
+ const abort = () => { rejectOpen(signal.reason); rpc.disconnect(); socket.terminate(); };
13
+ signal.addEventListener('abort', abort, { once: true });
14
+ socket.on('message', data => rpc.receive(data, () => undefined));
15
+ socket.on('close', () => { rejectOpen(new Error('Identity connection closed')); rpc.disconnect(); });
16
+ try {
17
+ await new Promise<void>((resolve, reject) => { rejectOpen = reject; socket.once('open', resolve); socket.on('error', reject); });
18
+ signal.throwIfAborted();
19
+ const response = await rpc.call({ action: 'get_login_info', signal });
20
+ readNapCatActionResponse(response);
21
+ signal.throwIfAborted();
22
+ const data: unknown = response.data;
23
+ if (!data || typeof data !== 'object' || !('user_id' in data)
24
+ || (typeof data.user_id !== 'string' && typeof data.user_id !== 'number') || !String(data.user_id).trim() || String(data.user_id) === '0') return undefined;
25
+ return `qq:${data.user_id}`;
26
+ } finally {
27
+ signal.removeEventListener('abort', abort); rpc.dispose(); socket.terminate();
28
+ }
29
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export {
27
27
  type ParsedSendTarget,
28
28
  type ResolvedNapCatConfig,
29
29
  } from './protocol.js';
30
+ export { NapcatAdapter } from './adapter.js';
30
31
 
31
32
  export {
32
33
  InboundMessageDeduper,
@@ -36,9 +37,13 @@ export {
36
37
  } from './napcat-inbound.js';
37
38
 
38
39
  export {
39
- napcatClient,
40
40
  type NapcatClient,
41
41
  type NapcatClientEventMap,
42
+ type NapcatClientOptions,
43
+ type NapcatMemberTitleRequest,
44
+ type NapcatProfileRequest,
45
+ type NapcatGroupFileRequest,
46
+ type NapcatMiniAppRequest,
42
47
  } from './client.js';
43
48
 
44
49
  export { parseOneBotGetMsgResponse } from './onebot-get-msg.js';
@@ -61,11 +66,10 @@ export {
61
66
  export type { NapCatWsSocket, NapCatWsCreateOptions } from './ws-types.js';
62
67
 
63
68
  export {
64
- callNapCatWsAction,
69
+ createNapCatRpc,
65
70
  decodeWsPayload,
66
- handleNapCatWsMessage,
67
- rejectAllPending,
68
- startNapCatHeartbeat,
71
+ type NapCatRpc,
72
+ type NapCatRpcOptions,
69
73
  } from './ws-transport.js';
70
74
 
71
75
  export { verifyNapCatAccessToken } from './wss-auth.js';
@@ -39,7 +39,7 @@ export function parseOneBotGetMsgResponse(
39
39
  }
40
40
 
41
41
  export function createNapCatContentPort(
42
- callApi: (action: string, params?: Record<string, unknown>) => Promise<unknown>,
42
+ callApi: (request: PendingRpcRequest) => Promise<unknown>,
43
43
  ): EndpointContentPort {
44
44
  return Object.freeze({
45
45
  async resolve(reference: ConversationReference, context: EndpointContentResolveContext) {
@@ -51,7 +51,7 @@ export function createNapCatContentPort(
51
51
  : Object.freeze({ status: 'resolved' as const, reference, value: reference.media });
52
52
  }
53
53
  if (reference.kind === 'message') {
54
- const parsed = parseOneBotGetMsgResponse(reference.message.id, await callApi('get_msg', { message_id: reference.message.id }));
54
+ const parsed = parseOneBotGetMsgResponse(reference.message.id, await callApi({ action: 'get_msg', params: { message_id: reference.message.id }, signal: context.signal }));
55
55
  context.signal.throwIfAborted();
56
56
  const value: ConversationMessage = Object.freeze({
57
57
  ref: reference.message,
@@ -61,7 +61,7 @@ export function createNapCatContentPort(
61
61
  });
62
62
  return Object.freeze({ status: 'resolved' as const, reference, value });
63
63
  }
64
- const raw = await callApi('get_forward_msg', { message_id: reference.forwardId, id: reference.forwardId }) as Record<string, unknown>;
64
+ const raw = await callApi({ action: 'get_forward_msg', params: { message_id: reference.forwardId, id: reference.forwardId }, signal: context.signal }) as Record<string, unknown>;
65
65
  context.signal.throwIfAborted();
66
66
  const nodes = Array.isArray(raw.messages) ? raw.messages.slice(0, context.maxEntries) : [];
67
67
  const value: readonly ForwardEntry[] = Object.freeze(nodes.map((node) => {
@@ -93,5 +93,5 @@ function toMillis(value: unknown): number {
93
93
  const time = Number(value);
94
94
  return Number.isFinite(time) ? (time < 10_000_000_000 ? time * 1000 : time) : Date.now();
95
95
  }
96
- import type { EndpointContentPort, EndpointContentResolveContext } from 'zhin.js/adapter';
96
+ import type { PendingRpcRequest, EndpointContentPort, EndpointContentResolveContext } from 'zhin.js/adapter';
97
97
  import type { ConversationMessage, ConversationReference, ForwardEntry } from '@zhin.js/im-contract';
package/src/protocol.ts CHANGED
@@ -104,6 +104,22 @@ export interface NapCatActionResponse {
104
104
  wording?: string;
105
105
  }
106
106
 
107
+ /** OneBot 11: async/1 is submitted without a known result, not a rejection.
108
+ * https://github.com/botuniverse/onebot-11/blob/master/communication/http.md#响应
109
+ */
110
+ export function readNapCatActionResponse(value: unknown): NapCatActionResponse {
111
+ if (!value || typeof value !== 'object' || !('status' in value) || !('retcode' in value)
112
+ || !Number.isInteger(value.retcode)) throw new EndpointDeliveryError('delivery_unconfirmed', 'NapCat returned an invalid action response', 'unknown');
113
+ const response = value as NapCatActionResponse;
114
+ if (response.status === 'failed' && response.retcode !== 0 && response.retcode !== 1) {
115
+ throw new EndpointDeliveryError('platform_rejected',
116
+ `NapCat action failed [${response.retcode}]: ${response.message || response.wording || 'unknown'}`, 'rejected');
117
+ }
118
+ if (response.status !== 'ok' || response.retcode !== 0) throw new EndpointDeliveryError('delivery_unconfirmed',
119
+ 'NapCat did not confirm the action result', 'unknown');
120
+ return response;
121
+ }
122
+
107
123
  export interface NapCatWireSegment {
108
124
  readonly type: string;
109
125
  readonly data?: Record<string, unknown>;
@@ -430,6 +446,7 @@ export function buildWsConnectOptions(config: NapCatWsConfig): {
430
446
  export interface NapCatHttpOptions {
431
447
  readonly http_url: string;
432
448
  readonly access_token?: string;
449
+ readonly signal?: AbortSignal;
433
450
  }
434
451
 
435
452
  /**
@@ -448,24 +465,19 @@ export async function callNapCatHttpAction(
448
465
  headers.Authorization = `Bearer ${options.access_token}`;
449
466
  }
450
467
  const res = await fetch(url, {
451
- signal: AbortSignal.timeout(30_000),
468
+ signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
452
469
  method: 'POST',
453
470
  headers,
454
471
  body: JSON.stringify(params),
455
472
  });
456
473
  const text = await res.text();
457
- if (res.status === 401) throw new EndpointDeliveryError('platform_rejected', `NapCat HTTP auth failed: ${text}`, 'rejected');
458
- if (res.status !== 200) throw new EndpointDeliveryError(res.status === 429 || res.status === 403 ? 'platform_rejected' : 'delivery_unconfirmed', `NapCat HTTP ${res.status}: ${text}`, res.status === 429 || res.status === 403 ? 'rejected' : 'unknown');
459
- let data: NapCatActionResponse;
460
- try {
461
- data = JSON.parse(text) as NapCatActionResponse;
462
- } catch {
463
- throw new Error(`NapCat HTTP invalid response: ${text.slice(0, 200)}`);
464
- }
465
- if (data.status !== 'ok' && data.retcode !== 0) {
466
- throw new EndpointDeliveryError(
467
- 'platform_rejected', `NapCat HTTP action failed [${data.retcode}]: ${data.message || data.wording || 'unknown'}`, 'rejected',
468
- );
474
+ if (res.status !== 200) {
475
+ const rejected = [400, 401, 403, 404, 406, 429].includes(res.status);
476
+ throw new EndpointDeliveryError(rejected ? 'platform_rejected' : 'delivery_unconfirmed',
477
+ `NapCat HTTP ${res.status}: ${text}`, rejected ? 'rejected' : 'unknown');
469
478
  }
470
- return data;
479
+ let data: unknown;
480
+ try { data = JSON.parse(text); }
481
+ catch (cause) { throw new EndpointDeliveryError('delivery_unconfirmed', 'NapCat returned invalid JSON', 'unknown', { cause }); }
482
+ return readNapCatActionResponse(data);
471
483
  }
@@ -4,7 +4,7 @@ import { EndpointDeliveryError } from '@zhin.js/im-contract';
4
4
  */
5
5
  import WebSocket from 'ws';
6
6
  import {
7
- Endpoint,
7
+ createEndpointSpecification, endpointSpec, type Endpoint, type EndpointSpecification,
8
8
  createRecallEndpointControl,
9
9
  createEndpointLifecycle,
10
10
  type EndpointConnectHandle,
@@ -38,12 +38,6 @@ import {
38
38
  } from './protocol.js';
39
39
  import { receiveNapCatSideEvent } from './side-event-dispatch.js';
40
40
  import {
41
- callNapCatWsAction,
42
- handleNapCatWsMessage,
43
- rejectAllPending,
44
- } from './ws-transport.js';
45
- import {
46
- type NapCatPendingAction,
47
41
  type NapCatWsCreateOptions,
48
42
  type NapCatWsSocket,
49
43
  } from './ws-types.js';
@@ -52,6 +46,7 @@ import { NapcatClient } from './client.js';
52
46
 
53
47
  export interface NapCatWsEndpointOptions {
54
48
  readonly id: CapabilityId;
49
+ readonly signal?: AbortSignal;
55
50
  readonly config: NapCatWsConfig;
56
51
  readonly createWebSocket?: (
57
52
  url: string,
@@ -59,25 +54,36 @@ export interface NapCatWsEndpointOptions {
59
54
  ) => NapCatWsSocket;
60
55
  }
61
56
 
62
- export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
63
- readonly client = new NapcatClient((action, params) => this.#callApi(action, params));
57
+ export class NapCatWsEndpoint extends NapcatClient implements Endpoint {
58
+ readonly [endpointSpec]: EndpointSpecification;
64
59
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
65
60
 
66
61
  readonly #options: NapCatWsEndpointOptions;
67
62
  readonly #inboundDeduper = new InboundMessageDeduper();
68
- readonly management: EndpointManagement = createNapCatEndpointManagement(this.client);
69
- readonly control: EndpointControl = createRecallEndpointControl((id) => this.recallMessage(id));
70
- readonly content = createNapCatContentPort((action, params) => this.client.callApi(action, params));
63
+ readonly #management: EndpointManagement = createNapCatEndpointManagement(this);
64
+ readonly #control: EndpointControl = createRecallEndpointControl((id, signal) => this.#recallMessage(id, signal));
65
+ readonly #content = createNapCatContentPort(request => this.requestApi(request));
71
66
  readonly #lifecycle: EndpointLifecycle;
72
- #ws?: NapCatWsSocket;
73
- #requestId = { value: 0 };
74
- #pending = new Map<string, NapCatPendingAction>();
75
67
  #open = false;
76
68
 
77
69
  constructor(options: NapCatWsEndpointOptions) {
78
- super();
70
+ super({ config: options.config, signal: options.signal });
79
71
  this.#logger = getAdapterLogger('napcat', options.config.id);
80
72
  this.#options = options;
73
+ const readTransportState = () => this.#transportState;
74
+ this[endpointSpec] = createEndpointSpecification(this, {
75
+ name: options.config.id,
76
+ start: () => this.#start(), open: () => this.#openEndpoint(),
77
+ close: () => this.#close(), stop: () => this.#stop(),
78
+ send: request => this.#send(request),
79
+ management: this.#management, control: this.#control, content: this.#content,
80
+ get transportState() { return readTransportState(); },
81
+ });
82
+ this.on('event', event => {
83
+ void this[endpointSpec].events.run(() => this.#admit(event)).catch((error: unknown) => {
84
+ this.#logger.debug(formatCompact({ op: 'napcat_receive_failed', error: error instanceof Error ? error.message : String(error) }));
85
+ });
86
+ });
81
87
  this.#lifecycle = createEndpointLifecycle({
82
88
  name: options.config.id,
83
89
  heartbeat: { intervalMs: options.config.heartbeat_interval, watchdogMisses: 2 },
@@ -91,42 +97,46 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
91
97
  });
92
98
  }
93
99
 
94
- get transportState(): EndpointTransportState {
100
+ get #transportState(): EndpointTransportState {
95
101
  return this.#lifecycle.state;
96
102
  }
97
103
 
98
- async start(): Promise<void> {
104
+ protected override get actionSocket(): NapCatWsSocket | undefined {
105
+ return this.#lifecycle.state === 'open' ? this.transportSocket : undefined;
106
+ }
107
+
108
+ async #start(): Promise<void> {
99
109
  if (this.#lifecycle.started) return;
100
110
  await this.#lifecycle.start((handle) => this.#connect(handle));
101
111
  }
102
112
 
103
- open(): void {
113
+ #openEndpoint(): void {
104
114
  this.#open = true;
105
115
  }
106
116
 
107
- close(): void {
117
+ #close(): void {
108
118
  this.#open = false;
109
119
  }
110
120
 
111
- async stop(): Promise<void> {
121
+ async #stop(): Promise<void> {
112
122
  this.#open = false;
113
123
  await this.#lifecycle.stop();
114
- rejectAllPending(this.#pending);
124
+ this.rpc.disconnect();
115
125
  this.#inboundDeduper.clear();
116
- if (this.#ws) {
126
+ if (this.transportSocket) {
117
127
  try {
118
- this.#ws.close();
128
+ this.transportSocket.close();
119
129
  } catch {
120
130
  /* ignore */
121
131
  }
122
- this.#ws = undefined;
132
+ this.transportSocket = undefined;
123
133
  }
124
134
  }
125
135
 
126
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
136
+ async #send({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
127
137
  const message = formatOutboundSegments(payload);
128
138
  const { action, params } = buildSendAction(napcatOutboundTarget(conversation), message);
129
- const data = await this.client.callApi(action, params) as { message_id?: number | string } | undefined;
139
+ const data = await this.requestApi({ action, params, signal }) as { message_id?: number | string } | undefined;
130
140
  const rawId = data?.message_id;
131
141
  if ((typeof rawId !== 'number' && typeof rawId !== 'string') || String(rawId).trim() === '' || (typeof rawId === 'number' && !Number.isFinite(rawId))) {
132
142
  throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: platform returned no message_id', 'unknown');
@@ -141,19 +151,18 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
141
151
  return messageId;
142
152
  }
143
153
 
144
- async recallMessage(messageId: string): Promise<void> {
154
+ async #recallMessage(messageId: string, signal?: AbortSignal): Promise<void> {
145
155
  if (!messageId) return;
146
- await this.client.callApi('delete_msg', { message_id: Number(messageId) });
156
+ await this.requestApi({ action: 'delete_msg', params: { message_id: Number(messageId) }, signal });
147
157
  }
148
158
 
149
- #callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
150
- return callNapCatWsAction(this.#lifecycle.state === 'open' ? this.#ws : undefined, this.#pending, this.#requestId, action, params);
151
- }
159
+
152
160
 
153
161
  /** Test / internal: admit a parsed event when the endpoint is open. */
154
- admit(ev: NapCatEvent): void {
155
- if (!this.#open) return;
156
- void this.emitPlatform(napCatPlatformEventName(ev), ev).catch((error) => {
162
+ #admit(ev: NapCatEvent): void {
163
+ const nativeEvent = ev;
164
+ if (!this.#open && !this.#lifecycle.started) return;
165
+ void this[endpointSpec].events.platform(napCatPlatformEventName(ev), ev).catch((error) => {
157
166
  this.#logger.warn(formatCompact({
158
167
  op: 'napcat_platform_event_failed',
159
168
  error: error instanceof Error ? error.message : String(error),
@@ -161,9 +170,9 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
161
170
  });
162
171
  if (!isMessageEvent(ev)) {
163
172
  receiveNapCatSideEvent(
164
- (name, payload) => this.emit(name, payload),
173
+ (name, payload) => this[endpointSpec].events.emit(name, payload),
165
174
  this.#options.config.id,
166
- this.client,
175
+ this,
167
176
  ev,
168
177
  this.#logger,
169
178
  );
@@ -175,11 +184,13 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
175
184
  if (Array.isArray(ev.message) || typeof ev.message === 'string') {
176
185
  ev = { ...ev, message: normalizeMessage(ev.message) };
177
186
  }
178
- const conversation = napcatInboundConversation(String(this.#options.id), ev);
187
+ const conversation = { ...napcatInboundConversation(String(this.#options.id), ev), endpoint: this[endpointSpec].identity };
179
188
  const content = formatInboundContent(ev);
180
189
  const nickname = senderNickname(ev);
181
190
  const mentioned = isNapCatBotMentioned(ev);
182
- void this.emit('message.receive', {
191
+ void this[endpointSpec].events.message({
192
+ event: nativeEvent,
193
+ ...(typeof nativeEvent.time === 'number' ? { platformSentAt: nativeEvent.time * 1000 } : {}),
183
194
  conversation,
184
195
  message: { conversation, id: msgId },
185
196
  content,
@@ -217,10 +228,10 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
217
228
  await new Promise<void>((resolve, reject) => {
218
229
  let settled = false;
219
230
  const ws = create(url, { headers });
220
- this.#ws = ws;
231
+ this.transportSocket = ws;
221
232
  handle.onForceClose(() => {
222
- if (this.#ws === ws) {
223
- rejectAllPending(this.#pending);
233
+ if (this.transportSocket === ws) {
234
+ this.rpc.disconnect();
224
235
  }
225
236
  try {
226
237
  ws.close();
@@ -230,7 +241,7 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
230
241
  });
231
242
 
232
243
  ws.on('open', () => {
233
- if (settled || this.#ws !== ws || !this.#lifecycle.started) return;
244
+ if (settled || this.transportSocket !== ws || !this.#lifecycle.started) return;
234
245
  settled = true;
235
246
  if (!this.#options.config.access_token) {
236
247
  this.#logger.warn(formatCompact({
@@ -258,22 +269,18 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
258
269
  });
259
270
 
260
271
  ws.on('pong', () => {
261
- if (this.#ws === ws) this.#lifecycle.notifyHeartbeatAck();
272
+ if (this.transportSocket === ws) this.#lifecycle.notifyHeartbeatAck();
262
273
  });
263
274
  ws.on('message', (data) => {
264
- if (this.#ws !== ws || ws.readyState !== 1 || !this.#lifecycle.started) return;
275
+ if (this.transportSocket !== ws || ws.readyState !== 1 || !this.#lifecycle.started) return;
265
276
  this.#lifecycle.notifyHeartbeatAck();
266
- handleNapCatWsMessage(data, {
267
- endpointId: this.#options.config.id,
268
- pending: this.#pending,
269
- admit: (event) => this.admit(event),
270
- });
277
+ this.rpc.receive(data, event => this.ingest(event));
271
278
  });
272
279
 
273
280
  ws.on('close', (code, reason) => {
274
- if (this.#ws !== ws) return;
275
- this.#ws = undefined;
276
- rejectAllPending(this.#pending);
281
+ if (this.transportSocket !== ws) return;
282
+ this.transportSocket = undefined;
283
+ this.rpc.disconnect();
277
284
  const reasonStr = typeof reason === 'string'
278
285
  ? reason
279
286
  : Buffer.isBuffer(reason)
@@ -300,7 +307,7 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
300
307
  });
301
308
 
302
309
  ws.on('error', (err) => {
303
- if (this.#ws !== ws) return;
310
+ if (this.transportSocket !== ws) return;
304
311
  const error = err instanceof Error ? err : new Error(String(err));
305
312
  this.#logger.warn(formatCompact({
306
313
  op: 'ws_error',
@@ -1,112 +1,51 @@
1
- import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
- import { clearInterval, clearTimeout, setInterval, setTimeout } from 'node:timers';
1
+ import { createPendingRpc, type PendingRpc, type PendingRpcRequest } from 'zhin.js/adapter';
3
2
  import { formatCompact, getLogger } from '@zhin.js/logger';
4
- import {
5
- type NapCatActionRequest,
6
- type NapCatActionResponse,
7
- type NapCatEvent,
8
- } from './protocol.js';
9
- import {
10
- WS_OPEN,
11
- type NapCatPendingAction,
12
- type NapCatWsSocket,
13
- } from './ws-types.js';
3
+ import type { NapCatActionResponse, NapCatEvent } from './protocol.js';
4
+ import { WS_OPEN, type NapCatWsSocket } from './ws-types.js';
14
5
 
15
6
  const logger = getLogger('napcat');
16
-
17
- export function decodeWsPayload(data: unknown): string {
18
- if (typeof data === 'string') return data;
19
- if (Buffer.isBuffer(data)) return data.toString();
20
- if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
21
- return String(data ?? '');
7
+ export interface NapCatRpcOptions {
8
+ readonly endpointId: string;
9
+ readonly signal?: AbortSignal;
10
+ /** Reads the lifecycle-selected socket; RPC does not open or reconnect it. */
11
+ readonly socket: () => NapCatWsSocket | undefined;
12
+ }
13
+ export interface NapCatRpc {
14
+ call(request: PendingRpcRequest): Promise<NapCatActionResponse>;
15
+ receive(data: unknown, ingest: (event: NapCatEvent) => void): void;
16
+ disconnect: PendingRpc<NapCatActionResponse>['disconnect'];
17
+ dispose(): void;
18
+ readonly pendingCount: number;
22
19
  }
23
20
 
24
- export function handleNapCatWsMessage(
25
- data: unknown,
26
- options: {
27
- readonly endpointId: string;
28
- readonly pending: Map<string, NapCatPendingAction>;
29
- readonly admit: (ev: NapCatEvent) => void;
30
- },
31
- ): void {
32
- try {
33
- const raw = decodeWsPayload(data);
34
- const msg = JSON.parse(raw) as NapCatEvent | NapCatActionResponse;
35
- if ('echo' in msg && typeof (msg as NapCatActionResponse).echo === 'string') {
36
- const resp = msg as NapCatActionResponse;
37
- const pending = options.pending.get(resp.echo!);
38
- if (pending) {
39
- options.pending.delete(resp.echo!);
40
- clearTimeout(pending.timeout);
41
- if (resp.status === 'ok') pending.resolve(resp.data);
42
- else {
43
- pending.reject(new EndpointDeliveryError(
44
- 'platform_rejected', `API error [${resp.retcode}]: ${resp.message || resp.wording || 'unknown'}`, 'rejected',
45
- ));
21
+ /** Native SDK and semantic ports share this Client's correlation and cancellation owner. */
22
+ export function createNapCatRpc(options: NapCatRpcOptions): NapCatRpc {
23
+ const pending = createPendingRpc<NapCatActionResponse>({
24
+ signal: options.signal, requestPrefix: 'req_', timeoutMs: 30_000,
25
+ transport: () => { const socket = options.socket(); return socket?.readyState === WS_OPEN ? socket : undefined; },
26
+ serialize: request => JSON.stringify({ action: request.action, params: request.params ?? {}, echo: request.id }),
27
+ });
28
+ return Object.freeze({
29
+ call: pending.call, disconnect: pending.disconnect, dispose: pending.dispose,
30
+ get pendingCount() { return pending.pendingCount; },
31
+ receive(data: unknown, ingest: (event: NapCatEvent) => void) {
32
+ try {
33
+ const message = JSON.parse(decodeWsPayload(data)) as NapCatEvent | NapCatActionResponse;
34
+ if ('echo' in message && typeof message.echo === 'string') {
35
+ pending.accept({ id: message.echo, value: message as NapCatActionResponse }); return;
46
36
  }
37
+ ingest(message as NapCatEvent);
38
+ } catch (error) {
39
+ logger.warn(formatCompact({ op: 'napcat_parse_failed', endpoint: options.endpointId,
40
+ error: error instanceof Error ? error.message : String(error) }));
47
41
  }
48
- return;
49
- }
50
- options.admit(msg as NapCatEvent);
51
- } catch (error) {
52
- logger.warn(formatCompact({
53
- op: 'napcat_parse_failed',
54
- endpoint: options.endpointId,
55
- error: error instanceof Error ? error.message : String(error),
56
- }));
57
- }
58
- }
59
-
60
- export function callNapCatWsAction(
61
- ws: NapCatWsSocket | undefined,
62
- pending: Map<string, NapCatPendingAction>,
63
- requestId: { value: number },
64
- action: string,
65
- params: Record<string, unknown>,
66
- ): Promise<unknown> {
67
- if (!ws || ws.readyState !== WS_OPEN) {
68
- return Promise.reject(new EndpointDeliveryError('endpoint_disconnected', 'WebSocket is not connected', 'not_sent'));
69
- }
70
- const echo = `req_${++requestId.value}`;
71
- const req: NapCatActionRequest = { action, params, echo };
72
- return new Promise((resolve, reject) => {
73
- const timeout = setTimeout(() => {
74
- pending.delete(echo);
75
- reject(new EndpointDeliveryError('endpoint_timeout', `API call timeout: ${action}`, 'unknown'));
76
- }, 30_000);
77
- pending.set(echo, { resolve, reject, timeout });
78
- try {
79
- ws.send(JSON.stringify(req));
80
- } catch (error) {
81
- pending.delete(echo);
82
- clearTimeout(timeout);
83
- reject(error instanceof Error ? error : new Error(String(error)));
84
- }
42
+ },
85
43
  });
86
44
  }
87
45
 
88
- export function startNapCatHeartbeat(
89
- ws: NapCatWsSocket | undefined,
90
- intervalMs: number,
91
- existingTimer?: NodeJS.Timeout,
92
- ): NodeJS.Timeout {
93
- if (existingTimer) clearInterval(existingTimer);
94
- return setInterval(() => {
95
- try {
96
- ws?.ping?.();
97
- } catch {
98
- /* ignore */
99
- }
100
- }, intervalMs);
101
- }
102
-
103
- export function rejectAllPending(
104
- pending: Map<string, NapCatPendingAction>,
105
- message = 'Connection closed',
106
- ): void {
107
- for (const [, entry] of pending) {
108
- clearTimeout(entry.timeout);
109
- entry.reject(new EndpointDeliveryError('endpoint_disconnected', message, 'unknown'));
110
- }
111
- pending.clear();
46
+ export function decodeWsPayload(data: unknown): string {
47
+ if (typeof data === 'string') return data;
48
+ if (Buffer.isBuffer(data)) return data.toString();
49
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
50
+ return String(data ?? '');
112
51
  }
package/src/ws-types.ts CHANGED
@@ -12,9 +12,3 @@ export interface NapCatWsCreateOptions {
12
12
  }
13
13
 
14
14
  export const WS_OPEN = 1;
15
-
16
- export interface NapCatPendingAction {
17
- resolve: (value: unknown) => void;
18
- reject: (err: Error) => void;
19
- timeout: NodeJS.Timeout;
20
- }