@zhin.js/adapter-milky 1.0.0 → 1.1.0

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +962 -0
  2. package/README.md +58 -117
  3. package/adapters/milky.js +51 -0
  4. package/adapters/milky.ts +74 -0
  5. package/agent/PERMITS.md +14 -0
  6. package/{skills/milky/SKILL.md → agent/skills/milky.md} +1 -1
  7. package/commands/endpoint/add/[id].js +3 -0
  8. package/commands/endpoint/add/[id].ts +3 -0
  9. package/commands/endpoint/list.js +3 -0
  10. package/commands/endpoint/list.ts +3 -0
  11. package/commands/endpoint/remove/[id].js +3 -0
  12. package/commands/endpoint/remove/[id].ts +3 -0
  13. package/lib/client.d.ts +21 -0
  14. package/lib/client.js +33 -0
  15. package/lib/endpoint-management.d.ts +17 -0
  16. package/lib/endpoint-management.js +47 -0
  17. package/lib/endpoint.d.ts +7 -0
  18. package/lib/endpoint.js +6 -0
  19. package/lib/index.d.ts +6 -20
  20. package/lib/index.js +4 -33
  21. package/lib/milky-auth.d.ts +2 -0
  22. package/lib/milky-auth.js +16 -0
  23. package/lib/milky-endpoint-commands.d.ts +1 -0
  24. package/lib/milky-endpoint-commands.js +18 -0
  25. package/lib/milky-runtime-state.d.ts +1 -0
  26. package/lib/milky-runtime-state.js +6 -0
  27. package/lib/protocol.d.ts +174 -0
  28. package/lib/protocol.js +494 -0
  29. package/lib/sse-client.d.ts +20 -0
  30. package/lib/sse-client.js +85 -0
  31. package/lib/sse-endpoint.d.ts +32 -0
  32. package/lib/sse-endpoint.js +207 -0
  33. package/lib/webhook-endpoint.d.ts +22 -0
  34. package/lib/webhook-endpoint.js +149 -0
  35. package/lib/ws-endpoint.d.ts +23 -0
  36. package/lib/ws-endpoint.js +233 -0
  37. package/lib/ws-types.d.ts +11 -0
  38. package/lib/ws-types.js +1 -0
  39. package/lib/wss-endpoint.d.ts +25 -0
  40. package/lib/wss-endpoint.js +184 -0
  41. package/package.json +73 -19
  42. package/plugin.js +14 -0
  43. package/schema.json +100 -0
  44. package/src/client.ts +80 -0
  45. package/src/endpoint-management.ts +74 -0
  46. package/src/endpoint.ts +26 -0
  47. package/src/index.ts +63 -45
  48. package/src/milky-auth.ts +14 -0
  49. package/src/milky-endpoint-commands.ts +19 -0
  50. package/src/milky-runtime-state.ts +7 -0
  51. package/src/protocol.ts +647 -0
  52. package/src/sse-client.ts +106 -0
  53. package/src/sse-endpoint.ts +258 -0
  54. package/src/webhook-endpoint.ts +196 -0
  55. package/src/ws-endpoint.ts +291 -0
  56. package/src/ws-types.ts +12 -0
  57. package/src/wss-endpoint.ts +228 -0
  58. package/lib/adapter.d.ts +0 -28
  59. package/lib/adapter.d.ts.map +0 -1
  60. package/lib/adapter.js +0 -90
  61. package/lib/adapter.js.map +0 -1
  62. package/lib/api.d.ts +0 -10
  63. package/lib/api.d.ts.map +0 -1
  64. package/lib/api.js +0 -48
  65. package/lib/api.js.map +0 -1
  66. package/lib/bot-sse.d.ts +0 -31
  67. package/lib/bot-sse.d.ts.map +0 -1
  68. package/lib/bot-sse.js +0 -195
  69. package/lib/bot-sse.js.map +0 -1
  70. package/lib/bot-webhook.d.ts +0 -34
  71. package/lib/bot-webhook.d.ts.map +0 -1
  72. package/lib/bot-webhook.js +0 -187
  73. package/lib/bot-webhook.js.map +0 -1
  74. package/lib/bot-ws.d.ts +0 -35
  75. package/lib/bot-ws.d.ts.map +0 -1
  76. package/lib/bot-ws.js +0 -262
  77. package/lib/bot-ws.js.map +0 -1
  78. package/lib/bot-wss.d.ts +0 -34
  79. package/lib/bot-wss.d.ts.map +0 -1
  80. package/lib/bot-wss.js +0 -225
  81. package/lib/bot-wss.js.map +0 -1
  82. package/lib/index.d.ts.map +0 -1
  83. package/lib/index.js.map +0 -1
  84. package/lib/types.d.ts +0 -75
  85. package/lib/types.d.ts.map +0 -1
  86. package/lib/types.js +0 -5
  87. package/lib/types.js.map +0 -1
  88. package/lib/utils.d.ts +0 -35
  89. package/lib/utils.d.ts.map +0 -1
  90. package/lib/utils.js +0 -157
  91. package/lib/utils.js.map +0 -1
  92. package/plugin.yml +0 -3
  93. package/src/adapter.ts +0 -101
  94. package/src/api.ts +0 -63
  95. package/src/bot-sse.ts +0 -228
  96. package/src/bot-webhook.ts +0 -221
  97. package/src/bot-ws.ts +0 -296
  98. package/src/bot-wss.ts +0 -268
  99. package/src/types.ts +0 -73
  100. package/src/utils.ts +0 -174
