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