@zhin.js/adapter-milky 1.1.26 → 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 +146 -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/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/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-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.25"
|
|
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.60",
|
|
72
|
-
"zhin.js": "^1.1.25"
|
|
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": [
|
|
@@ -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({
|
package/src/webhook-endpoint.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { createMilkySelfProfile } from './self-profile.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Milky webhook endpoint — httpHostToken POST inbound + baseUrl HTTP API outbound.
|
|
4
4
|
*/
|
|
5
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
5
|
import { acceptHttpIngress } from 'imhelper';
|
|
7
6
|
import {
|
|
8
7
|
createEndpointSpecification, endpointSpec, type Endpoint, type EndpointSpecification,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
formatOutboundSegments,
|
|
29
28
|
isMentioned,
|
|
30
29
|
milkyInboundConversation,
|
|
30
|
+
milkySenderStanding,
|
|
31
31
|
parseMessageReceiveData,
|
|
32
32
|
parseMilkyMessageId,
|
|
33
33
|
senderNickname,
|
|
@@ -150,6 +150,7 @@ export class MilkyWebhookEndpoint extends MilkyClient implements Endpoint {
|
|
|
150
150
|
const segments = formatInboundSegments(data);
|
|
151
151
|
const audioUrl = extractInboundAudioUrl(data);
|
|
152
152
|
const nickname = senderNickname(data);
|
|
153
|
+
const platform = milkySenderStanding(data);
|
|
153
154
|
const mentioned = isMentioned(data, event.self_id);
|
|
154
155
|
void this[endpointSpec].events.message({
|
|
155
156
|
event,
|
|
@@ -158,7 +159,7 @@ export class MilkyWebhookEndpoint extends MilkyClient implements Endpoint {
|
|
|
158
159
|
message: { conversation, id: formatInboundMessageId(data) },
|
|
159
160
|
content,
|
|
160
161
|
segments,
|
|
161
|
-
sender: { id: String(data.sender_id), name: nickname },
|
|
162
|
+
sender: { id: String(data.sender_id), name: nickname, ...(platform ? { platform } : {}) },
|
|
162
163
|
endpointId: this.#options.config.id,
|
|
163
164
|
...(mentioned ? { mentioned: true } : {}),
|
|
164
165
|
metadata: Object.freeze({
|
|
@@ -182,34 +183,26 @@ export class MilkyWebhookEndpoint extends MilkyClient implements Endpoint {
|
|
|
182
183
|
#setupRoutes(): void {
|
|
183
184
|
const path = this.#options.config.path;
|
|
184
185
|
this.#routeReleases.push(
|
|
185
|
-
this.#options.http.route('POST', path,
|
|
186
|
-
await this.#handleWebhook(request, response);
|
|
187
|
-
}, { summary: 'Milky webhook callback', tags: ['milky'] }),
|
|
186
|
+
this.#options.http.route('POST', path, (request) => this.#handleWebhook(request), { summary: 'Milky webhook callback', tags: ['milky'] }),
|
|
188
187
|
);
|
|
189
188
|
}
|
|
190
189
|
|
|
191
|
-
async #handleWebhook(request:
|
|
190
|
+
async #handleWebhook(request: Request): Promise<Response> {
|
|
192
191
|
try {
|
|
193
192
|
if (!verifyMilkyAccessToken(this.#options.config.access_token, request)) {
|
|
194
|
-
|
|
195
|
-
response.end(JSON.stringify({ message: 'Unauthorized' }));
|
|
196
|
-
return;
|
|
193
|
+
return new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
197
194
|
}
|
|
198
195
|
if (!this.#open) {
|
|
199
|
-
|
|
200
|
-
response.end(JSON.stringify({ status: 'ok' }));
|
|
201
|
-
return;
|
|
196
|
+
return new Response(JSON.stringify({ status: 'ok' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
202
197
|
}
|
|
203
|
-
await acceptHttpIngress<MilkyEvent>(request,
|
|
198
|
+
const result = await acceptHttpIngress<MilkyEvent>(request, undefined, event => {
|
|
204
199
|
void this[endpointSpec].events.run(() => this.ingest(event as Parameters<MilkyClient['ingest']>[0]))
|
|
205
200
|
.catch(error => this.#warnPlatformEvent(error));
|
|
206
201
|
});
|
|
202
|
+
return new Response(JSON.stringify(result.body), { status: result.status, headers: result.headers });
|
|
207
203
|
} catch (error) {
|
|
208
204
|
this.#logger.error('Milky webhook error:', error);
|
|
209
|
-
|
|
210
|
-
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
211
|
-
response.end(JSON.stringify({ message: 'Internal Server Error' }));
|
|
212
|
-
}
|
|
205
|
+
return new Response(JSON.stringify({ message: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
213
206
|
}
|
|
214
207
|
}
|
|
215
208
|
}
|
package/src/ws-endpoint.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
formatOutboundSegments,
|
|
35
35
|
isMentioned,
|
|
36
36
|
milkyInboundConversation,
|
|
37
|
+
milkySenderStanding,
|
|
37
38
|
parseMessageReceiveData,
|
|
38
39
|
parseMilkyMessageId,
|
|
39
40
|
senderNickname,
|
|
@@ -183,6 +184,7 @@ export class MilkyWsEndpoint extends MilkyClient implements Endpoint {
|
|
|
183
184
|
const segments = formatInboundSegments(data);
|
|
184
185
|
const audioUrl = extractInboundAudioUrl(data);
|
|
185
186
|
const nickname = senderNickname(data);
|
|
187
|
+
const platform = milkySenderStanding(data);
|
|
186
188
|
const mentioned = isMentioned(data, event.self_id);
|
|
187
189
|
void this[endpointSpec].events.message({
|
|
188
190
|
event,
|
|
@@ -191,7 +193,7 @@ export class MilkyWsEndpoint extends MilkyClient implements Endpoint {
|
|
|
191
193
|
message: { conversation, id: formatInboundMessageId(data) },
|
|
192
194
|
content,
|
|
193
195
|
segments,
|
|
194
|
-
sender: { id: String(data.sender_id), name: nickname },
|
|
196
|
+
sender: { id: String(data.sender_id), name: nickname, ...(platform ? { platform } : {}) },
|
|
195
197
|
endpointId: this.#options.config.id,
|
|
196
198
|
...(mentioned ? { mentioned: true } : {}),
|
|
197
199
|
metadata: Object.freeze({
|
package/src/ws-types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WsConnection } from '@zhin.js/host-http';
|
|
2
|
+
|
|
1
3
|
/** Minimal WS surface used by the endpoint (real `ws` or test mock). */
|
|
2
4
|
export interface MilkyWsSocket {
|
|
3
5
|
readonly readyState: number;
|
|
@@ -10,3 +12,34 @@ export interface MilkyWsSocket {
|
|
|
10
12
|
export interface MilkyWsCreateOptions {
|
|
11
13
|
readonly headers?: Record<string, string>;
|
|
12
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A Host reverse-WebSocket connection seen through the MilkyWsSocket surface. Protocol
|
|
18
|
+
* keepalive (ping / pong) belongs to the Host transport, so `ping` is absent.
|
|
19
|
+
*/
|
|
20
|
+
export function socketOfConnection(connection: WsConnection): MilkyWsSocket {
|
|
21
|
+
let readyState = 1;
|
|
22
|
+
connection.onClose(() => { readyState = 3; });
|
|
23
|
+
const subscriptions = new Map<(...args: unknown[]) => void, () => void>();
|
|
24
|
+
const off = (_event: string, listener: (...args: unknown[]) => void): void => {
|
|
25
|
+
subscriptions.get(listener)?.();
|
|
26
|
+
subscriptions.delete(listener);
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
get readyState() { return readyState; },
|
|
30
|
+
send: (data: string) => connection.send(data),
|
|
31
|
+
close: (code?: number, reason?: string) => connection.close(code, reason),
|
|
32
|
+
on(event: string, listener: (...args: unknown[]) => void) {
|
|
33
|
+
if (event === 'message') {
|
|
34
|
+
subscriptions.set(listener, connection.onMessage((data) => {
|
|
35
|
+
listener(typeof data === 'string' ? data : Buffer.from(data));
|
|
36
|
+
}));
|
|
37
|
+
} else if (event === 'close') {
|
|
38
|
+
subscriptions.set(listener, connection.onClose((code, reason) => listener(code, reason)));
|
|
39
|
+
}
|
|
40
|
+
// 'error' surfaces as a close on a WsConnection; 'open' already happened.
|
|
41
|
+
},
|
|
42
|
+
off,
|
|
43
|
+
removeListener: off,
|
|
44
|
+
} as MilkyWsSocket;
|
|
45
|
+
}
|