@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/lib/ws-types.js CHANGED
@@ -1 +1,31 @@
1
- export {};
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
+ }
@@ -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 { HttpHost } from '@zhin.js/host-http';
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';
@@ -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
- const handle = this.#options.http.ws(this.#options.config.path);
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 (!verifyMilkyAccessToken(this.#options.config.access_token, connection.request)) {
170
- connection.socket.close(4003, 'Unauthorized');
181
+ if (!verifyProtocolAccessToken(this.#options.config.access_token, connection)) {
182
+ connection.close(4003, 'Unauthorized');
171
183
  return;
172
184
  }
173
- const socket = connection.socket;
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
- this.#connectionLifecycle.startHeartbeat(() => {
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.request.socket.remoteAddress,
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.26",
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.32",
47
- "@zhin.js/console-contract": "1.1.5",
48
- "@zhin.js/core": "1.1.60",
49
- "@zhin.js/feature-kit": "1.1.15",
50
- "@zhin.js/host-http": "1.1.17",
51
- "@zhin.js/im-contract": "1.1.13",
52
- "@zhin.js/logger": "1.1.2",
53
- "@zhin.js/page": "1.1.17",
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
- "zod": "^4.4.3",
62
- "@zhin.js/agent": "1.1.51",
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
- "zod": "^4.0.0",
68
- "@zhin.js/adapter": "^1.1.32",
69
- "@zhin.js/agent": "^1.1.51",
70
- "@zhin.js/command": "^1.1.17",
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": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/adapters/milky/tests"
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 { IncomingMessage } from 'node:http';
1
+ import { verifyProtocolAccessToken, type ProtocolAuthRequest } from '@zhin.js/host-http';
2
2
 
3
- export function verifyMilkyAccessToken(accessToken: string | undefined, request: IncomingMessage): boolean {
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
- const auth = request.headers.authorization ?? '';
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 (!controller.signal.aborted) {
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;
@@ -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({
@@ -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, async (request, response) => {
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: IncomingMessage, response: ServerResponse): Promise<void> {
190
+ async #handleWebhook(request: Request): Promise<Response> {
192
191
  try {
193
192
  if (!verifyMilkyAccessToken(this.#options.config.access_token, request)) {
194
- response.writeHead(403, { 'Content-Type': 'application/json' });
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
- response.writeHead(200, { 'Content-Type': 'application/json' });
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, response, event => {
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
- if (!response.headersSent) {
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
  }
@@ -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
+ }