@zhin.js/adapter-milky 3.0.2 → 5.0.0

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 (71) hide show
  1. package/CHANGELOG.md +464 -0
  2. package/README.md +39 -124
  3. package/adapters/milky.ts +68 -0
  4. package/lib/endpoint.d.ts +7 -0
  5. package/lib/endpoint.js +6 -0
  6. package/lib/index.d.ts +3 -0
  7. package/lib/index.js +3 -0
  8. package/lib/milky-agent-deps.d.ts +24 -0
  9. package/lib/milky-agent-deps.js +30 -0
  10. package/lib/milky-auth.d.ts +3 -0
  11. package/lib/milky-auth.js +30 -0
  12. package/lib/protocol.d.ts +162 -0
  13. package/lib/protocol.js +337 -0
  14. package/lib/sse-client.d.ts +20 -0
  15. package/lib/sse-client.js +85 -0
  16. package/lib/sse-endpoint.d.ts +50 -0
  17. package/lib/sse-endpoint.js +261 -0
  18. package/lib/webhook-endpoint.d.ts +40 -0
  19. package/lib/webhook-endpoint.js +212 -0
  20. package/lib/ws-endpoint.d.ts +42 -0
  21. package/lib/ws-endpoint.js +320 -0
  22. package/lib/ws-types.d.ts +11 -0
  23. package/lib/ws-types.js +1 -0
  24. package/lib/wss-endpoint.d.ts +40 -0
  25. package/lib/wss-endpoint.js +263 -0
  26. package/package.json +43 -20
  27. package/plugin.ts +8 -0
  28. package/schema.json +64 -0
  29. package/src/endpoint.ts +26 -0
  30. package/src/index.ts +59 -57
  31. package/src/milky-agent-deps.ts +44 -8
  32. package/src/milky-auth.ts +29 -0
  33. package/src/protocol.ts +491 -0
  34. package/src/sse-client.ts +106 -0
  35. package/src/sse-endpoint.ts +318 -0
  36. package/src/webhook-endpoint.ts +262 -0
  37. package/src/ws-endpoint.ts +373 -0
  38. package/src/ws-types.ts +12 -0
  39. package/src/wss-endpoint.ts +305 -0
  40. package/lib/src/adapter.js +0 -99
  41. package/lib/src/adapter.js.map +0 -1
  42. package/lib/src/api.js +0 -48
  43. package/lib/src/api.js.map +0 -1
  44. package/lib/src/endpoint-sse.js +0 -197
  45. package/lib/src/endpoint-sse.js.map +0 -1
  46. package/lib/src/endpoint-webhook.js +0 -189
  47. package/lib/src/endpoint-webhook.js.map +0 -1
  48. package/lib/src/endpoint-ws.js +0 -264
  49. package/lib/src/endpoint-ws.js.map +0 -1
  50. package/lib/src/endpoint-wss.js +0 -227
  51. package/lib/src/endpoint-wss.js.map +0 -1
  52. package/lib/src/index.js +0 -45
  53. package/lib/src/index.js.map +0 -1
  54. package/lib/src/milky-agent-deps.js +0 -10
  55. package/lib/src/milky-agent-deps.js.map +0 -1
  56. package/lib/src/segment-mapper.js +0 -2
  57. package/lib/src/segment-mapper.js.map +0 -1
  58. package/lib/src/types.js +0 -5
  59. package/lib/src/types.js.map +0 -1
  60. package/lib/src/utils.js +0 -169
  61. package/lib/src/utils.js.map +0 -1
  62. package/plugin.yml +0 -3
  63. package/src/adapter.ts +0 -111
  64. package/src/api.ts +0 -63
  65. package/src/endpoint-sse.ts +0 -232
  66. package/src/endpoint-webhook.ts +0 -225
  67. package/src/endpoint-ws.ts +0 -300
  68. package/src/endpoint-wss.ts +0 -272
  69. package/src/segment-mapper.ts +0 -1
  70. package/src/types.ts +0 -73
  71. package/src/utils.ts +0 -182
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Milky WS client endpoint — outbound connect to Milky protocol server.
3
+ */
4
+ import WebSocket from 'ws';
5
+ import { clearInterval, clearTimeout } from 'node:timers';
6
+ import type { EndpointInstance } from '@zhin.js/adapter';
7
+ import type { MessageGateway } from '@zhin.js/core/runtime';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
11
+ import {
12
+ buildSendAction,
13
+ buildWsConnectOptions,
14
+ callApi,
15
+ extractInboundAudioUrl,
16
+ formatInboundContent,
17
+ formatInboundMessageId,
18
+ formatInboundTarget,
19
+ formatOutboundMessageId,
20
+ formatOutboundSegments,
21
+ isMentioned,
22
+ parseMessageReceiveData,
23
+ parseMilkyMessageId,
24
+ senderNickname,
25
+ type MilkyEvent,
26
+ type MilkyIncomingMessage,
27
+ type MilkyWsConfig,
28
+ } from './protocol.js';
29
+ import type { MilkyWsCreateOptions, MilkyWsSocket } from './ws-types.js';
30
+
31
+ const logger = getLogger('milky');
32
+ const WS_OPEN = 1;
33
+
34
+ export interface MilkyWsEndpointOptions {
35
+ readonly id: CapabilityId;
36
+ readonly gateway: MessageGateway;
37
+ readonly config: MilkyWsConfig;
38
+ readonly createWebSocket?: (
39
+ url: string,
40
+ options: MilkyWsCreateOptions,
41
+ ) => MilkyWsSocket;
42
+ readonly callApi?: typeof callApi;
43
+ }
44
+
45
+ export class MilkyWsEndpoint implements EndpointInstance {
46
+ readonly #options: MilkyWsEndpointOptions;
47
+ readonly #callApi: typeof callApi;
48
+ #ws?: MilkyWsSocket;
49
+ #reconnectTimer?: NodeJS.Timeout;
50
+ #heartbeatTimer?: NodeJS.Timeout;
51
+ #open = false;
52
+ #started = false;
53
+ #stopping = false;
54
+ #unregisterAgent?: () => void;
55
+
56
+ constructor(options: MilkyWsEndpointOptions) {
57
+ this.#options = options;
58
+ this.#callApi = options.callApi ?? callApi;
59
+ }
60
+
61
+ async start(): Promise<void> {
62
+ if (this.#started) return;
63
+ this.#started = true;
64
+ this.#stopping = false;
65
+ this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.name, this);
66
+ await this.#connect();
67
+ }
68
+
69
+ open(): void {
70
+ this.#open = true;
71
+ }
72
+
73
+ close(): void {
74
+ this.#open = false;
75
+ }
76
+
77
+ async stop(): Promise<void> {
78
+ this.#open = false;
79
+ this.#stopping = true;
80
+ this.#started = false;
81
+ this.#unregisterAgent?.();
82
+ 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
+ if (this.#ws) {
92
+ try {
93
+ this.#ws.close();
94
+ } catch {
95
+ /* ignore */
96
+ }
97
+ this.#ws = undefined;
98
+ }
99
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
100
+ }
101
+
102
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
103
+ const message = formatOutboundSegments(payload);
104
+ const { action, params } = buildSendAction(target, message);
105
+ const data = await this.callApi(action, params) as { message_seq?: number } | undefined;
106
+ const messageId = formatOutboundMessageId(target, data?.message_seq);
107
+ logger.debug(formatCompact({
108
+ op: 'milky_send',
109
+ endpoint: this.#options.config.name,
110
+ target,
111
+ messageId,
112
+ }));
113
+ return messageId;
114
+ }
115
+
116
+ /** Public API for agent tools / callers. */
117
+ callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
118
+ return this.#callApi(this.apiOptions(), action, params);
119
+ }
120
+
121
+ async recallMessage(id: string): Promise<void> {
122
+ const parsed = parseMilkyMessageId(id);
123
+ if (!parsed) throw new Error(`Invalid message id: ${id}`);
124
+ if (parsed.message_scene === 'group') {
125
+ await this.callApi('recall_group_message', {
126
+ group_id: parsed.peer_id,
127
+ message_seq: parsed.message_seq,
128
+ });
129
+ } else {
130
+ await this.callApi('recall_private_message', {
131
+ user_id: parsed.peer_id,
132
+ message_seq: parsed.message_seq,
133
+ });
134
+ }
135
+ }
136
+
137
+ async kickMember(groupId: number, userId: number, rejectAddRequest = false): Promise<boolean> {
138
+ await this.callApi('kick_group_member', {
139
+ group_id: groupId,
140
+ user_id: userId,
141
+ reject_add_request: rejectAddRequest,
142
+ });
143
+ return true;
144
+ }
145
+
146
+ async muteMember(groupId: number, userId: number, duration = 600): Promise<boolean> {
147
+ await this.callApi('set_group_member_mute', {
148
+ group_id: groupId,
149
+ user_id: userId,
150
+ duration,
151
+ });
152
+ return true;
153
+ }
154
+
155
+ async muteAll(groupId: number, enable = true): Promise<boolean> {
156
+ await this.callApi('set_group_whole_mute', { group_id: groupId, is_mute: enable });
157
+ return true;
158
+ }
159
+
160
+ async setAdmin(groupId: number, userId: number, enable = true): Promise<boolean> {
161
+ await this.callApi('set_group_member_admin', {
162
+ group_id: groupId,
163
+ user_id: userId,
164
+ is_set: enable,
165
+ });
166
+ return true;
167
+ }
168
+
169
+ async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
170
+ await this.callApi('set_group_member_card', {
171
+ group_id: groupId,
172
+ user_id: userId,
173
+ card,
174
+ });
175
+ return true;
176
+ }
177
+
178
+ async setTitle(groupId: number, userId: number, title: string): Promise<boolean> {
179
+ await this.callApi('set_group_member_special_title', {
180
+ group_id: groupId,
181
+ user_id: userId,
182
+ special_title: title,
183
+ });
184
+ return true;
185
+ }
186
+
187
+ async setGroupName(groupId: number, name: string): Promise<boolean> {
188
+ await this.callApi('set_group_name', { group_id: groupId, new_group_name: name });
189
+ return true;
190
+ }
191
+
192
+ async getMemberList(groupId: number): Promise<unknown[]> {
193
+ return this.callApi('get_group_member_list', { group_id: groupId }) as Promise<unknown[]>;
194
+ }
195
+
196
+ async getGroupInfo(groupId: number): Promise<unknown> {
197
+ return this.callApi('get_group_info', { group_id: groupId });
198
+ }
199
+
200
+ /** Test / internal: admit a parsed event when the endpoint is open. */
201
+ admit(event: MilkyEvent): void {
202
+ const data = parseMessageReceiveData(event);
203
+ if (!this.#open || !data) return;
204
+ this.#admitMessage(data, event);
205
+ }
206
+
207
+ apiOptions(): { baseUrl: string; access_token?: string } {
208
+ return {
209
+ baseUrl: this.#options.config.baseUrl,
210
+ access_token: this.#options.config.access_token,
211
+ };
212
+ }
213
+
214
+ #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
215
+ const target = formatInboundTarget(data);
216
+ const content = formatInboundContent(data);
217
+ const audioUrl = extractInboundAudioUrl(data);
218
+ const nickname = senderNickname(data);
219
+ const mentioned = isMentioned(data, event.self_id);
220
+ void this.#options.gateway.receive({
221
+ adapter: this.#options.id,
222
+ target,
223
+ content,
224
+ sender: String(data.sender_id),
225
+ id: formatInboundMessageId(data),
226
+ metadata: Object.freeze({
227
+ message_scene: data.message_scene,
228
+ peer_id: String(data.peer_id),
229
+ sender_id: String(data.sender_id),
230
+ message_seq: data.message_seq,
231
+ endpoint: this.#options.config.name,
232
+ time: data.time ?? event.time,
233
+ self_id: event.self_id != null ? String(event.self_id) : undefined,
234
+ ...(nickname ? { nickname } : {}),
235
+ ...(mentioned ? { mentioned: true } : {}),
236
+ ...(audioUrl ? { audio_url: audioUrl } : {}),
237
+ }),
238
+ }).catch((err) => {
239
+ logger.warn(formatCompact({
240
+ op: 'milky_gateway_receive_failed',
241
+ target,
242
+ error: err instanceof Error ? err.message : String(err),
243
+ }));
244
+ });
245
+ }
246
+
247
+ async #connect(): Promise<void> {
248
+ const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
249
+ const create = this.#options.createWebSocket
250
+ ?? ((connectUrl: string, options: MilkyWsCreateOptions) =>
251
+ new WebSocket(connectUrl, { headers: options.headers }) as unknown as MilkyWsSocket);
252
+
253
+ await new Promise<void>((resolve, reject) => {
254
+ let settled = false;
255
+ const ws = create(url, { headers });
256
+ this.#ws = ws;
257
+
258
+ ws.on('open', () => {
259
+ if (settled) return;
260
+ settled = true;
261
+ if (!this.#options.config.access_token) {
262
+ logger.warn(formatCompact({
263
+ endpoint: this.#options.config.name,
264
+ ok: false,
265
+ error: 'missing access_token',
266
+ }));
267
+ }
268
+ logger.debug(formatCompact({
269
+ endpoint: this.#options.config.name,
270
+ mode: 'ws',
271
+ url: safeUrl,
272
+ }));
273
+ this.#startHeartbeat();
274
+ resolve();
275
+ });
276
+
277
+ ws.on('message', (data) => {
278
+ this.#onMessage(data);
279
+ });
280
+
281
+ ws.on('close', (code, reason) => {
282
+ const reasonStr = typeof reason === 'string'
283
+ ? reason
284
+ : Buffer.isBuffer(reason)
285
+ ? reason.toString()
286
+ : String(reason ?? '');
287
+ const codeNum = typeof code === 'number' ? code : Number(code ?? 0);
288
+ const codeHint = codeNum === 1005
289
+ ? ' [无状态,多为服务端/代理未发 close 帧即断开]'
290
+ : codeNum === 1006
291
+ ? ' [异常关闭]'
292
+ : '';
293
+ logger.warn(formatCompact({
294
+ op: 'disconnect',
295
+ endpoint: this.#options.config.name,
296
+ code: codeNum,
297
+ error: `${reasonStr || 'closed'}${codeHint}`,
298
+ reconnect_ms: this.#options.config.reconnect_interval,
299
+ }));
300
+ if (!settled) {
301
+ settled = true;
302
+ reject(new Error(`Milky WS 关闭: ${codeNum} ${reasonStr}`));
303
+ }
304
+ this.#scheduleReconnect();
305
+ });
306
+
307
+ ws.on('error', (err) => {
308
+ const error = err instanceof Error ? err : new Error(String(err));
309
+ logger.warn(formatCompact({
310
+ op: 'ws_error',
311
+ endpoint: this.#options.config.name,
312
+ ok: false,
313
+ error: error.message,
314
+ }));
315
+ if (!settled) {
316
+ settled = true;
317
+ reject(error);
318
+ }
319
+ });
320
+ });
321
+ }
322
+
323
+ #onMessage(data: unknown): void {
324
+ try {
325
+ const raw = typeof data === 'string'
326
+ ? data
327
+ : Buffer.isBuffer(data)
328
+ ? data.toString()
329
+ : data instanceof ArrayBuffer
330
+ ? new TextDecoder().decode(data)
331
+ : String(data ?? '');
332
+ const event = JSON.parse(raw) as MilkyEvent;
333
+ this.admit(event);
334
+ } catch (error) {
335
+ logger.warn(formatCompact({
336
+ op: 'milky_parse_failed',
337
+ endpoint: this.#options.config.name,
338
+ error: error instanceof Error ? error.message : String(error),
339
+ }));
340
+ }
341
+ }
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
+ }
@@ -0,0 +1,12 @@
1
+ /** Minimal WS surface used by the endpoint (real `ws` or test mock). */
2
+ export interface MilkyWsSocket {
3
+ readonly readyState: number;
4
+ send?(data: string): void;
5
+ close(code?: number, reason?: string): void;
6
+ ping?(): void;
7
+ on(event: 'open' | 'message' | 'close' | 'error', listener: (...args: unknown[]) => void): void;
8
+ }
9
+
10
+ export interface MilkyWsCreateOptions {
11
+ readonly headers?: Record<string, string>;
12
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Milky reverse WSS endpoint — httpHostToken WS upgrade inbound + baseUrl HTTP API outbound.
3
+ */
4
+ import { clearInterval } from 'node:timers';
5
+ import type { EndpointInstance } from '@zhin.js/adapter';
6
+ import type { MessageGateway } from '@zhin.js/core/runtime';
7
+ import type { HttpHost, WsConnection } from '@zhin.js/host-http';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
10
+ import { verifyMilkyAccessToken } from './milky-auth.js';
11
+ import { registerMilkyAgentEndpoint } from './milky-agent-deps.js';
12
+ import {
13
+ buildSendAction,
14
+ callApi,
15
+ extractInboundAudioUrl,
16
+ formatInboundContent,
17
+ formatInboundMessageId,
18
+ formatInboundTarget,
19
+ formatOutboundMessageId,
20
+ formatOutboundSegments,
21
+ isMentioned,
22
+ parseMessageReceiveData,
23
+ parseMilkyMessageId,
24
+ senderNickname,
25
+ type MilkyEvent,
26
+ type MilkyIncomingMessage,
27
+ type MilkyWssConfig,
28
+ } from './protocol.js';
29
+ import type { MilkyWsSocket } from './ws-types.js';
30
+
31
+ const logger = getLogger('milky');
32
+ const WS_OPEN = 1;
33
+
34
+ export interface MilkyWssEndpointOptions {
35
+ readonly id: CapabilityId;
36
+ readonly gateway: MessageGateway;
37
+ readonly http: HttpHost;
38
+ readonly config: MilkyWssConfig;
39
+ readonly callApi?: typeof callApi;
40
+ }
41
+
42
+ export class MilkyWssEndpoint implements EndpointInstance {
43
+ readonly #options: MilkyWssEndpointOptions;
44
+ readonly #callApi: typeof callApi;
45
+ #ws?: MilkyWsSocket;
46
+ #wsRelease?: () => void;
47
+ #heartbeatTimer?: NodeJS.Timeout;
48
+ #open = false;
49
+ #started = false;
50
+ #unregisterAgent?: () => void;
51
+
52
+ constructor(options: MilkyWssEndpointOptions) {
53
+ this.#options = options;
54
+ this.#callApi = options.callApi ?? callApi;
55
+ }
56
+
57
+ async start(): Promise<void> {
58
+ if (this.#started) return;
59
+ this.#started = true;
60
+ this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.name, this);
61
+ const handle = this.#options.http.ws(this.#options.config.path);
62
+ this.#wsRelease = handle.onConnection((connection) => {
63
+ this.#acceptConnection(connection);
64
+ });
65
+ logger.info(formatCompact({
66
+ op: 'listen',
67
+ endpoint: this.#options.config.name,
68
+ mode: 'wss',
69
+ path: this.#options.config.path,
70
+ }));
71
+ }
72
+
73
+ open(): void {
74
+ this.#open = true;
75
+ }
76
+
77
+ close(): void {
78
+ this.#open = false;
79
+ }
80
+
81
+ async stop(): Promise<void> {
82
+ this.#open = false;
83
+ this.#unregisterAgent?.();
84
+ this.#unregisterAgent = undefined;
85
+ this.#wsRelease?.();
86
+ this.#wsRelease = undefined;
87
+ if (this.#heartbeatTimer) {
88
+ clearInterval(this.#heartbeatTimer);
89
+ this.#heartbeatTimer = undefined;
90
+ }
91
+ if (this.#ws) {
92
+ try {
93
+ this.#ws.close();
94
+ } catch {
95
+ /* ignore */
96
+ }
97
+ this.#ws = undefined;
98
+ }
99
+ this.#started = false;
100
+ }
101
+
102
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
103
+ const message = formatOutboundSegments(payload);
104
+ const { action, params } = buildSendAction(target, message);
105
+ const data = await this.callApi(action, params) as { message_seq?: number } | undefined;
106
+ const messageId = formatOutboundMessageId(target, data?.message_seq);
107
+ logger.debug(formatCompact({
108
+ op: 'milky_send',
109
+ endpoint: this.#options.config.name,
110
+ target,
111
+ messageId,
112
+ }));
113
+ return messageId;
114
+ }
115
+
116
+ callApi(action: string, params: Record<string, unknown> = {}): Promise<unknown> {
117
+ return this.#callApi(this.apiOptions(), action, params);
118
+ }
119
+
120
+ async recallMessage(id: string): Promise<void> {
121
+ const parsed = parseMilkyMessageId(id);
122
+ if (!parsed) throw new Error(`Invalid message id: ${id}`);
123
+ if (parsed.message_scene === 'group') {
124
+ await this.callApi('recall_group_message', {
125
+ group_id: parsed.peer_id,
126
+ message_seq: parsed.message_seq,
127
+ });
128
+ } else {
129
+ await this.callApi('recall_private_message', {
130
+ user_id: parsed.peer_id,
131
+ message_seq: parsed.message_seq,
132
+ });
133
+ }
134
+ }
135
+
136
+ async kickMember(groupId: number, userId: number, rejectAddRequest = false): Promise<boolean> {
137
+ await this.callApi('kick_group_member', {
138
+ group_id: groupId,
139
+ user_id: userId,
140
+ reject_add_request: rejectAddRequest,
141
+ });
142
+ return true;
143
+ }
144
+
145
+ async muteMember(groupId: number, userId: number, duration = 600): Promise<boolean> {
146
+ await this.callApi('set_group_member_mute', {
147
+ group_id: groupId,
148
+ user_id: userId,
149
+ duration,
150
+ });
151
+ return true;
152
+ }
153
+
154
+ async muteAll(groupId: number, enable = true): Promise<boolean> {
155
+ await this.callApi('set_group_whole_mute', { group_id: groupId, is_mute: enable });
156
+ return true;
157
+ }
158
+
159
+ async setAdmin(groupId: number, userId: number, enable = true): Promise<boolean> {
160
+ await this.callApi('set_group_member_admin', {
161
+ group_id: groupId,
162
+ user_id: userId,
163
+ is_set: enable,
164
+ });
165
+ return true;
166
+ }
167
+
168
+ async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
169
+ await this.callApi('set_group_member_card', {
170
+ group_id: groupId,
171
+ user_id: userId,
172
+ card,
173
+ });
174
+ return true;
175
+ }
176
+
177
+ async setTitle(groupId: number, userId: number, title: string): Promise<boolean> {
178
+ await this.callApi('set_group_member_special_title', {
179
+ group_id: groupId,
180
+ user_id: userId,
181
+ special_title: title,
182
+ });
183
+ return true;
184
+ }
185
+
186
+ async setGroupName(groupId: number, name: string): Promise<boolean> {
187
+ await this.callApi('set_group_name', { group_id: groupId, new_group_name: name });
188
+ return true;
189
+ }
190
+
191
+ async getMemberList(groupId: number): Promise<unknown[]> {
192
+ return this.callApi('get_group_member_list', { group_id: groupId }) as Promise<unknown[]>;
193
+ }
194
+
195
+ async getGroupInfo(groupId: number): Promise<unknown> {
196
+ return this.callApi('get_group_info', { group_id: groupId });
197
+ }
198
+
199
+ admit(event: MilkyEvent): void {
200
+ const data = parseMessageReceiveData(event);
201
+ if (!this.#open || !data) return;
202
+ this.#admitMessage(data, event);
203
+ }
204
+
205
+ apiOptions(): { baseUrl: string; access_token?: string } {
206
+ return {
207
+ baseUrl: this.#options.config.baseUrl,
208
+ access_token: this.#options.config.access_token,
209
+ };
210
+ }
211
+
212
+ #admitMessage(data: MilkyIncomingMessage, event: MilkyEvent): void {
213
+ const target = formatInboundTarget(data);
214
+ const content = formatInboundContent(data);
215
+ const audioUrl = extractInboundAudioUrl(data);
216
+ const nickname = senderNickname(data);
217
+ const mentioned = isMentioned(data, event.self_id);
218
+ void this.#options.gateway.receive({
219
+ adapter: this.#options.id,
220
+ target,
221
+ content,
222
+ sender: String(data.sender_id),
223
+ id: formatInboundMessageId(data),
224
+ metadata: Object.freeze({
225
+ message_scene: data.message_scene,
226
+ peer_id: String(data.peer_id),
227
+ sender_id: String(data.sender_id),
228
+ message_seq: data.message_seq,
229
+ endpoint: this.#options.config.name,
230
+ time: data.time ?? event.time,
231
+ self_id: event.self_id != null ? String(event.self_id) : undefined,
232
+ ...(nickname ? { nickname } : {}),
233
+ ...(mentioned ? { mentioned: true } : {}),
234
+ ...(audioUrl ? { audio_url: audioUrl } : {}),
235
+ }),
236
+ }).catch((err) => {
237
+ logger.warn(formatCompact({
238
+ op: 'milky_gateway_receive_failed',
239
+ target,
240
+ error: err instanceof Error ? err.message : String(err),
241
+ }));
242
+ });
243
+ }
244
+
245
+ #acceptConnection(connection: WsConnection): void {
246
+ if (!verifyMilkyAccessToken(this.#options.config.access_token, connection.request)) {
247
+ connection.socket.close(4003, 'Unauthorized');
248
+ return;
249
+ }
250
+ const socket = connection.socket as unknown as MilkyWsSocket;
251
+ if (this.#ws) {
252
+ try {
253
+ this.#ws.close();
254
+ } catch {
255
+ /* ignore */
256
+ }
257
+ }
258
+ this.#ws = socket;
259
+ this.#startHeartbeat();
260
+ socket.on('message', (data) => {
261
+ this.#onMessage(data);
262
+ });
263
+ socket.on('close', () => {
264
+ if (this.#ws === socket) this.#ws = undefined;
265
+ });
266
+ logger.debug(formatCompact({
267
+ endpoint: this.#options.config.name,
268
+ mode: 'wss',
269
+ peer: connection.request.socket.remoteAddress,
270
+ }));
271
+ }
272
+
273
+ #onMessage(data: unknown): void {
274
+ try {
275
+ const raw = typeof data === 'string'
276
+ ? data
277
+ : Buffer.isBuffer(data)
278
+ ? data.toString()
279
+ : data instanceof ArrayBuffer
280
+ ? new TextDecoder().decode(data)
281
+ : String(data ?? '');
282
+ const event = JSON.parse(raw) as MilkyEvent;
283
+ this.admit(event);
284
+ } catch (error) {
285
+ logger.warn(formatCompact({
286
+ op: 'milky_parse_failed',
287
+ endpoint: this.#options.config.name,
288
+ error: error instanceof Error ? error.message : String(error),
289
+ }));
290
+ }
291
+ }
292
+
293
+ #startHeartbeat(): void {
294
+ if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
295
+ const interval = this.#options.config.heartbeat_interval;
296
+ if (interval <= 0) return;
297
+ this.#heartbeatTimer = setInterval(() => {
298
+ try {
299
+ if (this.#ws?.readyState === WS_OPEN) this.#ws.ping?.();
300
+ } catch {
301
+ /* ignore */
302
+ }
303
+ }, interval);
304
+ }
305
+ }