@zhin.js/adapter-milky 5.0.0 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/adapters/milky.ts +11 -0
- package/commands/endpoint/add/[name:string].ts +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/endpoint-management.d.ts +17 -0
- package/lib/endpoint-management.js +47 -0
- package/lib/milky-endpoint-commands.d.ts +1 -0
- package/lib/milky-endpoint-commands.js +18 -0
- package/lib/milky-runtime-state.d.ts +1 -0
- package/lib/milky-runtime-state.js +6 -0
- package/lib/protocol.d.ts +8 -0
- package/lib/protocol.js +121 -0
- package/lib/sse-endpoint.d.ts +2 -1
- package/lib/sse-endpoint.js +51 -42
- package/lib/webhook-endpoint.d.ts +2 -1
- package/lib/webhook-endpoint.js +5 -1
- package/lib/ws-endpoint.d.ts +2 -1
- package/lib/ws-endpoint.js +51 -57
- package/lib/wss-endpoint.d.ts +2 -1
- package/lib/wss-endpoint.js +5 -1
- package/package.json +18 -12
- package/plugin.ts +6 -0
- package/src/endpoint-management.ts +74 -0
- package/src/milky-endpoint-commands.ts +19 -0
- package/src/milky-runtime-state.ts +7 -0
- package/src/protocol.ts +120 -0
- package/src/sse-endpoint.ts +52 -41
- package/src/webhook-endpoint.ts +6 -1
- package/src/ws-endpoint.ts +53 -55
- package/src/wss-endpoint.ts +6 -1
package/lib/ws-endpoint.js
CHANGED
|
@@ -2,33 +2,48 @@
|
|
|
2
2
|
* Milky WS client endpoint — outbound connect to Milky protocol server.
|
|
3
3
|
*/
|
|
4
4
|
import WebSocket from 'ws';
|
|
5
|
-
import {
|
|
5
|
+
import { createEndpointLifecycle, } from '@zhin.js/adapter';
|
|
6
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
import { createMilkyEndpointManagement } from './endpoint-management.js';
|
|
7
8
|
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
|
+
import { buildSendAction, buildWsConnectOptions, callApi, extractInboundAudioUrl, formatInboundContent, formatInboundMessageId, formatInboundSegments, formatInboundTarget, formatOutboundMessageId, formatOutboundSegments, isMentioned, parseMessageReceiveData, parseMilkyMessageId, senderNickname, } from './protocol.js';
|
|
9
10
|
const logger = getLogger('milky');
|
|
10
11
|
const WS_OPEN = 1;
|
|
11
12
|
export class MilkyWsEndpoint {
|
|
12
13
|
#options;
|
|
13
14
|
#callApi;
|
|
15
|
+
management = createMilkyEndpointManagement(this);
|
|
16
|
+
#lifecycle;
|
|
14
17
|
#ws;
|
|
15
|
-
#reconnectTimer;
|
|
16
|
-
#heartbeatTimer;
|
|
17
18
|
#open = false;
|
|
18
|
-
#started = false;
|
|
19
|
-
#stopping = false;
|
|
20
19
|
#unregisterAgent;
|
|
21
20
|
constructor(options) {
|
|
22
21
|
this.#options = options;
|
|
23
22
|
this.#callApi = options.callApi ?? callApi;
|
|
23
|
+
this.#lifecycle = createEndpointLifecycle({
|
|
24
|
+
name: options.config.name,
|
|
25
|
+
// reconnect_interval 旧语义为固定间隔:multiplier 1 + 无 jitter + 不封顶
|
|
26
|
+
reconnect: {
|
|
27
|
+
initialIntervalMs: options.config.reconnect_interval,
|
|
28
|
+
multiplier: 1,
|
|
29
|
+
maxIntervalMs: Number.MAX_SAFE_INTEGER,
|
|
30
|
+
jitterMs: 0,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
24
33
|
}
|
|
25
34
|
async start() {
|
|
26
|
-
if (this.#started)
|
|
35
|
+
if (this.#lifecycle.started)
|
|
27
36
|
return;
|
|
28
|
-
this.#started = true;
|
|
29
|
-
this.#stopping = false;
|
|
30
37
|
this.#unregisterAgent = registerMilkyAgentEndpoint(this.#options.config.name, this);
|
|
31
|
-
|
|
38
|
+
try {
|
|
39
|
+
await this.#lifecycle.start((handle) => this.#connect(handle));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// start 失败复位由基座保证;agent 注册/反注册是适配器专有依赖,留在适配器侧
|
|
43
|
+
this.#unregisterAgent?.();
|
|
44
|
+
this.#unregisterAgent = undefined;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
32
47
|
}
|
|
33
48
|
open() {
|
|
34
49
|
this.#open = true;
|
|
@@ -38,18 +53,9 @@ export class MilkyWsEndpoint {
|
|
|
38
53
|
}
|
|
39
54
|
async stop() {
|
|
40
55
|
this.#open = false;
|
|
41
|
-
this.#
|
|
42
|
-
this.#started = false;
|
|
56
|
+
await this.#lifecycle.stop();
|
|
43
57
|
this.#unregisterAgent?.();
|
|
44
58
|
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
59
|
if (this.#ws) {
|
|
54
60
|
try {
|
|
55
61
|
this.#ws.close();
|
|
@@ -59,7 +65,6 @@ export class MilkyWsEndpoint {
|
|
|
59
65
|
}
|
|
60
66
|
this.#ws = undefined;
|
|
61
67
|
}
|
|
62
|
-
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
|
|
63
68
|
}
|
|
64
69
|
async send({ target, payload }) {
|
|
65
70
|
const message = formatOutboundSegments(payload);
|
|
@@ -165,6 +170,7 @@ export class MilkyWsEndpoint {
|
|
|
165
170
|
#admitMessage(data, event) {
|
|
166
171
|
const target = formatInboundTarget(data);
|
|
167
172
|
const content = formatInboundContent(data);
|
|
173
|
+
const segments = formatInboundSegments(data);
|
|
168
174
|
const audioUrl = extractInboundAudioUrl(data);
|
|
169
175
|
const nickname = senderNickname(data);
|
|
170
176
|
const mentioned = isMentioned(data, event.self_id);
|
|
@@ -172,6 +178,7 @@ export class MilkyWsEndpoint {
|
|
|
172
178
|
adapter: this.#options.id,
|
|
173
179
|
target,
|
|
174
180
|
content,
|
|
181
|
+
segments,
|
|
175
182
|
sender: String(data.sender_id),
|
|
176
183
|
id: formatInboundMessageId(data),
|
|
177
184
|
metadata: Object.freeze({
|
|
@@ -194,7 +201,7 @@ export class MilkyWsEndpoint {
|
|
|
194
201
|
}));
|
|
195
202
|
});
|
|
196
203
|
}
|
|
197
|
-
async #connect() {
|
|
204
|
+
async #connect(handle) {
|
|
198
205
|
const { url, headers, safeUrl } = buildWsConnectOptions(this.#options.config);
|
|
199
206
|
const create = this.#options.createWebSocket
|
|
200
207
|
?? ((connectUrl, options) => new WebSocket(connectUrl, { headers: options.headers }));
|
|
@@ -202,6 +209,14 @@ export class MilkyWsEndpoint {
|
|
|
202
209
|
let settled = false;
|
|
203
210
|
const ws = create(url, { headers });
|
|
204
211
|
this.#ws = ws;
|
|
212
|
+
handle.onForceClose(() => {
|
|
213
|
+
try {
|
|
214
|
+
ws.close();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* ignore */
|
|
218
|
+
}
|
|
219
|
+
});
|
|
205
220
|
ws.on('open', () => {
|
|
206
221
|
if (settled)
|
|
207
222
|
return;
|
|
@@ -218,7 +233,18 @@ export class MilkyWsEndpoint {
|
|
|
218
233
|
mode: 'ws',
|
|
219
234
|
url: safeUrl,
|
|
220
235
|
}));
|
|
221
|
-
|
|
236
|
+
// stop-during-connect 竞态:已停止则不再武装心跳(基座 stop 已清理定时器)
|
|
237
|
+
if (this.#lifecycle.started) {
|
|
238
|
+
this.#lifecycle.startHeartbeat(() => {
|
|
239
|
+
try {
|
|
240
|
+
if (ws.readyState === WS_OPEN)
|
|
241
|
+
ws.ping?.();
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* ignore */
|
|
245
|
+
}
|
|
246
|
+
}, this.#options.config.heartbeat_interval);
|
|
247
|
+
}
|
|
222
248
|
resolve();
|
|
223
249
|
});
|
|
224
250
|
ws.on('message', (data) => {
|
|
@@ -243,11 +269,12 @@ export class MilkyWsEndpoint {
|
|
|
243
269
|
error: `${reasonStr || 'closed'}${codeHint}`,
|
|
244
270
|
reconnect_ms: this.#options.config.reconnect_interval,
|
|
245
271
|
}));
|
|
272
|
+
// 基座语义:仅曾 open 的连接才武装重连;初始连接失败由 start() 的 catch 复位
|
|
273
|
+
handle.notifyClosed(new Error(`Milky WS 关闭: ${codeNum} ${reasonStr}`));
|
|
246
274
|
if (!settled) {
|
|
247
275
|
settled = true;
|
|
248
276
|
reject(new Error(`Milky WS 关闭: ${codeNum} ${reasonStr}`));
|
|
249
277
|
}
|
|
250
|
-
this.#scheduleReconnect();
|
|
251
278
|
});
|
|
252
279
|
ws.on('error', (err) => {
|
|
253
280
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -284,37 +311,4 @@ export class MilkyWsEndpoint {
|
|
|
284
311
|
}));
|
|
285
312
|
}
|
|
286
313
|
}
|
|
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
314
|
}
|
package/lib/wss-endpoint.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
1
|
+
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
2
2
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
3
3
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
4
4
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
@@ -12,6 +12,7 @@ export interface MilkyWssEndpointOptions {
|
|
|
12
12
|
}
|
|
13
13
|
export declare class MilkyWssEndpoint implements EndpointInstance {
|
|
14
14
|
#private;
|
|
15
|
+
readonly management: EndpointManagement;
|
|
15
16
|
constructor(options: MilkyWssEndpointOptions);
|
|
16
17
|
start(): Promise<void>;
|
|
17
18
|
open(): void;
|
package/lib/wss-endpoint.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
import { clearInterval } from 'node:timers';
|
|
5
5
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
6
6
|
import { verifyMilkyAccessToken } from './milky-auth.js';
|
|
7
|
+
import { createMilkyEndpointManagement } from './endpoint-management.js';
|
|
7
8
|
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
|
+
import { buildSendAction, callApi, extractInboundAudioUrl, formatInboundContent, formatInboundMessageId, formatInboundSegments, formatInboundTarget, formatOutboundMessageId, formatOutboundSegments, isMentioned, parseMessageReceiveData, parseMilkyMessageId, senderNickname, } from './protocol.js';
|
|
9
10
|
const logger = getLogger('milky');
|
|
10
11
|
const WS_OPEN = 1;
|
|
11
12
|
export class MilkyWssEndpoint {
|
|
12
13
|
#options;
|
|
13
14
|
#callApi;
|
|
15
|
+
management = createMilkyEndpointManagement(this);
|
|
14
16
|
#ws;
|
|
15
17
|
#wsRelease;
|
|
16
18
|
#heartbeatTimer;
|
|
@@ -166,6 +168,7 @@ export class MilkyWssEndpoint {
|
|
|
166
168
|
#admitMessage(data, event) {
|
|
167
169
|
const target = formatInboundTarget(data);
|
|
168
170
|
const content = formatInboundContent(data);
|
|
171
|
+
const segments = formatInboundSegments(data);
|
|
169
172
|
const audioUrl = extractInboundAudioUrl(data);
|
|
170
173
|
const nickname = senderNickname(data);
|
|
171
174
|
const mentioned = isMentioned(data, event.self_id);
|
|
@@ -173,6 +176,7 @@ export class MilkyWssEndpoint {
|
|
|
173
176
|
adapter: this.#options.id,
|
|
174
177
|
target,
|
|
175
178
|
content,
|
|
179
|
+
segments,
|
|
176
180
|
sender: String(data.sender_id),
|
|
177
181
|
id: formatInboundMessageId(data),
|
|
178
182
|
metadata: Object.freeze({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-milky",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"description": "Zhin.js Milky adapter for Plugin Runtime (WebSocket client)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"adapters",
|
|
17
|
+
"commands",
|
|
17
18
|
"plugin.ts",
|
|
18
19
|
"schema.json",
|
|
19
20
|
"src",
|
|
@@ -39,11 +40,12 @@
|
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"ws": "^8.21.0",
|
|
42
|
-
"@zhin.js/adapter": "1.1.
|
|
43
|
-
"@zhin.js/
|
|
44
|
-
"@zhin.js/
|
|
43
|
+
"@zhin.js/adapter": "1.1.1",
|
|
44
|
+
"@zhin.js/command": "1.0.3",
|
|
45
|
+
"@zhin.js/core": "1.4.1",
|
|
46
|
+
"@zhin.js/host-http": "1.0.3",
|
|
45
47
|
"@zhin.js/logger": "1.0.75",
|
|
46
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
48
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
47
49
|
},
|
|
48
50
|
"devDependencies": {
|
|
49
51
|
"@types/node": "^26.1.0",
|
|
@@ -51,16 +53,16 @@
|
|
|
51
53
|
"typescript": "^6.0.3",
|
|
52
54
|
"vitest": "^4.1.10",
|
|
53
55
|
"zod": "^4.4.3",
|
|
54
|
-
"@zhin.js/agent": "1.0.
|
|
55
|
-
"@zhin.js/host-http": "1.0.
|
|
56
|
+
"@zhin.js/agent": "1.0.6",
|
|
57
|
+
"@zhin.js/host-http": "1.0.3"
|
|
56
58
|
},
|
|
57
59
|
"peerDependencies": {
|
|
58
60
|
"zod": "^4.0.0",
|
|
59
|
-
"@zhin.js/adapter": "1.1.
|
|
60
|
-
"@zhin.js/core": "1.4.
|
|
61
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
62
|
-
"zhin.js": "5.0.
|
|
63
|
-
"@zhin.js/agent": "1.0.
|
|
61
|
+
"@zhin.js/adapter": "1.1.1",
|
|
62
|
+
"@zhin.js/core": "1.4.1",
|
|
63
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
64
|
+
"zhin.js": "5.0.1",
|
|
65
|
+
"@zhin.js/agent": "1.0.6"
|
|
64
66
|
},
|
|
65
67
|
"peerDependenciesMeta": {
|
|
66
68
|
"zhin.js": {
|
|
@@ -95,6 +97,10 @@
|
|
|
95
97
|
{
|
|
96
98
|
"package": "@zhin.js/adapter",
|
|
97
99
|
"api": "^1.0.0"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"package": "@zhin.js/command",
|
|
103
|
+
"api": "^1.0.0"
|
|
98
104
|
}
|
|
99
105
|
],
|
|
100
106
|
"plugins": []
|
package/plugin.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
1
2
|
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
3
|
+
import { milkyRuntimeStateToken } from './src/milky-runtime-state.js';
|
|
2
4
|
|
|
3
5
|
export default definePlugin({
|
|
4
6
|
name: 'milky',
|
|
5
7
|
metadata: {
|
|
6
8
|
displayName: 'Milky Adapter',
|
|
7
9
|
},
|
|
10
|
+
setup(context) {
|
|
11
|
+
// 运行中 endpoint 注册表(milky endpoint list 的"运行中"数据源)
|
|
12
|
+
context.resources.provide(milkyRuntimeStateToken, createEndpointRuntimeState());
|
|
13
|
+
},
|
|
8
14
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milky endpoint management 语义端口(Console 社交面 RPC 消费,见
|
|
3
|
+
* packages/im/adapter/src/endpoint-management.ts)。
|
|
4
|
+
*
|
|
5
|
+
* Milky 协议响应 data 为包裹对象:get_friend_list → {friends:[...]}、
|
|
6
|
+
* get_group_list → {groups:[...]}、get_group_member_list → {members:[...]}。
|
|
7
|
+
* 归一化取舍:
|
|
8
|
+
* - friend → {user_id:number, nickname, remark: remark ?? ''}
|
|
9
|
+
* - group → {group_id:number, name: group_name ?? name}
|
|
10
|
+
* - 群成员列表保持 Milky 原生形状,仅保证数组
|
|
11
|
+
*/
|
|
12
|
+
import type {
|
|
13
|
+
EndpointFriend,
|
|
14
|
+
EndpointGroup,
|
|
15
|
+
EndpointManagement,
|
|
16
|
+
} from '@zhin.js/adapter';
|
|
17
|
+
|
|
18
|
+
/** 管理面只依赖 endpoint 的 callApi(ws/wss/sse/webhook 四种传输各自实现)。 */
|
|
19
|
+
export interface MilkyManagementCaller {
|
|
20
|
+
callApi(action: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createMilkyEndpointManagement(
|
|
24
|
+
endpoint: MilkyManagementCaller,
|
|
25
|
+
): EndpointManagement {
|
|
26
|
+
return Object.freeze<EndpointManagement>({
|
|
27
|
+
async listFriends(): Promise<readonly EndpointFriend[]> {
|
|
28
|
+
const data = asRecord(await endpoint.callApi('get_friend_list'));
|
|
29
|
+
return toArray(data.friends).map((value) => {
|
|
30
|
+
const friend = asRecord(value);
|
|
31
|
+
return {
|
|
32
|
+
user_id: toNumberId(friend.user_id, 'user_id'),
|
|
33
|
+
nickname: String(friend.nickname ?? ''),
|
|
34
|
+
remark: String(friend.remark ?? ''),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
async listGroups(): Promise<readonly EndpointGroup[]> {
|
|
39
|
+
const data = asRecord(await endpoint.callApi('get_group_list'));
|
|
40
|
+
return toArray(data.groups).map((value) => {
|
|
41
|
+
const group = asRecord(value);
|
|
42
|
+
return {
|
|
43
|
+
group_id: toNumberId(group.group_id, 'group_id'),
|
|
44
|
+
name: String(group.group_name ?? group.name ?? ''),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
|
|
49
|
+
const data = asRecord(await endpoint.callApi('get_group_member_list', {
|
|
50
|
+
group_id: toNumberId(groupId, 'group_id'),
|
|
51
|
+
}));
|
|
52
|
+
return toArray(data.members);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
58
|
+
return value !== null && typeof value === 'object'
|
|
59
|
+
? value as Record<string, unknown>
|
|
60
|
+
: {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toArray(value: unknown): readonly unknown[] {
|
|
64
|
+
return Array.isArray(value) ? value : [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
|
|
68
|
+
function toNumberId(value: unknown, label: string): number {
|
|
69
|
+
const n = Number(value);
|
|
70
|
+
if (!Number.isFinite(n) || String(value ?? '').trim() === '') {
|
|
71
|
+
throw new TypeError(`milky ${label} 必须是数字: ${String(value)}`);
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `milky endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项。
|
|
4
|
+
*/
|
|
5
|
+
import { createEndpointCommands } from '@zhin.js/adapter';
|
|
6
|
+
import { defineCommand } from '@zhin.js/command';
|
|
7
|
+
import { milkyRuntimeStateToken } from './milky-runtime-state.js';
|
|
8
|
+
|
|
9
|
+
export const milkyEndpointCommands = createEndpointCommands({
|
|
10
|
+
adapterKey: 'milky',
|
|
11
|
+
adapterDisplayName: 'Milky',
|
|
12
|
+
fields: [
|
|
13
|
+
{ key: 'baseUrl', required: true, description: 'Milky HTTP API base URL' },
|
|
14
|
+
{ key: 'path', description: 'webhook / reverse-wss 路径' },
|
|
15
|
+
{ key: 'access_token', env: true, description: 'Milky access token' },
|
|
16
|
+
],
|
|
17
|
+
running: (use) => use(milkyRuntimeStateToken).endpoints.values(),
|
|
18
|
+
describeEntry: (entry) => `baseUrl: ${String(entry.baseUrl)}`,
|
|
19
|
+
}, defineCommand);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milky 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `milky endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
|
|
7
|
+
export const milkyRuntimeStateToken = defineEndpointRuntimeStateToken('milky');
|
package/src/protocol.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Spec: https://milky.ntqqrev.org/
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { Segment } from '@zhin.js/core/runtime';
|
|
8
|
+
|
|
7
9
|
/** Transitional legacy endpoint row (`endpoints[]` with `context: milky`). */
|
|
8
10
|
export interface MilkyLegacyEndpointRow {
|
|
9
11
|
readonly context?: string;
|
|
@@ -272,6 +274,124 @@ export function formatInboundContent(data: MilkyIncomingMessage): string {
|
|
|
272
274
|
}).join('');
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
function firstNonEmptyString(...values: unknown[]): string | undefined {
|
|
278
|
+
for (const value of values) {
|
|
279
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 入站消息段 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
|
|
286
|
+
* 媒体段(image/record/video)优先 temp_url(kind=url),缺 URL 时回退
|
|
287
|
+
* resource_id(kind=file,平台不透明引用,经协议端 get_resource 解析);
|
|
288
|
+
* file 段只有 file_id(kind=file)。未识别段类型按宽松段透传,不丢信息。
|
|
289
|
+
*/
|
|
290
|
+
export function formatInboundSegments(data: MilkyIncomingMessage): Segment[] {
|
|
291
|
+
const out: Segment[] = [];
|
|
292
|
+
for (const seg of data.segments) {
|
|
293
|
+
const segData = seg.data ?? {};
|
|
294
|
+
switch (seg.type) {
|
|
295
|
+
case 'text': {
|
|
296
|
+
const text = String(segData.text ?? '');
|
|
297
|
+
if (text) out.push({ type: 'text', data: { text } });
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case 'mention':
|
|
301
|
+
out.push({
|
|
302
|
+
type: 'mention',
|
|
303
|
+
data: {
|
|
304
|
+
target: String(segData.user_id ?? ''),
|
|
305
|
+
...(typeof segData.name === 'string' && segData.name
|
|
306
|
+
? { name: segData.name }
|
|
307
|
+
: {}),
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
break;
|
|
311
|
+
case 'mention_all':
|
|
312
|
+
out.push({ type: 'mention', data: { target: 'all' } });
|
|
313
|
+
break;
|
|
314
|
+
case 'face': {
|
|
315
|
+
const id = firstNonEmptyString(segData.face_id, segData.id);
|
|
316
|
+
if (id) out.push({ type: 'face', data: { id } });
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case 'reply': {
|
|
320
|
+
const seq = Number(segData.message_seq);
|
|
321
|
+
if (Number.isFinite(seq)) {
|
|
322
|
+
// 与 formatInboundMessageId 同一复合格式,保证 reply 链可定位原消息
|
|
323
|
+
out.push({
|
|
324
|
+
type: 'reply',
|
|
325
|
+
data: { message_id: `${data.message_scene}:${data.peer_id}:${seq}` },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
case 'image':
|
|
331
|
+
case 'record':
|
|
332
|
+
case 'audio':
|
|
333
|
+
case 'video': {
|
|
334
|
+
const url = firstNonEmptyString(segData.temp_url, segData.uri, segData.url);
|
|
335
|
+
const resourceId = firstNonEmptyString(segData.resource_id);
|
|
336
|
+
if (!url && !resourceId) break;
|
|
337
|
+
const type = seg.type === 'image'
|
|
338
|
+
? 'image'
|
|
339
|
+
: seg.type === 'video'
|
|
340
|
+
? 'video'
|
|
341
|
+
: 'audio';
|
|
342
|
+
out.push({
|
|
343
|
+
type,
|
|
344
|
+
data: {
|
|
345
|
+
media: url
|
|
346
|
+
? { kind: 'url', value: url }
|
|
347
|
+
: { kind: 'file', value: resourceId! },
|
|
348
|
+
...(typeof segData.summary === 'string' && segData.summary
|
|
349
|
+
? { alt: segData.summary }
|
|
350
|
+
: {}),
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case 'file': {
|
|
356
|
+
const fileId = firstNonEmptyString(segData.file_id);
|
|
357
|
+
if (!fileId) break;
|
|
358
|
+
out.push({
|
|
359
|
+
type: 'file',
|
|
360
|
+
data: {
|
|
361
|
+
media: { kind: 'file', value: fileId },
|
|
362
|
+
...(typeof segData.file_name === 'string' && segData.file_name
|
|
363
|
+
? { name: segData.file_name }
|
|
364
|
+
: {}),
|
|
365
|
+
...(typeof segData.file_size === 'number'
|
|
366
|
+
? { size: segData.file_size }
|
|
367
|
+
: {}),
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case 'forward': {
|
|
373
|
+
const forwardId = firstNonEmptyString(segData.forward_id);
|
|
374
|
+
if (forwardId) {
|
|
375
|
+
out.push({
|
|
376
|
+
type: 'forward',
|
|
377
|
+
data: {
|
|
378
|
+
forward_id: forwardId,
|
|
379
|
+
...(typeof segData.title === 'string' && segData.title
|
|
380
|
+
? { title: segData.title }
|
|
381
|
+
: {}),
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
default:
|
|
388
|
+
// 未识别段(market_face / light_app / xml 等)按宽松 canonical 段透传
|
|
389
|
+
out.push({ type: seg.type, data: segData });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
275
395
|
/** First audio URI from inbound segments (for Agent Host STT preprocess). */
|
|
276
396
|
export function extractInboundAudioUrl(data: MilkyIncomingMessage): string | undefined {
|
|
277
397
|
for (const seg of data.segments) {
|