@zhin.js/adapter-milky 7.0.0 → 7.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,39 +1,62 @@
1
1
  /**
2
2
  * Milky reverse WSS endpoint — httpHostToken WS upgrade inbound + baseUrl HTTP API outbound.
3
3
  */
4
- import { clearInterval } from 'node:timers';
5
- import { createRecallEndpointControl, } from 'zhin.js/adapter';
4
+ import { ClientEndpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
6
5
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
7
6
  import { verifyMilkyAccessToken } from './milky-auth.js';
8
7
  import { createMilkyEndpointManagement } from './endpoint-management.js';
9
- import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
8
+ import { callMilkyClient, createMilkyEndpointClient, forwardMilkyClientEvents } from './client.js';
10
9
  import { buildSendAction, callApi, extractInboundAudioUrl, formatInboundContent, formatInboundMessageId, formatInboundSegments, formatOutboundMessageId, formatOutboundSegments, isMentioned, milkyInboundConversation, parseMessageReceiveData, parseMilkyMessageId, senderNickname, } from './protocol.js';
11
10
  const WS_OPEN = 1;
12
- export class MilkyWssEndpoint {
11
+ export class MilkyWssEndpoint extends ClientEndpoint {
12
+ client;
13
13
  #logger;
14
14
  #options;
15
15
  #callApi;
16
- management = createMilkyEndpointManagement(this);
16
+ management;
17
17
  control = createRecallEndpointControl((id) => this.recallMessage(id));
18
18
  #ws;
19
19
  #wsRelease;
20
- #heartbeatTimer;
21
- #open = false;
22
- #started = false;
23
- #unregisterAgent;
20
+ #clientSocketRelease;
21
+ #lifecycle;
24
22
  constructor(options) {
23
+ super();
25
24
  this.#logger = getAdapterLogger('milky', options.config.id);
26
25
  this.#options = options;
27
26
  this.#callApi = options.callApi ?? callApi;
27
+ this.#lifecycle = createEndpointLifecycle({
28
+ name: options.config.id,
29
+ reconnect: false,
30
+ heartbeat: { intervalMs: options.config.heartbeat_interval },
31
+ });
32
+ this.client = createMilkyEndpointClient(options.config, this.#callApi);
33
+ this.management = createMilkyEndpointManagement({
34
+ callApi: (action, params) => callMilkyClient(this.client, action, params),
35
+ });
36
+ this.bindClientEvents((receive) => forwardMilkyClientEvents(this.client, receive), (name, payload) => {
37
+ if (name === 'event')
38
+ this.#admitRaw(payload);
39
+ }, (_name, error) => this.#warnPlatformEvent(error));
28
40
  }
29
41
  async start() {
30
- if (this.#started)
31
- return;
32
- this.#started = true;
33
- this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.id, this);
34
- const handle = this.#options.http.ws(this.#options.config.path);
35
- this.#wsRelease = handle.onConnection((connection) => {
36
- this.#acceptConnection(connection);
42
+ await this.#lifecycle.start(async (lifecycleHandle) => {
43
+ const handle = this.#options.http.ws(this.#options.config.path);
44
+ this.#wsRelease = handle.onConnection((connection) => {
45
+ this.#acceptConnection(connection);
46
+ });
47
+ lifecycleHandle.onForceClose(() => {
48
+ this.#wsRelease?.();
49
+ this.#wsRelease = undefined;
50
+ this.#clientSocketRelease?.();
51
+ this.#clientSocketRelease = undefined;
52
+ try {
53
+ this.#ws?.close();
54
+ }
55
+ catch {
56
+ /* ignore */
57
+ }
58
+ this.#ws = undefined;
59
+ });
37
60
  });
38
61
  this.#logger.info(formatCompact({
39
62
  op: 'listen',
@@ -42,37 +65,14 @@ export class MilkyWssEndpoint {
42
65
  path: this.#options.config.path,
43
66
  }));
44
67
  }
