@zhin.js/adapter-milky 1.1.25 → 1.1.27
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 +155 -0
- package/LICENSE +21 -0
- package/PERMITS.md +3 -3
- package/README.md +6 -3
- package/adapters/milky/index.js +3 -0
- package/adapters/milky/index.ts +3 -0
- package/lib/console-spec.js +33 -3
- package/lib/endpoint-management.js +37 -0
- package/lib/milky-auth.d.ts +2 -2
- package/lib/milky-auth.js +3 -12
- package/lib/protocol.d.ts +6 -1
- package/lib/protocol.js +10 -0
- package/lib/sse-client.d.ts +6 -0
- package/lib/sse-client.js +41 -2
- package/lib/sse-endpoint.js +3 -2
- package/lib/webhook-endpoint.js +13 -17
- package/lib/ws-endpoint.js +3 -2
- package/lib/ws-types.d.ts +6 -0
- package/lib/ws-types.js +31 -1
- package/lib/wss-endpoint.d.ts +1 -1
- package/lib/wss-endpoint.js +21 -17
- package/package.json +17 -33
- package/schema.json +0 -56
- package/src/console-spec.ts +33 -3
- package/src/endpoint-management.ts +33 -0
- package/src/milky-auth.ts +4 -11
- package/src/protocol.ts +11 -1
- package/src/sse-client.ts +45 -2
- package/src/sse-endpoint.ts +3 -1
- package/src/webhook-endpoint.ts +10 -17
- package/src/ws-endpoint.ts +3 -1
- package/src/ws-types.ts +33 -0
- package/src/wss-endpoint.ts +21 -16
package/lib/ws-endpoint.js
CHANGED
|
@@ -7,7 +7,7 @@ import { createEndpointSpecification, endpointSpec, createRecallEndpointControl,
|
|
|
7
7
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
8
8
|
import { createMilkyEndpointManagement } from './endpoint-management.js';
|
|
9
9
|
import { createMilkyActionCaller, readMilkyResponse, milkyClientOptions, forwardMilkyClientEvents, MilkyClient, } from './client.js';
|
|
10
|
-
import { buildSendAction, buildWsConnectOptions, callApi, extractInboundAudioUrl, formatInboundContent, formatInboundMessageId, formatInboundSegments, formatOutboundMessageId, formatOutboundSegments, isMentioned, milkyInboundConversation, parseMessageReceiveData, parseMilkyMessageId, senderNickname, } from './protocol.js';
|
|
10
|
+
import { buildSendAction, buildWsConnectOptions, callApi, extractInboundAudioUrl, formatInboundContent, formatInboundMessageId, formatInboundSegments, formatOutboundMessageId, formatOutboundSegments, isMentioned, milkyInboundConversation, milkySenderStanding, parseMessageReceiveData, parseMilkyMessageId, senderNickname, } from './protocol.js';
|
|
11
11
|
const WS_OPEN = 1;
|
|
12
12
|
export class MilkyWsEndpoint extends MilkyClient {
|
|
13
13
|
[endpointSpec];
|
|
@@ -134,6 +134,7 @@ export class MilkyWsEndpoint extends MilkyClient {
|
|
|
134
134
|
const segments = formatInboundSegments(data);
|
|
135
135
|
const audioUrl = extractInboundAudioUrl(data);
|
|
136
136
|
const nickname = senderNickname(data);
|
|
137
|
+
const platform = milkySenderStanding(data);
|
|
137
138
|
const mentioned = isMentioned(data, event.self_id);
|
|
138
139
|
void this[endpointSpec].events.message({
|
|
139
140
|
event,
|
|
@@ -142,7 +143,7 @@ export class MilkyWsEndpoint extends MilkyClient {
|
|
|
142
143
|
message: { conversation, id: formatInboundMessageId(data) },
|
|
143
144
|
content,
|
|
144
145
|
segments,
|
|
145
|
-
sender: { id: String(data.sender_id), name: nickname },
|
|
146
|
+
sender: { id: String(data.sender_id), name: nickname, ...(platform ? { platform } : {}) },
|
|
146
147
|
endpointId: this.#options.config.id,
|
|
147
148
|
...(mentioned ? { mentioned: true } : {}),
|
|
148
149
|
metadata: Object.freeze({
|
package/lib/ws-types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WsConnection } from '@zhin.js/host-http';
|
|
1
2
|
/** Minimal WS surface used by the endpoint (real `ws` or test mock). */
|
|
2
3
|
export interface MilkyWsSocket {
|
|
3
4
|
readonly readyState: number;
|
|
@@ -9,3 +10,8 @@ export interface MilkyWsSocket {
|
|
|
9
10
|
export interface MilkyWsCreateOptions {
|
|
10
11
|
readonly headers?: Record<string, string>;
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* A Host reverse-WebSocket connection seen through the MilkyWsSocket surface. Protocol
|
|
15
|
+
* keepalive (ping / pong) belongs to the Host transport, so `ping` is absent.
|
|
16
|
+
*/
|
|
17
|
+
export declare function socketOfConnection(connection: WsConnection): MilkyWsSocket;
|
package/lib/ws-types.js
CHANGED
|
@@ -1 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A Host reverse-WebSocket connection seen through the MilkyWsSocket surface. Protocol
|
|
3
|
+
* keepalive (ping / pong) belongs to the Host transport, so `ping` is absent.
|
|
4
|
+
*/
|
|
5
|
+
export function socketOfConnection(connection) {
|
|
6
|
+
let readyState = 1;
|
|
7
|
+
connection.onClose(() => { readyState = 3; });
|
|
8
|
+
const subscriptions = new Map();
|
|
9
|
+
const off = (_event, listener) => {
|
|
10
|
+
subscriptions.get(listener)?.();
|
|
11
|
+
subscriptions.delete(listener);
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
get readyState() { return readyState; },
|
|
15
|
+
send: (data) => connection.send(data),
|
|
16
|
+
close: (code, reason) => connection.close(code, reason),
|
|
17
|
+
on(event, listener) {
|
|
18
|
+
if (event === 'message') {
|
|
19
|
+
subscriptions.set(listener, connection.onMessage((data) => {
|
|
20
|
+
listener(typeof data === 'string' ? data : Buffer.from(data));
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
else if (event === 'close') {
|
|
24
|
+
subscriptions.set(listener, connection.onClose((code, reason) => listener(code, reason)));
|
|
25
|
+
}
|
|
26
|
+
// 'error' surfaces as a close on a WsConnection; 'open' already happened.
|
|
27
|
+
},
|
|
28
|
+
off,
|
|
29
|
+
removeListener: off,
|
|
30
|
+
};
|
|
31
|
+
}
|
package/lib/wss-endpoint.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Milky reverse WSS endpoint — httpHostToken WS upgrade inbound + baseUrl HTTP API outbound.
|
|
3
3
|
*/
|
|
4
4
|
import { endpointSpec, type Endpoint, type EndpointSpecification } from 'zhin.js/adapter';
|
|
5
|
-
import type
|
|
5
|
+
import { type HttpHost } from '@zhin.js/host-http';
|
|
6
6
|
import type { CapabilityId } from 'zhin.js';
|
|
7
7
|
import { MilkyClient } from './client.js';
|
|
8
8
|
import { callApi, type MilkyWssConfig } from './protocol.js';
|
package/lib/wss-endpoint.js
CHANGED
|
@@ -3,11 +3,12 @@ import { createMilkySelfProfile } from './self-profile.js';
|
|
|
3
3
|
* Milky reverse WSS endpoint — httpHostToken WS upgrade inbound + baseUrl HTTP API outbound.
|
|
4
4
|
*/
|
|
5
5
|
import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
|
|
6
|
+
import { verifyProtocolAccessToken } from '@zhin.js/host-http';
|
|
6
7
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
7
|
-
import { verifyMilkyAccessToken } from './milky-auth.js';
|
|
8
8
|
import { createMilkyEndpointManagement } from './endpoint-management.js';
|
|
9
9
|
import { createMilkyActionCaller, readMilkyResponse, milkyClientOptions, forwardMilkyClientEvents, MilkyClient } from './client.js';
|
|
10
|
-
import { buildSendAction, callApi, extractInboundAudioUrl, formatInboundContent, formatInboundMessageId, formatInboundSegments, formatOutboundMessageId, formatOutboundSegments, isMentioned, milkyInboundConversation, parseMessageReceiveData, parseMilkyMessageId, senderNickname, } from './protocol.js';
|
|
10
|
+
import { buildSendAction, callApi, extractInboundAudioUrl, formatInboundContent, formatInboundMessageId, formatInboundSegments, formatOutboundMessageId, formatOutboundSegments, isMentioned, milkyInboundConversation, milkySenderStanding, parseMessageReceiveData, parseMilkyMessageId, senderNickname, } from './protocol.js';
|
|
11
|
+
import { socketOfConnection } from './ws-types.js';
|
|
11
12
|
const WS_OPEN = 1;
|
|
12
13
|
export class MilkyWssEndpoint extends MilkyClient {
|
|
13
14
|
[endpointSpec];
|
|
@@ -61,7 +62,17 @@ export class MilkyWssEndpoint extends MilkyClient {
|
|
|
61
62
|
if (this.#started)
|
|
62
63
|
return;
|
|
63
64
|
this.#started = true;
|
|
64
|
-
|
|
65
|
+
if (!this.#options.config.access_token) {
|
|
66
|
+
// 未配 access_token 时只接受本机回环连接(verifyProtocolAccessToken)。
|
|
67
|
+
this.#logger.warn(formatCompact({
|
|
68
|
+
endpoint: this.#options.config.id,
|
|
69
|
+
mode: 'wss',
|
|
70
|
+
ok: false,
|
|
71
|
+
error: 'missing access_token: only loopback peers are accepted',
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
// The peer authenticates with this endpoint's own access_token, not a Console credential.
|
|
75
|
+
const handle = this.#options.http.ws(this.#options.config.path, { auth: 'protocol' });
|
|
65
76
|
this.#wsRelease = handle.onConnection((connection) => {
|
|
66
77
|
this.#connectionTask = this.#connectionTask
|
|
67
78
|
.then(() => this.#acceptConnection(connection))
|
|
@@ -137,6 +148,7 @@ export class MilkyWssEndpoint extends MilkyClient {
|
|
|
137
148
|
const segments = formatInboundSegments(data);
|
|
138
149
|
const audioUrl = extractInboundAudioUrl(data);
|
|
139
150
|
const nickname = senderNickname(data);
|
|
151
|
+
const platform = milkySenderStanding(data);
|
|
140
152
|
const mentioned = isMentioned(data, event.self_id);
|
|
141
153
|
void this[endpointSpec].events.message({
|
|
142
154
|
event,
|
|
@@ -145,7 +157,7 @@ export class MilkyWssEndpoint extends MilkyClient {
|
|
|
145
157
|
message: { conversation, id: formatInboundMessageId(data) },
|
|
146
158
|
content,
|
|
147
159
|
segments,
|
|
148
|
-
sender: { id: String(data.sender_id) },
|
|
160
|
+
sender: { id: String(data.sender_id), ...(platform ? { platform } : {}) },
|
|
149
161
|
endpointId: this.#options.config.id,
|
|
150
162
|
...(mentioned ? { mentioned: true } : {}),
|
|
151
163
|
metadata: Object.freeze({
|
|
@@ -166,11 +178,11 @@ export class MilkyWssEndpoint extends MilkyClient {
|
|
|
166
178
|
});
|
|
167
179
|
}
|
|
168
180
|
async #acceptConnection(connection) {
|
|
169
|
-
if (!
|
|
170
|
-
connection.
|
|
181
|
+
if (!verifyProtocolAccessToken(this.#options.config.access_token, connection)) {
|
|
182
|
+
connection.close(4003, 'Unauthorized');
|
|
171
183
|
return;
|
|
172
184
|
}
|
|
173
|
-
const socket = connection
|
|
185
|
+
const socket = socketOfConnection(connection);
|
|
174
186
|
await this.#connectionLifecycle.stop();
|
|
175
187
|
await this.#connectionLifecycle.start(async (lifecycleHandle) => {
|
|
176
188
|
this.#ws = socket;
|
|
@@ -190,15 +202,7 @@ export class MilkyWssEndpoint extends MilkyClient {
|
|
|
190
202
|
if (this.#ws === socket)
|
|
191
203
|
this.#ws = undefined;
|
|
192
204
|
});
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
if (socket.readyState === WS_OPEN)
|
|
196
|
-
socket.ping?.();
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
/* ignore */
|
|
200
|
-
}
|
|
201
|
-
}, this.#options.config.heartbeat_interval);
|
|
205
|
+
// Dead-peer detection is the Host transport's keepalive (protocol ping / pong).
|
|
202
206
|
socket.on('message', () => {
|
|
203
207
|
this.#connectionLifecycle.notifyHeartbeatAck();
|
|
204
208
|
});
|
|
@@ -215,7 +219,7 @@ export class MilkyWssEndpoint extends MilkyClient {
|
|
|
215
219
|
this.#logger.debug(formatCompact({
|
|
216
220
|
endpoint: this.#options.config.id,
|
|
217
221
|
mode: 'wss',
|
|
218
|
-
peer: connection.
|
|
222
|
+
peer: connection.remoteAddress,
|
|
219
223
|
}));
|
|
220
224
|
}
|
|
221
225
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-milky",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
4
4
|
"description": "Zhin.js Milky adapter for Plugin Runtime (WebSocket client)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"schema.json",
|
|
20
20
|
"src",
|
|
21
21
|
"lib",
|
|
22
|
-
"skills",
|
|
23
22
|
"README.md",
|
|
24
23
|
"CHANGELOG.md",
|
|
25
24
|
"PERMITS.md"
|
|
@@ -36,53 +35,42 @@
|
|
|
36
35
|
"license": "MIT",
|
|
37
36
|
"repository": {
|
|
38
37
|
"type": "git",
|
|
39
|
-
"url": "https://github.com/zhinjs/zhin",
|
|
38
|
+
"url": "git+https://github.com/zhinjs/zhin.git",
|
|
40
39
|
"directory": "plugins/adapters/milky"
|
|
41
40
|
},
|
|
42
41
|
"dependencies": {
|
|
43
42
|
"@imhelper/milky-v1": "1.0.7",
|
|
44
43
|
"imhelper": "1.0.7",
|
|
45
44
|
"ws": "^8.21.1",
|
|
46
|
-
"@zhin.js/adapter": "1.1.
|
|
47
|
-
"@zhin.js/console-contract": "1.1.
|
|
48
|
-
"@zhin.js/core": "1.
|
|
49
|
-
"@zhin.js/feature-kit": "1.1.
|
|
50
|
-
"@zhin.js/host-http": "1.
|
|
51
|
-
"@zhin.js/im-contract": "1.1.
|
|
52
|
-
"@zhin.js/logger": "1.1.
|
|
53
|
-
"@zhin.js/page": "1.1.
|
|
54
|
-
"@zhin.js/skill": "1.1.16"
|
|
45
|
+
"@zhin.js/adapter": "1.1.33",
|
|
46
|
+
"@zhin.js/console-contract": "1.1.6",
|
|
47
|
+
"@zhin.js/core": "1.2.0",
|
|
48
|
+
"@zhin.js/feature-kit": "1.1.16",
|
|
49
|
+
"@zhin.js/host-http": "1.2.0",
|
|
50
|
+
"@zhin.js/im-contract": "1.1.14",
|
|
51
|
+
"@zhin.js/logger": "1.1.3",
|
|
52
|
+
"@zhin.js/page": "1.1.18"
|
|
55
53
|
},
|
|
56
54
|
"devDependencies": {
|
|
57
55
|
"@types/node": "^26.1.2",
|
|
58
56
|
"@types/ws": "^8.18.1",
|
|
59
57
|
"typescript": "^6.0.3",
|
|
60
58
|
"vitest": "^4.1.10",
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"@zhin.js/host-http": "1.1.17",
|
|
64
|
-
"zhin.js": "1.1.24"
|
|
59
|
+
"@zhin.js/host-http": "1.2.0",
|
|
60
|
+
"zhin.js": "1.1.26"
|
|
65
61
|
},
|
|
66
62
|
"peerDependencies": {
|
|
67
|
-
"
|
|
68
|
-
"@zhin.js/
|
|
69
|
-
"@zhin.js/
|
|
70
|
-
"
|
|
71
|
-
"@zhin.js/core": "^1.1.59",
|
|
72
|
-
"zhin.js": "^1.1.24"
|
|
63
|
+
"@zhin.js/adapter": "^1.1.33",
|
|
64
|
+
"@zhin.js/command": "^1.2.0",
|
|
65
|
+
"@zhin.js/core": "^1.2.0",
|
|
66
|
+
"zhin.js": "^1.1.26"
|
|
73
67
|
},
|
|
74
68
|
"peerDependenciesMeta": {
|
|
75
|
-
"@zhin.js/agent": {
|
|
76
|
-
"optional": true
|
|
77
|
-
},
|
|
78
69
|
"@zhin.js/command": {
|
|
79
70
|
"optional": true
|
|
80
71
|
},
|
|
81
72
|
"zhin.js": {
|
|
82
73
|
"optional": true
|
|
83
|
-
},
|
|
84
|
-
"zod": {
|
|
85
|
-
"optional": true
|
|
86
74
|
}
|
|
87
75
|
},
|
|
88
76
|
"author": {
|
|
@@ -112,10 +100,6 @@
|
|
|
112
100
|
"package": "@zhin.js/command",
|
|
113
101
|
"api": "^1.0.0"
|
|
114
102
|
},
|
|
115
|
-
{
|
|
116
|
-
"package": "@zhin.js/skill",
|
|
117
|
-
"api": "^1.0.0"
|
|
118
|
-
},
|
|
119
103
|
{
|
|
120
104
|
"package": "@zhin.js/page",
|
|
121
105
|
"api": "^1.0.0"
|
|
@@ -126,6 +110,6 @@
|
|
|
126
110
|
"scripts": {
|
|
127
111
|
"build": "tsc",
|
|
128
112
|
"clean": "rimraf lib",
|
|
129
|
-
"test": "
|
|
113
|
+
"test": "vitest run --root ../../.. plugins/adapters/milky/tests"
|
|
130
114
|
}
|
|
131
115
|
}
|
package/schema.json
CHANGED
|
@@ -22,34 +22,6 @@
|
|
|
22
22
|
"type": "number",
|
|
23
23
|
"default": 30000
|
|
24
24
|
},
|
|
25
|
-
"master": {
|
|
26
|
-
"type": [
|
|
27
|
-
"string",
|
|
28
|
-
"number"
|
|
29
|
-
],
|
|
30
|
-
"description": "框架 master(QQ uin;AI/工具权限、endpoint 管理)。endpoints[i].master 可逐项覆盖",
|
|
31
|
-
"x-i18n": {
|
|
32
|
-
"en": {
|
|
33
|
-
"description": "Framework masters (QQ uin; AI/tool permissions and endpoint management). endpoints[i].master can override this value."
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
"trusted": {
|
|
38
|
-
"type": "array",
|
|
39
|
-
"items": {
|
|
40
|
-
"type": [
|
|
41
|
-
"string",
|
|
42
|
-
"number"
|
|
43
|
-
],
|
|
44
|
-
"description": "Trusted QQ uin"
|
|
45
|
-
},
|
|
46
|
-
"description": "框架 trusted 用户列表(弱于 master)。endpoints[i].trusted 可逐项追加",
|
|
47
|
-
"x-i18n": {
|
|
48
|
-
"en": {
|
|
49
|
-
"description": "Framework trusted users, below master authority. endpoints[i].trusted can add users."
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
25
|
"endpoints": {
|
|
54
26
|
"type": "array",
|
|
55
27
|
"minItems": 1,
|
|
@@ -58,34 +30,6 @@
|
|
|
58
30
|
"type": "object",
|
|
59
31
|
"additionalProperties": false,
|
|
60
32
|
"properties": {
|
|
61
|
-
"master": {
|
|
62
|
-
"type": [
|
|
63
|
-
"string",
|
|
64
|
-
"number"
|
|
65
|
-
],
|
|
66
|
-
"description": "本 endpoint 的框架 master(QQ uin);覆盖顶层 master",
|
|
67
|
-
"x-i18n": {
|
|
68
|
-
"en": {
|
|
69
|
-
"description": "Framework masters for this endpoint (QQ uin), overriding the top-level master list."
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
"trusted": {
|
|
74
|
-
"type": "array",
|
|
75
|
-
"items": {
|
|
76
|
-
"type": [
|
|
77
|
-
"string",
|
|
78
|
-
"number"
|
|
79
|
-
],
|
|
80
|
-
"description": "Trusted QQ uin"
|
|
81
|
-
},
|
|
82
|
-
"description": "本 endpoint 的 trusted 列表",
|
|
83
|
-
"x-i18n": {
|
|
84
|
-
"en": {
|
|
85
|
-
"description": "Trusted users for this endpoint."
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
33
|
"connection": {
|
|
90
34
|
"type": "string",
|
|
91
35
|
"enum": [
|
package/src/console-spec.ts
CHANGED
|
@@ -7,9 +7,39 @@ export const milkyConsoleSpec = {
|
|
|
7
7
|
adapterKey: 'milky',
|
|
8
8
|
adapterDisplayName: 'Milky',
|
|
9
9
|
fields: [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
{
|
|
11
|
+
key: 'baseUrl',
|
|
12
|
+
required: true,
|
|
13
|
+
label: 'Milky 服务地址',
|
|
14
|
+
labelI18n: { en: 'Milky Base URL' },
|
|
15
|
+
group: '连接模式',
|
|
16
|
+
groupI18n: { en: 'Connection Mode' },
|
|
17
|
+
placeholder: 'http://127.0.0.1:3000',
|
|
18
|
+
description: 'Milky HTTP API base URL',
|
|
19
|
+
descriptionI18n: { en: 'Milky HTTP API base URL' },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: 'path',
|
|
23
|
+
label: '事件回调 / 反向 WS 路径',
|
|
24
|
+
labelI18n: { en: 'Webhook / Reverse WS Path' },
|
|
25
|
+
group: '连接模式',
|
|
26
|
+
groupI18n: { en: 'Connection Mode' },
|
|
27
|
+
placeholder: '/milky/event',
|
|
28
|
+
description: 'webhook / reverse-wss 路径',
|
|
29
|
+
descriptionI18n: { en: 'Webhook / reverse-wss path' },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
key: 'access_token',
|
|
33
|
+
env: true,
|
|
34
|
+
label: 'Access Token (访问令牌)',
|
|
35
|
+
labelI18n: { en: 'Access Token' },
|
|
36
|
+
group: '安全认证',
|
|
37
|
+
groupI18n: { en: 'Authentication' },
|
|
38
|
+
placeholder: 'Milky 服务端配置的 Access Token',
|
|
39
|
+
placeholderI18n: { en: 'Access token configured on Milky server' },
|
|
40
|
+
description: 'Milky 接口鉴权令牌(Access Token)',
|
|
41
|
+
descriptionI18n: { en: 'Milky access token' },
|
|
42
|
+
},
|
|
13
43
|
] as readonly AdapterConsoleField[],
|
|
14
44
|
docsUrl: 'https://zhin.js.org/adapters/milky',
|
|
15
45
|
readPermission: 'milky:read',
|
|
@@ -17,16 +17,49 @@ import {
|
|
|
17
17
|
type EndpointGroupRequest,
|
|
18
18
|
type PendingRpcRequest,
|
|
19
19
|
} from 'zhin.js/adapter';
|
|
20
|
+
import { standingFromMemberRole, type PlatformStanding, type SelfStandingRequest } from '@zhin.js/im-contract';
|
|
20
21
|
|
|
21
22
|
/** 管理面只依赖 endpoint 的 callApi(ws/wss/sse/webhook 四种传输各自实现)。 */
|
|
22
23
|
export interface MilkyManagementCaller {
|
|
23
24
|
callApi(request: PendingRpcRequest): Promise<unknown>;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/** How long one `get_login_info` answer is reused (bursts only; see inspectSelfStanding). */
|
|
28
|
+
const SELF_ID_TTL_MS = 5_000;
|
|
29
|
+
|
|
26
30
|
export function createMilkyEndpointManagement(
|
|
27
31
|
endpoint: MilkyManagementCaller,
|
|
28
32
|
): EndpointManagement {
|
|
33
|
+
// The login account can change across reconnects (or a different account on the same
|
|
34
|
+
// connection), so `get_login_info` is not remembered for the endpoint's lifetime: only briefly,
|
|
35
|
+
// to coalesce bursts, and never across a failed lookup.
|
|
36
|
+
let login: { readonly id: string; readonly at: number } | undefined;
|
|
37
|
+
const loginId = async (signal: AbortSignal | undefined): Promise<string | undefined> => {
|
|
38
|
+
if (login && Date.now() - login.at < SELF_ID_TTL_MS) return login.id;
|
|
39
|
+
login = undefined;
|
|
40
|
+
const info = asRecord(await endpoint.callApi({ action: 'get_login_info', signal }));
|
|
41
|
+
const id = info.uin ?? info.user_id;
|
|
42
|
+
const value = (typeof id === 'number' && id > 0) || (typeof id === 'string' && id.trim() !== '' && id !== '0') ? String(id) : undefined;
|
|
43
|
+
if (value) login = { id: value, at: Date.now() };
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
29
46
|
return Object.freeze<EndpointManagement>({
|
|
47
|
+
/** ADR 0056 §1: the Bot's own group role (`get_login_info`, then `get_group_member_info` self). */
|
|
48
|
+
async inspectSelfStanding({ conversation, signal }: SelfStandingRequest): Promise<PlatformStanding | undefined> {
|
|
49
|
+
if (conversation.kind !== 'group') return undefined;
|
|
50
|
+
const selfId = await loginId(signal);
|
|
51
|
+
if (!selfId) return undefined;
|
|
52
|
+
let data: Record<string, unknown>;
|
|
53
|
+
try {
|
|
54
|
+
data = asRecord(await endpoint.callApi({ action: 'get_group_member_info', signal, params: {
|
|
55
|
+
group_id: toNumberId(conversation.id, 'group_id'), user_id: toNumberId(selfId, 'user_id'), no_cache: true,
|
|
56
|
+
} }));
|
|
57
|
+
} catch (error) {
|
|
58
|
+
login = undefined;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return standingFromMemberRole(asRecord(data.member).role ?? data.role);
|
|
62
|
+
},
|
|
30
63
|
async listFriends(request: EndpointManagementRequest = {}): Promise<readonly EndpointFriend[]> {
|
|
31
64
|
const data = asRecord(await endpoint.callApi({ action: 'get_friend_list', signal: request.signal }));
|
|
32
65
|
return toArray(data.friends).map((value) => {
|
package/src/milky-auth.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { verifyProtocolAccessToken, type ProtocolAuthRequest } from '@zhin.js/host-http';
|
|
2
2
|
|
|
3
|
-
export function verifyMilkyAccessToken(accessToken: string | undefined, request:
|
|
3
|
+
export function verifyMilkyAccessToken(accessToken: string | undefined, request: ProtocolAuthRequest): boolean {
|
|
4
|
+
// Webhook semantics: no configured token admits any caller (reverse sockets use verifyProtocolAccessToken).
|
|
4
5
|
if (!accessToken) return true;
|
|
5
|
-
|
|
6
|
-
if (auth === `Bearer ${accessToken}`) return true;
|
|
7
|
-
try {
|
|
8
|
-
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
9
|
-
if (url.searchParams.get('access_token') === accessToken) return true;
|
|
10
|
-
} catch {
|
|
11
|
-
/* ignore */
|
|
12
|
-
}
|
|
13
|
-
return false;
|
|
6
|
+
return verifyProtocolAccessToken(accessToken, request);
|
|
14
7
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
|
|
|
5
5
|
* Spec: https://milky.ntqqrev.org/
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { EndpointDeliveryError, isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
8
|
+
import { EndpointDeliveryError, isMediaRef, type ConversationRef, type PlatformStanding } from '@zhin.js/im-contract';
|
|
9
9
|
import type { Segment } from '@zhin.js/core/runtime';
|
|
10
10
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
11
11
|
|
|
@@ -405,6 +405,16 @@ export function extractInboundAudioUrl(data: MilkyIncomingMessage): string | und
|
|
|
405
405
|
}
|
|
406
406
|
|
|
407
407
|
/** 发送者显示名(群名片/群昵称/好友昵称);没有可靠的显示名时返回 undefined。 */
|
|
408
|
+
/**
|
|
409
|
+
* ADR 0056 §1:群消息 `group_member.role`(owner / admin / member)→ `sender.platform.standing`。
|
|
410
|
+
* 私聊 / 临时会话与未知角色词不上报;Milky 没有平台原生角色与权限位。
|
|
411
|
+
*/
|
|
412
|
+
export function milkySenderStanding(data: MilkyIncomingMessage): PlatformStanding | undefined {
|
|
413
|
+
if (data.message_scene !== 'group') return undefined;
|
|
414
|
+
const role = data.group_member?.role;
|
|
415
|
+
return role === 'owner' || role === 'admin' || role === 'member' ? { standing: role } : undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
408
418
|
export function senderNickname(data: MilkyIncomingMessage): string | undefined {
|
|
409
419
|
const isGroup = data.message_scene === 'group';
|
|
410
420
|
const name = isGroup
|
package/src/sse-client.ts
CHANGED
|
@@ -10,8 +10,15 @@ export interface SseClientOptions {
|
|
|
10
10
|
readonly onMessage: (data: string) => void;
|
|
11
11
|
readonly onError?: (error: Error) => void;
|
|
12
12
|
readonly onOpen?: () => void;
|
|
13
|
+
/** Abort and report an error when no bytes arrive for this long (default 5 min; 0 disables). */
|
|
14
|
+
readonly idleTimeoutMs?: number;
|
|
15
|
+
/** Abort and report an error when an unterminated frame grows past this many characters (default 8 Mi). */
|
|
16
|
+
readonly maxBufferLength?: number;
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
export const SSE_DEFAULT_IDLE_TIMEOUT_MS = 300_000;
|
|
20
|
+
export const SSE_DEFAULT_MAX_BUFFER_LENGTH = 8 * 1024 * 1024;
|
|
21
|
+
|
|
15
22
|
export interface SseClientHandle {
|
|
16
23
|
readonly closed: Promise<void>;
|
|
17
24
|
close(): void;
|
|
@@ -23,6 +30,20 @@ export function openSseStream(options: SseClientOptions): SseClientHandle {
|
|
|
23
30
|
let settle!: () => void;
|
|
24
31
|
const closed = new Promise<void>((resolve) => { settle = resolve; });
|
|
25
32
|
|
|
33
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? SSE_DEFAULT_IDLE_TIMEOUT_MS;
|
|
34
|
+
const maxBufferLength = options.maxBufferLength ?? SSE_DEFAULT_MAX_BUFFER_LENGTH;
|
|
35
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
36
|
+
// Set before aborting our own controller so the abort is still reported as an error.
|
|
37
|
+
let failure: Error | undefined;
|
|
38
|
+
const armIdleTimer = (): void => {
|
|
39
|
+
if (idleTimeoutMs <= 0) return;
|
|
40
|
+
clearTimeout(idleTimer);
|
|
41
|
+
idleTimer = setTimeout(() => {
|
|
42
|
+
failure = new Error(`SSE idle for ${idleTimeoutMs}ms`);
|
|
43
|
+
controller.abort();
|
|
44
|
+
}, idleTimeoutMs);
|
|
45
|
+
};
|
|
46
|
+
|
|
26
47
|
const run = async (): Promise<void> => {
|
|
27
48
|
try {
|
|
28
49
|
const response = await fetchImpl(options.url, {
|
|
@@ -43,20 +64,30 @@ export function openSseStream(options: SseClientOptions): SseClientHandle {
|
|
|
43
64
|
const reader = response.body.getReader();
|
|
44
65
|
const decoder = new TextDecoder();
|
|
45
66
|
let buffer = '';
|
|
67
|
+
armIdleTimer();
|
|
46
68
|
while (true) {
|
|
47
69
|
const { done, value } = await reader.read();
|
|
48
70
|
if (done) break;
|
|
71
|
+
armIdleTimer();
|
|
49
72
|
buffer += decoder.decode(value, { stream: true });
|
|
50
73
|
buffer = consumeSseBuffer(buffer, options.onMessage);
|
|
74
|
+
if (buffer.length > maxBufferLength) {
|
|
75
|
+
throw new Error(`SSE frame exceeds ${maxBufferLength} characters`);
|
|
76
|
+
}
|
|
51
77
|
}
|
|
52
78
|
buffer += decoder.decode();
|
|
53
79
|
consumeSseBuffer(`${buffer}\n\n`, options.onMessage);
|
|
54
80
|
} catch (error) {
|
|
55
|
-
if (
|
|
81
|
+
if (failure) {
|
|
82
|
+
options.onError?.(failure);
|
|
83
|
+
} else if (!controller.signal.aborted) {
|
|
56
84
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
57
85
|
options.onError?.(err);
|
|
58
86
|
}
|
|
59
87
|
} finally {
|
|
88
|
+
clearTimeout(idleTimer);
|
|
89
|
+
// Release the connection after an oversize frame; a no-op when already closed.
|
|
90
|
+
controller.abort();
|
|
60
91
|
settle();
|
|
61
92
|
}
|
|
62
93
|
};
|
|
@@ -71,12 +102,24 @@ export function openSseStream(options: SseClientOptions): SseClientHandle {
|
|
|
71
102
|
};
|
|
72
103
|
}
|
|
73
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Normalize CRLF / lone CR line endings to LF (all three are valid in
|
|
107
|
+
* `text/event-stream`). A trailing CR is kept: it may be half of a CRLF split
|
|
108
|
+
* across chunks.
|
|
109
|
+
*/
|
|
110
|
+
function normalizeSseNewlines(buffer: string): string {
|
|
111
|
+
if (!buffer.includes('\r')) return buffer;
|
|
112
|
+
const tail = buffer.endsWith('\r') ? '\r' : '';
|
|
113
|
+
const body = tail ? buffer.slice(0, -1) : buffer;
|
|
114
|
+
return body.replace(/\r\n?/g, '\n') + tail;
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
/** Exported for unit tests. */
|
|
75
118
|
export function consumeSseBuffer(
|
|
76
119
|
buffer: string,
|
|
77
120
|
onMessage: (data: string) => void,
|
|
78
121
|
): string {
|
|
79
|
-
let rest = buffer;
|
|
122
|
+
let rest = normalizeSseNewlines(buffer);
|
|
80
123
|
while (true) {
|
|
81
124
|
const sep = rest.indexOf('\n\n');
|
|
82
125
|
if (sep < 0) return rest;
|
package/src/sse-endpoint.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
formatOutboundSegments,
|
|
29
29
|
isMentioned,
|
|
30
30
|
milkyInboundConversation,
|
|
31
|
+
milkySenderStanding,
|
|
31
32
|
parseMessageReceiveData,
|
|
32
33
|
parseMilkyMessageId,
|
|
33
34
|
senderNickname,
|
|
@@ -170,6 +171,7 @@ export class MilkySseEndpoint extends MilkyClient implements Endpoint {
|
|
|
170
171
|
const segments = formatInboundSegments(data);
|
|
171
172
|
const audioUrl = extractInboundAudioUrl(data);
|
|
172
173
|
const nickname = senderNickname(data);
|
|
174
|
+
const platform = milkySenderStanding(data);
|
|
173
175
|
const mentioned = isMentioned(data, event.self_id);
|
|
174
176
|
void this[endpointSpec].events.message({
|
|
175
177
|
event,
|
|
@@ -178,7 +180,7 @@ export class MilkySseEndpoint extends MilkyClient implements Endpoint {
|
|
|
178
180
|
message: { conversation, id: formatInboundMessageId(data) },
|
|
179
181
|
content,
|
|
180
182
|
segments,
|
|
181
|
-
sender: { id: String(data.sender_id), name: nickname },
|
|
183
|
+
sender: { id: String(data.sender_id), name: nickname, ...(platform ? { platform } : {}) },
|
|
182
184
|
endpointId: this.#options.config.id,
|
|
183
185
|
...(mentioned ? { mentioned: true } : {}),
|
|
184
186
|
metadata: Object.freeze({
|