@zhin.js/adapter-milky 5.0.0 → 5.0.1

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.
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * Milky SSE client endpoint — GET text/event-stream on /event.
3
3
  */
4
- import type { EndpointInstance } from '@zhin.js/adapter';
4
+ import {
5
+ createEndpointLifecycle,
6
+ type EndpointConnectHandle,
7
+ type EndpointInstance,
8
+ type EndpointLifecycle,
9
+ type EndpointManagement,
10
+ } from '@zhin.js/adapter';
5
11
  import type { MessageGateway } from '@zhin.js/core/runtime';
6
12
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
13
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
14
+ import { createMilkyEndpointManagement } from './endpoint-management.js';
8
15
  import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
9
16
  import {
10
17
  buildSendAction,
@@ -13,6 +20,7 @@ import {
13
20
  extractInboundAudioUrl,
14
21
  formatInboundContent,
15
22
  formatInboundMessageId,
23
+ formatInboundSegments,
16
24
  formatInboundTarget,
17
25
  formatOutboundMessageId,
18
26
  formatOutboundSegments,
@@ -47,24 +55,38 @@ export interface MilkySseEndpointOptions {
47
55
  export class MilkySseEndpoint implements EndpointInstance {
48
56
  readonly #options: MilkySseEndpointOptions;
49
57
  readonly #callApi: typeof callApi;
58
+ readonly management: EndpointManagement = createMilkyEndpointManagement(this);
59
+ readonly #lifecycle: EndpointLifecycle;
50
60
  #stream?: SseClientHandle;
51
- #reconnectTimer?: NodeJS.Timeout;
52
61
  #open = false;
53
- #started = false;
54
- #stopping = false;
55
62
  #unregisterAgent?: () => void;
56
63
 
57
64
  constructor(options: MilkySseEndpointOptions) {
58
65
  this.#options = options;
59
66
  this.#callApi = options.callApi ?? callApi;
67
+ this.#lifecycle = createEndpointLifecycle({
68
+ name: options.config.name,
69
+ // reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
70
+ reconnect: {
71
+ initialIntervalMs: options.config.reconnect_interval,
72
+ multiplier: 1,
73
+ maxIntervalMs: Number.MAX_SAFE_INTEGER,
74
+ jitterMs: 0,
75
+ },
76
+ });
60
77
  }
61
78
 
62
79
  async start(): Promise<void> {
63
- if (this.#started) return;
64
- this.#started = true;
65
- this.#stopping = false;
80
+ if (this.#lifecycle.started) return;
66
81
  this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.name, this);
67
- await this.#connect();
82
+ try {
83
+ await this.#lifecycle.start((handle) => this.#connect(handle));
84
+ } catch (err) {
85
+ // 与 WS 对齐:start 失败复位由基座保证;agent 反注册留在适配器侧,允许重试
86
+ this.#unregisterAgent?.();
87
+ this.#unregisterAgent = undefined;
88
+ throw err;
89
+ }
68
90
  }
69
91
 
70
92
  open(): void {
@@ -77,17 +99,11 @@ export class MilkySseEndpoint implements EndpointInstance {
77
99
 
78
100
  async stop(): Promise<void> {
79
101
  this.#open = false;
80
- this.#stopping = true;
81
- this.#started = false;
102
+ await this.#lifecycle.stop();
82
103
  this.#unregisterAgent?.();
83
104
  this.#unregisterAgent = undefined;
84
- if (this.#reconnectTimer) {
85
- clearTimeout(this.#reconnectTimer);
86
- this.#reconnectTimer = undefined;
87
- }
88
105
  this.#stream?.close();
89
106
  this.#stream = undefined;
90
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name, mode: 'sse' }));
91
107
  }
92
108
 
93
109
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
@@ -204,6 +220,7 @@ export class MilkySseEndpoint implements EndpointInstance {
204
220
  #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
205
221
  const target = formatInboundTarget(data);
206
222
  const content = formatInboundContent(data);
223
+ const segments = formatInboundSegments(data);
207
224
  const audioUrl = extractInboundAudioUrl(data);
208
225
  const nickname = senderNickname(data);
209
226
  const mentioned = isMentioned(data, event.self_id);
@@ -211,6 +228,7 @@ export class MilkySseEndpoint implements EndpointInstance {
211
228
  adapter: this.#options.id,
212
229
  target,
213
230
  content,
231
+ segments,
214
232
  sender: String(data.sender_id),
215
233
  id: formatInboundMessageId(data),
216
234
  metadata: Object.freeze({
@@ -234,7 +252,7 @@ export class MilkySseEndpoint implements EndpointInstance {
234
252
  });
235
253
  }
236
254
 
237
- async #connect(): Promise<void> {
255
+ async #connect(handle: EndpointConnectHandle): Promise<void> {
238
256
  const { url, headers, safeUrl } = buildSseConnectOptions(this.#options.config);
239
257
  const create = this.#options.createSseStream ?? ((opts) => openSseStream(opts));
240
258
 
@@ -268,19 +286,29 @@ export class MilkySseEndpoint implements EndpointInstance {
268
286
  },
269
287
  });
270
288
  this.#stream = stream;
289
+ handle.onForceClose(() => {
290
+ try {
291
+ stream.close();
292
+ } catch {
293
+ /* ignore */
294
+ }
295
+ });
271
296
  void stream.closed.then(() => {
272
- if (this.#stopping) return;
273
- logger.warn(formatCompact({
274
- op: 'disconnect',
275
- endpoint: this.#options.config.name,
276
- mode: 'sse',
277
- reconnect_ms: this.#options.config.reconnect_interval,
278
- }));
297
+ // stop-during-connect 竞态由基座静默 settle(主动停止不算失败),此处仅对运行期断开告警
298
+ if (this.#lifecycle.state !== 'stopped') {
299
+ logger.warn(formatCompact({
300
+ op: 'disconnect',
301
+ endpoint: this.#options.config.name,
302
+ mode: 'sse',
303
+ reconnect_ms: this.#options.config.reconnect_interval,
304
+ }));
305
+ }
306
+ // 基座语义:仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
307
+ handle.notifyClosed(new Error('Milky SSE closed'));
279
308
  if (!settled) {
280
309
  settled = true;
281
310
  reject(new Error('Milky SSE closed before open'));
282
311
  }
283
- this.#scheduleReconnect();
284
312
  });
285
313
  });
286
314
  }
@@ -298,21 +326,4 @@ export class MilkySseEndpoint implements EndpointInstance {
298
326
  }));