45
- open() {
46
- this.#open = true;
47
- }
48
- close() {
49
- this.#open = false;
50
- }
51
68
  async stop() {
52
- this.#open = false;
53
- this.#unregisterAgent?.();
54
- this.#unregisterAgent = undefined;
55
- this.#wsRelease?.();
56
- this.#wsRelease = undefined;
57
- if (this.#heartbeatTimer) {
58
- clearInterval(this.#heartbeatTimer);
59
- this.#heartbeatTimer = undefined;
60
- }
61
- if (this.#ws) {
62
- try {
63
- this.#ws.close();
64
- }
65
- catch {
66
- /* ignore */
67
- }
68
- this.#ws = undefined;
69
- }
70
- this.#started = false;
69
+ this.close();
70
+ await this.#lifecycle.stop();
71
71
  }
72
72
  async send({ conversation, payload }) {
73
73
  const message = formatOutboundSegments(payload);
74
74
  const { action, params } = buildSendAction(conversation, message);
75
- const data = await this.callApi(action, params);
75
+ const data = await callMilkyClient(this.client, action, params);
76
76
  const messageId = formatOutboundMessageId(conversation, data?.message_seq);
77
77
  this.#logger.debug(formatCompact({
78
78
  op: 'milky_send',
@@ -82,91 +82,31 @@ export class MilkyWssEndpoint {
82
82
  }));
83
83
  return messageId;
84
84
  }
