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