@zhin.js/adapter-milky 3.0.1 → 4.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +443 -0
  2. package/README.md +36 -117
  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 -20
  7. package/lib/index.js +3 -35
  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 +52 -19
  27. package/plugin.ts +8 -0
  28. package/schema.json +36 -0
  29. package/src/endpoint.ts +26 -0
  30. package/src/index.ts +59 -48
  31. package/src/milky-agent-deps.ts +53 -0
  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/adapter.d.ts +0 -37
  41. package/lib/adapter.d.ts.map +0 -1
  42. package/lib/adapter.js +0 -99
  43. package/lib/adapter.js.map +0 -1
  44. package/lib/api.d.ts +0 -10
  45. package/lib/api.d.ts.map +0 -1
  46. package/lib/api.js +0 -48
  47. package/lib/api.js.map +0 -1
  48. package/lib/endpoint-sse.d.ts +0 -31
  49. package/lib/endpoint-sse.d.ts.map +0 -1
  50. package/lib/endpoint-sse.js +0 -197
  51. package/lib/endpoint-sse.js.map +0 -1
  52. package/lib/endpoint-webhook.d.ts +0 -34
  53. package/lib/endpoint-webhook.d.ts.map +0 -1
  54. package/lib/endpoint-webhook.js +0 -189
  55. package/lib/endpoint-webhook.js.map +0 -1
  56. package/lib/endpoint-ws.d.ts +0 -35
  57. package/lib/endpoint-ws.d.ts.map +0 -1
  58. package/lib/endpoint-ws.js +0 -264
  59. package/lib/endpoint-ws.js.map +0 -1
  60. package/lib/endpoint-wss.d.ts +0 -34
  61. package/lib/endpoint-wss.d.ts.map +0 -1
  62. package/lib/endpoint-wss.js +0 -227
  63. package/lib/endpoint-wss.js.map +0 -1
  64. package/lib/index.d.ts.map +0 -1
  65. package/lib/index.js.map +0 -1
  66. package/lib/segment-mapper.d.ts +0 -2
  67. package/lib/segment-mapper.d.ts.map +0 -1
  68. package/lib/segment-mapper.js +0 -2
  69. package/lib/segment-mapper.js.map +0 -1
  70. package/lib/types.d.ts +0 -75
  71. package/lib/types.d.ts.map +0 -1
  72. package/lib/types.js +0 -5
  73. package/lib/types.js.map +0 -1
  74. package/lib/utils.d.ts +0 -35
  75. package/lib/utils.d.ts.map +0 -1
  76. package/lib/utils.js +0 -166
  77. package/lib/utils.js.map +0 -1
  78. package/plugin.yml +0 -3
  79. package/src/adapter.ts +0 -111
  80. package/src/api.ts +0 -63
  81. package/src/endpoint-sse.ts +0 -232
  82. package/src/endpoint-webhook.ts +0 -225
  83. package/src/endpoint-ws.ts +0 -300
  84. package/src/endpoint-wss.ts +0 -272
  85. package/src/segment-mapper.ts +0 -1
  86. package/src/types.ts +0 -73
  87. package/src/utils.ts +0 -182
  88. /package/{skills/milky → agent}/PERMITS.md +0 -0
  89. /package/{skills/milky/SKILL.md → agent/skills/milky.md} +0 -0
@@ -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
+ }