85
- callApi(action, params = {}) {
86
- return this.#callApi(this.apiOptions(), action, params);
87
- }
88
85
  async recallMessage(id) {
89
86
  const parsed = parseMilkyMessageId(id);
90
87
  if (!parsed)
91
88
  throw new Error(`Invalid message id: ${id}`);
92
89
  if (parsed.message_scene === 'group') {
93
- await this.callApi('recall_group_message', {
90
+ await callMilkyClient(this.client, 'recall_group_message', {
94
91
  group_id: parsed.peer_id,
95
92
  message_seq: parsed.message_seq,
96
93
  });
97
94
  }
98
95
  else {
99
- await this.callApi('recall_private_message', {
96
+ await callMilkyClient(this.client, 'recall_private_message', {
100
97
  user_id: parsed.peer_id,
101
98
  message_seq: parsed.message_seq,
102
99
  });
103
100
  }
104
101
  }
105
- async kickMember(groupId, userId, rejectAddRequest = false) {
106
- await this.callApi('kick_group_member', {
107
- group_id: groupId,
108
- user_id: userId,
109
- reject_add_request: rejectAddRequest,
110
- });
111
- return true;
112
- }
113
- async muteMember(groupId, userId, duration = 600) {
114
- await this.callApi('set_group_member_mute', {
115
- group_id: groupId,
116
- user_id: userId,
117
- duration,
118
- });
119
- return true;
120
- }
121
- async muteAll(groupId, enable = true) {
122
- await this.callApi('set_group_whole_mute', { group_id: groupId, is_mute: enable });
123
- return true;
124
- }
125
- async setAdmin(groupId, userId, enable = true) {
126
- await this.callApi('set_group_member_admin', {
127
- group_id: groupId,
128
- user_id: userId,
129
- is_set: enable,
130
- });
131
- return true;
132
- }
133
- async setCard(groupId, userId, card) {
134
- await this.callApi('set_group_member_card', {
135
- group_id: groupId,
136
- user_id: userId,
137
- card,
138
- });
139
- return true;
140
- }
141
- async setTitle(groupId, userId, title) {
142
- await this.callApi('set_group_member_special_title', {
143
- group_id: groupId,
144
- user_id: userId,
145
- special_title: title,
146
- });
147
- return true;
148
- }
149
- async setGroupName(groupId, name) {
150
- await this.callApi('set_group_name', { group_id: groupId, new_group_name: name });
151
- return true;
152
- }
153
- async getMemberList(groupId) {
154
- return this.callApi('get_group_member_list', { group_id: groupId });
155
- }
156
- async getGroupInfo(groupId) {
157
- return this.callApi('get_group_info', { group_id: groupId });
158
- }
159
- admit(event) {
102
+ #admitRaw(event) {
160
103
  const data = parseMessageReceiveData(event);
161
- if (!this.#open || !data)
104
+ if (!data)
162
105
  return;
163
106
  this.#admitMessage(data, event);
164
107
  }
165
- apiOptions() {
166
- return {
167
- baseUrl: this.#options.config.baseUrl,
168
- access_token: this.#options.config.access_token,
169
- };
108
+ #warnPlatformEvent(error) {
109
+ this.#logger.warn(formatCompact({ op: 'milky_platform_event_failed', error: error instanceof Error ? error.message : String(error) }));
170
110
  }
171
111
  #admitMessage(data, event) {
172
112
  const conversation = milkyInboundConversation(String(this.#options.id), data);
@@ -176,7 +116,7 @@ export class MilkyWssEndpoint {
176
116
  const audioUrl = extractInboundAudioUrl(data);
177
117
  const nickname = senderNickname(data);
178
118
  const mentioned = isMentioned(data, event.self_id);
179
- void this.#options.gateway.receive({
119
+ void this.emit('message.receive', {
180
120
  conversation,
181
121
  message: { conversation, id: formatInboundMessageId(data) },
182
122
  content,
@@ -216,13 +156,24 @@ export class MilkyWssEndpoint {
216
156
  }
217
157
  }
218
158
  this.#ws = socket;
219
- this.#startHeartbeat();
220
- socket.on('message', (data) => {
221
- this.#onMessage(data);
159
+ this.#lifecycle.startHeartbeat(() => {
160
+ try {
161
+ if (this.#ws?.readyState === WS_OPEN)
162
+ this.#ws.ping?.();
163
+ }
164
+ catch {
165
+ /* ignore */
166
+ }
222
167
  });
168
+ this.#clientSocketRelease?.();
169
+ this.#clientSocketRelease = this.client.acceptWebSocket(socket);
223
170
  socket.on('close', () => {
224
- if (this.#ws === socket)
171
+ this.#clientSocketRelease?.();
172
+ this.#clientSocketRelease = undefined;
173
+ if (this.#ws === socket) {
225
174
  this.#ws = undefined;
175
+ this.#lifecycle.stopHeartbeat();
176
+ }
226
177
  });
227
178
  this.#logger.debug(formatCompact({
228
179
  endpoint: this.#options.config.id,
@@ -230,40 +181,4 @@ export class MilkyWssEndpoint {
230
181
  peer: connection.request.socket.remoteAddress,
231
182
  }));
232
183
  }
233
- #onMessage(data) {
234
- try {
235
- const raw = typeof data === 'string'
236
- ? data
237
- : Buffer.isBuffer(data)
238
- ? data.toString()
239
- : data instanceof ArrayBuffer
240
- ? new TextDecoder().decode(data)
241
- : String(data ?? '');
242
- const event = JSON.parse(raw);
243
- this.admit(event);
244
- }
245
- catch (error) {
246
- this.#logger.warn(formatCompact({
247
- op: 'milky_parse_failed',
248
- endpoint: this.#options.config.id,
249
- error: error instanceof Error ? error.message : String(error),
250
- }));
251
- }
252
- }
253
- #startHeartbeat() {
254
- if (this.#heartbeatTimer)
255
- clearInterval(this.#heartbeatTimer);
256
- const interval = this.#options.config.heartbeat_interval;
257
- if (interval <= 0)
258
- return;
259
- this.#heartbeatTimer = setInterval(() => {
260
- try {
261
- if (this.#ws?.readyState === WS_OPEN)
262
- this.#ws.ping?.();
263
- }
264
- catch {
265
- /* ignore */
266
- }
267
- }, interval);
268
- }
269
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-milky",
3
- "version": "7.0.0",
3
+ "version": "7.0.1",
4
4
  "description": "Zhin.js Milky adapter for Plugin Runtime (WebSocket client)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -39,12 +39,15 @@
39
39
  "directory": "plugins/adapters/milky"
40
40
  },
41
41
  "dependencies": {
42
+ "@imhelper/milky-v1": "1.0.7",
43
+ "imhelper": "1.0.7",
42
44
  "ws": "^8.21.1",
43
- "@zhin.js/adapter": "1.2.0",
44
- "@zhin.js/core": "1.5.13",
45
- "@zhin.js/host-http": "1.0.12",
45
+ "@zhin.js/adapter": "1.2.1",
46
+ "@zhin.js/core": "1.5.14",
47
+ "@zhin.js/feature-kit": "1.0.13",
48
+ "@zhin.js/host-http": "1.0.13",
46
49
  "@zhin.js/im-contract": "1.0.4",
47
- "@zhin.js/logger": "1.0.76"
50
+ "@zhin.js/logger": "1.0.77"
48
51
  },
49
52
  "devDependencies": {
50
53
  "@types/node": "^26.1.2",
@@ -52,17 +55,17 @@
52
55
  "typescript": "^6.0.3",
53
56
  "vitest": "^4.1.10",
54
57
  "zod": "^4.4.3",
55
- "@zhin.js/agent": "1.1.15",
56
- "@zhin.js/host-http": "1.0.12",
57
- "zhin.js": "6.0.13"
58
+ "@zhin.js/agent": "1.1.16",
59
+ "@zhin.js/host-http": "1.0.13",
60
+ "zhin.js": "6.0.14"
58
61
  },
59
62
  "peerDependencies": {
60
63
  "zod": "^4.0.0",
61
- "@zhin.js/adapter": "1.2.0",
62
- "@zhin.js/agent": "1.1.15",
63
- "@zhin.js/command": "1.0.15",
64
- "@zhin.js/core": "1.5.13",
65
- "zhin.js": "6.0.13"
64
+ "@zhin.js/adapter": "1.2.1",
65
+ "@zhin.js/agent": "1.1.16",
66
+ "@zhin.js/command": "1.0.16",
67
+ "@zhin.js/core": "1.5.14",
68
+ "zhin.js": "6.0.14"
66
69
  },
67
70
  "peerDependenciesMeta": {
68
71
  "@zhin.js/agent": {
package/src/client.ts ADDED
@@ -0,0 +1,80 @@
1
+ import {
2
+ MilkyV1Client,
3
+ type MilkyV1Event,
4
+ type MilkyV1Response,
5
+ } from '@imhelper/milky-v1';
6
+ import {
7
+ EventFactory,
8
+ type EventMap,
9
+ type ImHelperEventMap,
10
+ } from 'imhelper';
11
+ import {
12
+ defineEndpointClient,
13
+ forwardEndpointClientEvents,
14
+ type ClientEventPayloads,
15
+ } from 'zhin.js/adapter';
16
+ import type { ResolvedMilkyConfig, callApi } from './protocol.js';
17
+
18
+ export { MilkyV1Client as MilkyClient } from '@imhelper/milky-v1';
19
+
20
+ type MilkyClientEvents = ImHelperEventMap<string, MilkyV1Event, EventMap<string>>;
21
+ export type MilkyClientEventMap = ClientEventPayloads<MilkyClientEvents>;
22
+
23
+ /** Construct the exact public imhelper Client without handing it transport ownership. */
24
+ export function createMilkyEndpointClient(
25
+ config: ResolvedMilkyConfig,
26
+ request: typeof callApi,
27
+ ): MilkyV1Client {
28
+ return new MilkyV1Client({
29
+ baseUrl: config.baseUrl,
30
+ selfId: config.id,
31
+ accessToken: config.access_token,
32
+ receiveMode: 'manual',
33
+ call: (action, params) => request(
34
+ {
35
+ baseUrl: config.baseUrl,
36
+ access_token: config.access_token,
37
+ },
38
+ action,
39
+ params,
40
+ ),
41
+ });
42
+ }
43
+
44
+ /** Use the Client's complete protocol response while keeping Endpoint internals data-oriented. */
45
+ export async function callMilkyClient<T = unknown>(
46
+ client: MilkyV1Client,
47
+ action: string,
48
+ params?: Record<string, unknown>,
49
+ ): Promise<T | undefined> {
50
+ const response: MilkyV1Response<T> = await client.call<T>(action, params);
51
+ if (response.status !== 'ok') {
52
+ throw new Error(
53
+ `Milky API ${action}: retcode=${response.retcode}${response.message ? ` ${response.message}` : ''}`,
54
+ );
55
+ }
56
+ return response.data;
57
+ }
58
+
59
+ const milkyClientEventNames = Object.freeze([
60
+ ...EventFactory.getSupportedEventTypes<string>(),
61
+ 'event',
62
+ ]);
63
+
64
+ export function forwardMilkyClientEvents(
65
+ client: MilkyV1Client,
66
+ receive: (name: string, payload: unknown) => void,
67
+ ): () => void {
68
+ return forwardEndpointClientEvents(client, milkyClientEventNames, receive);
69
+ }
70
+
71
+ declare module '@zhin.js/feature-kit' {
72
+ interface AdapterClientRegistry {
73
+ readonly milky: {
74
+ readonly client: MilkyV1Client;
75
+ readonly events: MilkyClientEventMap;
76
+ };
77
+ }
78
+ }
79
+
80
+ export const milkyClient = defineEndpointClient<MilkyV1Client, MilkyClientEventMap>('milky');
package/src/endpoint.ts CHANGED
@@ -21,6 +21,6 @@ export {
21
21
 
22
22
  export type { MilkyWsSocket, MilkyWsCreateOptions } from './ws-types.js';
23
23
 
24
- export { verifyMilkyAccessToken, readRequestBody } from './milky-auth.js';
24
+ export { verifyMilkyAccessToken } from './milky-auth.js';
25
25
 
26
26
  export { openSseStream, consumeSseBuffer } from './sse-client.js';
package/src/index.ts CHANGED
@@ -36,7 +36,6 @@ export {
36
36
  MilkyWsEndpoint,
37
37
  consumeSseBuffer,
38
38
  openSseStream,
39
- readRequestBody,
40
39
  verifyMilkyAccessToken,
41
40
  type CreateMilkySseStream,
42
41
  type MilkySseEndpointOptions,
@@ -48,9 +47,20 @@ export {
48
47
  } from './endpoint.js';
49
48
 
50
49
  export {
51
- getMilkyAgentDeps,
52
- registerMilkyAgentEndpoint,
53
- setMilkyAgentDeps,
54
- type MilkyAgentDeps,
55
- type MilkyAgentEndpoint,
56
- } from './milky-agent-deps.js';
50
+ MilkyClient,
51
+ milkyClient,
52
+ type MilkyClientEventMap,
53
+ } from './client.js';
54
+
55
+ export type {
56
+ MilkyAdapterConfig as ImHelperMilkyAdapterConfig,
57
+ MilkyActionUrlResolver,
58
+ MilkyCall,
59
+ MilkyMessageReceiveEvent,
60
+ MilkyMessageRecallEvent,
61
+ MilkyV1ClientConfig,
62
+ MilkyV1Event,
63
+ MilkyV1Response,
64
+ } from '@imhelper/milky-v1';
65
+ export { ProtocolError } from '@imhelper/milky-v1';
66
+ export type { ProtocolErrorKind, ProtocolErrorOptions } from 'imhelper';
package/src/milky-auth.ts CHANGED
@@ -12,18 +12,3 @@ export function verifyMilkyAccessToken(accessToken: string | undefined, request:
12
12
  }
13
13
  return false;
14
14
  }
15
-
16
- export async function readRequestBody(request: IncomingMessage): Promise<string> {
17
- const chunks: Buffer[] = [];
18
- let size = 0;
19
- for await (const chunk of request) {
20
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
21
- size += buffer.length;
22
- if (size > 1_048_576) {
23
- request.destroy();
24
- throw new Error('Request body exceeds 1MB');
25
- }
26
- chunks.push(buffer);
27
- }
28
- return Buffer.concat(chunks).toString('utf8');
29
- }
package/src/protocol.ts CHANGED
@@ -77,7 +77,7 @@ export type ResolvedMilkyConfig =
77
77
  export type MilkyEndpointConfig = ResolvedMilkyConfig;
78
78
 
79
79
  export interface MilkyApiResponse<T = unknown> {
80
- status: string;
80
+ status: 'ok' | 'failed';
81
81
  retcode: number;
82
82
  data?: T;
83
83
  message?: string;
@@ -203,13 +203,13 @@ function authQuery(access_token?: string): string {
203
203
 
204
204
  /**
205
205
  * 调用协议端 API:POST {baseUrl}/api/{apiName},Body JSON,鉴权。
206
- * 200 retcode !== 0 时抛错。
206
+ * HTTP 层错误抛出;协议响应原样返回,由 Client 公开 call() 保留完整语义。
207
207
  */
208
208
  export async function callApi<T = unknown>(
209
209
  options: MilkyApiClientOptions,
210
210
  apiName: string,
211
211
  params: Record<string, unknown> = {},
212
- ): Promise<T> {
212
+ ): Promise<MilkyApiResponse<T>> {
213
213
  const { baseUrl, access_token } = options;
214
214
  const url = new URL(`/api/${apiName}`, baseUrl.replace(/\/$/, ''));
215
215
  const q = authQuery(access_token);
@@ -235,10 +235,7 @@ export async function callApi<T = unknown>(
235
235
  if (res.status !== 200) {
236
236
  throw new Error(`Milky API ${apiName}: HTTP ${res.status} ${body.message ?? text}`);
237
237
  }
238
- if (body.retcode !== 0) {
239
- throw new Error(`Milky API ${apiName}: retcode=${body.retcode} ${body.message ?? ''}`);
240
- }
241
- return (body.data ?? {}) as T;
238
+ return body;
242
239
  }
243
240
 
244
241
  /** 根据 event_type 判断是否为 message_receive,并解析 data */