299
327
  }
300
328
  }
301
-
302
- #scheduleReconnect(): void {
303
- if (this.#stopping || !this.#started || this.#reconnectTimer) return;
304
- const delay = this.#options.config.reconnect_interval;
305
- this.#reconnectTimer = setTimeout(() => {
306
- this.#reconnectTimer = undefined;
307
- void this.#connect().catch((err) => {
308
- logger.warn(formatCompact({
309
- op: 'reconnect',
310
- endpoint: this.#options.config.name,
311
- mode: 'sse',
312
- ok: false,
313
- error: err instanceof Error ? err.message : String(err),
314
- }));
315
- });
316
- }, delay);
317
- }
318
329
  }
@@ -2,12 +2,13 @@
2
2
  * Milky webhook endpoint — httpHostToken POST inbound + baseUrl HTTP API outbound.
3
3
  */
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
5
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
6
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
9
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
10
  import { readRequestBody, verifyMilkyAccessToken } from './milky-auth.js';
11
+ import { createMilkyEndpointManagement } from './endpoint-management.js';
11
12
  import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
12
13
  import {
13
14
  buildSendAction,
@@ -15,6 +16,7 @@ import {
15
16
  extractInboundAudioUrl,
16
17
  formatInboundContent,
17
18
  formatInboundMessageId,
19
+ formatInboundSegments,
18
20
  formatInboundTarget,
19
21
  formatOutboundMessageId,
20
22
  formatOutboundSegments,
@@ -40,6 +42,7 @@ export interface MilkyWebhookEndpointOptions {
40
42
  export class MilkyWebhookEndpoint implements EndpointInstance {
41
43
  readonly #options: MilkyWebhookEndpointOptions;
42
44
  readonly #callApi: typeof callApi;
45
+ readonly management: EndpointManagement = createMilkyEndpointManagement(this);
43
46
  #routeReleases: HttpRouteRegistration[] = [];
44
47
  #open = false;
45
48
  #started = false;
@@ -193,6 +196,7 @@ export class MilkyWebhookEndpoint implements EndpointInstance {
193
196
  #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
194
197
  const target = formatInboundTarget(data);
195
198
  const content = formatInboundContent(data);
199
+ const segments = formatInboundSegments(data);
196
200
  const audioUrl = extractInboundAudioUrl(data);
197
201
  const nickname = senderNickname(data);
198
202
  const mentioned = isMentioned(data, event.self_id);
@@ -200,6 +204,7 @@ export class MilkyWebhookEndpoint implements EndpointInstance {
200
204
  adapter: this.#options.id,
201
205
  target,
202
206
  content,
207
+ segments,
203
208
  sender: String(data.sender_id),
204
209
  id: formatInboundMessageId(data),
205
210
  metadata: Object.freeze({
@@ -2,11 +2,17 @@
2
2
  * Milky WS client endpoint — outbound connect to Milky protocol server.
3
3
  */
4
4
  import WebSocket from 'ws';
5
- import { clearInterval, clearTimeout } from 'node:timers';
6
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import {
6
+ createEndpointLifecycle,
7
+ type EndpointConnectHandle,
8
+ type EndpointInstance,
9
+ type EndpointLifecycle,
10
+ type EndpointManagement,
11
+ } from '@zhin.js/adapter';
7
12
  import type { MessageGateway } from '@zhin.js/core/runtime';
8
13
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
14
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
15
+ import { createMilkyEndpointManagement } from './endpoint-management.js';
10
16
  import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
11
17
  import {
12
18
  buildSendAction,
@@ -15,6 +21,7 @@ import {
15
21
  extractInboundAudioUrl,
16
22
  formatInboundContent,
17
23
  formatInboundMessageId,
24
+ formatInboundSegments,
18
25
  formatInboundTarget,
19
26
  formatOutboundMessageId,
20
27
  formatOutboundSegments,
@@ -45,25 +52,38 @@ export interface MilkyWsEndpointOptions {
45
52
  export class MilkyWsEndpoint implements EndpointInstance {
46
53
  readonly #options: MilkyWsEndpointOptions;
47
54
  readonly #callApi: typeof callApi;
55
+ readonly management: EndpointManagement = createMilkyEndpointManagement(this);
56
+ readonly #lifecycle: EndpointLifecycle;
48
57
  #ws?: MilkyWsSocket;
49
- #reconnectTimer?: NodeJS.Timeout;
50
- #heartbeatTimer?: NodeJS.Timeout;
51
58
  #open = false;
52
- #started = false;
53
- #stopping = false;
54
59
  #unregisterAgent?: () => void;
55
60
 
56
61
  constructor(options: MilkyWsEndpointOptions) {
57
62
  this.#options = options;
58
63
  this.#callApi = options.callApi ?? callApi;
64
+ this.#lifecycle = createEndpointLifecycle({
65
+ name: options.config.name,
66
+ // reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
67
+ reconnect: {
68
+ initialIntervalMs: options.config.reconnect_interval,
69
+ multiplier: 1,
70
+ maxIntervalMs: Number.MAX_SAFE_INTEGER,
71
+ jitterMs: 0,
72
+ },
73
+ });
59
74
  }
60
75
 
61
76
  async start(): Promise<void> {
62
- if (this.#started) return;
63
- this.#started = true;
64
- this.#stopping = false;
77
+ if (this.#lifecycle.started) return;
65
78
  this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.name, this);
66
- await this.#connect();
79
+ try {
80
+ await this.#lifecycle.start((handle) => this.#connect(handle));
81
+ } catch (err) {
82
+ // start 失败复位由基座保证;agent 注册/反注册是适配器专有依赖,留在适配器侧
83
+ this.#unregisterAgent?.();
84
+ this.#unregisterAgent = undefined;
85
+ throw err;
86
+ }
67
87
  }
68
88
 
69
89
  open(): void {
@@ -76,18 +96,9 @@ export class MilkyWsEndpoint implements EndpointInstance {
76
96
 
77
97
  async stop(): Promise<void> {
78
98
  this.#open = false;
79
- this.#stopping = true;
80
- this.#started = false;
99
+ await this.#lifecycle.stop();
81
100
  this.#unregisterAgent?.();
82
101
  this.#unregisterAgent = undefined;
83
- if (this.#reconnectTimer) {
84
- clearTimeout(this.#reconnectTimer);
85
- this.#reconnectTimer = undefined;
86
- }
87
- if (this.#heartbeatTimer) {
88
- clearInterval(this.#heartbeatTimer);
89
- this.#heartbeatTimer = undefined;
90
- }
91
102
  if (this.#ws) {
92
103
  try {
93
104
  this.#ws.close();
@@ -96,7 +107,6 @@ export class MilkyWsEndpoint implements EndpointInstance {
96
107
  }
97
108
  this.#ws = undefined;
98
109
  }
99
- logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
100
110
  }
101
111
 
102
112
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
@@ -214,6 +224,7 @@ export class MilkyWsEndpoint implements EndpointInstance {
214
224
  #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
215
225
  const target = formatInboundTarget(data);
216
226
  const content = formatInboundContent(data);
227
+ const segments = formatInboundSegments(data);
217
228
  const audioUrl = extractInboundAudioUrl(data);
218
229
  const nickname = senderNickname(data);
219
230
  const mentioned = isMentioned(data, event.self_id);
@@ -221,6 +232,7 @@ export class MilkyWsEndpoint implements EndpointInstance {
221
232
  adapter: this.#options.id,
222
233
  target,
223
234
  content,
235
+ segments,
224
236
  sender: String(data.sender_id),
225
237
  id: formatInboundMessageId(data),
226
238
  metadata: Object.freeze({
@@ -244,7 +256,7 @@ export class MilkyWsEndpoint implements EndpointInstance {
244
256
  });
245
257
  }
246
258
 
247
- async #connect(): Promise<void> {
259
+ async #connect(handle: EndpointConnectHandle): Promise<void> {
248
260
  const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
249
261
  const create = this.#options.createWebSocket
250
262
  ?? ((connectUrl: string, options: MilkyWsCreateOptions) =>
@@ -254,6 +266,13 @@ export class MilkyWsEndpoint implements EndpointInstance {
254
266
  let settled = false;
255
267
  const ws = create(url, { headers });
256
268
  this.#ws = ws;
269
+ handle.onForceClose(() => {
270
+ try {
271
+ ws.close();
272
+ } catch {
273
+ /* ignore */
274
+ }
275
+ });
257
276
 
258
277
  ws.on('open', () => {
259
278
  if (settled) return;
@@ -270,7 +289,16 @@ export class MilkyWsEndpoint implements EndpointInstance {
270
289
  mode: 'ws',
271
290
  url: safeUrl,
272
291
  }));
273
- this.#startHeartbeat();
292
+ // stop-during-connect 竞态:已停止则不再武装心跳(基座 stop 已清理定时器)
293
+ if (this.#lifecycle.started) {
294
+ this.#lifecycle.startHeartbeat(() => {
295
+ try {
296
+ if (ws.readyState === WS_OPEN) ws.ping?.();
297
+ } catch {
298
+ /* ignore */
299
+ }
300
+ }, this.#options.config.heartbeat_interval);
301
+ }
274
302
  resolve();
275
303
  });
276
304
 
@@ -297,11 +325,12 @@ export class MilkyWsEndpoint implements EndpointInstance {
297
325
  error: `${reasonStr || 'closed'}${codeHint}`,
298
326
  reconnect_ms: this.#options.config.reconnect_interval,
299
327
  }));
328
+ // 基座语义:仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
329
+ handle.notifyClosed(new Error(`Milky WS 关闭: ${codeNum} ${reasonStr}`));
300
330
  if (!settled) {
301
331
  settled = true;
302
332
  reject(new Error(`Milky WS 关闭: ${codeNum} ${reasonStr}`));
303
333
  }
304
- this.#scheduleReconnect();
305
334
  });
306
335
 
307
336
  ws.on('error', (err) => {
@@ -339,35 +368,4 @@ export class MilkyWsEndpoint implements EndpointInstance {
339
368
  }));
340
369
  }
341
370
  }
342
-
343
- #startHeartbeat(): void {
344
- if (this.#heartbeatTimer) {
345
- clearInterval(this.#heartbeatTimer);
346
- }
347
- const interval = this.#options.config.heartbeat_interval;
348
- if (interval <= 0) return;
349
- this.#heartbeatTimer = setInterval(() => {
350
- try {
351
- if (this.#ws?.readyState === WS_OPEN) this.#ws.ping?.();
352
- } catch {
353
- /* ignore */
354
- }
355
- }, interval);
356
- }
357
-
358
- #scheduleReconnect(): void {
359
- if (this.#stopping || !this.#started || this.#reconnectTimer) return;
360
- const delay = this.#options.config.reconnect_interval;
361
- this.#reconnectTimer = setTimeout(() => {
362
- this.#reconnectTimer = undefined;
363
- void this.#connect().catch((err) => {
364
- logger.warn(formatCompact({
365
- op: 'reconnect',
366
- endpoint: this.#options.config.name,
367
- ok: false,
368
- error: err instanceof Error ? err.message : String(err),
369
- }));
370
- });
371
- }, delay);
372
- }
373
371
  }
@@ -2,12 +2,13 @@
2
2
  * Milky reverse WSS endpoint — httpHostToken WS upgrade inbound + baseUrl HTTP API outbound.
3
3
  */
4
4
  import { clearInterval } from 'node:timers';
5
- import type { EndpointInstance } from '@zhin.js/adapter';
5
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
6
6
  import type { MessageGateway } from '@zhin.js/core/runtime';
7
7
  import type { HttpHost, WsConnection } from '@zhin.js/host-http';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
9
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
10
  import { verifyMilkyAccessToken } from './milky-auth.js';
11
+ import { createMilkyEndpointManagement } from './endpoint-management.js';
11
12
  import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
12
13
  import {
13
14
  buildSendAction,
@@ -15,6 +16,7 @@ import {
15
16
  extractInboundAudioUrl,
16
17
  formatInboundContent,
17
18
  formatInboundMessageId,
19
+ formatInboundSegments,
18
20
  formatInboundTarget,
19
21
  formatOutboundMessageId,
20
22
  formatOutboundSegments,
@@ -42,6 +44,7 @@ export interface MilkyWssEndpointOptions {
42
44
  export class MilkyWssEndpoint implements EndpointInstance {
43
45
  readonly #options: MilkyWssEndpointOptions;
44
46
  readonly #callApi: typeof callApi;
47
+ readonly management: EndpointManagement = createMilkyEndpointManagement(this);
45
48
  #ws?: MilkyWsSocket;
46
49
  #wsRelease?: () => void;
47
50
  #heartbeatTimer?: NodeJS.Timeout;
@@ -212,6 +215,7 @@ export class MilkyWssEndpoint implements EndpointInstance {
212
215
  #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
213
216
  const target = formatInboundTarget(data);
214
217
  const content = formatInboundContent(data);
218
+ const segments = formatInboundSegments(data);
215
219
  const audioUrl = extractInboundAudioUrl(data);
216
220
  const nickname = senderNickname(data);
217
221
  const mentioned = isMentioned(data, event.self_id);
@@ -219,6 +223,7 @@ export class MilkyWssEndpoint implements EndpointInstance {
219
223
  adapter: this.#options.id,
220
224
  target,
221
225
  content,
226
+ segments,
222
227
  sender: String(data.sender_id),
223
228
  id: formatInboundMessageId(data),
224
229
  metadata: Object.freeze({