@@ -0,0 +1,6 @@
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
+ export const milkyRuntimeStateToken = defineEndpointRuntimeStateToken('milky');
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Milky protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ * Spec: https://milky.ntqqrev.org/
5
+ */
6
+ import type { Segment } from '@zhin.js/core/runtime';
7
+ import type { ConversationRef } from '@zhin.js/im-contract';
8
+ /** Transitional legacy endpoint row (`endpoints[]` with `context: milky`). */
9
+ export interface MilkyLegacyEndpointRow {
10
+ readonly context?: string;
11
+ readonly connection?: 'ws' | 'sse' | 'webhook' | 'wss';
12
+ readonly id?: string;
13
+ readonly baseUrl?: string;
14
+ readonly access_token?: string;
15
+ readonly path?: string;
16
+ readonly reconnect_interval?: number;
17
+ readonly heartbeat_interval?: number;
18
+ }
19
+ /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
20
+ export interface MilkyAdapterConfig {
21
+ readonly connection?: 'ws' | 'sse' | 'webhook' | 'wss';
22
+ readonly id?: string;
23
+ readonly baseUrl?: string;
24
+ readonly access_token?: string;
25
+ readonly path?: string;
26
+ readonly reconnect_interval?: number;
27
+ readonly heartbeat_interval?: number;
28
+ /** Transitional: legacy root `endpoints[]` with `context: milky`. */
29
+ readonly endpoints?: ReadonlyArray<MilkyLegacyEndpointRow>;
30
+ }
31
+ export interface MilkyConfigBase {
32
+ readonly context: 'milky';
33
+ readonly id: string;
34
+ readonly baseUrl: string;
35
+ readonly access_token?: string;
36
+ }
37
+ /** WebSocket 正向:应用连协议端 ws(s)://baseUrl/event */
38
+ export interface MilkyWsConfig extends MilkyConfigBase {
39
+ readonly connection: 'ws';
40
+ readonly reconnect_interval: number;
41
+ readonly heartbeat_interval: number;
42
+ }
43
+ /** SSE:HTTP GET `text/event-stream` on `/event`(同 WS 事件流) */
44
+ export interface MilkySseConfig extends MilkyConfigBase {
45
+ readonly connection: 'sse';
46
+ readonly reconnect_interval: number;
47
+ }
48
+ /** Webhook:httpHostToken POST 入站 + baseUrl HTTP API 出站 */
49
+ export interface MilkyWebhookConfig extends MilkyConfigBase {
50
+ readonly connection: 'webhook';
51
+ readonly path: string;
52
+ }
53
+ /** 反向 WS:httpHostToken WS upgrade 入站 + baseUrl HTTP API 出站 */
54
+ export interface MilkyWssConfig extends MilkyConfigBase {
55
+ readonly connection: 'wss';
56
+ readonly path: string;
57
+ readonly heartbeat_interval: number;
58
+ }
59
+ export type ResolvedMilkyConfig = MilkyWsConfig | MilkySseConfig | MilkyWebhookConfig | MilkyWssConfig;
60
+ export type MilkyEndpointConfig = ResolvedMilkyConfig;
61
+ export interface MilkyApiResponse<T = unknown> {
62
+ status: 'ok' | 'failed';
63
+ retcode: number;
64
+ data?: T;
65
+ message?: string;
66
+ }
67
+ export interface MilkyEvent {
68
+ event_type: string;
69
+ time: number;
70
+ self_id: number;
71
+ data?: Record<string, unknown>;
72
+ }
73
+ export interface MilkyIncomingMessage {
74
+ message_scene: 'friend' | 'group' | 'temp';
75
+ peer_id: number;
76
+ message_seq: number;
77
+ sender_id: number;
78
+ time: number;
79
+ segments: MilkyIncomingSegment[];
80
+ friend?: {
81
+ user_id: number;
82
+ nickname?: string;
83
+ };
84
+ group?: {
85
+ group_id: number;
86
+ group_name?: string;
87
+ };
88
+ group_member?: {
89
+ user_id: number;
90
+ nickname?: string;
91
+ card?: string;
92
+ role?: string;
93
+ };
94
+ }
95
+ export interface MilkyIncomingSegment {
96
+ type: string;
97
+ data?: Record<string, unknown>;
98
+ }
99
+ export interface MilkyWireSegment {
100
+ readonly type: string;
101
+ readonly data?: Record<string, unknown>;
102
+ }
103
+ export interface MilkyOutgoingSegment {
104
+ type: string;
105
+ data: Record<string, unknown>;
106
+ }
107
+ export interface MilkyApiClientOptions {
108
+ readonly baseUrl: string;
109
+ readonly access_token?: string;
110
+ }
111
+ export declare function resolveMilkyConfig(config?: MilkyAdapterConfig): ResolvedMilkyConfig;
112
+ /**
113
+ * 调用协议端 API:POST {baseUrl}/api/{apiName},Body JSON,鉴权。
114
+ * HTTP 层错误抛出;协议响应原样返回,由 Client 公开 call() 保留完整语义。
115
+ */
116
+ export declare function callApi<T = unknown>(options: MilkyApiClientOptions, apiName: string, params?: Record<string, unknown>): Promise<MilkyApiResponse<T>>;
117
+ /** 根据 event_type 判断是否为 message_receive,并解析 data */
118
+ export declare function parseMessageReceiveData(event: MilkyEvent): MilkyIncomingMessage | null;
119
+ export declare function isMessageReceiveEvent(event: MilkyEvent): event is MilkyEvent & {
120
+ data: MilkyIncomingMessage;
121
+ };
122
+ /**
123
+ * 入站归一化 → ConversationRef:`friend`(私聊)→ `private`;`group` → `group`;
124
+ * `temp`(群临时会话)→ 群容器内的 `private` 会话(parent = 来源群)。
125
+ */
126
+ export declare function milkyInboundConversation(endpointKey: string, data: MilkyIncomingMessage): ConversationRef;
127
+ export declare function formatInboundContent(data: MilkyIncomingMessage): string;
128
+ /**
129
+ * 入站消息段 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
130
+ * 媒体段(image/record/video)优先 temp_url(kind=url),缺 URL 时回退
131
+ * resource_id(kind=file,平台不透明引用,经协议端 get_resource 解析);
132
+ * file 段只有 file_id(kind=file)。未识别段类型按宽松段透传,不丢信息。
133
+ */
134
+ export declare function formatInboundSegments(data: MilkyIncomingMessage): Segment[];
135
+ /** First audio URI from inbound segments (for Agent Host STT preprocess). */
136
+ export declare function extractInboundAudioUrl(data: MilkyIncomingMessage): string | undefined;
137
+ /** 发送者显示名(群名片/群昵称/好友昵称);没有可靠的显示名时返回 undefined。 */
138
+ export declare function senderNickname(data: MilkyIncomingMessage): string | undefined;
139
+ /** 消息段中含 @ 本机(mention 段 user_id 等于协议端上报的 self_id)。 */
140
+ export declare function isMentioned(data: MilkyIncomingMessage, selfId: number | undefined): boolean;
141
+ export declare function formatInboundMessageId(data: MilkyIncomingMessage): string;
142
+ /**
143
+ * Wire-encode an already-rendered outbound payload into Milky outgoing segments.
144
+ * Segment canonicalization is intentionally not done here.
145
+ * 媒体段(image/audio/record/video)只消费 canonical `data.media` MediaRef;
146
+ * file 段 Milky 消息 API 无对应 wire 段(文件走 upload_* API),warn + 丢弃。
147
+ */
148
+ export declare function formatOutboundSegments(payload: unknown): MilkyOutgoingSegment[];
149
+ export declare function buildSendAction(conversation: ConversationRef, message: MilkyOutgoingSegment[]): {
150
+ action: string;
151
+ params: Record<string, unknown>;
152
+ };
153
+ /**
154
+ * message_seq result → gateway message id(`scene:peer:seq` 复合格式,
155
+ * 与 formatInboundMessageId 同源,仅供 recall 链在本端点边界内解析)。
156
+ */
157
+ export declare function formatOutboundMessageId(conversation: ConversationRef, messageSeq: number | undefined): string;
158
+ export declare function parseMilkyMessageId(msgId: string): {
159
+ message_scene: 'friend' | 'group' | 'temp';
160
+ peer_id: number;
161
+ message_seq: number;
162
+ } | null;
163
+ /** Build WS event URL + headers (access_token via Bearer + query). */
164
+ export declare function buildWsConnectOptions(config: MilkyWsConfig): {
165
+ readonly url: string;
166
+ readonly headers: Record<string, string>;
167
+ readonly safeUrl: string;
168
+ };
169
+ /** Build SSE event URL (HTTP) + headers. */
170
+ export declare function buildSseConnectOptions(config: MilkySseConfig): {
171
+ readonly url: string;
172
+ readonly headers: Record<string, string>;
173
+ readonly safeUrl: string;
174
+ };
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Milky protocol helpers (no legacy Adapter/Endpoint / segment-mapper).
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ * Spec: https://milky.ntqqrev.org/
5
+ */
6
+ import { isMediaRef } from '@zhin.js/core';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+ const logger = getLogger('milky');
9
+ function normalizeConnection(connection) {
10
+ if (connection === 'sse' || connection === 'webhook' || connection === 'wss')
11
+ return connection;
12
+ return 'ws';
13
+ }
14
+ export function resolveMilkyConfig(config = {}) {
15
+ const entry = config.endpoints?.find((item) => item.context === 'milky');
16
+ const connection = normalizeConnection(config.connection ?? entry?.connection);
17
+ const id = (typeof config.id === 'string' && config.id)
18
+ || (typeof entry?.id === 'string' && entry.id)
19
+ || process.env.MILKY_BOT_NAME
20
+ || 'milky-bot';
21
+ const baseUrl = config.baseUrl ?? entry?.baseUrl;
22
+ if (!baseUrl) {
23
+ throw new TypeError('Milky requires baseUrl (plugins.<key>.baseUrl or endpoints with context: milky)');
24
+ }
25
+ const access_token = config.access_token ?? entry?.access_token;
26
+ const base = {
27
+ context: 'milky',
28
+ id,
29
+ baseUrl,
30
+ access_token,
31
+ };
32
+ if (connection === 'ws') {
33
+ return {
34
+ ...base,
35
+ connection: 'ws',
36
+ reconnect_interval: config.reconnect_interval ?? entry?.reconnect_interval ?? 5000,
37
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
38
+ };
39
+ }
40
+ if (connection === 'sse') {
41
+ return {
42
+ ...base,
43
+ connection: 'sse',
44
+ reconnect_interval: config.reconnect_interval
45
+ ?? entry?.reconnect_interval
46
+ ?? 5_000,
47
+ };
48
+ }
49
+ if (connection === 'webhook') {
50
+ const path = config.path ?? entry?.path;
51
+ if (!path)
52
+ throw new TypeError('Milky connection:webhook requires path');
53
+ return { ...base, connection: 'webhook', path };
54
+ }
55
+ if (connection === 'wss') {
56
+ const path = config.path ?? entry?.path;
57
+ if (!path)
58
+ throw new TypeError('Milky connection:wss requires path');
59
+ return {
60
+ ...base,
61
+ connection: 'wss',
62
+ path,
63
+ heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
64
+ };
65
+ }
66
+ throw new TypeError(`Unknown Milky connection: ${String(connection)}`);
67
+ }
68
+ /** 鉴权:Header Authorization: Bearer {token} 或 URL query access_token=xxx */
69
+ function authHeaders(access_token) {
70
+ const headers = {};
71
+ if (access_token) {
72
+ headers.Authorization = `Bearer ${access_token}`;
73
+ }
74
+ return headers;
75
+ }
76
+ function authQuery(access_token) {
77
+ if (!access_token)
78
+ return '';
79
+ return `access_token=${encodeURIComponent(access_token)}`;
80
+ }
81
+ /**
82
+ * 调用协议端 API:POST {baseUrl}/api/{apiName},Body JSON,鉴权。
83
+ * HTTP 层错误抛出;协议响应原样返回,由 Client 公开 call() 保留完整语义。
84
+ */
85
+ export async function callApi(options, apiName, params = {}) {
86
+ const { baseUrl, access_token } = options;
87
+ const url = new URL(`/api/${apiName}`, baseUrl.replace(/\/$/, ''));
88
+ const q = authQuery(access_token);
89
+ if (q)
90
+ url.search = (url.search ? `${url.search}&` : '') + q;
91
+ const res = await fetch(url.toString(), {
92
+ method: 'POST',
93
+ headers: {
94
+ 'Content-Type': 'application/json',
95
+ ...authHeaders(access_token),
96
+ },
97
+ body: JSON.stringify(Object.keys(params).length ? params : {}),
98
+ });
99
+ const text = await res.text();
100
+ let body;
101
+ try {
102
+ body = JSON.parse(text);
103
+ }
104
+ catch {
105
+ throw new Error(`Milky API ${apiName}: invalid JSON response (${res.status}) ${text.slice(0, 200)}`);
106
+ }
107
+ if (res.status !== 200) {
108
+ throw new Error(`Milky API ${apiName}: HTTP ${res.status} ${body.message ?? text}`);
109
+ }
110
+ return body;
111
+ }
112
+ /** 根据 event_type 判断是否为 message_receive,并解析 data */
113
+ export function parseMessageReceiveData(event) {
114
+ if (event.event_type !== 'message_receive' || !event.data)
115
+ return null;
116
+ const data = event.data;
117
+ if (!data.message_scene || !Number.isInteger(data.peer_id) || !Array.isArray(data.segments)) {
118
+ return null;
119
+ }
120
+ return data;
121
+ }
122
+ export function isMessageReceiveEvent(event) {
123
+ return parseMessageReceiveData(event) != null;
124
+ }
125
+ /**
126
+ * 入站归一化 → ConversationRef:`friend`(私聊)→ `private`;`group` → `group`;
127
+ * `temp`(群临时会话)→ 群容器内的 `private` 会话(parent = 来源群)。
128
+ */
129
+ export function milkyInboundConversation(endpointKey, data) {
130
+ return {
131
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
132
+ kind: data.message_scene === 'group' ? 'group' : 'private',
133
+ id: String(data.peer_id),
134
+ ...(data.message_scene === 'temp' && data.group
135
+ ? { parent: { kind: 'group', id: String(data.group.group_id) } }
136
+ : {}),
137
+ };
138
+ }
139
+ export function formatInboundContent(data) {
140
+ return data.segments.map((seg) => {
141
+ if (seg.type === 'text')
142
+ return String(seg.data?.text ?? '');
143
+ if (seg.type === 'record' || seg.type === 'audio') {
144
+ const uri = String(seg.data?.uri ?? seg.data?.url ?? '');
145
+ return uri ? `[audio:${uri}]` : '[audio]';
146
+ }
147
+ return '';
148
+ }).join('');
149
+ }
150
+ function firstNonEmptyString(...values) {
151
+ for (const value of values) {
152
+ if (typeof value === 'string' && value.trim())
153
+ return value.trim();
154
+ }
155
+ return undefined;
156
+ }
157
+ /**
158
+ * 入站消息段 → canonical Segment[](与 formatInboundContent 纯文本视图同源双轨)。
159
+ * 媒体段(image/record/video)优先 temp_url(kind=url),缺 URL 时回退
160
+ * resource_id(kind=file,平台不透明引用,经协议端 get_resource 解析);
161
+ * file 段只有 file_id(kind=file)。未识别段类型按宽松段透传,不丢信息。
162
+ */
163
+ export function formatInboundSegments(data) {
164
+ const out = [];
165
+ for (const seg of data.segments) {
166
+ const segData = seg.data ?? {};
167
+ switch (seg.type) {
168
+ case 'text': {
169
+ const text = String(segData.text ?? '');
170
+ if (text)
171
+ out.push({ type: 'text', data: { text } });
172
+ break;
173
+ }
174
+ case 'mention':
175
+ out.push({
176
+ type: 'mention',
177
+ data: {
178
+ target: String(segData.user_id ?? ''),
179
+ ...(typeof segData.name === 'string' && segData.name
180
+ ? { name: segData.name }
181
+ : {}),
182
+ },
183
+ });
184
+ break;
185
+ case 'mention_all':
186
+ out.push({ type: 'mention', data: { target: 'all' } });
187
+ break;
188
+ case 'face': {
189
+ const id = firstNonEmptyString(segData.face_id, segData.id);
190
+ if (id)
191
+ out.push({ type: 'face', data: { id } });
192
+ break;
193
+ }
194
+ case 'reply': {
195
+ const seq = Number(segData.message_seq);
196
+ if (Number.isFinite(seq)) {
197
+ // 与 formatInboundMessageId 同一复合格式,保证 reply 链可定位原消息
198
+ out.push({
199
+ type: 'reply',
200
+ data: { message_id: `${data.message_scene}:${data.peer_id}:${seq}` },
201
+ });
202
+ }
203
+ break;
204
+ }
205
+ case 'image':
206
+ case 'record':
207
+ case 'audio':
208
+ case 'video': {
209
+ const url = firstNonEmptyString(segData.temp_url, segData.uri, segData.url);
210
+ const resourceId = firstNonEmptyString(segData.resource_id);
211
+ if (!url && !resourceId)
212
+ break;
213
+ const type = seg.type === 'image'
214
+ ? 'image'
215
+ : seg.type === 'video'
216
+ ? 'video'
217
+ : 'audio';
218
+ out.push({
219
+ type,
220
+ data: {
221
+ media: url
222
+ ? { kind: 'url', value: url }
223
+ : { kind: 'file', value: resourceId },
224
+ ...(typeof segData.summary === 'string' && segData.summary
225
+ ? { alt: segData.summary }
226
+ : {}),
227
+ },
228
+ });
229
+ break;
230
+ }
231
+ case 'file': {
232
+ const fileId = firstNonEmptyString(segData.file_id);
233
+ if (!fileId)
234
+ break;
235
+ out.push({
236
+ type: 'file',
237
+ data: {
238
+ media: { kind: 'file', value: fileId },
239
+ ...(typeof segData.file_name === 'string' && segData.file_name
240
+ ? { name: segData.file_name }
241
+ : {}),
242
+ ...(typeof segData.file_size === 'number'
243
+ ? { size: segData.file_size }
244
+ : {}),
245
+ },
246
+ });
247
+ break;
248
+ }
249
+ case 'forward': {
250
+ const forwardId = firstNonEmptyString(segData.forward_id);
251
+ if (forwardId) {
252
+ out.push({
253
+ type: 'forward',
254
+ data: {
255
+ forward_id: forwardId,
256
+ ...(typeof segData.title === 'string' && segData.title
257
+ ? { title: segData.title }
258
+ : {}),
259
+ },
260
+ });
261
+ }
262
+ break;
263
+ }
264
+ default:
265
+ // 未识别段(market_face / light_app / xml 等)按宽松 canonical 段透传
266
+ out.push({ type: seg.type, data: segData });
267
+ }
268
+ }
269
+ return out;
270
+ }
271
+ /** First audio URI from inbound segments (for Agent Host STT preprocess). */
272
+ export function extractInboundAudioUrl(data) {
273
+ for (const seg of data.segments) {
274
+ if (seg.type !== 'record' && seg.type !== 'audio')
275
+ continue;
276
+ const uri = String(seg.data?.uri ?? seg.data?.url ?? '').trim();
277
+ if (uri)
278
+ return uri;
279
+ }
280
+ return undefined;
281
+ }
282
+ /** 发送者显示名(群名片/群昵称/好友昵称);没有可靠的显示名时返回 undefined。 */
283
+ export function senderNickname(data) {
284
+ const isGroup = data.message_scene === 'group';
285
+ const name = isGroup
286
+ ? data.group_member?.card ?? data.group_member?.nickname
287
+ : data.friend?.nickname;
288
+ return typeof name === 'string' && name ? name : undefined;
289
+ }
290
+ /** 消息段中含 @ 本机(mention 段 user_id 等于协议端上报的 self_id)。 */
291
+ export function isMentioned(data, selfId) {
292
+ if (selfId == null)
293
+ return false;
294
+ return data.segments.some((seg) => seg.type === 'mention' && Number(seg.data?.user_id) === selfId);
295
+ }
296
+ export function formatInboundMessageId(data) {
297
+ return `${data.message_scene}:${data.peer_id}:${data.message_seq}`;
298
+ }
299
+ /**
300
+ * 解析 image/audio/video 段的投递 uri(canonical MediaRef 唯一来源):
301
+ * - kind=url → http(s):// 直发;
302
+ * - kind=base64 → base64:// 直发;
303
+ * - kind=path / file → Milky 资源 uri 仅消费 http(s):// 与 base64://,不支持,丢弃。
304
+ */
305
+ function resolveMediaUri(data) {
306
+ const media = data.media;
307
+ if (!isMediaRef(media))
308
+ return undefined;
309
+ if (media.kind === 'url')
310
+ return media.value;
311
+ if (media.kind === 'base64') {
312
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
313
+ }
314
+ return undefined;
315
+ }
316
+ /** 媒体段 → Milky 资源段(image/record/video);不可投递时 warn + 丢弃。 */
317
+ function normalizeMediaSegment(seg) {
318
+ const data = seg.data ?? {};
319
+ const wireType = seg.type === 'image'
320
+ ? 'image'
321
+ : seg.type === 'video'
322
+ ? 'video'
323
+ : 'record';
324
+ const uri = resolveMediaUri(data);
325
+ if (!uri) {
326
+ const media = data.media;
327
+ logger.warn(formatCompact({
328
+ op: 'milky_outbound_media_dropped',
329
+ type: seg.type,
330
+ reason: isMediaRef(media) ? 'unsupported_media_kind' : 'missing_media_ref',
331
+ ...(isMediaRef(media) ? { kind: media.kind } : {}),
332
+ }));
333
+ return null;
334
+ }
335
+ return {
336
+ type: wireType,
337
+ data: wireType === 'video' ? { uri, thumb_uri: data.thumb_uri } : { uri },
338
+ };
339
+ }
340
+ /**
341
+ * Wire-encode an already-rendered outbound payload into Milky outgoing segments.
342
+ * Segment canonicalization is intentionally not done here.
343
+ * 媒体段(image/audio/record/video)只消费 canonical `data.media` MediaRef;
344
+ * file 段 Milky 消息 API 无对应 wire 段(文件走 upload_* API),warn + 丢弃。
345
+ */
346
+ export function formatOutboundSegments(payload) {
347
+ if (typeof payload === 'string') {
348
+ return [{ type: 'text', data: { text: payload } }];
349
+ }
350
+ const items = Array.isArray(payload)
351
+ ? payload
352
+ : payload && typeof payload === 'object' && 'type' in payload
353
+ ? [payload]
354
+ : [];
355
+ if (items.length === 0) {
356
+ const text = payload == null
357
+ ? ''
358
+ : typeof payload === 'object'
359
+ ? JSON.stringify(payload)
360
+ : String(payload);
361
+ return [{ type: 'text', data: { text } }];
362
+ }
363
+ const out = [];
364
+ for (const item of items) {
365
+ if (typeof item === 'string') {
366
+ out.push({ type: 'text', data: { text: item } });
367
+ continue;
368
+ }
369
+ const type = item.type;
370
+ const data = item.data ?? {};
371
+ switch (type) {
372
+ case 'text':
373
+ out.push({ type: 'text', data: { text: String(data.text ?? '') } });
374
+ break;
375
+ case 'at':
376
+ if (data.type === 'all') {
377
+ out.push({ type: 'mention_all', data: {} });
378
+ }
379
+ else {
380
+ const id = data.id;
381
+ if (id)
382
+ out.push({ type: 'mention', data: { user_id: Number(id) || 0 } });
383
+ }
384
+ break;
385
+ case 'mention':
386
+ out.push({ type: 'mention', data: { user_id: Number(data.user_id ?? data.id ?? 0) || 0 } });
387
+ break;
388
+ case 'mention_all':
389
+ out.push({ type: 'mention_all', data: {} });
390
+ break;
391
+ case 'face':
392
+ out.push({
393
+ type: 'face',
394
+ data: { face_id: String(data.face_id ?? data.id ?? ''), is_large: false },
395
+ });
396
+ break;
397
+ case 'reply': {
398
+ const mid = data.message_id ?? data.message_seq;
399
+ const seq = typeof mid === 'number'
400
+ ? mid
401
+ : parseInt(String(mid).split(':').pop() ?? '0', 10);
402
+ out.push({ type: 'reply', data: { message_seq: seq } });
403
+ break;
404
+ }
405
+ case 'image':
406
+ case 'record':
407
+ case 'audio':
408
+ case 'video': {
409
+ const media = normalizeMediaSegment(item);
410
+ if (media)
411
+ out.push(media);
412
+ break;
413
+ }
414
+ case 'file':
415
+ // Milky 消息 API 无 file 段(文件走 upload_private/group_file),无法投递
416
+ logger.warn(formatCompact({
417
+ op: 'milky_outbound_media_dropped',
418
+ type,
419
+ reason: 'unsupported_segment',
420
+ }));
421
+ break;
422
+ default:
423
+ out.push({ type, data: data });
424
+ }
425
+ }
426
+ return out.length ? out : [{ type: 'text', data: { text: '' } }];
427
+ }
428
+ export function buildSendAction(conversation, message) {
429
+ if (conversation.kind === 'group') {
430
+ return {
431
+ action: 'send_group_message',
432
+ params: {
433
+ group_id: parseInt(conversation.id, 10),
434
+ message,
435
+ },
436
+ };
437
+ }
438
+ return {
439
+ action: 'send_private_message',
440
+ params: {
441
+ user_id: parseInt(conversation.id, 10),
442
+ message,
443
+ },
444
+ };
445
+ }
446
+ /**
447
+ * message_seq result → gateway message id(`scene:peer:seq` 复合格式,
448
+ * 与 formatInboundMessageId 同源,仅供 recall 链在本端点边界内解析)。
449
+ */
450
+ export function formatOutboundMessageId(conversation, messageSeq) {
451
+ if (messageSeq == null)
452
+ return '';
453
+ const scene = conversation.kind === 'group' ? 'group' : 'friend';
454
+ return `${scene}:${conversation.id}:${messageSeq}`;
455
+ }
456
+ export function parseMilkyMessageId(msgId) {
457
+ const parts = msgId.split(':');
458
+ if (parts.length < 3)
459
+ return null;
460
+ const [scene, peer, seq] = parts;
461
+ if (scene !== 'friend' && scene !== 'group' && scene !== 'temp')
462
+ return null;
463
+ const peerId = parseInt(peer, 10);
464
+ const messageSeq = parseInt(seq, 10);
465
+ if (Number.isNaN(peerId) || Number.isNaN(messageSeq))
466
+ return null;
467
+ return { message_scene: scene, peer_id: peerId, message_seq: messageSeq };
468
+ }
469
+ /** Build WS event URL + headers (access_token via Bearer + query). */
470
+ export function buildWsConnectOptions(config) {
471
+ const base = config.baseUrl.replace(/\/$/, '');
472
+ let url = `${base.replace(/^http/, 'ws')}/event`;
473
+ const headers = {};
474
+ if (config.access_token) {
475
+ headers.Authorization = `Bearer ${config.access_token}`;
476
+ url = `${url}${url.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(config.access_token)}`;
477
+ }
478
+ const safeUrl = url.replace(/([?&]access_token=)[^&]*/g, '$1***');
479
+ return { url, headers, safeUrl };
480
+ }
481
+ /** Build SSE event URL (HTTP) + headers. */
482
+ export function buildSseConnectOptions(config) {
483
+ const base = config.baseUrl.replace(/\/$/, '');
484
+ let url = `${base}/event`;
485
+ const headers = {
486
+ Accept: 'text/event-stream',
487
+ };
488
+ if (config.access_token) {
489
+ headers.Authorization = `Bearer ${config.access_token}`;
490
+ url = `${url}${url.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(config.access_token)}`;
491
+ }
492
+ const safeUrl = url.replace(/([?&]access_token=)[^&]*/g, '$1***');
493
+ return { url, headers, safeUrl };
494
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Minimal fetch-based SSE client (no EventSource dependency).
3
+ * Parses `text/event-stream` frames and invokes onMessage for each `data:` payload.
4
+ */
5
+ export interface SseClientOptions {
6
+ readonly url: string;
7
+ readonly headers?: Record<string, string>;
8
+ readonly fetch?: typeof globalThis.fetch;
9
+ readonly signal?: AbortSignal;
10
+ readonly onMessage: (data: string) => void;
11
+ readonly onError?: (error: Error) => void;
12
+ readonly onOpen?: () => void;
13
+ }
14
+ export interface SseClientHandle {
15
+ readonly closed: Promise<void>;
16
+ close(): void;
17
+ }
18
+ export declare function openSseStream(options: SseClientOptions): SseClientHandle;
19
+ /** Exported for unit tests. */
20
+ export declare function consumeSseBuffer(buffer: string, onMessage: (data: string) => void): string;