@zhin.js/adapter-milky 3.0.1 → 4.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.
- package/CHANGELOG.md +443 -0
- package/README.md +36 -117
- package/adapters/milky.ts +68 -0
- package/lib/endpoint.d.ts +7 -0
- package/lib/endpoint.js +6 -0
- package/lib/index.d.ts +3 -20
- package/lib/index.js +3 -35
- package/lib/milky-agent-deps.d.ts +24 -0
- package/lib/milky-agent-deps.js +30 -0
- package/lib/milky-auth.d.ts +3 -0
- package/lib/milky-auth.js +30 -0
- package/lib/protocol.d.ts +162 -0
- package/lib/protocol.js +337 -0
- package/lib/sse-client.d.ts +20 -0
- package/lib/sse-client.js +85 -0
- package/lib/sse-endpoint.d.ts +50 -0
- package/lib/sse-endpoint.js +261 -0
- package/lib/webhook-endpoint.d.ts +40 -0
- package/lib/webhook-endpoint.js +212 -0
- package/lib/ws-endpoint.d.ts +42 -0
- package/lib/ws-endpoint.js +320 -0
- package/lib/ws-types.d.ts +11 -0
- package/lib/ws-types.js +1 -0
- package/lib/wss-endpoint.d.ts +40 -0
- package/lib/wss-endpoint.js +263 -0
- package/package.json +52 -19
- package/plugin.ts +8 -0
- package/schema.json +36 -0
- package/src/endpoint.ts +26 -0
- package/src/index.ts +59 -48
- package/src/milky-agent-deps.ts +53 -0
- package/src/milky-auth.ts +29 -0
- package/src/protocol.ts +491 -0
- package/src/sse-client.ts +106 -0
- package/src/sse-endpoint.ts +318 -0
- package/src/webhook-endpoint.ts +262 -0
- package/src/ws-endpoint.ts +373 -0
- package/src/ws-types.ts +12 -0
- package/src/wss-endpoint.ts +305 -0
- package/lib/adapter.d.ts +0 -37
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -99
- package/lib/adapter.js.map +0 -1
- package/lib/api.d.ts +0 -10
- package/lib/api.d.ts.map +0 -1
- package/lib/api.js +0 -48
- package/lib/api.js.map +0 -1
- package/lib/endpoint-sse.d.ts +0 -31
- package/lib/endpoint-sse.d.ts.map +0 -1
- package/lib/endpoint-sse.js +0 -197
- package/lib/endpoint-sse.js.map +0 -1
- package/lib/endpoint-webhook.d.ts +0 -34
- package/lib/endpoint-webhook.d.ts.map +0 -1
- package/lib/endpoint-webhook.js +0 -189
- package/lib/endpoint-webhook.js.map +0 -1
- package/lib/endpoint-ws.d.ts +0 -35
- package/lib/endpoint-ws.d.ts.map +0 -1
- package/lib/endpoint-ws.js +0 -264
- package/lib/endpoint-ws.js.map +0 -1
- package/lib/endpoint-wss.d.ts +0 -34
- package/lib/endpoint-wss.d.ts.map +0 -1
- package/lib/endpoint-wss.js +0 -227
- package/lib/endpoint-wss.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/segment-mapper.d.ts +0 -2
- package/lib/segment-mapper.d.ts.map +0 -1
- package/lib/segment-mapper.js +0 -2
- package/lib/segment-mapper.js.map +0 -1
- package/lib/types.d.ts +0 -75
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -5
- package/lib/types.js.map +0 -1
- package/lib/utils.d.ts +0 -35
- package/lib/utils.d.ts.map +0 -1
- package/lib/utils.js +0 -166
- package/lib/utils.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -111
- package/src/api.ts +0 -63
- package/src/endpoint-sse.ts +0 -232
- package/src/endpoint-webhook.ts +0 -225
- package/src/endpoint-ws.ts +0 -300
- package/src/endpoint-wss.ts +0 -272
- package/src/segment-mapper.ts +0 -1
- package/src/types.ts +0 -73
- package/src/utils.ts +0 -182
- /package/{skills/milky → agent}/PERMITS.md +0 -0
- /package/{skills/milky/SKILL.md → agent/skills/milky.md} +0 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
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
|
+
|
|
7
|
+
/** Transitional legacy endpoint row (`endpoints[]` with `context: milky`). */
|
|
8
|
+
export interface MilkyLegacyEndpointRow {
|
|
9
|
+
readonly context?: string;
|
|
10
|
+
readonly connection?: 'ws' | 'sse' | 'webhook' | 'wss';
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
readonly baseUrl?: string;
|
|
13
|
+
readonly access_token?: string;
|
|
14
|
+
readonly path?: string;
|
|
15
|
+
readonly reconnect_interval?: number;
|
|
16
|
+
readonly heartbeat_interval?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
20
|
+
export interface MilkyAdapterConfig {
|
|
21
|
+
readonly connection?: 'ws' | 'sse' | 'webhook' | 'wss';
|
|
22
|
+
readonly name?: 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
|
+
|
|
32
|
+
export interface MilkyConfigBase {
|
|
33
|
+
readonly context: 'milky';
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly baseUrl: string;
|
|
36
|
+
readonly access_token?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** WebSocket 正向:应用连协议端 ws(s)://baseUrl/event */
|
|
40
|
+
export interface MilkyWsConfig extends MilkyConfigBase {
|
|
41
|
+
readonly connection: 'ws';
|
|
42
|
+
readonly reconnect_interval: number;
|
|
43
|
+
readonly heartbeat_interval: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** SSE:HTTP GET `text/event-stream` on `/event`(同 WS 事件流) */
|
|
47
|
+
export interface MilkySseConfig extends MilkyConfigBase {
|
|
48
|
+
readonly connection: 'sse';
|
|
49
|
+
readonly reconnect_interval: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Webhook:httpHostToken POST 入站 + baseUrl HTTP API 出站 */
|
|
53
|
+
export interface MilkyWebhookConfig extends MilkyConfigBase {
|
|
54
|
+
readonly connection: 'webhook';
|
|
55
|
+
readonly path: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 反向 WS:httpHostToken WS upgrade 入站 + baseUrl HTTP API 出站 */
|
|
59
|
+
export interface MilkyWssConfig extends MilkyConfigBase {
|
|
60
|
+
readonly connection: 'wss';
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly heartbeat_interval: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ResolvedMilkyConfig =
|
|
66
|
+
| MilkyWsConfig
|
|
67
|
+
| MilkySseConfig
|
|
68
|
+
| MilkyWebhookConfig
|
|
69
|
+
| MilkyWssConfig;
|
|
70
|
+
export type MilkyEndpointConfig = ResolvedMilkyConfig;
|
|
71
|
+
|
|
72
|
+
export interface MilkyApiResponse<T = unknown> {
|
|
73
|
+
status: string;
|
|
74
|
+
retcode: number;
|
|
75
|
+
data?: T;
|
|
76
|
+
message?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MilkyEvent {
|
|
80
|
+
event_type: string;
|
|
81
|
+
time: number;
|
|
82
|
+
self_id: number;
|
|
83
|
+
data?: Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MilkyIncomingMessage {
|
|
87
|
+
message_scene: 'friend' | 'group' | 'temp';
|
|
88
|
+
peer_id: number;
|
|
89
|
+
message_seq: number;
|
|
90
|
+
sender_id: number;
|
|
91
|
+
time: number;
|
|
92
|
+
segments: MilkyIncomingSegment[];
|
|
93
|
+
friend?: { user_id: number; nickname?: string };
|
|
94
|
+
group?: { group_id: number; group_name?: string };
|
|
95
|
+
group_member?: { user_id: number; nickname?: string; card?: string; role?: string };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MilkyIncomingSegment {
|
|
99
|
+
type: string;
|
|
100
|
+
data?: Record<string, unknown>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MilkyWireSegment {
|
|
104
|
+
readonly type: string;
|
|
105
|
+
readonly data?: Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface MilkyOutgoingSegment {
|
|
109
|
+
type: string;
|
|
110
|
+
data: Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ParsedSendTarget {
|
|
114
|
+
readonly message_type: 'private' | 'group';
|
|
115
|
+
readonly id: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface MilkyApiClientOptions {
|
|
119
|
+
readonly baseUrl: string;
|
|
120
|
+
readonly access_token?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeConnection(connection: string | undefined): 'ws' | 'sse' | 'webhook' | 'wss' {
|
|
124
|
+
if (connection === 'sse' || connection === 'webhook' || connection === 'wss') return connection;
|
|
125
|
+
return 'ws';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function resolveMilkyConfig(config: MilkyAdapterConfig = {}): ResolvedMilkyConfig {
|
|
129
|
+
const entry = config.endpoints?.find((item) => item.context === 'milky');
|
|
130
|
+
const connection = normalizeConnection(config.connection ?? entry?.connection);
|
|
131
|
+
const name = (typeof config.name === 'string' && config.name)
|
|
132
|
+
|| (typeof entry?.name === 'string' && entry.name)
|
|
133
|
+
|| process.env.MILKY_BOT_NAME
|
|
134
|
+
|| 'milky-bot';
|
|
135
|
+
const baseUrl = config.baseUrl ?? entry?.baseUrl;
|
|
136
|
+
if (!baseUrl) {
|
|
137
|
+
throw new TypeError(
|
|
138
|
+
'Milky requires baseUrl (plugins.<key>.baseUrl or endpoints with context: milky)',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const access_token = config.access_token ?? entry?.access_token;
|
|
142
|
+
const base = {
|
|
143
|
+
context: 'milky' as const,
|
|
144
|
+
name,
|
|
145
|
+
baseUrl,
|
|
146
|
+
access_token,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (connection === 'ws') {
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
connection: 'ws',
|
|
153
|
+
reconnect_interval: config.reconnect_interval ?? entry?.reconnect_interval ?? 5000,
|
|
154
|
+
heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (connection === 'sse') {
|
|
159
|
+
return {
|
|
160
|
+
...base,
|
|
161
|
+
connection: 'sse',
|
|
162
|
+
reconnect_interval: config.reconnect_interval
|
|
163
|
+
?? entry?.reconnect_interval
|
|
164
|
+
?? 5_000,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (connection === 'webhook') {
|
|
169
|
+
const path = config.path ?? entry?.path;
|
|
170
|
+
if (!path) throw new TypeError('Milky connection:webhook requires path');
|
|
171
|
+
return { ...base, connection: 'webhook', path };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (connection === 'wss') {
|
|
175
|
+
const path = config.path ?? entry?.path;
|
|
176
|
+
if (!path) throw new TypeError('Milky connection:wss requires path');
|
|
177
|
+
return {
|
|
178
|
+
...base,
|
|
179
|
+
connection: 'wss',
|
|
180
|
+
path,
|
|
181
|
+
heartbeat_interval: config.heartbeat_interval ?? entry?.heartbeat_interval ?? 30_000,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new TypeError(`Unknown Milky connection: ${String(connection)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 鉴权:Header Authorization: Bearer {token} 或 URL query access_token=xxx */
|
|
189
|
+
function authHeaders(access_token?: string): Record<string, string> {
|
|
190
|
+
const headers: Record<string, string> = {};
|
|
191
|
+
if (access_token) {
|
|
192
|
+
headers.Authorization = `Bearer ${access_token}`;
|
|
193
|
+
}
|
|
194
|
+
return headers;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function authQuery(access_token?: string): string {
|
|
198
|
+
if (!access_token) return '';
|
|
199
|
+
return `access_token=${encodeURIComponent(access_token)}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 调用协议端 API:POST {baseUrl}/api/{apiName},Body JSON,鉴权。
|
|
204
|
+
* 非 200 或 retcode !== 0 时抛错。
|
|
205
|
+
*/
|
|
206
|
+
export async function callApi<T = unknown>(
|
|
207
|
+
options: MilkyApiClientOptions,
|
|
208
|
+
apiName: string,
|
|
209
|
+
params: Record<string, unknown> = {},
|
|
210
|
+
): Promise<T> {
|
|
211
|
+
const { baseUrl, access_token } = options;
|
|
212
|
+
const url = new URL(`/api/${apiName}`, baseUrl.replace(/\/$/, ''));
|
|
213
|
+
const q = authQuery(access_token);
|
|
214
|
+
if (q) url.search = (url.search ? `${url.search}&` : '') + q;
|
|
215
|
+
|
|
216
|
+
const res = await fetch(url.toString(), {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: {
|
|
219
|
+
'Content-Type': 'application/json',
|
|
220
|
+
...authHeaders(access_token),
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify(Object.keys(params).length ? params : {}),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const text = await res.text();
|
|
226
|
+
let body: MilkyApiResponse<T>;
|
|
227
|
+
try {
|
|
228
|
+
body = JSON.parse(text) as MilkyApiResponse<T>;
|
|
229
|
+
} catch {
|
|
230
|
+
throw new Error(`Milky API ${apiName}: invalid JSON response (${res.status}) ${text.slice(0, 200)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (res.status !== 200) {
|
|
234
|
+
throw new Error(`Milky API ${apiName}: HTTP ${res.status} ${body.message ?? text}`);
|
|
235
|
+
}
|
|
236
|
+
if (body.retcode !== 0) {
|
|
237
|
+
throw new Error(`Milky API ${apiName}: retcode=${body.retcode} ${body.message ?? ''}`);
|
|
238
|
+
}
|
|
239
|
+
return (body.data ?? {}) as T;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 根据 event_type 判断是否为 message_receive,并解析 data */
|
|
243
|
+
export function parseMessageReceiveData(event: MilkyEvent): MilkyIncomingMessage | null {
|
|
244
|
+
if (event.event_type !== 'message_receive' || !event.data) return null;
|
|
245
|
+
const data = event.data as unknown as MilkyIncomingMessage;
|
|
246
|
+
if (!data.message_scene || !Number.isInteger(data.peer_id) || !Array.isArray(data.segments)) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return data;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function isMessageReceiveEvent(
|
|
253
|
+
event: MilkyEvent,
|
|
254
|
+
): event is MilkyEvent & { data: MilkyIncomingMessage } {
|
|
255
|
+
return parseMessageReceiveData(event) != null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Gateway reply target:`private:uid` / `group:gid` */
|
|
259
|
+
export function formatInboundTarget(data: MilkyIncomingMessage): string {
|
|
260
|
+
const isGroup = data.message_scene === 'group';
|
|
261
|
+
return `${isGroup ? 'group' : 'private'}:${data.peer_id}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function formatInboundContent(data: MilkyIncomingMessage): string {
|
|
265
|
+
return data.segments.map((seg) => {
|
|
266
|
+
if (seg.type === 'text') return String(seg.data?.text ?? '');
|
|
267
|
+
if (seg.type === 'record' || seg.type === 'audio') {
|
|
268
|
+
const uri = String(seg.data?.uri ?? seg.data?.url ?? '');
|
|
269
|
+
return uri ? `[audio:${uri}]` : '[audio]';
|
|
270
|
+
}
|
|
271
|
+
return '';
|
|
272
|
+
}).join('');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** First audio URI from inbound segments (for Agent Host STT preprocess). */
|
|
276
|
+
export function extractInboundAudioUrl(data: MilkyIncomingMessage): string | undefined {
|
|
277
|
+
for (const seg of data.segments) {
|
|
278
|
+
if (seg.type !== 'record' && seg.type !== 'audio') continue;
|
|
279
|
+
const uri = String(seg.data?.uri ?? seg.data?.url ?? '').trim();
|
|
280
|
+
if (uri) return uri;
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 发送者显示名(群名片/群昵称/好友昵称);没有可靠的显示名时返回 undefined。 */
|
|
286
|
+
export function senderNickname(data: MilkyIncomingMessage): string | undefined {
|
|
287
|
+
const isGroup = data.message_scene === 'group';
|
|
288
|
+
const name = isGroup
|
|
289
|
+
? data.group_member?.card ?? data.group_member?.nickname
|
|
290
|
+
: data.friend?.nickname;
|
|
291
|
+
return typeof name === 'string' && name ? name : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 消息段中含 @ 本机(mention 段 user_id 等于协议端上报的 self_id)。 */
|
|
295
|
+
export function isMentioned(data: MilkyIncomingMessage, selfId: number | undefined): boolean {
|
|
296
|
+
if (selfId == null) return false;
|
|
297
|
+
return data.segments.some(
|
|
298
|
+
(seg) => seg.type === 'mention' && Number(seg.data?.user_id) === selfId,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function formatInboundMessageId(data: MilkyIncomingMessage): string {
|
|
303
|
+
return `${data.message_scene}:${data.peer_id}:${data.message_seq}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function parseSendTarget(target: string): ParsedSendTarget {
|
|
307
|
+
const sep = target.indexOf(':');
|
|
308
|
+
if (sep <= 0) {
|
|
309
|
+
return { message_type: 'private', id: target };
|
|
310
|
+
}
|
|
311
|
+
const head = target.slice(0, sep);
|
|
312
|
+
const rest = target.slice(sep + 1);
|
|
313
|
+
if (head === 'group') return { message_type: 'group', id: rest };
|
|
314
|
+
if (head === 'private') return { message_type: 'private', id: rest };
|
|
315
|
+
return { message_type: 'private', id: target };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Wire-encode an already-rendered outbound payload into Milky outgoing segments.
|
|
320
|
+
* Segment canonicalization is intentionally not done here.
|
|
321
|
+
*/
|
|
322
|
+
export function formatOutboundSegments(payload: unknown): MilkyOutgoingSegment[] {
|
|
323
|
+
if (typeof payload === 'string') {
|
|
324
|
+
return [{ type: 'text', data: { text: payload } }];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const items: Array<string | MilkyWireSegment> = Array.isArray(payload)
|
|
328
|
+
? payload as Array<string | MilkyWireSegment>
|
|
329
|
+
: payload && typeof payload === 'object' && 'type' in (payload as object)
|
|
330
|
+
? [payload as MilkyWireSegment]
|
|
331
|
+
: [];
|
|
332
|
+
|
|
333
|
+
if (items.length === 0) {
|
|
334
|
+
const text = payload == null
|
|
335
|
+
? ''
|
|
336
|
+
: typeof payload === 'object'
|
|
337
|
+
? JSON.stringify(payload)
|
|
338
|
+
: String(payload);
|
|
339
|
+
return [{ type: 'text', data: { text } }];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const out: MilkyOutgoingSegment[] = [];
|
|
343
|
+
for (const item of items) {
|
|
344
|
+
if (typeof item === 'string') {
|
|
345
|
+
out.push({ type: 'text', data: { text: item } });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const type = item.type;
|
|
349
|
+
const data = item.data ?? {};
|
|
350
|
+
switch (type) {
|
|
351
|
+
case 'text':
|
|
352
|
+
out.push({ type: 'text', data: { text: String(data.text ?? '') } });
|
|
353
|
+
break;
|
|
354
|
+
case 'at':
|
|
355
|
+
if (data.type === 'all') {
|
|
356
|
+
out.push({ type: 'mention_all', data: {} });
|
|
357
|
+
} else {
|
|
358
|
+
const id = data.id;
|
|
359
|
+
if (id) out.push({ type: 'mention', data: { user_id: Number(id) || 0 } });
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
case 'mention':
|
|
363
|
+
out.push({ type: 'mention', data: { user_id: Number(data.user_id ?? data.id ?? 0) || 0 } });
|
|
364
|
+
break;
|
|
365
|
+
case 'mention_all':
|
|
366
|
+
out.push({ type: 'mention_all', data: {} });
|
|
367
|
+
break;
|
|
368
|
+
case 'face':
|
|
369
|
+
out.push({
|
|
370
|
+
type: 'face',
|
|
371
|
+
data: { face_id: String(data.face_id ?? data.id ?? ''), is_large: false },
|
|
372
|
+
});
|
|
373
|
+
break;
|
|
374
|
+
case 'reply': {
|
|
375
|
+
const mid = data.message_id ?? data.message_seq;
|
|
376
|
+
const seq = typeof mid === 'number'
|
|
377
|
+
? mid
|
|
378
|
+
: parseInt(String(mid).split(':').pop() ?? '0', 10);
|
|
379
|
+
out.push({ type: 'reply', data: { message_seq: seq } });
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
case 'image':
|
|
383
|
+
out.push({
|
|
384
|
+
type: 'image',
|
|
385
|
+
data: { uri: String(data.url ?? data.uri ?? '') },
|
|
386
|
+
});
|
|
387
|
+
break;
|
|
388
|
+
case 'record':
|
|
389
|
+
out.push({
|
|
390
|
+
type: 'record',
|
|
391
|
+
data: { uri: String(data.url ?? data.uri ?? '') },
|
|
392
|
+
});
|
|
393
|
+
break;
|
|
394
|
+
case 'video':
|
|
395
|
+
out.push({
|
|
396
|
+
type: 'video',
|
|
397
|
+
data: {
|
|
398
|
+
uri: String(data.url ?? data.uri ?? ''),
|
|
399
|
+
thumb_uri: data.thumb_uri,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
break;
|
|
403
|
+
default:
|
|
404
|
+
out.push({ type, data: data as Record<string, unknown> });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out.length ? out : [{ type: 'text', data: { text: '' } }];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function buildSendAction(
|
|
411
|
+
target: string,
|
|
412
|
+
message: MilkyOutgoingSegment[],
|
|
413
|
+
): { action: string; params: Record<string, unknown> } {
|
|
414
|
+
const parsed = parseSendTarget(target);
|
|
415
|
+
if (parsed.message_type === 'group') {
|
|
416
|
+
return {
|
|
417
|
+
action: 'send_group_message',
|
|
418
|
+
params: {
|
|
419
|
+
group_id: parseInt(parsed.id, 10),
|
|
420
|
+
message,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
action: 'send_private_message',
|
|
426
|
+
params: {
|
|
427
|
+
user_id: parseInt(parsed.id, 10),
|
|
428
|
+
message,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** message_seq result → gateway message id */
|
|
434
|
+
export function formatOutboundMessageId(
|
|
435
|
+
target: string,
|
|
436
|
+
messageSeq: number | undefined,
|
|
437
|
+
): string {
|
|
438
|
+
if (messageSeq == null) return '';
|
|
439
|
+
const parsed = parseSendTarget(target);
|
|
440
|
+
const scene = parsed.message_type === 'group' ? 'group' : 'friend';
|
|
441
|
+
return `${scene}:${parsed.id}:${messageSeq}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function parseMilkyMessageId(
|
|
445
|
+
msgId: string,
|
|
446
|
+
): { message_scene: 'friend' | 'group' | 'temp'; peer_id: number; message_seq: number } | null {
|
|
447
|
+
const parts = msgId.split(':');
|
|
448
|
+
if (parts.length < 3) return null;
|
|
449
|
+
const [scene, peer, seq] = parts;
|
|
450
|
+
if (scene !== 'friend' && scene !== 'group' && scene !== 'temp') return null;
|
|
451
|
+
const peerId = parseInt(peer!, 10);
|
|
452
|
+
const messageSeq = parseInt(seq!, 10);
|
|
453
|
+
if (Number.isNaN(peerId) || Number.isNaN(messageSeq)) return null;
|
|
454
|
+
return { message_scene: scene, peer_id: peerId, message_seq: messageSeq };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Build WS event URL + headers (access_token via Bearer + query). */
|
|
458
|
+
export function buildWsConnectOptions(config: MilkyWsConfig): {
|
|
459
|
+
readonly url: string;
|
|
460
|
+
readonly headers: Record<string, string>;
|
|
461
|
+
readonly safeUrl: string;
|
|
462
|
+
} {
|
|
463
|
+
const base = config.baseUrl.replace(/\/$/, '');
|
|
464
|
+
let url = `${base.replace(/^http/, 'ws')}/event`;
|
|
465
|
+
const headers: Record<string, string> = {};
|
|
466
|
+
if (config.access_token) {
|
|
467
|
+
headers.Authorization = `Bearer ${config.access_token}`;
|
|
468
|
+
url = `${url}${url.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(config.access_token)}`;
|
|
469
|
+
}
|
|
470
|
+
const safeUrl = url.replace(/([?&]access_token=)[^&]*/g, '$1***');
|
|
471
|
+
return { url, headers, safeUrl };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Build SSE event URL (HTTP) + headers. */
|
|
475
|
+
export function buildSseConnectOptions(config: MilkySseConfig): {
|
|
476
|
+
readonly url: string;
|
|
477
|
+
readonly headers: Record<string, string>;
|
|
478
|
+
readonly safeUrl: string;
|
|
479
|
+
} {
|
|
480
|
+
const base = config.baseUrl.replace(/\/$/, '');
|
|
481
|
+
let url = `${base}/event`;
|
|
482
|
+
const headers: Record<string, string> = {
|
|
483
|
+
Accept: 'text/event-stream',
|
|
484
|
+
};
|
|
485
|
+
if (config.access_token) {
|
|
486
|
+
headers.Authorization = `Bearer ${config.access_token}`;
|
|
487
|
+
url = `${url}${url.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(config.access_token)}`;
|
|
488
|
+
}
|
|
489
|
+
const safeUrl = url.replace(/([?&]access_token=)[^&]*/g, '$1***');
|
|
490
|
+
return { url, headers, safeUrl };
|
|
491
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
|
|
15
|
+
export interface SseClientHandle {
|
|
16
|
+
readonly closed: Promise<void>;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function openSseStream(options: SseClientOptions): SseClientHandle {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
23
|
+
let settle!: () => void;
|
|
24
|
+
const closed = new Promise<void>((resolve) => { settle = resolve; });
|
|
25
|
+
|
|
26
|
+
const run = async (): Promise<void> => {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetchImpl(options.url, {
|
|
29
|
+
method: 'GET',
|
|
30
|
+
headers: {
|
|
31
|
+
Accept: 'text/event-stream',
|
|
32
|
+
...(options.headers ?? {}),
|
|
33
|
+
},
|
|
34
|
+
signal: anySignal([controller.signal, options.signal]),
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`SSE HTTP ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
if (!response.body) {
|
|
40
|
+
throw new Error('SSE response has no body');
|
|
41
|
+
}
|
|
42
|
+
options.onOpen?.();
|
|
43
|
+
const reader = response.body.getReader();
|
|
44
|
+
const decoder = new TextDecoder();
|
|
45
|
+
let buffer = '';
|
|
46
|
+
while (true) {
|
|
47
|
+
const { done, value } = await reader.read();
|
|
48
|
+
if (done) break;
|
|
49
|
+
buffer += decoder.decode(value, { stream: true });
|
|
50
|
+
buffer = consumeSseBuffer(buffer, options.onMessage);
|
|
51
|
+
}
|
|
52
|
+
buffer += decoder.decode();
|
|
53
|
+
consumeSseBuffer(`${buffer}\n\n`, options.onMessage);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!controller.signal.aborted) {
|
|
56
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
57
|
+
options.onError?.(err);
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
settle();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
void run();
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
closed,
|
|
68
|
+
close() {
|
|
69
|
+
controller.abort();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Exported for unit tests. */
|
|
75
|
+
export function consumeSseBuffer(
|
|
76
|
+
buffer: string,
|
|
77
|
+
onMessage: (data: string) => void,
|
|
78
|
+
): string {
|
|
79
|
+
let rest = buffer;
|
|
80
|
+
while (true) {
|
|
81
|
+
const sep = rest.indexOf('\n\n');
|
|
82
|
+
if (sep < 0) return rest;
|
|
83
|
+
const frame = rest.slice(0, sep);
|
|
84
|
+
rest = rest.slice(sep + 2);
|
|
85
|
+
const dataLines: string[] = [];
|
|
86
|
+
for (const line of frame.split('\n')) {
|
|
87
|
+
if (line.startsWith('data:')) {
|
|
88
|
+
dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (dataLines.length > 0) onMessage(dataLines.join('\n'));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function anySignal(signals: Array<AbortSignal | undefined>): AbortSignal {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
for (const signal of signals) {
|
|
98
|
+
if (!signal) continue;
|
|
99
|
+
if (signal.aborted) {
|
|
100
|
+
controller.abort();
|
|
101
|
+
return controller.signal;
|
|
102
|
+
}
|
|
103
|
+
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
104
|
+
}
|
|
105
|
+
return controller.signal;
|
|
106
|
+
}
|