@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.
@@ -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
+ }
@@ -11,10 +11,9 @@ import {
11
11
  type EndpointManagement,
12
12
  type EndpointSendRequest,
13
13
  } from 'zhin.js/adapter';
14
- import type { HttpHost, WsConnection } from '@zhin.js/host-http';
14
+ import { verifyProtocolAccessToken, type HttpHost, type WsConnection } from '@zhin.js/host-http';
15
15
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
16
16
  import type { CapabilityId } from 'zhin.js';
17
- import { verifyMilkyAccessToken } from './milky-auth.js';
18
17
  import { createMilkyEndpointManagement } from './endpoint-management.js';
19
18
  import { createMilkyActionCaller, readMilkyResponse, type MilkyActionCaller, milkyClientOptions, forwardMilkyClientEvents, MilkyClient } from './client.js';
20
19
  import {
@@ -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,
@@ -35,7 +35,7 @@ import {
35
35
  type MilkyIncomingMessage,
36
36
  type MilkyWssConfig,
37
37
  } from './protocol.js';
38
- import type { MilkyWsSocket } from './ws-types.js';
38
+ import { socketOfConnection, type MilkyWsSocket } from './ws-types.js';
39
39
 
40
40
  const WS_OPEN = 1;
41
41
 
@@ -99,7 +99,17 @@ export class MilkyWssEndpoint extends MilkyClient implements Endpoint {
99
99
  async #start(): Promise<void> {
100
100
  if (this.#started) return;
101
101
  this.#started = true;
102
- const handle = this.#options.http.ws(this.#options.config.path);
102
+ if (!this.#options.config.access_token) {
103
+ // 未配 access_token 时只接受本机回环连接(verifyProtocolAccessToken)。
104
+ this.#logger.warn(formatCompact({
105
+ endpoint: this.#options.config.id,
106
+ mode: 'wss',
107
+ ok: false,
108
+ error: 'missing access_token: only loopback peers are accepted',
109
+ }));
110
+ }
111
+ // The peer authenticates with this endpoint's own access_token, not a Console credential.
112
+ const handle = this.#options.http.ws(this.#options.config.path, { auth: 'protocol' });
103
113
  this.#wsRelease = handle.onConnection((connection) => {
104
114
  this.#connectionTask = this.#connectionTask
105
115
  .then(() => this.#acceptConnection(connection))
@@ -177,6 +187,7 @@ export class MilkyWssEndpoint extends MilkyClient implements Endpoint {
177
187
  const segments = formatInboundSegments(data);
178
188
  const audioUrl = extractInboundAudioUrl(data);
179
189
  const nickname = senderNickname(data);
190
+ const platform = milkySenderStanding(data);
180
191
  const mentioned = isMentioned(data, event.self_id);
181
192
  void this[endpointSpec].events.message({
182
193
  event,
@@ -185,7 +196,7 @@ export class MilkyWssEndpoint extends MilkyClient implements Endpoint {
185
196
  message: { conversation, id: formatInboundMessageId(data) },
186
197
  content,
187
198
  segments,
188
- sender: { id: String(data.sender_id) },
199
+ sender: { id: String(data.sender_id), ...(platform ? { platform } : {}) },
189
200
  endpointId: this.#options.config.id,
190
201
  ...(mentioned ? { mentioned: true } : {}),
191
202
  metadata: Object.freeze({
@@ -207,11 +218,11 @@ export class MilkyWssEndpoint extends MilkyClient implements Endpoint {
207
218
  }
208
219
 
209
220
  async #acceptConnection(connection: WsConnection): Promise<void> {
210
- if (!verifyMilkyAccessToken(this.#options.config.access_token, connection.request)) {
211
- connection.socket.close(4003, 'Unauthorized');
221
+ if (!verifyProtocolAccessToken(this.#options.config.access_token, connection)) {
222
+ connection.close(4003, 'Unauthorized');
212
223
  return;
213
224
  }
214
- const socket = connection.socket as unknown as MilkyWsSocket;
225
+ const socket = socketOfConnection(connection);
215
226
  await this.#connectionLifecycle.stop();
216
227
  await this.#connectionLifecycle.start(async (lifecycleHandle) => {
217
228
  this.#ws = socket;
@@ -229,13 +240,7 @@ export class MilkyWssEndpoint extends MilkyClient implements Endpoint {
229
240
  }
230
241
  if (this.#ws === socket) this.#ws = undefined;
231
242
  });
232
- this.#connectionLifecycle.startHeartbeat(() => {
233
- try {
234
- if (socket.readyState === WS_OPEN) socket.ping?.();
235
- } catch {
236
- /* ignore */
237
- }
238
- }, this.#options.config.heartbeat_interval);
243
+ // Dead-peer detection is the Host transport's keepalive (protocol ping / pong).
239
244
  socket.on('message', () => {
240
245
  this.#connectionLifecycle.notifyHeartbeatAck();
241
246
  });
@@ -251,7 +256,7 @@ export class MilkyWssEndpoint extends MilkyClient implements Endpoint {
251
256
  this.#logger.debug(formatCompact({
252
257
  endpoint: this.#options.config.id,
253
258
  mode: 'wss',
254
- peer: connection.request.socket.remoteAddress,
259
+ peer: connection.remoteAddress,
255
260
  }));
256
261
  }
257
262