@zhin.js/adapter-napcat 1.1.3 → 1.1.5
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 +37 -0
- package/README.md +4 -0
- package/lib/http-endpoint.d.ts +2 -2
- package/lib/http-endpoint.js +13 -3
- package/lib/protocol.js +9 -4
- package/lib/ws-endpoint.d.ts +2 -2
- package/lib/ws-endpoint.js +29 -5
- package/lib/ws-transport.js +13 -5
- package/lib/ws-types.d.ts +1 -1
- package/lib/wss-endpoint.d.ts +2 -2
- package/lib/wss-endpoint.js +46 -6
- package/package.json +15 -15
- package/src/http-endpoint.ts +15 -2
- package/src/protocol.ts +11 -5
- package/src/ws-endpoint.ts +27 -4
- package/src/ws-transport.ts +13 -6
- package/src/ws-types.ts +1 -1
- package/src/wss-endpoint.ts +40 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# @zhin.js/adapter-napcat
|
|
2
2
|
|
|
3
|
+
## 1.1.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b9b3953: Fix adapter connection races, transport health reporting, candidate rollback and delivery acknowledgement boundaries. Separate generation admission from locally observed connectivity, reject unconfirmed message receipts, and mark uncertain delivery results without recommending automatic replay.
|
|
8
|
+
|
|
9
|
+
Class and compact endpoints should expose a `transportState` getter to report physical connection or local listener health. Unobserved endpoints now report an unknown transport and remain offline in diagnostics. Platform stability tiers remain unchanged until real account acceptance evidence is available.
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [b9b3953]
|
|
12
|
+
- Updated dependencies [b9b3953]
|
|
13
|
+
- Updated dependencies [b9b3953]
|
|
14
|
+
- Updated dependencies [b9b3953]
|
|
15
|
+
- Updated dependencies [b9b3953]
|
|
16
|
+
- Updated dependencies [b9b3953]
|
|
17
|
+
- Updated dependencies [b9b3953]
|
|
18
|
+
- Updated dependencies [b9b3953]
|
|
19
|
+
- Updated dependencies [b9b3953]
|
|
20
|
+
- Updated dependencies [b9b3953]
|
|
21
|
+
- Updated dependencies [b9b3953]
|
|
22
|
+
- @zhin.js/adapter@1.1.16
|
|
23
|
+
- @zhin.js/im-contract@1.1.3
|
|
24
|
+
- @zhin.js/core@1.1.40
|
|
25
|
+
- @zhin.js/host-http@1.1.3
|
|
26
|
+
- @zhin.js/tool@1.1.3
|
|
27
|
+
- @zhin.js/skill@1.1.3
|
|
28
|
+
- @zhin.js/agent-feature@1.1.3
|
|
29
|
+
|
|
30
|
+
## 1.1.4
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- Updated dependencies [43eda99]
|
|
35
|
+
- Updated dependencies [0de4836]
|
|
36
|
+
- @zhin.js/core@1.1.39
|
|
37
|
+
- @zhin.js/permission@1.1.3
|
|
38
|
+
- @zhin.js/adapter@1.1.15
|
|
39
|
+
|
|
3
40
|
## 1.1.3
|
|
4
41
|
|
|
5
42
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -97,3 +97,7 @@ endpoint 配置,不会从环境变量推断 endpoint id。
|
|
|
97
97
|
## 许可证
|
|
98
98
|
|
|
99
99
|
MIT License
|
|
100
|
+
|
|
101
|
+
### 高阶消息支持边界
|
|
102
|
+
|
|
103
|
+
NapCat 官方消息兼容表标明 share 仅接收,不支持发送;Markdown 仅可在双层合并转发内发送,不支持直接消息。Canonical `markdown`、`share`、`keyboard` 直接出站明确返回 `unsupported_operation / not_sent`,不会将占位段或原始协议猜测报告成功。当前未实现合并转发 Markdown,亦无原生按钮回调闭环。参考:[官方兼容表](https://napneko.github.io/develop/msg)。
|
package/lib/http-endpoint.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
-
import { type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
1
|
+
import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
|
|
3
2
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
4
3
|
import type { CapabilityId } from 'zhin.js';
|
|
5
4
|
import { callNapCatHttpAction, type NapCatEvent, type NapCatHttpConfig } from './protocol.js';
|
|
@@ -17,6 +16,7 @@ export declare class NapCatHttpEndpoint extends Endpoint<NapcatClient> {
|
|
|
17
16
|
readonly control: EndpointControl;
|
|
18
17
|
readonly content: import("@zhin.js/adapter").EndpointContentPort;
|
|
19
18
|
constructor(options: NapCatHttpEndpointOptions);
|
|
19
|
+
get transportState(): EndpointTransportState;
|
|
20
20
|
start(): Promise<void>;
|
|
21
21
|
open(): void;
|
|
22
22
|
close(): void;
|
package/lib/http-endpoint.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createRecallEndpointControl, } from 'zhin.js/adapter';
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
|
+
import { Endpoint, createRecallEndpointControl, } from 'zhin.js/adapter';
|
|
3
3
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
4
4
|
import { createNapCatEndpointManagement } from './endpoint-management.js';
|
|
5
5
|
import { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
|
|
@@ -21,15 +21,20 @@ export class NapCatHttpEndpoint extends Endpoint {
|
|
|
21
21
|
#routeReleases = [];
|
|
22
22
|
#open = false;
|
|
23
23
|
#started = false;
|
|
24
|
+
#stopped = false;
|
|
24
25
|
constructor(options) {
|
|
25
26
|
super();
|
|
26
27
|
this.#logger = getAdapterLogger('napcat', options.config.id);
|
|
27
28
|
this.#options = options;
|
|
28
29
|
this.#callHttpAction = options.callHttpAction ?? callNapCatHttpAction;
|
|
29
30
|
}
|
|
31
|
+
get transportState() {
|
|
32
|
+
return this.#stopped ? 'stopped' : this.#started ? 'open' : 'idle';
|
|
33
|
+
}
|
|
30
34
|
async start() {
|
|
31
35
|
if (this.#started)
|
|
32
36
|
return;
|
|
37
|
+
this.#stopped = false;
|
|
33
38
|
this.#started = true;
|
|
34
39
|
this.#setupRoutes();
|
|
35
40
|
this.#logger.info(formatCompact({
|
|
@@ -46,6 +51,7 @@ export class NapCatHttpEndpoint extends Endpoint {
|
|
|
46
51
|
this.#open = false;
|
|
47
52
|
}
|
|
48
53
|
async stop() {
|
|
54
|
+
this.#stopped = true;
|
|
49
55
|
this.#open = false;
|
|
50
56
|
for (const release of this.#routeReleases.splice(0))
|
|
51
57
|
release();
|
|
@@ -61,7 +67,11 @@ export class NapCatHttpEndpoint extends Endpoint {
|
|
|
61
67
|
access_token: this.#options.config.access_token,
|
|
62
68
|
}, action, params);
|
|
63
69
|
const data = resp.data;
|
|
64
|
-
const
|
|
70
|
+
const rawId = data?.message_id;
|
|
71
|
+
if ((typeof rawId !== 'number' && typeof rawId !== 'string') || String(rawId).trim() === '' || (typeof rawId === 'number' && !Number.isFinite(rawId))) {
|
|
72
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: platform returned no message_id', 'unknown');
|
|
73
|
+
}
|
|
74
|
+
const messageId = String(rawId);
|
|
65
75
|
this.#logger.debug(formatCompact({
|
|
66
76
|
op: 'napcat_send',
|
|
67
77
|
endpoint: this.#options.config.id,
|
package/lib/protocol.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* No legacy Adapter/Endpoint / segment-mapper.
|
|
4
4
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
5
5
|
*/
|
|
6
|
-
import { isMediaRef } from '@zhin.js/im-contract';
|
|
6
|
+
import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
|
|
7
7
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
8
|
const logger = getLogger('napcat');
|
|
9
9
|
function normalizeConnection(connection) {
|
|
@@ -189,6 +189,10 @@ function oneBotMediaSegment(type, data) {
|
|
|
189
189
|
function canonicalToOneBotSegment(segment) {
|
|
190
190
|
const data = segment.data ?? {};
|
|
191
191
|
switch (segment.type) {
|
|
192
|
+
case 'markdown':
|
|
193
|
+
case 'share':
|
|
194
|
+
case 'keyboard':
|
|
195
|
+
throw new EndpointDeliveryError('unsupported_operation', `NapCat direct ${segment.type} sending is not supported`, 'not_sent');
|
|
192
196
|
case 'mention': {
|
|
193
197
|
const target = data.target ?? data.qq ?? data.id;
|
|
194
198
|
if (target == null)
|
|
@@ -295,15 +299,16 @@ export async function callNapCatHttpAction(options, action, params = {}) {
|
|
|
295
299
|
headers.Authorization = `Bearer ${options.access_token}`;
|
|
296
300
|
}
|
|
297
301
|
const res = await fetch(url, {
|
|
302
|
+
signal: AbortSignal.timeout(30_000),
|
|
298
303
|
method: 'POST',
|
|
299
304
|
headers,
|
|
300
305
|
body: JSON.stringify(params),
|
|
301
306
|
});
|
|
302
307
|
const text = await res.text();
|
|
303
308
|
if (res.status === 401)
|
|
304
|
-
throw new
|
|
309
|
+
throw new EndpointDeliveryError('platform_rejected', `NapCat HTTP auth failed: ${text}`, 'rejected');
|
|
305
310
|
if (res.status !== 200)
|
|
306
|
-
throw new
|
|
311
|
+
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');
|
|
307
312
|
let data;
|
|
308
313
|
try {
|
|
309
314
|
data = JSON.parse(text);
|
|
@@ -312,7 +317,7 @@ export async function callNapCatHttpAction(options, action, params = {}) {
|
|
|
312
317
|
throw new Error(`NapCat HTTP invalid response: ${text.slice(0, 200)}`);
|
|
313
318
|
}
|
|
314
319
|
if (data.status !== 'ok' && data.retcode !== 0) {
|
|
315
|
-
throw new
|
|
320
|
+
throw new EndpointDeliveryError('platform_rejected', `NapCat HTTP action failed [${data.retcode}]: ${data.message || data.wording || 'unknown'}`, 'rejected');
|
|
316
321
|
}
|
|
317
322
|
return data;
|
|
318
323
|
}
|
package/lib/ws-endpoint.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { Endpoint } from 'zhin.js/adapter';
|
|
2
|
-
import { type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
1
|
+
import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
|
|
3
2
|
import type { CapabilityId } from 'zhin.js';
|
|
4
3
|
import { type NapCatEvent, type NapCatWsConfig } from './protocol.js';
|
|
5
4
|
import { type NapCatWsCreateOptions, type NapCatWsSocket } from './ws-types.js';
|
|
@@ -16,6 +15,7 @@ export declare class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
16
15
|
readonly control: EndpointControl;
|
|
17
16
|
readonly content: import("@zhin.js/adapter").EndpointContentPort;
|
|
18
17
|
constructor(options: NapCatWsEndpointOptions);
|
|
18
|
+
get transportState(): EndpointTransportState;
|
|
19
19
|
start(): Promise<void>;
|
|
20
20
|
open(): void;
|
|
21
21
|
close(): void;
|
package/lib/ws-endpoint.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* NapCat WS client endpoint — outbound connect to NapCat.
|
|
4
4
|
*/
|
|
5
5
|
import WebSocket from 'ws';
|
|
6
|
-
import { createRecallEndpointControl, createEndpointLifecycle, } from 'zhin.js/adapter';
|
|
6
|
+
import { Endpoint, createRecallEndpointControl, createEndpointLifecycle, } from 'zhin.js/adapter';
|
|
7
7
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
8
8
|
import { createNapCatEndpointManagement } from './endpoint-management.js';
|
|
9
9
|
import { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
|
|
@@ -31,6 +31,7 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
31
31
|
this.#options = options;
|
|
32
32
|
this.#lifecycle = createEndpointLifecycle({
|
|
33
33
|
name: options.config.id,
|
|
34
|
+
heartbeat: { intervalMs: options.config.heartbeat_interval, watchdogMisses: 2 },
|
|
34
35
|
// reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
|
|
35
36
|
reconnect: {
|
|
36
37
|
initialIntervalMs: options.config.reconnect_interval,
|
|
@@ -40,6 +41,9 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
40
41
|
},
|
|
41
42
|
});
|
|
42
43
|
}
|
|
44
|
+
get transportState() {
|
|
45
|
+
return this.#lifecycle.state;
|
|
46
|
+
}
|
|
43
47
|
async start() {
|
|
44
48
|
if (this.#lifecycle.started)
|
|
45
49
|
return;
|
|
@@ -70,7 +74,11 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
70
74
|
const message = formatOutboundSegments(payload);
|
|
71
75
|
const { action, params } = buildSendAction(napcatOutboundTarget(conversation), message);
|
|
72
76
|
const data = await this.client.callApi(action, params);
|
|
73
|
-
const
|
|
77
|
+
const rawId = data?.message_id;
|
|
78
|
+
if ((typeof rawId !== 'number' && typeof rawId !== 'string') || String(rawId).trim() === '' || (typeof rawId === 'number' && !Number.isFinite(rawId))) {
|
|
79
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: platform returned no message_id', 'unknown');
|
|
80
|
+
}
|
|
81
|
+
const messageId = String(rawId);
|
|
74
82
|
this.#logger.debug(formatCompact({
|
|
75
83
|
op: 'napcat_send',
|
|
76
84
|
endpoint: this.#options.config.id,
|
|
@@ -85,7 +93,7 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
85
93
|
await this.client.callApi('delete_msg', { message_id: Number(messageId) });
|
|
86
94
|
}
|
|
87
95
|
#callApi(action, params = {}) {
|
|
88
|
-
return callNapCatWsAction(this.#ws, this.#pending, this.#requestId, action, params);
|
|
96
|
+
return callNapCatWsAction(this.#lifecycle.state === 'open' ? this.#ws : undefined, this.#pending, this.#requestId, action, params);
|
|
89
97
|
}
|
|
90
98
|
/** Test / internal: admit a parsed event when the endpoint is open. */
|
|
91
99
|
admit(ev) {
|
|
@@ -150,6 +158,9 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
150
158
|
const ws = create(url, { headers });
|
|
151
159
|
this.#ws = ws;
|
|
152
160
|
handle.onForceClose(() => {
|
|
161
|
+
if (this.#ws === ws) {
|
|
162
|
+
rejectAllPending(this.#pending);
|
|
163
|
+
}
|
|
153
164
|
try {
|
|
154
165
|
ws.close();
|
|
155
166
|
}
|
|
@@ -158,7 +169,7 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
158
169
|
}
|
|
159
170
|
});
|
|
160
171
|
ws.on('open', () => {
|
|
161
|
-
if (settled)
|
|
172
|
+
if (settled || this.#ws !== ws || !this.#lifecycle.started)
|
|
162
173
|
return;
|
|
163
174
|
settled = true;
|
|
164
175
|
if (!this.#options.config.access_token) {
|
|
@@ -186,7 +197,14 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
186
197
|
}
|
|
187
198
|
resolve();
|
|
188
199
|
});
|
|
200
|
+
ws.on('pong', () => {
|
|
201
|
+
if (this.#ws === ws)
|
|
202
|
+
this.#lifecycle.notifyHeartbeatAck();
|
|
203
|
+
});
|
|
189
204
|
ws.on('message', (data) => {
|
|
205
|
+
if (this.#ws !== ws || ws.readyState !== 1 || !this.#lifecycle.started)
|
|
206
|
+
return;
|
|
207
|
+
this.#lifecycle.notifyHeartbeatAck();
|
|
190
208
|
handleNapCatWsMessage(data, {
|
|
191
209
|
endpointId: this.#options.config.id,
|
|
192
210
|
pending: this.#pending,
|
|
@@ -194,6 +212,10 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
194
212
|
});
|
|
195
213
|
});
|
|
196
214
|
ws.on('close', (code, reason) => {
|
|
215
|
+
if (this.#ws !== ws)
|
|
216
|
+
return;
|
|
217
|
+
this.#ws = undefined;
|
|
218
|
+
rejectAllPending(this.#pending);
|
|
197
219
|
const reasonStr = typeof reason === 'string'
|
|
198
220
|
? reason
|
|
199
221
|
: Buffer.isBuffer(reason)
|
|
@@ -219,6 +241,8 @@ export class NapCatWsEndpoint extends Endpoint {
|
|
|
219
241
|
}
|
|
220
242
|
});
|
|
221
243
|
ws.on('error', (err) => {
|
|
244
|
+
if (this.#ws !== ws)
|
|
245
|
+
return;
|
|
222
246
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
223
247
|
this.#logger.warn(formatCompact({
|
|
224
248
|
op: 'ws_error',
|
package/lib/ws-transport.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
1
2
|
import { clearInterval, clearTimeout, setInterval, setTimeout } from 'node:timers';
|
|
2
3
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
3
4
|
import { WS_OPEN, } from './ws-types.js';
|
|
@@ -24,7 +25,7 @@ export function handleNapCatWsMessage(data, options) {
|
|
|
24
25
|
if (resp.status === 'ok')
|
|
25
26
|
pending.resolve(resp.data);
|
|
26
27
|
else {
|
|
27
|
-
pending.reject(new
|
|
28
|
+
pending.reject(new EndpointDeliveryError('platform_rejected', `API error [${resp.retcode}]: ${resp.message || resp.wording || 'unknown'}`, 'rejected'));
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
return;
|
|
@@ -41,17 +42,24 @@ export function handleNapCatWsMessage(data, options) {
|
|
|
41
42
|
}
|
|
42
43
|
export function callNapCatWsAction(ws, pending, requestId, action, params) {
|
|
43
44
|
if (!ws || ws.readyState !== WS_OPEN) {
|
|
44
|
-
return Promise.reject(new
|
|
45
|
+
return Promise.reject(new EndpointDeliveryError('endpoint_disconnected', 'WebSocket is not connected', 'not_sent'));
|
|
45
46
|
}
|
|
46
47
|
const echo = `req_${++requestId.value}`;
|
|
47
48
|
const req = { action, params, echo };
|
|
48
49
|
return new Promise((resolve, reject) => {
|
|
49
50
|
const timeout = setTimeout(() => {
|
|
50
51
|
pending.delete(echo);
|
|
51
|
-
reject(new
|
|
52
|
+
reject(new EndpointDeliveryError('endpoint_timeout', `API call timeout: ${action}`, 'unknown'));
|
|
52
53
|
}, 30_000);
|
|
53
54
|
pending.set(echo, { resolve, reject, timeout });
|
|
54
|
-
|
|
55
|
+
try {
|
|
56
|
+
ws.send(JSON.stringify(req));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
pending.delete(echo);
|
|
60
|
+
clearTimeout(timeout);
|
|
61
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
62
|
+
}
|
|
55
63
|
});
|
|
56
64
|
}
|
|
57
65
|
export function startNapCatHeartbeat(ws, intervalMs, existingTimer) {
|
|
@@ -69,7 +77,7 @@ export function startNapCatHeartbeat(ws, intervalMs, existingTimer) {
|
|
|
69
77
|
export function rejectAllPending(pending, message = 'Connection closed') {
|
|
70
78
|
for (const [, entry] of pending) {
|
|
71
79
|
clearTimeout(entry.timeout);
|
|
72
|
-
entry.reject(new
|
|
80
|
+
entry.reject(new EndpointDeliveryError('endpoint_disconnected', message, 'unknown'));
|
|
73
81
|
}
|
|
74
82
|
pending.clear();
|
|
75
83
|
}
|
package/lib/ws-types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface NapCatWsSocket {
|
|
|
4
4
|
send(data: string): void;
|
|
5
5
|
close(code?: number, reason?: string): void;
|
|
6
6
|
ping?(): void;
|
|
7
|
-
on(event: 'open' | 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
|
|
7
|
+
on(event: 'open' | 'message' | 'close' | 'error' | 'pong', listener: (...args: unknown[]) => void): void;
|
|
8
8
|
}
|
|
9
9
|
export interface NapCatWsCreateOptions {
|
|
10
10
|
readonly headers?: Record<string, string>;
|
package/lib/wss-endpoint.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { Endpoint } from 'zhin.js/adapter';
|
|
2
1
|
/**
|
|
3
2
|
* NapCat reverse WSS endpoint — accepts inbound WebSocket from NapCat.
|
|
4
3
|
*/
|
|
5
|
-
import { type EndpointControl, type EndpointManagement, type EndpointSendRequest } from 'zhin.js/adapter';
|
|
4
|
+
import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
|
|
6
5
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
7
6
|
import type { CapabilityId } from 'zhin.js';
|
|
8
7
|
import { type NapCatEvent, type NapCatWssConfig } from './protocol.js';
|
|
@@ -19,6 +18,7 @@ export declare class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
19
18
|
readonly control: EndpointControl;
|
|
20
19
|
readonly content: import("@zhin.js/adapter").EndpointContentPort;
|
|
21
20
|
constructor(options: NapCatWssEndpointOptions);
|
|
21
|
+
get transportState(): EndpointTransportState;
|
|
22
22
|
start(): Promise<void>;
|
|
23
23
|
open(): void;
|
|
24
24
|
close(): void;
|
package/lib/wss-endpoint.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* NapCat reverse WSS endpoint — accepts inbound WebSocket from NapCat.
|
|
4
4
|
*/
|
|
5
|
-
import { createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
|
|
5
|
+
import { Endpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
|
|
6
6
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
7
7
|
import { createNapCatEndpointManagement } from './endpoint-management.js';
|
|
8
8
|
import { InboundMessageDeduper, isNapCatBotMentioned, isSelfMessage, normalizeMessage, } from './napcat-inbound.js';
|
|
@@ -28,6 +28,7 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
28
28
|
#pending = new Map();
|
|
29
29
|
#open = false;
|
|
30
30
|
#started = false;
|
|
31
|
+
#stopped = false;
|
|
31
32
|
constructor(options) {
|
|
32
33
|
super();
|
|
33
34
|
this.#logger = getAdapterLogger('napcat', options.config.id);
|
|
@@ -35,11 +36,24 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
35
36
|
this.#connectionLifecycle = createEndpointLifecycle({
|
|
36
37
|
name: `${options.config.id}:inbound`,
|
|
37
38
|
reconnect: false,
|
|
39
|
+
heartbeat: { intervalMs: options.config.heartbeat_interval, watchdogMisses: 2 },
|
|
38
40
|
});
|
|
39
41
|
}
|
|
42
|
+
get transportState() {
|
|
43
|
+
if (this.#stopped)
|
|
44
|
+
return 'stopped';
|
|
45
|
+
if (!this.#started)
|
|
46
|
+
return 'idle';
|
|
47
|
+
if (this.#connectionLifecycle.state === 'closed')
|
|
48
|
+
return 'closed';
|
|
49
|
+
if (this.#ws?.readyState === 1)
|
|
50
|
+
return 'open';
|
|
51
|
+
return this.#connectionLifecycle.state === 'connecting' ? 'connecting' : 'idle';
|
|
52
|
+
}
|
|
40
53
|
async start() {
|
|
41
54
|
if (this.#started)
|
|
42
55
|
return;
|
|
56
|
+
this.#stopped = false;
|
|
43
57
|
this.#started = true;
|
|
44
58
|
const handle = this.#options.http.ws(this.#options.config.path);
|
|
45
59
|
this.#wsRelease = handle.onConnection((connection) => {
|
|
@@ -67,6 +81,7 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
67
81
|
this.#open = false;
|
|
68
82
|
}
|
|
69
83
|
async stop() {
|
|
84
|
+
this.#stopped = true;
|
|
70
85
|
this.#open = false;
|
|
71
86
|
this.#wsRelease?.();
|
|
72
87
|
this.#wsRelease = undefined;
|
|
@@ -81,7 +96,11 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
81
96
|
const message = formatOutboundSegments(payload);
|
|
82
97
|
const { action, params } = buildSendAction(napcatOutboundTarget(conversation), message);
|
|
83
98
|
const data = await this.client.callApi(action, params);
|
|
84
|
-
|
|
99
|
+
const rawId = data?.message_id;
|
|
100
|
+
if ((typeof rawId !== 'number' && typeof rawId !== 'string') || String(rawId).trim() === '' || (typeof rawId === 'number' && !Number.isFinite(rawId))) {
|
|
101
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: platform returned no message_id', 'unknown');
|
|
102
|
+
}
|
|
103
|
+
return String(rawId);
|
|
85
104
|
}
|
|
86
105
|
async recallMessage(messageId) {
|
|
87
106
|
if (!messageId)
|
|
@@ -89,7 +108,7 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
89
108
|
await this.client.callApi('delete_msg', { message_id: Number(messageId) });
|
|
90
109
|
}
|
|
91
110
|
#callApi(action, params = {}) {
|
|
92
|
-
return callNapCatWsAction(this.#ws, this.#pending, this.#requestId, action, params);
|
|
111
|
+
return callNapCatWsAction(this.#connectionLifecycle.state === 'closed' ? undefined : this.#ws, this.#pending, this.#requestId, action, params);
|
|
93
112
|
}
|
|
94
113
|
admit(ev) {
|
|
95
114
|
if (!this.#open)
|
|
@@ -144,15 +163,23 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
144
163
|
});
|
|
145
164
|
}
|
|
146
165
|
async #acceptConnection(connection) {
|
|
166
|
+
if (!this.#started || this.#stopped) {
|
|
167
|
+
connection.socket.close();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
147
170
|
if (!verifyNapCatAccessToken(this.#options.config.access_token, connection.request)) {
|
|
148
171
|
connection.socket.close(4003, 'Unauthorized');
|
|
149
172
|
return;
|
|
150
173
|
}
|
|
151
174
|
const socket = connection.socket;
|
|
152
175
|
await this.#connectionLifecycle.stop();
|
|
176
|
+
rejectAllPending(this.#pending, 'Connection replaced');
|
|
153
177
|
await this.#connectionLifecycle.start(async (lifecycleHandle) => {
|
|
154
178
|
this.#ws = socket;
|
|
155
179
|
lifecycleHandle.onForceClose(() => {
|
|
180
|
+
if (this.#ws === socket) {
|
|
181
|
+
rejectAllPending(this.#pending);
|
|
182
|
+
}
|
|
156
183
|
try {
|
|
157
184
|
socket.close();
|
|
158
185
|
}
|
|
@@ -168,7 +195,13 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
168
195
|
/* ignore */
|
|
169
196
|
}
|
|
170
197
|
}, this.#options.config.heartbeat_interval);
|
|
198
|
+
socket.on('pong', () => {
|
|
199
|
+
if (this.#ws === socket)
|
|
200
|
+
this.#connectionLifecycle.notifyHeartbeatAck();
|
|
201
|
+
});
|
|
171
202
|
socket.on('message', (data) => {
|
|
203
|
+
if (this.#ws !== socket || this.#connectionLifecycle.state === 'closed')
|
|
204
|
+
return;
|
|
172
205
|
this.#connectionLifecycle.notifyHeartbeatAck();
|
|
173
206
|
handleNapCatWsMessage(data, {
|
|
174
207
|
endpointId: this.#options.config.id,
|
|
@@ -176,9 +209,16 @@ export class NapCatWssEndpoint extends Endpoint {
|
|
|
176
209
|
admit: (event) => this.admit(event),
|
|
177
210
|
});
|
|
178
211
|
});
|
|
212
|
+
socket.on('error', (error) => {
|
|
213
|
+
if (this.#ws !== socket)
|
|
214
|
+
return;
|
|
215
|
+
this.#logger.warn(formatCompact({ op: 'ws_error', error: String(error) }));
|
|
216
|
+
});
|
|
179
217
|
socket.on('close', () => {
|
|
180
|
-
if (this.#ws
|
|
181
|
-
|
|
218
|
+
if (this.#ws !== socket)
|
|
219
|
+
return;
|
|
220
|
+
this.#ws = undefined;
|
|
221
|
+
rejectAllPending(this.#pending);
|
|
182
222
|
lifecycleHandle.notifyClosed(new Error('NapCat reverse WebSocket closed'));
|
|
183
223
|
});
|
|
184
224
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-napcat",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Zhin.js NapCat adapter for Plugin Runtime (WebSocket client, OneBot11 + NapCat extensions)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -45,16 +45,16 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"ws": "^8.21.1",
|
|
48
|
-
"@zhin.js/adapter": "1.1.
|
|
49
|
-
"@zhin.js/agent-feature": "1.1.
|
|
50
|
-
"@zhin.js/core": "1.1.
|
|
48
|
+
"@zhin.js/adapter": "1.1.16",
|
|
49
|
+
"@zhin.js/agent-feature": "1.1.3",
|
|
50
|
+
"@zhin.js/core": "1.1.40",
|
|
51
51
|
"@zhin.js/feature-kit": "1.1.2",
|
|
52
|
-
"@zhin.js/host-http": "1.1.
|
|
53
|
-
"@zhin.js/im-contract": "1.1.
|
|
52
|
+
"@zhin.js/host-http": "1.1.3",
|
|
53
|
+
"@zhin.js/im-contract": "1.1.3",
|
|
54
54
|
"@zhin.js/logger": "1.1.1",
|
|
55
|
-
"@zhin.js/permission": "1.1.
|
|
56
|
-
"@zhin.js/skill": "1.1.
|
|
57
|
-
"@zhin.js/tool": "1.1.
|
|
55
|
+
"@zhin.js/permission": "1.1.3",
|
|
56
|
+
"@zhin.js/skill": "1.1.3",
|
|
57
|
+
"@zhin.js/tool": "1.1.3"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^26.1.2",
|
|
@@ -62,15 +62,15 @@
|
|
|
62
62
|
"typescript": "^6.0.3",
|
|
63
63
|
"vitest": "^4.1.10",
|
|
64
64
|
"zod": "^4.4.3",
|
|
65
|
-
"@zhin.js/host-http": "1.1.
|
|
66
|
-
"zhin.js": "1.1.
|
|
65
|
+
"@zhin.js/host-http": "1.1.3",
|
|
66
|
+
"zhin.js": "1.1.5"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"zod": "^4.0.0",
|
|
70
|
-
"@zhin.js/adapter": "^1.1.
|
|
71
|
-
"@zhin.js/command": "^1.1.
|
|
72
|
-
"@zhin.js/core": "^1.1.
|
|
73
|
-
"zhin.js": "^1.1.
|
|
70
|
+
"@zhin.js/adapter": "^1.1.16",
|
|
71
|
+
"@zhin.js/command": "^1.1.4",
|
|
72
|
+
"@zhin.js/core": "^1.1.40",
|
|
73
|
+
"zhin.js": "^1.1.5"
|
|
74
74
|
},
|
|
75
75
|
"peerDependenciesMeta": {
|
|
76
76
|
"@zhin.js/command": {
|
package/src/http-endpoint.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* NapCat HTTP endpoint — POST inbound events + HTTP API outbound.
|
|
4
4
|
*/
|
|
5
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
6
|
import {
|
|
7
|
+
Endpoint,
|
|
7
8
|
createRecallEndpointControl,
|
|
8
9
|
type EndpointControl,
|
|
9
10
|
type EndpointManagement,
|
|
10
11
|
type EndpointSendRequest,
|
|
12
|
+
type EndpointTransportState,
|
|
11
13
|
} from 'zhin.js/adapter';
|
|
12
14
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
13
15
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
@@ -59,6 +61,7 @@ export class NapCatHttpEndpoint extends Endpoint<NapcatClient> {
|
|
|
59
61
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
60
62
|
#open = false;
|
|
61
63
|
#started = false;
|
|
64
|
+
#stopped = false;
|
|
62
65
|
|
|
63
66
|
constructor(options: NapCatHttpEndpointOptions) {
|
|
64
67
|
super();
|
|
@@ -67,8 +70,13 @@ export class NapCatHttpEndpoint extends Endpoint<NapcatClient> {
|
|
|
67
70
|
this.#callHttpAction = options.callHttpAction ?? callNapCatHttpAction;
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
get transportState(): EndpointTransportState {
|
|
74
|
+
return this.#stopped ? 'stopped' : this.#started ? 'open' : 'idle';
|
|
75
|
+
}
|
|
76
|
+
|
|
70
77
|
async start(): Promise<void> {
|
|
71
78
|
if (this.#started) return;
|
|
79
|
+
this.#stopped = false;
|
|
72
80
|
this.#started = true;
|
|
73
81
|
this.#setupRoutes();
|
|
74
82
|
this.#logger.info(formatCompact({
|
|
@@ -88,6 +96,7 @@ export class NapCatHttpEndpoint extends Endpoint<NapcatClient> {
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
async stop(): Promise<void> {
|
|
99
|
+
this.#stopped = true;
|
|
91
100
|
this.#open = false;
|
|
92
101
|
for (const release of this.#routeReleases.splice(0)) release();
|
|
93
102
|
this.#inboundDeduper.clear();
|
|
@@ -107,7 +116,11 @@ export class NapCatHttpEndpoint extends Endpoint<NapcatClient> {
|
|
|
107
116
|
params,
|
|
108
117
|
);
|
|
109
118
|
const data = resp.data as { message_id?: number | string } | undefined;
|
|
110
|
-
const
|
|
119
|
+
const rawId = data?.message_id;
|
|
120
|
+
if ((typeof rawId !== 'number' && typeof rawId !== 'string') || String(rawId).trim() === '' || (typeof rawId === 'number' && !Number.isFinite(rawId))) {
|
|
121
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: platform returned no message_id', 'unknown');
|
|
122
|
+
}
|
|
123
|
+
const messageId = String(rawId);
|
|
111
124
|
this.#logger.debug(formatCompact({
|
|
112
125
|
op: 'napcat_send',
|
|
113
126
|
endpoint: this.#options.config.id,
|
package/src/protocol.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* No legacy Adapter/Endpoint / segment-mapper.
|
|
4
4
|
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
5
5
|
*/
|
|
6
|
-
import { isMediaRef, type MediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
6
|
+
import { EndpointDeliveryError, isMediaRef, type MediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
7
7
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
8
|
|
|
9
9
|
const logger = getLogger('napcat');
|
|
@@ -318,6 +318,11 @@ function oneBotMediaSegment(
|
|
|
318
318
|
function canonicalToOneBotSegment(segment: NapCatWireSegment): MessageSegment | null {
|
|
319
319
|
const data = segment.data ?? {};
|
|
320
320
|
switch (segment.type) {
|
|
321
|
+
case 'markdown':
|
|
322
|
+
case 'share':
|
|
323
|
+
case 'keyboard':
|
|
324
|
+
throw new EndpointDeliveryError('unsupported_operation', `NapCat direct ${segment.type} sending is not supported`, 'not_sent');
|
|
325
|
+
|
|
321
326
|
case 'mention': {
|
|
322
327
|
const target = data.target ?? data.qq ?? data.id;
|
|
323
328
|
if (target == null) return { type: segment.type, data };
|
|
@@ -443,13 +448,14 @@ export async function callNapCatHttpAction(
|
|
|
443
448
|
headers.Authorization = `Bearer ${options.access_token}`;
|
|
444
449
|
}
|
|
445
450
|
const res = await fetch(url, {
|
|
451
|
+
signal: AbortSignal.timeout(30_000),
|
|
446
452
|
method: 'POST',
|
|
447
453
|
headers,
|
|
448
454
|
body: JSON.stringify(params),
|
|
449
455
|
});
|
|
450
456
|
const text = await res.text();
|
|
451
|
-
if (res.status === 401) throw new
|
|
452
|
-
if (res.status !== 200) throw new
|
|
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');
|
|
453
459
|
let data: NapCatActionResponse;
|
|
454
460
|
try {
|
|
455
461
|
data = JSON.parse(text) as NapCatActionResponse;
|
|
@@ -457,8 +463,8 @@ export async function callNapCatHttpAction(
|
|
|
457
463
|
throw new Error(`NapCat HTTP invalid response: ${text.slice(0, 200)}`);
|
|
458
464
|
}
|
|
459
465
|
if (data.status !== 'ok' && data.retcode !== 0) {
|
|
460
|
-
throw new
|
|
461
|
-
`NapCat HTTP action failed [${data.retcode}]: ${data.message || data.wording || 'unknown'}`,
|
|
466
|
+
throw new EndpointDeliveryError(
|
|
467
|
+
'platform_rejected', `NapCat HTTP action failed [${data.retcode}]: ${data.message || data.wording || 'unknown'}`, 'rejected',
|
|
462
468
|
);
|
|
463
469
|
}
|
|
464
470
|
return data;
|
package/src/ws-endpoint.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* NapCat WS client endpoint — outbound connect to NapCat.
|
|
4
4
|
*/
|
|
5
5
|
import WebSocket from 'ws';
|
|
6
6
|
import {
|
|
7
|
+
Endpoint,
|
|
7
8
|
createRecallEndpointControl,
|
|
8
9
|
createEndpointLifecycle,
|
|
9
10
|
type EndpointConnectHandle,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
type EndpointLifecycle,
|
|
12
13
|
type EndpointManagement,
|
|
13
14
|
type EndpointSendRequest,
|
|
15
|
+
type EndpointTransportState,
|
|
14
16
|
} from 'zhin.js/adapter';
|
|
15
17
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
16
18
|
import type { CapabilityId } from 'zhin.js';
|
|
@@ -78,6 +80,7 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
78
80
|
this.#options = options;
|
|
79
81
|
this.#lifecycle = createEndpointLifecycle({
|
|
80
82
|
name: options.config.id,
|
|
83
|
+
heartbeat: { intervalMs: options.config.heartbeat_interval, watchdogMisses: 2 },
|
|
81
84
|
// reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
|
|
82
85
|
reconnect: {
|
|
83
86
|
initialIntervalMs: options.config.reconnect_interval,
|
|
@@ -88,6 +91,10 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
88
91
|
});
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
get transportState(): EndpointTransportState {
|
|
95
|
+
return this.#lifecycle.state;
|
|
96
|
+
}
|
|
97
|
+
|
|
91
98
|
async start(): Promise<void> {
|
|
92
99
|
if (this.#lifecycle.started) return;
|
|
93
100
|
await this.#lifecycle.start((handle) => this.#connect(handle));
|
|
@@ -120,7 +127,11 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
120
127
|
const message = formatOutboundSegments(payload);
|
|
121
128
|
const { action, params } = buildSendAction(napcatOutboundTarget(conversation), message);
|
|
122
129
|
const data = await this.client.callApi(action, params) as { message_id?: number | string } | undefined;
|
|
123
|
-
const
|
|
130
|
+
const rawId = data?.message_id;
|
|
131
|
+
if ((typeof rawId !== 'number' && typeof rawId !== 'string') || String(rawId).trim() === '' || (typeof rawId === 'number' && !Number.isFinite(rawId))) {
|
|
132
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: platform returned no message_id', 'unknown');
|
|
133
|
+
}
|
|
134
|
+
const messageId = String(rawId);
|
|
124
135
|
this.#logger.debug(formatCompact({
|
|
125
136
|
op: 'napcat_send',
|
|
126
137
|
endpoint: this.#options.config.id,
|
|
@@ -136,7 +147,7 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
#callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
139
|
-
return callNapCatWsAction(this.#ws, this.#pending, this.#requestId, action, params);
|
|
150
|
+
return callNapCatWsAction(this.#lifecycle.state === 'open' ? this.#ws : undefined, this.#pending, this.#requestId, action, params);
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
/** Test / internal: admit a parsed event when the endpoint is open. */
|
|
@@ -208,6 +219,9 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
208
219
|
const ws = create(url, { headers });
|
|
209
220
|
this.#ws = ws;
|
|
210
221
|
handle.onForceClose(() => {
|
|
222
|
+
if (this.#ws === ws) {
|
|
223
|
+
rejectAllPending(this.#pending);
|
|
224
|
+
}
|
|
211
225
|
try {
|
|
212
226
|
ws.close();
|
|
213
227
|
} catch {
|
|
@@ -216,7 +230,7 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
216
230
|
});
|
|
217
231
|
|
|
218
232
|
ws.on('open', () => {
|
|
219
|
-
if (settled) return;
|
|
233
|
+
if (settled || this.#ws !== ws || !this.#lifecycle.started) return;
|
|
220
234
|
settled = true;
|
|
221
235
|
if (!this.#options.config.access_token) {
|
|
222
236
|
this.#logger.warn(formatCompact({
|
|
@@ -243,7 +257,12 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
243
257
|
resolve();
|
|
244
258
|
});
|
|
245
259
|
|
|
260
|
+
ws.on('pong', () => {
|
|
261
|
+
if (this.#ws === ws) this.#lifecycle.notifyHeartbeatAck();
|
|
262
|
+
});
|
|
246
263
|
ws.on('message', (data) => {
|
|
264
|
+
if (this.#ws !== ws || ws.readyState !== 1 || !this.#lifecycle.started) return;
|
|
265
|
+
this.#lifecycle.notifyHeartbeatAck();
|
|
247
266
|
handleNapCatWsMessage(data, {
|
|
248
267
|
endpointId: this.#options.config.id,
|
|
249
268
|
pending: this.#pending,
|
|
@@ -252,6 +271,9 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
252
271
|
});
|
|
253
272
|
|
|
254
273
|
ws.on('close', (code, reason) => {
|
|
274
|
+
if (this.#ws !== ws) return;
|
|
275
|
+
this.#ws = undefined;
|
|
276
|
+
rejectAllPending(this.#pending);
|
|
255
277
|
const reasonStr = typeof reason === 'string'
|
|
256
278
|
? reason
|
|
257
279
|
: Buffer.isBuffer(reason)
|
|
@@ -278,6 +300,7 @@ export class NapCatWsEndpoint extends Endpoint<NapcatClient> {
|
|
|
278
300
|
});
|
|
279
301
|
|
|
280
302
|
ws.on('error', (err) => {
|
|
303
|
+
if (this.#ws !== ws) return;
|
|
281
304
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
282
305
|
this.#logger.warn(formatCompact({
|
|
283
306
|
op: 'ws_error',
|
package/src/ws-transport.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
1
2
|
import { clearInterval, clearTimeout, setInterval, setTimeout } from 'node:timers';
|
|
2
3
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
3
4
|
import {
|
|
@@ -39,8 +40,8 @@ export function handleNapCatWsMessage(
|
|
|
39
40
|
clearTimeout(pending.timeout);
|
|
40
41
|
if (resp.status === 'ok') pending.resolve(resp.data);
|
|
41
42
|
else {
|
|
42
|
-
pending.reject(new
|
|
43
|
-
`API error [${resp.retcode}]: ${resp.message || resp.wording || 'unknown'}`,
|
|
43
|
+
pending.reject(new EndpointDeliveryError(
|
|
44
|
+
'platform_rejected', `API error [${resp.retcode}]: ${resp.message || resp.wording || 'unknown'}`, 'rejected',
|
|
44
45
|
));
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -64,17 +65,23 @@ export function callNapCatWsAction(
|
|
|
64
65
|
params: Record<string, unknown>,
|
|
65
66
|
): Promise<unknown> {
|
|
66
67
|
if (!ws || ws.readyState !== WS_OPEN) {
|
|
67
|
-
return Promise.reject(new
|
|
68
|
+
return Promise.reject(new EndpointDeliveryError('endpoint_disconnected', 'WebSocket is not connected', 'not_sent'));
|
|
68
69
|
}
|
|
69
70
|
const echo = `req_${++requestId.value}`;
|
|
70
71
|
const req: NapCatActionRequest = { action, params, echo };
|
|
71
72
|
return new Promise((resolve, reject) => {
|
|
72
73
|
const timeout = setTimeout(() => {
|
|
73
74
|
pending.delete(echo);
|
|
74
|
-
reject(new
|
|
75
|
+
reject(new EndpointDeliveryError('endpoint_timeout', `API call timeout: ${action}`, 'unknown'));
|
|
75
76
|
}, 30_000);
|
|
76
77
|
pending.set(echo, { resolve, reject, timeout });
|
|
77
|
-
|
|
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
|
+
}
|
|
78
85
|
});
|
|
79
86
|
}
|
|
80
87
|
|
|
@@ -99,7 +106,7 @@ export function rejectAllPending(
|
|
|
99
106
|
): void {
|
|
100
107
|
for (const [, entry] of pending) {
|
|
101
108
|
clearTimeout(entry.timeout);
|
|
102
|
-
entry.reject(new
|
|
109
|
+
entry.reject(new EndpointDeliveryError('endpoint_disconnected', message, 'unknown'));
|
|
103
110
|
}
|
|
104
111
|
pending.clear();
|
|
105
112
|
}
|
package/src/ws-types.ts
CHANGED
|
@@ -4,7 +4,7 @@ export interface NapCatWsSocket {
|
|
|
4
4
|
send(data: string): void;
|
|
5
5
|
close(code?: number, reason?: string): void;
|
|
6
6
|
ping?(): void;
|
|
7
|
-
on(event: 'open' | 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
|
|
7
|
+
on(event: 'open' | 'message' | 'close' | 'error' | 'pong', listener: (...args: unknown[]) => void): void;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export interface NapCatWsCreateOptions {
|
package/src/wss-endpoint.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
2
2
|
/**
|
|
3
3
|
* NapCat reverse WSS endpoint — accepts inbound WebSocket from NapCat.
|
|
4
4
|
*/
|
|
5
5
|
import {
|
|
6
|
+
Endpoint,
|
|
6
7
|
createEndpointLifecycle,
|
|
7
8
|
createRecallEndpointControl,
|
|
8
9
|
type EndpointLifecycle,
|
|
9
10
|
type EndpointControl,
|
|
10
11
|
type EndpointManagement,
|
|
11
12
|
type EndpointSendRequest,
|
|
13
|
+
type EndpointTransportState,
|
|
12
14
|
} from 'zhin.js/adapter';
|
|
13
15
|
import type { HttpHost, WsConnection } from '@zhin.js/host-http';
|
|
14
16
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
@@ -70,6 +72,7 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
70
72
|
#pending = new Map<string, NapCatPendingAction>();
|
|
71
73
|
#open = false;
|
|
72
74
|
#started = false;
|
|
75
|
+
#stopped = false;
|
|
73
76
|
|
|
74
77
|
constructor(options: NapCatWssEndpointOptions) {
|
|
75
78
|
super();
|
|
@@ -78,11 +81,21 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
78
81
|
this.#connectionLifecycle = createEndpointLifecycle({
|
|
79
82
|
name: `${options.config.id}:inbound`,
|
|
80
83
|
reconnect: false,
|
|
84
|
+
heartbeat: { intervalMs: options.config.heartbeat_interval, watchdogMisses: 2 },
|
|
81
85
|
});
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
get transportState(): EndpointTransportState {
|
|
89
|
+
if (this.#stopped) return 'stopped';
|
|
90
|
+
if (!this.#started) return 'idle';
|
|
91
|
+
if (this.#connectionLifecycle.state === 'closed') return 'closed';
|
|
92
|
+
if (this.#ws?.readyState === 1) return 'open';
|
|
93
|
+
return this.#connectionLifecycle.state === 'connecting' ? 'connecting' : 'idle';
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
async start(): Promise<void> {
|
|
85
97
|
if (this.#started) return;
|
|
98
|
+
this.#stopped = false;
|
|
86
99
|
this.#started = true;
|
|
87
100
|
const handle = this.#options.http.ws(this.#options.config.path);
|
|
88
101
|
this.#wsRelease = handle.onConnection((connection) => {
|
|
@@ -113,6 +126,7 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
async stop(): Promise<void> {
|
|
129
|
+
this.#stopped = true;
|
|
116
130
|
this.#open = false;
|
|
117
131
|
this.#wsRelease?.();
|
|
118
132
|
this.#wsRelease = undefined;
|
|
@@ -128,7 +142,11 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
128
142
|
const message = formatOutboundSegments(payload);
|
|
129
143
|
const { action, params } = buildSendAction(napcatOutboundTarget(conversation), message);
|
|
130
144
|
const data = await this.client.callApi(action, params) as { message_id?: number | string } | undefined;
|
|
131
|
-
|
|
145
|
+
const rawId = data?.message_id;
|
|
146
|
+
if ((typeof rawId !== 'number' && typeof rawId !== 'string') || String(rawId).trim() === '' || (typeof rawId === 'number' && !Number.isFinite(rawId))) {
|
|
147
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: platform returned no message_id', 'unknown');
|
|
148
|
+
}
|
|
149
|
+
return String(rawId);
|
|
132
150
|
}
|
|
133
151
|
|
|
134
152
|
async recallMessage(messageId: string): Promise<void> {
|
|
@@ -137,7 +155,7 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
#callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
140
|
-
return callNapCatWsAction(this.#ws, this.#pending, this.#requestId, action, params);
|
|
158
|
+
return callNapCatWsAction(this.#connectionLifecycle.state === 'closed' ? undefined : this.#ws, this.#pending, this.#requestId, action, params);
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
admit(ev: NapCatEvent): void {
|
|
@@ -197,15 +215,23 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
197
215
|
}
|
|
198
216
|
|
|
199
217
|
async #acceptConnection(connection: WsConnection): Promise<void> {
|
|
218
|
+
if (!this.#started || this.#stopped) {
|
|
219
|
+
connection.socket.close();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
200
222
|
if (!verifyNapCatAccessToken(this.#options.config.access_token, connection.request)) {
|
|
201
223
|
connection.socket.close(4003, 'Unauthorized');
|
|
202
224
|
return;
|
|
203
225
|
}
|
|
204
226
|
const socket = connection.socket as unknown as NapCatWsSocket;
|
|
205
227
|
await this.#connectionLifecycle.stop();
|
|
228
|
+
rejectAllPending(this.#pending, 'Connection replaced');
|
|
206
229
|
await this.#connectionLifecycle.start(async (lifecycleHandle) => {
|
|
207
230
|
this.#ws = socket;
|
|
208
231
|
lifecycleHandle.onForceClose(() => {
|
|
232
|
+
if (this.#ws === socket) {
|
|
233
|
+
rejectAllPending(this.#pending);
|
|
234
|
+
}
|
|
209
235
|
try {
|
|
210
236
|
socket.close();
|
|
211
237
|
} catch {
|
|
@@ -219,7 +245,11 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
219
245
|
/* ignore */
|
|
220
246
|
}
|
|
221
247
|
}, this.#options.config.heartbeat_interval);
|
|
248
|
+
socket.on('pong', () => {
|
|
249
|
+
if (this.#ws === socket) this.#connectionLifecycle.notifyHeartbeatAck();
|
|
250
|
+
});
|
|
222
251
|
socket.on('message', (data) => {
|
|
252
|
+
if (this.#ws !== socket || this.#connectionLifecycle.state === 'closed') return;
|
|
223
253
|
this.#connectionLifecycle.notifyHeartbeatAck();
|
|
224
254
|
handleNapCatWsMessage(data, {
|
|
225
255
|
endpointId: this.#options.config.id,
|
|
@@ -227,8 +257,14 @@ export class NapCatWssEndpoint extends Endpoint<NapcatClient> {
|
|
|
227
257
|
admit: (event) => this.admit(event),
|
|
228
258
|
});
|
|
229
259
|
});
|
|
260
|
+
socket.on('error', (error) => {
|
|
261
|
+
if (this.#ws !== socket) return;
|
|
262
|
+
this.#logger.warn(formatCompact({ op: 'ws_error', error: String(error) }));
|
|
263
|
+
});
|
|
230
264
|
socket.on('close', () => {
|
|
231
|
-
if (this.#ws
|
|
265
|
+
if (this.#ws !== socket) return;
|
|
266
|
+
this.#ws = undefined;
|
|
267
|
+
rejectAllPending(this.#pending);
|
|
232
268
|
lifecycleHandle.notifyClosed(new Error('NapCat reverse WebSocket closed'));
|
|
233
269
|
});
|
|
234
270
|
});
|