@zhin.js/adapter-milky 5.0.0 → 5.0.1

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