@okclaw-build/cli 1.0.0-beta.6 → 1.0.0-beta.60
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/dist/bridge/daemon/config.d.ts +24 -0
- package/dist/bridge/daemon/config.js +96 -0
- package/dist/bridge/daemon/config.js.map +1 -0
- package/dist/bridge/daemon/daemon.d.ts +7 -0
- package/dist/bridge/daemon/daemon.js +23 -0
- package/dist/bridge/daemon/daemon.js.map +1 -0
- package/dist/bridge/daemon/event-id.d.ts +2 -0
- package/dist/bridge/daemon/event-id.js +18 -0
- package/dist/bridge/daemon/event-id.js.map +1 -0
- package/dist/bridge/daemon/frames.d.ts +81 -0
- package/dist/bridge/daemon/frames.js +64 -0
- package/dist/bridge/daemon/frames.js.map +1 -0
- package/dist/bridge/daemon/openclaw-chat-runtime.d.ts +80 -0
- package/dist/bridge/daemon/openclaw-chat-runtime.js +1222 -0
- package/dist/bridge/daemon/openclaw-chat-runtime.js.map +1 -0
- package/dist/bridge/daemon/openclaw-gateway-client.d.ts +58 -0
- package/dist/bridge/daemon/openclaw-gateway-client.js +251 -0
- package/dist/bridge/daemon/openclaw-gateway-client.js.map +1 -0
- package/dist/bridge/daemon/relay-client.d.ts +75 -0
- package/dist/bridge/daemon/relay-client.js +369 -0
- package/dist/bridge/daemon/relay-client.js.map +1 -0
- package/dist/bridge/daemon/stub-chat-runtime.d.ts +4 -0
- package/dist/bridge/daemon/stub-chat-runtime.js +91 -0
- package/dist/bridge/daemon/stub-chat-runtime.js.map +1 -0
- package/dist/bridge/service.d.ts +12 -0
- package/dist/bridge/service.js +86 -0
- package/dist/bridge/service.js.map +1 -0
- package/dist/commands/backup.d.ts +16 -0
- package/dist/commands/backup.js +99 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/bridge.d.ts +7 -0
- package/dist/commands/bridge.js +100 -0
- package/dist/commands/bridge.js.map +1 -0
- package/dist/commands/check.js +22 -6
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/edit.d.ts +2 -7
- package/dist/commands/edit.js +28 -16
- package/dist/commands/edit.js.map +1 -1
- package/dist/commands/feed.js +48 -16
- package/dist/commands/feed.js.map +1 -1
- package/dist/commands/install.js +99 -18
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/migration.d.ts +1 -0
- package/dist/commands/migration.js +205 -0
- package/dist/commands/migration.js.map +1 -0
- package/dist/commands/openclaw-channel-login.d.ts +54 -0
- package/dist/commands/openclaw-channel-login.js +334 -0
- package/dist/commands/openclaw-channel-login.js.map +1 -0
- package/dist/commands/restore.d.ts +16 -0
- package/dist/commands/restore.js +94 -0
- package/dist/commands/restore.js.map +1 -0
- package/dist/commands/service.js +56 -13
- package/dist/commands/service.js.map +1 -1
- package/dist/commands/show.d.ts +13 -0
- package/dist/commands/show.js +70 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/skill.d.ts +1 -0
- package/dist/commands/skill.js +137 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/uninstall.d.ts +1 -0
- package/dist/commands/uninstall.js +46 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/index.js +121 -12
- package/dist/index.js.map +1 -1
- package/dist/installers/base.d.ts +3 -1
- package/dist/installers/channel.d.ts +10 -2
- package/dist/installers/channel.js +143 -24
- package/dist/installers/channel.js.map +1 -1
- package/dist/installers/openclaw.d.ts +19 -2
- package/dist/installers/openclaw.js +186 -55
- package/dist/installers/openclaw.js.map +1 -1
- package/dist/installers/openviking-purge.d.ts +52 -0
- package/dist/installers/openviking-purge.js +380 -0
- package/dist/installers/openviking-purge.js.map +1 -0
- package/dist/installers/openviking.js +1 -2
- package/dist/installers/openviking.js.map +1 -1
- package/dist/installers/skill.d.ts +5 -2
- package/dist/installers/skill.js +67 -18
- package/dist/installers/skill.js.map +1 -1
- package/dist/migration/archive.d.ts +14 -0
- package/dist/migration/archive.js +84 -0
- package/dist/migration/archive.js.map +1 -0
- package/dist/migration/crypto.d.ts +16 -0
- package/dist/migration/crypto.js +56 -0
- package/dist/migration/crypto.js.map +1 -0
- package/dist/migration/manifest.d.ts +39 -0
- package/dist/migration/manifest.js +140 -0
- package/dist/migration/manifest.js.map +1 -0
- package/dist/openclaw-user-data.d.ts +86 -0
- package/dist/openclaw-user-data.js +422 -0
- package/dist/openclaw-user-data.js.map +1 -0
- package/dist/output/mode.d.ts +11 -0
- package/dist/output/mode.js +27 -0
- package/dist/output/mode.js.map +1 -0
- package/dist/output/ndjson.d.ts +57 -0
- package/dist/output/ndjson.js +136 -0
- package/dist/output/ndjson.js.map +1 -0
- package/dist/utils/constants.d.ts +14 -13
- package/dist/utils/constants.js +40 -21
- package/dist/utils/constants.js.map +1 -1
- package/dist/utils/deps.d.ts +34 -3
- package/dist/utils/deps.js +62 -100
- package/dist/utils/deps.js.map +1 -1
- package/dist/utils/install-safety.d.ts +25 -0
- package/dist/utils/install-safety.js +97 -0
- package/dist/utils/install-safety.js.map +1 -0
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.js +31 -4
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/mirror.js +14 -0
- package/dist/utils/mirror.js.map +1 -1
- package/dist/utils/openclaw-config-cli.d.ts +19 -0
- package/dist/utils/openclaw-config-cli.js +81 -0
- package/dist/utils/openclaw-config-cli.js.map +1 -0
- package/dist/utils/openclaw-daemon.d.ts +58 -0
- package/dist/utils/openclaw-daemon.js +154 -0
- package/dist/utils/openclaw-daemon.js.map +1 -0
- package/dist/utils/shell.d.ts +23 -2
- package/dist/utils/shell.js +138 -5
- package/dist/utils/shell.js.map +1 -1
- package/package.json +6 -4
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readFile as fsReadFile, realpath as fsRealpath, stat as fsStat } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, isAbsolute, join as joinPath, resolve as resolvePath, sep as pathSep } from 'node:path';
|
|
6
|
+
import { bridgeEventId } from './event-id.js';
|
|
7
|
+
import { OpenClawGatewayClient, OpenClawGatewayError, } from './openclaw-gateway-client.js';
|
|
8
|
+
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
|
9
|
+
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
const MAX_TOTAL_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024;
|
|
12
|
+
const FILE_REF_DOWNLOAD_ATTEMPTS = 2;
|
|
13
|
+
const MANAGED_MEDIA_PATH_PREFIX = '/api/chat/media/outgoing/';
|
|
14
|
+
const MAX_OUTBOUND_FILES = 5;
|
|
15
|
+
const FILE_EXT_MIME = {
|
|
16
|
+
md: 'text/markdown',
|
|
17
|
+
markdown: 'text/markdown',
|
|
18
|
+
txt: 'text/plain',
|
|
19
|
+
csv: 'text/csv',
|
|
20
|
+
json: 'application/json',
|
|
21
|
+
pdf: 'application/pdf',
|
|
22
|
+
zip: 'application/zip',
|
|
23
|
+
html: 'text/html',
|
|
24
|
+
xml: 'application/xml',
|
|
25
|
+
yaml: 'application/yaml',
|
|
26
|
+
yml: 'application/yaml',
|
|
27
|
+
log: 'text/plain',
|
|
28
|
+
png: 'image/png',
|
|
29
|
+
jpg: 'image/jpeg',
|
|
30
|
+
jpeg: 'image/jpeg',
|
|
31
|
+
gif: 'image/gif',
|
|
32
|
+
webp: 'image/webp',
|
|
33
|
+
svg: 'image/svg+xml',
|
|
34
|
+
mp3: 'audio/mpeg',
|
|
35
|
+
wav: 'audio/wav',
|
|
36
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
37
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
38
|
+
};
|
|
39
|
+
export class OpenClawChatRuntime {
|
|
40
|
+
config;
|
|
41
|
+
options;
|
|
42
|
+
gateway;
|
|
43
|
+
startPromise;
|
|
44
|
+
gatewayConnected = false;
|
|
45
|
+
gatewayVersion = 'unknown';
|
|
46
|
+
lastGatewayErrorCode;
|
|
47
|
+
byRequestId = new Map();
|
|
48
|
+
requestIdByRunId = new Map();
|
|
49
|
+
publishedArtifactKeys = new Set();
|
|
50
|
+
workspaceRootsCache;
|
|
51
|
+
constructor(config, options) {
|
|
52
|
+
this.config = config;
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
async start() {
|
|
56
|
+
if (this.gatewayConnected) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (this.startPromise) {
|
|
60
|
+
return this.startPromise;
|
|
61
|
+
}
|
|
62
|
+
this.startPromise = this.connectGateway();
|
|
63
|
+
try {
|
|
64
|
+
await this.startPromise;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
if (!this.gatewayConnected) {
|
|
68
|
+
this.startPromise = undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
stop() {
|
|
73
|
+
this.gateway?.close();
|
|
74
|
+
this.gateway = undefined;
|
|
75
|
+
this.gatewayConnected = false;
|
|
76
|
+
this.startPromise = undefined;
|
|
77
|
+
}
|
|
78
|
+
getRuntimeInfo() {
|
|
79
|
+
return {
|
|
80
|
+
gatewayConnected: this.gatewayConnected,
|
|
81
|
+
gatewayVersion: this.gatewayVersion,
|
|
82
|
+
...(this.lastGatewayErrorCode ? { lastGatewayErrorCode: this.lastGatewayErrorCode } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async handleChatSend(request, sink) {
|
|
86
|
+
const requestId = request.requestId;
|
|
87
|
+
const params = request.params;
|
|
88
|
+
const sessionKey = textParam(params, 'sessionKey');
|
|
89
|
+
if (!sessionKey) {
|
|
90
|
+
sink.sendRpcResult(rpcFailure(requestId, 'E_BRIDGE_CHAT_SESSION_REQUIRED', 'chat.send sessionKey is required'));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const openclawSessionKey = textParam(params, 'openclawSessionKey');
|
|
94
|
+
if (!openclawSessionKey) {
|
|
95
|
+
sink.sendRpcResult(rpcFailure(requestId, 'E_BRIDGE_CHAT_OPENCLAW_SESSION_REQUIRED', 'chat.send openclawSessionKey is required'));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const conversationId = longParam(params, 'conversationId');
|
|
99
|
+
if (conversationId === undefined || conversationId <= 0) {
|
|
100
|
+
sink.sendRpcResult(rpcFailure(requestId, 'E_BRIDGE_CHAT_CONVERSATION_REQUIRED', 'chat.send conversationId is required'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const message = textParam(params, 'message') ?? textParam(params, 'text');
|
|
104
|
+
if (!message || message.trim().length === 0) {
|
|
105
|
+
sink.sendRpcResult(rpcFailure(requestId, 'E_BRIDGE_CHAT_MESSAGE_REQUIRED', 'chat.send message is required'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (hasNonEmptyList(params.fileIds) || hasNonEmptyList(params.attachments)) {
|
|
109
|
+
sink.sendRpcResult(rpcFailure(requestId, 'E_BRIDGE_ATTACHMENTS_UNSUPPORTED', 'fileIds and raw attachments are not supported by this bridge runtime'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let attachments;
|
|
113
|
+
try {
|
|
114
|
+
attachments = hasNonEmptyList(params.fileRefs) ? await this.buildGatewayAttachments(params.fileRefs) : [];
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
const error = normalizeError(err, 'E_BRIDGE_ATTACHMENT_DOWNLOAD_FAILED');
|
|
118
|
+
sink.sendRpcResult(rpcFailure(requestId, error.code, error.message));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await this.start();
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const error = normalizeError(err, 'E_BRIDGE_GATEWAY_CONNECT_FAILED');
|
|
126
|
+
sink.sendRpcResult(rpcFailure(requestId, error.code, error.message));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const gateway = this.gateway;
|
|
130
|
+
if (!gateway) {
|
|
131
|
+
sink.sendRpcResult(rpcFailure(requestId, 'E_BRIDGE_GATEWAY_CONNECT_FAILED', 'OpenClaw Gateway is not connected'));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const active = {
|
|
135
|
+
requestId,
|
|
136
|
+
runId: requestId,
|
|
137
|
+
sessionKey,
|
|
138
|
+
openclawSessionKey,
|
|
139
|
+
conversationId,
|
|
140
|
+
customerId: longParam(params, 'customerId'),
|
|
141
|
+
shrimpId: longParam(params, 'shrimpId'),
|
|
142
|
+
sink,
|
|
143
|
+
acked: false,
|
|
144
|
+
finalSeen: false,
|
|
145
|
+
};
|
|
146
|
+
this.registerActive(active);
|
|
147
|
+
try {
|
|
148
|
+
const response = await gateway.request('chat.send', {
|
|
149
|
+
sessionKey: openclawSessionKey,
|
|
150
|
+
message,
|
|
151
|
+
attachments,
|
|
152
|
+
idempotencyKey: requestId,
|
|
153
|
+
}, request.deadlineMs ?? this.config.gateway.requestTimeoutMs);
|
|
154
|
+
const runId = textParam(response, 'runId') ?? requestId;
|
|
155
|
+
const gatewayStatus = textParam(response, 'status') ?? 'started';
|
|
156
|
+
this.requestIdByRunId.delete(active.runId);
|
|
157
|
+
active.runId = runId;
|
|
158
|
+
active.acked = true;
|
|
159
|
+
this.requestIdByRunId.set(runId, requestId);
|
|
160
|
+
sink.sendRpcResult({
|
|
161
|
+
type: 'rpc_result',
|
|
162
|
+
eventId: bridgeEventId(requestId, 'ack'),
|
|
163
|
+
requestId,
|
|
164
|
+
ok: true,
|
|
165
|
+
result: {
|
|
166
|
+
runId,
|
|
167
|
+
status: 'accepted',
|
|
168
|
+
gatewayStatus,
|
|
169
|
+
sessionKey,
|
|
170
|
+
openclawSessionKey,
|
|
171
|
+
conversationId,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
if (gatewayStatus === 'ok') {
|
|
175
|
+
this.emitFailure(active, {
|
|
176
|
+
errorCode: 'E_BRIDGE_GATEWAY_FINAL_UNAVAILABLE',
|
|
177
|
+
errorMessage: 'Gateway chat already completed but final message is unavailable',
|
|
178
|
+
gatewayStatus,
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (gatewayStatus === 'timeout') {
|
|
183
|
+
this.emitFailure(active, {
|
|
184
|
+
errorCode: 'E_BRIDGE_GATEWAY_CHAT_ABORTED',
|
|
185
|
+
errorMessage: 'Gateway chat ended before a final message was available',
|
|
186
|
+
gatewayStatus,
|
|
187
|
+
stopReason: textParam(response, 'stopReason'),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
this.unregisterActive(active);
|
|
193
|
+
const error = normalizeError(err, 'E_BRIDGE_GATEWAY_RPC_FAILED');
|
|
194
|
+
this.lastGatewayErrorCode = error.code;
|
|
195
|
+
sink.sendRpcResult(rpcFailure(requestId, error.code, error.message));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async connectGateway() {
|
|
199
|
+
const token = this.resolveGatewayToken();
|
|
200
|
+
if (!token) {
|
|
201
|
+
this.lastGatewayErrorCode = 'E_BRIDGE_GATEWAY_AUTH_MISSING';
|
|
202
|
+
throw new OpenClawGatewayError('E_BRIDGE_GATEWAY_AUTH_MISSING', 'Gateway token is missing');
|
|
203
|
+
}
|
|
204
|
+
const gateway = new OpenClawGatewayClient({
|
|
205
|
+
url: this.config.gateway.url,
|
|
206
|
+
token,
|
|
207
|
+
installedVersion: this.options.installedVersion,
|
|
208
|
+
nonce: this.options.nonce,
|
|
209
|
+
webSocketFactory: this.options.gatewayWebSocketFactory,
|
|
210
|
+
timers: this.options.timers,
|
|
211
|
+
connectTimeoutMs: this.config.gateway.connectTimeoutMs,
|
|
212
|
+
requestTimeoutMs: this.config.gateway.requestTimeoutMs,
|
|
213
|
+
});
|
|
214
|
+
gateway.onEvent((event) => this.handleGatewayEvent(event));
|
|
215
|
+
gateway.onClose(() => this.handleGatewayClose());
|
|
216
|
+
this.gateway = gateway;
|
|
217
|
+
try {
|
|
218
|
+
const hello = await gateway.connect();
|
|
219
|
+
if (!hello.methods.includes('chat.send')) {
|
|
220
|
+
this.lastGatewayErrorCode = 'E_BRIDGE_GATEWAY_METHOD_UNSUPPORTED';
|
|
221
|
+
throw new OpenClawGatewayError('E_BRIDGE_GATEWAY_METHOD_UNSUPPORTED', 'OpenClaw Gateway does not support chat.send');
|
|
222
|
+
}
|
|
223
|
+
if (hello.events !== undefined && !hello.events.includes('chat')) {
|
|
224
|
+
this.lastGatewayErrorCode = 'E_BRIDGE_GATEWAY_METHOD_UNSUPPORTED';
|
|
225
|
+
throw new OpenClawGatewayError('E_BRIDGE_GATEWAY_METHOD_UNSUPPORTED', 'OpenClaw Gateway does not advertise chat event');
|
|
226
|
+
}
|
|
227
|
+
this.gatewayConnected = true;
|
|
228
|
+
this.gatewayVersion = hello.serverVersion;
|
|
229
|
+
this.lastGatewayErrorCode = undefined;
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
gateway.close();
|
|
233
|
+
this.gatewayConnected = false;
|
|
234
|
+
this.gateway = undefined;
|
|
235
|
+
const error = normalizeError(err, 'E_BRIDGE_GATEWAY_CONNECT_REJECTED');
|
|
236
|
+
this.lastGatewayErrorCode = error.code;
|
|
237
|
+
throw new OpenClawGatewayError(error.code, error.message);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async buildGatewayAttachments(value) {
|
|
241
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
const attachments = [];
|
|
245
|
+
let totalBytes = 0;
|
|
246
|
+
for (const item of value) {
|
|
247
|
+
const fileRef = parseFileRef(item);
|
|
248
|
+
if (!fileRef) {
|
|
249
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_INVALID', 'fileRef is invalid');
|
|
250
|
+
}
|
|
251
|
+
const bytes = await this.downloadFileRef(fileRef, MAX_TOTAL_ATTACHMENT_BYTES - totalBytes);
|
|
252
|
+
if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
253
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_TOO_LARGE', 'fileRef exceeds 5MiB limit');
|
|
254
|
+
}
|
|
255
|
+
totalBytes += bytes.byteLength;
|
|
256
|
+
if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
|
|
257
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_TOO_LARGE', 'fileRefs exceed 10MiB total limit');
|
|
258
|
+
}
|
|
259
|
+
if (fileRef.sizeBytes !== undefined && fileRef.sizeBytes !== bytes.byteLength) {
|
|
260
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_INVALID', 'fileRef size mismatch');
|
|
261
|
+
}
|
|
262
|
+
if (fileRef.sha256 && sha256Hex(bytes) !== fileRef.sha256.toLowerCase()) {
|
|
263
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_INVALID', 'fileRef sha256 mismatch');
|
|
264
|
+
}
|
|
265
|
+
attachments.push({
|
|
266
|
+
type: fileRef.mimeType.startsWith('image/') ? 'image' : 'file',
|
|
267
|
+
fileName: fileRef.fileName,
|
|
268
|
+
mimeType: fileRef.mimeType,
|
|
269
|
+
content: Buffer.from(bytes).toString('base64'),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return attachments;
|
|
273
|
+
}
|
|
274
|
+
async downloadFileRef(fileRef, maxBytes) {
|
|
275
|
+
if (maxBytes <= 0) {
|
|
276
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_TOO_LARGE', 'fileRefs exceed 10MiB total limit');
|
|
277
|
+
}
|
|
278
|
+
const fetchImpl = this.options.fetch ?? globalThis.fetch;
|
|
279
|
+
if (!fetchImpl) {
|
|
280
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_DOWNLOAD_FAILED', 'fetch is not available');
|
|
281
|
+
}
|
|
282
|
+
let response;
|
|
283
|
+
let lastDownloadError;
|
|
284
|
+
for (let attempt = 1; attempt <= FILE_REF_DOWNLOAD_ATTEMPTS; attempt += 1) {
|
|
285
|
+
try {
|
|
286
|
+
response = await fetchWithTimeout(fetchImpl, fileRef.downloadUrl, undefined, this.config.gateway.requestTimeoutMs, 'E_BRIDGE_ATTACHMENT_DOWNLOAD_TIMEOUT', 'fileRef download timed out');
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
if (err instanceof OpenClawGatewayError) {
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
lastDownloadError = err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!response) {
|
|
297
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_DOWNLOAD_FAILED', errorMessage(lastDownloadError));
|
|
298
|
+
}
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_DOWNLOAD_FAILED', `fileRef download failed status=${response.status}`);
|
|
301
|
+
}
|
|
302
|
+
const contentLength = parseContentLength(response.headers?.get('content-length'));
|
|
303
|
+
if (contentLength !== undefined && contentLength > Math.min(MAX_ATTACHMENT_BYTES, maxBytes)) {
|
|
304
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_TOO_LARGE', 'fileRef exceeds size limit');
|
|
305
|
+
}
|
|
306
|
+
return readResponseBytes(response, Math.min(MAX_ATTACHMENT_BYTES, maxBytes));
|
|
307
|
+
}
|
|
308
|
+
handleGatewayEvent(frame) {
|
|
309
|
+
if (frame.event === 'session.message') {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (frame.event !== 'chat') {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const payload = frame.payload;
|
|
316
|
+
const runId = textParam(payload, 'runId');
|
|
317
|
+
if (!runId) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const active = this.activeByRunId(runId);
|
|
321
|
+
if (!active) {
|
|
322
|
+
console.warn(`[okclaw-bridge] ignored unmatched Gateway chat event runId=${runId}`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const state = textParam(payload, 'state');
|
|
326
|
+
if (state === 'delta') {
|
|
327
|
+
this.emitDelta(active, payload);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (state === 'final') {
|
|
331
|
+
this.emitFinal(active, payload);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (state === 'error') {
|
|
335
|
+
this.emitFailure(active, {
|
|
336
|
+
errorCode: 'E_BRIDGE_GATEWAY_CHAT_FAILED',
|
|
337
|
+
errorMessage: textParam(payload, 'errorMessage') ?? 'Gateway chat failed',
|
|
338
|
+
gatewayErrorKind: textParam(payload, 'errorKind'),
|
|
339
|
+
stopReason: textParam(payload, 'stopReason'),
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (state === 'aborted') {
|
|
344
|
+
this.emitFailure(active, {
|
|
345
|
+
errorCode: 'E_BRIDGE_GATEWAY_CHAT_ABORTED',
|
|
346
|
+
errorMessage: 'Gateway chat aborted',
|
|
347
|
+
stopReason: textParam(payload, 'stopReason'),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
handleGatewayClose() {
|
|
352
|
+
this.gatewayConnected = false;
|
|
353
|
+
this.gateway = undefined;
|
|
354
|
+
this.startPromise = undefined;
|
|
355
|
+
this.lastGatewayErrorCode = 'E_BRIDGE_GATEWAY_CONNECT_FAILED';
|
|
356
|
+
for (const active of [...this.byRequestId.values()]) {
|
|
357
|
+
if (active.acked && !active.finalSeen) {
|
|
358
|
+
this.emitFailure(active, {
|
|
359
|
+
errorCode: 'E_BRIDGE_GATEWAY_CHAT_FAILED',
|
|
360
|
+
errorMessage: 'OpenClaw Gateway connection closed before final message',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
this.unregisterActive(active);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
emitDelta(active, payload) {
|
|
369
|
+
const deltaText = textParam(payload, 'deltaText');
|
|
370
|
+
if (deltaText === undefined) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const seq = longParam(payload, 'seq') ?? 1;
|
|
374
|
+
active.sink.sendEvent({
|
|
375
|
+
type: 'event',
|
|
376
|
+
eventId: bridgeEventId(active.requestId, `delta_${seq}`),
|
|
377
|
+
requestId: active.requestId,
|
|
378
|
+
eventType: 'chat.delta',
|
|
379
|
+
payload: {
|
|
380
|
+
...basePayload(active),
|
|
381
|
+
deltaText,
|
|
382
|
+
replace: payload.replace === true,
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
emitFinal(active, payload) {
|
|
387
|
+
if (active.finalSeen) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
active.finalSeen = true;
|
|
391
|
+
const message = normalizeAssistantMessage(payload.message);
|
|
392
|
+
if (!message) {
|
|
393
|
+
this.emitFailure(active, {
|
|
394
|
+
errorCode: 'E_BRIDGE_GATEWAY_EMPTY_FINAL',
|
|
395
|
+
errorMessage: 'Gateway final message is empty',
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
active.sink.sendEvent({
|
|
400
|
+
type: 'event',
|
|
401
|
+
eventId: bridgeEventId(active.requestId, 'final'),
|
|
402
|
+
requestId: active.requestId,
|
|
403
|
+
eventType: 'session.message',
|
|
404
|
+
payload: {
|
|
405
|
+
...basePayload(active),
|
|
406
|
+
message,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
void this.publishOutboundMedia(active, payload.message).catch((err) => {
|
|
410
|
+
console.warn(`[okclaw-bridge] outbound media publish scan failed requestId=${active.requestId} runId=${active.runId} message=${errorMessage(err)}`);
|
|
411
|
+
});
|
|
412
|
+
this.unregisterActive(active);
|
|
413
|
+
}
|
|
414
|
+
// OpenClaw 把 agent 生成的媒体持久化进一条"gateway-injected"transcript 消息,
|
|
415
|
+
// 该消息不带 runId,因此 artifacts.list {runId} 永远过滤不到它(见 OpenClaw
|
|
416
|
+
// artifacts.test.ts "does not return untagged session artifacts for scoped runId queries")。
|
|
417
|
+
// 这里改为直接从 webchat final 消息内容里抽取媒体块(assistant 本轮产出、天然 run+role 限定,
|
|
418
|
+
// 不含历史/入站用户图片),data URL 本地解码、托管 /api/chat/media/outgoing/ URL 用 gateway
|
|
419
|
+
// 共享密钥 token 经 HTTP 取字节,再走 outbound grant → COS → artifact.published。
|
|
420
|
+
async publishOutboundMedia(active, message) {
|
|
421
|
+
// 图片/音频:webchat content block 或 OpenClaw reply payload 的 mediaUrl(s)。
|
|
422
|
+
// 文件:优先支持 reply payload 的 path/filePath/mediaUrls 本地路径;final 文本里的 markdown/MEDIA
|
|
423
|
+
// 本地路径只作为兼容兜底。所有本地路径都在工作区沙箱内 realpath 后再读字节。
|
|
424
|
+
const blocks = extractOutboundMediaBlocks(message);
|
|
425
|
+
const fileRefs = await this.extractOutboundFileRefs(message);
|
|
426
|
+
for (const media of [...blocks, ...fileRefs]) {
|
|
427
|
+
const dedupeKey = `${active.runId}:${media.artifactId}`;
|
|
428
|
+
if (this.publishedArtifactKeys.has(dedupeKey)) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
this.publishedArtifactKeys.add(dedupeKey);
|
|
432
|
+
await this.publishOneOutboundMedia(active, media);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async publishOneOutboundMedia(active, media) {
|
|
436
|
+
let bytes;
|
|
437
|
+
let mimeType;
|
|
438
|
+
try {
|
|
439
|
+
const resolved = await this.resolveOutboundMediaBytes(media);
|
|
440
|
+
bytes = resolved.bytes;
|
|
441
|
+
mimeType = normalizeMimeType(resolved.mimeType ?? media.mimeType);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
const reason = err instanceof OpenClawGatewayError && err.code === 'E_BRIDGE_ARTIFACT_FORBIDDEN_URL'
|
|
445
|
+
? 'unsupported_download_mode'
|
|
446
|
+
: 'download_failed';
|
|
447
|
+
this.emitOutboundUnsupported(active, media, reason, errorMessage(err));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (bytes.byteLength === 0) {
|
|
451
|
+
this.emitOutboundUnsupported(active, media, 'empty_artifact');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (bytes.byteLength > MAX_ARTIFACT_BYTES) {
|
|
455
|
+
this.emitOutboundUnsupported(active, media, 'artifact_too_large');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const sizeBytes = bytes.byteLength;
|
|
459
|
+
const fileName = media.fileName;
|
|
460
|
+
let grant;
|
|
461
|
+
try {
|
|
462
|
+
grant = await active.sink.sendOpsRequest({
|
|
463
|
+
method: 'oneberry.files.createOutboundUpload',
|
|
464
|
+
deadlineMs: this.config.gateway.requestTimeoutMs,
|
|
465
|
+
params: {
|
|
466
|
+
requestId: active.requestId,
|
|
467
|
+
runId: active.runId,
|
|
468
|
+
sessionKey: active.sessionKey,
|
|
469
|
+
conversationId: active.conversationId,
|
|
470
|
+
...(active.customerId !== undefined ? { customerId: active.customerId } : {}),
|
|
471
|
+
...(active.shrimpId !== undefined ? { shrimpId: active.shrimpId } : {}),
|
|
472
|
+
artifactId: media.artifactId,
|
|
473
|
+
fileName,
|
|
474
|
+
mimeType,
|
|
475
|
+
sizeBytes,
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
this.emitOutboundUnsupported(active, media, 'upload_grant_failed', errorMessage(err));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const uploadUrl = textParam(grant, 'uploadUrl');
|
|
484
|
+
if (!uploadUrl) {
|
|
485
|
+
this.emitOutboundUnsupported(active, media, 'upload_grant_invalid');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
await this.uploadArtifact(uploadUrl, bytes, mimeType);
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
this.emitOutboundUnsupported(active, media, 'upload_failed', errorMessage(err));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
active.sink.sendEvent({
|
|
496
|
+
type: 'event',
|
|
497
|
+
eventId: bridgeEventId(active.requestId, `artifact_published_${media.artifactId}`),
|
|
498
|
+
requestId: active.requestId,
|
|
499
|
+
eventType: 'artifact.published',
|
|
500
|
+
payload: {
|
|
501
|
+
...basePayload(active),
|
|
502
|
+
fileId: longParam(grant, 'fileId'),
|
|
503
|
+
artifactId: media.artifactId,
|
|
504
|
+
fileName,
|
|
505
|
+
mimeType,
|
|
506
|
+
sizeBytes,
|
|
507
|
+
...(textParam(grant, 'objectKey') ? { objectKey: textParam(grant, 'objectKey') } : {}),
|
|
508
|
+
source: 'openclaw.webchat.media',
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
async resolveOutboundMediaBytes(media) {
|
|
513
|
+
if (media.source.kind === 'data') {
|
|
514
|
+
return { bytes: Buffer.from(media.source.base64, 'base64'), mimeType: media.mimeType };
|
|
515
|
+
}
|
|
516
|
+
if (media.source.kind === 'localfile') {
|
|
517
|
+
return this.readOutboundLocalFile(media.source.path, media.mimeType);
|
|
518
|
+
}
|
|
519
|
+
const fetchImpl = this.options.fetch ?? globalThis.fetch;
|
|
520
|
+
if (!fetchImpl) {
|
|
521
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_DOWNLOAD_FAILED', 'fetch is not available');
|
|
522
|
+
}
|
|
523
|
+
const url = this.resolveGatewayHttpUrl(media.source.url);
|
|
524
|
+
const token = this.resolveGatewayToken();
|
|
525
|
+
const response = await fetchWithTimeout(fetchImpl, url, { headers: token ? { authorization: `Bearer ${token}` } : {} }, this.config.gateway.requestTimeoutMs, 'E_BRIDGE_ARTIFACT_DOWNLOAD_TIMEOUT', 'artifact download timed out');
|
|
526
|
+
if (!response.ok) {
|
|
527
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_DOWNLOAD_FAILED', `artifact download failed status=${response.status}`);
|
|
528
|
+
}
|
|
529
|
+
const bytes = await readResponseBytes(response, MAX_ARTIFACT_BYTES);
|
|
530
|
+
const responseMime = response.headers.get('content-type') ?? undefined;
|
|
531
|
+
return { bytes, mimeType: media.mimeType ?? responseMime };
|
|
532
|
+
}
|
|
533
|
+
// 仅允许回源到 gateway 自身的托管媒体路径,杜绝 bridge 被诱导去 fetch 任意外链(SSRF)。
|
|
534
|
+
resolveGatewayHttpUrl(raw) {
|
|
535
|
+
const base = new URL(this.config.gateway.url);
|
|
536
|
+
const httpProtocol = base.protocol === 'wss:' ? 'https:' : base.protocol === 'ws:' ? 'http:' : base.protocol;
|
|
537
|
+
const origin = `${httpProtocol}//${base.host}`;
|
|
538
|
+
let parsed;
|
|
539
|
+
try {
|
|
540
|
+
parsed = new URL(raw, origin);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_FORBIDDEN_URL', 'artifact url is invalid');
|
|
544
|
+
}
|
|
545
|
+
if (parsed.host !== base.host || !parsed.pathname.startsWith(MANAGED_MEDIA_PATH_PREFIX)) {
|
|
546
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_FORBIDDEN_URL', 'artifact url is not an allowed managed media path');
|
|
547
|
+
}
|
|
548
|
+
parsed.protocol = httpProtocol;
|
|
549
|
+
return parsed.toString();
|
|
550
|
+
}
|
|
551
|
+
resolveGatewayToken() {
|
|
552
|
+
return this.options.tokenProvider?.() ?? readGatewayToken(this.config);
|
|
553
|
+
}
|
|
554
|
+
// 从 final 文本里解析指向工作区的文件链接/路径,realpath 后必须落在工作区沙箱内才采纳。
|
|
555
|
+
async extractOutboundFileRefs(message) {
|
|
556
|
+
const roots = await this.resolveWorkspaceRoots();
|
|
557
|
+
if (roots.length === 0) {
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
const candidates = [
|
|
561
|
+
...extractStructuredLocalFileCandidates(message),
|
|
562
|
+
...parseFileCandidatesFromText(collectMessageText(message)),
|
|
563
|
+
];
|
|
564
|
+
const out = [];
|
|
565
|
+
const seen = new Set();
|
|
566
|
+
for (const candidate of candidates) {
|
|
567
|
+
if (out.length >= MAX_OUTBOUND_FILES) {
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
let real;
|
|
571
|
+
try {
|
|
572
|
+
const abs = isAbsolute(candidate) ? candidate : resolvePath(roots[0], candidate);
|
|
573
|
+
real = await fsRealpath(abs);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (!isInsideRoots(real, roots) || seen.has(real)) {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
let isFile = false;
|
|
582
|
+
try {
|
|
583
|
+
isFile = (await fsStat(real)).isFile();
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (!isFile) {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
seen.add(real);
|
|
592
|
+
const fileName = basename(real) || `file-${out.length + 1}`;
|
|
593
|
+
out.push({
|
|
594
|
+
artifactId: `file_${createHash('sha256').update(real).digest('hex').slice(0, 24)}`,
|
|
595
|
+
fileName,
|
|
596
|
+
mimeType: mimeForFileName(fileName),
|
|
597
|
+
source: { kind: 'localfile', path: real },
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
async readOutboundLocalFile(filePath, mimeType) {
|
|
603
|
+
const roots = await this.resolveWorkspaceRoots();
|
|
604
|
+
let real;
|
|
605
|
+
try {
|
|
606
|
+
real = await fsRealpath(filePath);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_DOWNLOAD_FAILED', 'outbound file not found');
|
|
610
|
+
}
|
|
611
|
+
// 读字节前再次沙箱校验(防 realpath 之后的 TOCTOU)。
|
|
612
|
+
if (roots.length === 0 || !isInsideRoots(real, roots)) {
|
|
613
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_FORBIDDEN_URL', 'outbound file is outside the workspace sandbox');
|
|
614
|
+
}
|
|
615
|
+
const stat = await fsStat(real);
|
|
616
|
+
if (!stat.isFile()) {
|
|
617
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_DOWNLOAD_FAILED', 'outbound file is not a regular file');
|
|
618
|
+
}
|
|
619
|
+
if (stat.size > MAX_ARTIFACT_BYTES) {
|
|
620
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_TOO_LARGE', 'outbound file exceeds size limit');
|
|
621
|
+
}
|
|
622
|
+
const buffer = await fsReadFile(real);
|
|
623
|
+
return { bytes: new Uint8Array(buffer), mimeType };
|
|
624
|
+
}
|
|
625
|
+
async resolveWorkspaceRoots() {
|
|
626
|
+
if (this.workspaceRootsCache) {
|
|
627
|
+
return this.workspaceRootsCache;
|
|
628
|
+
}
|
|
629
|
+
const roots = [];
|
|
630
|
+
const candidates = this.options.workspaceRoots && this.options.workspaceRoots.length > 0
|
|
631
|
+
? this.options.workspaceRoots
|
|
632
|
+
: [readOpenClawWorkspaceDir(this.options.openclawConfigPath)];
|
|
633
|
+
for (const candidate of candidates) {
|
|
634
|
+
const trimmed = candidate?.trim();
|
|
635
|
+
if (!trimmed) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
roots.push(await fsRealpath(trimmed));
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// 工作区不存在/不可读 → 该根不参与沙箱
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
this.workspaceRootsCache = roots;
|
|
646
|
+
return roots;
|
|
647
|
+
}
|
|
648
|
+
async uploadArtifact(uploadUrl, bytes, mimeType) {
|
|
649
|
+
const fetchImpl = this.options.fetch ?? globalThis.fetch;
|
|
650
|
+
if (!fetchImpl) {
|
|
651
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_UPLOAD_FAILED', 'fetch is not available');
|
|
652
|
+
}
|
|
653
|
+
const response = await fetchWithTimeout(fetchImpl, uploadUrl, {
|
|
654
|
+
method: 'PUT',
|
|
655
|
+
headers: {
|
|
656
|
+
'content-type': mimeType,
|
|
657
|
+
},
|
|
658
|
+
body: new Blob([arrayBufferCopy(bytes)], { type: mimeType }),
|
|
659
|
+
}, this.config.gateway.requestTimeoutMs, 'E_BRIDGE_ARTIFACT_UPLOAD_TIMEOUT', 'artifact upload timed out');
|
|
660
|
+
if (!response.ok) {
|
|
661
|
+
throw new OpenClawGatewayError('E_BRIDGE_ARTIFACT_UPLOAD_FAILED', `artifact upload failed status=${response.status}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
emitOutboundUnsupported(active, media, reason, message) {
|
|
665
|
+
active.sink.sendEvent({
|
|
666
|
+
type: 'event',
|
|
667
|
+
eventId: bridgeEventId(active.requestId, `artifact_unsupported_${media.artifactId}_${reason}`),
|
|
668
|
+
requestId: active.requestId,
|
|
669
|
+
eventType: 'artifact.unsupported',
|
|
670
|
+
payload: {
|
|
671
|
+
...basePayload(active),
|
|
672
|
+
artifactId: media.artifactId,
|
|
673
|
+
fileName: media.fileName,
|
|
674
|
+
mimeType: media.mimeType,
|
|
675
|
+
reason,
|
|
676
|
+
...(message ? { message } : {}),
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
emitFailure(active, failure) {
|
|
681
|
+
active.sink.sendEvent({
|
|
682
|
+
type: 'event',
|
|
683
|
+
eventId: bridgeEventId(active.requestId, 'failed'),
|
|
684
|
+
requestId: active.requestId,
|
|
685
|
+
eventType: 'message.failed',
|
|
686
|
+
payload: {
|
|
687
|
+
...basePayload(active),
|
|
688
|
+
errorCode: failure.errorCode,
|
|
689
|
+
errorMessage: failure.errorMessage,
|
|
690
|
+
...(failure.gatewayErrorKind ? { gatewayErrorKind: failure.gatewayErrorKind } : {}),
|
|
691
|
+
...(failure.stopReason ? { stopReason: failure.stopReason } : {}),
|
|
692
|
+
...(failure.gatewayStatus ? { gatewayStatus: failure.gatewayStatus } : {}),
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
this.unregisterActive(active);
|
|
696
|
+
}
|
|
697
|
+
registerActive(active) {
|
|
698
|
+
this.byRequestId.set(active.requestId, active);
|
|
699
|
+
this.requestIdByRunId.set(active.runId, active.requestId);
|
|
700
|
+
}
|
|
701
|
+
unregisterActive(active) {
|
|
702
|
+
this.byRequestId.delete(active.requestId);
|
|
703
|
+
this.requestIdByRunId.delete(active.runId);
|
|
704
|
+
}
|
|
705
|
+
activeByRunId(runId) {
|
|
706
|
+
const requestId = this.requestIdByRunId.get(runId);
|
|
707
|
+
return requestId ? this.byRequestId.get(requestId) : undefined;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function rpcFailure(requestId, errorCode, errorMessage) {
|
|
711
|
+
return {
|
|
712
|
+
type: 'rpc_result',
|
|
713
|
+
eventId: bridgeEventId(requestId, 'failed'),
|
|
714
|
+
requestId,
|
|
715
|
+
ok: false,
|
|
716
|
+
errorCode,
|
|
717
|
+
errorMessage,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function basePayload(active) {
|
|
721
|
+
return {
|
|
722
|
+
runId: active.runId,
|
|
723
|
+
sessionKey: active.sessionKey,
|
|
724
|
+
openclawSessionKey: active.openclawSessionKey,
|
|
725
|
+
conversationId: active.conversationId,
|
|
726
|
+
...(active.customerId !== undefined ? { customerId: active.customerId } : {}),
|
|
727
|
+
...(active.shrimpId !== undefined ? { shrimpId: active.shrimpId } : {}),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function readGatewayToken(config) {
|
|
731
|
+
const envName = config.gateway.authTokenEnv;
|
|
732
|
+
const envToken = envName ? process.env[envName]?.trim() : undefined;
|
|
733
|
+
if (envToken) {
|
|
734
|
+
return envToken;
|
|
735
|
+
}
|
|
736
|
+
const tokenFile = config.gateway.authTokenFile;
|
|
737
|
+
if (!tokenFile) {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
const fileToken = readFileSync(tokenFile, 'utf8').trim();
|
|
742
|
+
return fileToken || undefined;
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
throw new OpenClawGatewayError('E_BRIDGE_GATEWAY_AUTH_FILE_UNREADABLE', 'Gateway token file is unreadable');
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function normalizeError(err, fallbackCode) {
|
|
749
|
+
if (err instanceof OpenClawGatewayError) {
|
|
750
|
+
return {
|
|
751
|
+
code: err.code,
|
|
752
|
+
message: err.message,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
if (err instanceof Error) {
|
|
756
|
+
return {
|
|
757
|
+
code: fallbackCode,
|
|
758
|
+
message: err.message,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
code: fallbackCode,
|
|
763
|
+
message: fallbackCode,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
function errorMessage(err) {
|
|
767
|
+
return err instanceof Error ? err.message : String(err);
|
|
768
|
+
}
|
|
769
|
+
function normalizeAssistantMessage(value) {
|
|
770
|
+
if (!isRecord(value) || value.role !== 'assistant') {
|
|
771
|
+
return undefined;
|
|
772
|
+
}
|
|
773
|
+
const content = value.content;
|
|
774
|
+
if (typeof content === 'string') {
|
|
775
|
+
return { role: 'assistant', content };
|
|
776
|
+
}
|
|
777
|
+
if (Array.isArray(content)) {
|
|
778
|
+
const text = content
|
|
779
|
+
.map((item) => (isRecord(item) && typeof item.text === 'string' ? item.text : ''))
|
|
780
|
+
.join('');
|
|
781
|
+
if (text.length > 0) {
|
|
782
|
+
return { role: 'assistant', content: text };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
function hasNonEmptyList(value) {
|
|
788
|
+
return Array.isArray(value) && value.length > 0;
|
|
789
|
+
}
|
|
790
|
+
function parseFileRef(value) {
|
|
791
|
+
if (!isRecord(value)) {
|
|
792
|
+
return undefined;
|
|
793
|
+
}
|
|
794
|
+
const downloadUrl = textParam(value, 'downloadUrl');
|
|
795
|
+
const mimeType = normalizeMimeType(textParam(value, 'mimeType'));
|
|
796
|
+
const fileName = textParam(value, 'fileName') ?? textParam(value, 'originalName') ?? 'attachment';
|
|
797
|
+
if (!downloadUrl) {
|
|
798
|
+
return undefined;
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
fileName,
|
|
802
|
+
mimeType,
|
|
803
|
+
downloadUrl,
|
|
804
|
+
sizeBytes: longParam(value, 'sizeBytes'),
|
|
805
|
+
sha256: textParam(value, 'sha256'),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
function normalizeMimeType(value) {
|
|
809
|
+
const normalized = value?.trim().toLowerCase();
|
|
810
|
+
return normalized ? normalized : DEFAULT_MIME_TYPE;
|
|
811
|
+
}
|
|
812
|
+
function parseContentLength(value) {
|
|
813
|
+
if (!value) {
|
|
814
|
+
return undefined;
|
|
815
|
+
}
|
|
816
|
+
const parsed = Number(value);
|
|
817
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
818
|
+
}
|
|
819
|
+
async function fetchWithTimeout(fetchImpl, input, init, timeoutMs, timeoutCode, timeoutMessage) {
|
|
820
|
+
const controller = new AbortController();
|
|
821
|
+
let timedOut = false;
|
|
822
|
+
const timeout = setTimeout(() => {
|
|
823
|
+
timedOut = true;
|
|
824
|
+
controller.abort();
|
|
825
|
+
}, Math.max(1, timeoutMs));
|
|
826
|
+
try {
|
|
827
|
+
return await fetchImpl(input, {
|
|
828
|
+
...(init ?? {}),
|
|
829
|
+
signal: controller.signal,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
catch (err) {
|
|
833
|
+
if (timedOut || isAbortError(err)) {
|
|
834
|
+
throw new OpenClawGatewayError(timeoutCode, timeoutMessage);
|
|
835
|
+
}
|
|
836
|
+
throw err;
|
|
837
|
+
}
|
|
838
|
+
finally {
|
|
839
|
+
clearTimeout(timeout);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
function isAbortError(err) {
|
|
843
|
+
return err instanceof DOMException && err.name === 'AbortError';
|
|
844
|
+
}
|
|
845
|
+
async function readResponseBytes(response, maxBytes) {
|
|
846
|
+
const reader = response.body?.getReader();
|
|
847
|
+
if (!reader) {
|
|
848
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
849
|
+
if (bytes.byteLength > maxBytes) {
|
|
850
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_TOO_LARGE', 'fileRef exceeds size limit');
|
|
851
|
+
}
|
|
852
|
+
return bytes;
|
|
853
|
+
}
|
|
854
|
+
const chunks = [];
|
|
855
|
+
let totalBytes = 0;
|
|
856
|
+
while (true) {
|
|
857
|
+
const { value, done } = await reader.read();
|
|
858
|
+
if (done) {
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
if (!value) {
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
totalBytes += value.byteLength;
|
|
865
|
+
if (totalBytes > maxBytes) {
|
|
866
|
+
await reader.cancel();
|
|
867
|
+
throw new OpenClawGatewayError('E_BRIDGE_ATTACHMENT_TOO_LARGE', 'fileRef exceeds size limit');
|
|
868
|
+
}
|
|
869
|
+
chunks.push(value);
|
|
870
|
+
}
|
|
871
|
+
const bytes = new Uint8Array(totalBytes);
|
|
872
|
+
let offset = 0;
|
|
873
|
+
for (const chunk of chunks) {
|
|
874
|
+
bytes.set(chunk, offset);
|
|
875
|
+
offset += chunk.byteLength;
|
|
876
|
+
}
|
|
877
|
+
return bytes;
|
|
878
|
+
}
|
|
879
|
+
function sha256Hex(bytes) {
|
|
880
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
881
|
+
}
|
|
882
|
+
function arrayBufferCopy(bytes) {
|
|
883
|
+
const copy = new ArrayBuffer(bytes.byteLength);
|
|
884
|
+
new Uint8Array(copy).set(bytes);
|
|
885
|
+
return copy;
|
|
886
|
+
}
|
|
887
|
+
// 从 webchat final 消息的 content blocks 中抽取可回传的媒体(assistant 本轮产出)。
|
|
888
|
+
// 镜像 OpenClaw artifacts.ts 的 isArtifactBlock / resolveBlockDownload 取值逻辑,但只保留
|
|
889
|
+
// 两类安全来源:内联 data URL(本地解码)与 gateway 托管媒体路径(回源 fetch)。
|
|
890
|
+
function extractOutboundMediaBlocks(message) {
|
|
891
|
+
if (!isRecord(message)) {
|
|
892
|
+
return [];
|
|
893
|
+
}
|
|
894
|
+
const out = [];
|
|
895
|
+
for (const raw of Array.isArray(message.content) ? message.content : []) {
|
|
896
|
+
if (!isRecord(raw) || !isOutboundMediaBlock(raw)) {
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const resolved = resolveOutboundBlockSource(raw);
|
|
900
|
+
if (!resolved) {
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const type = normalizeOutboundMediaType(textParam(raw, 'type'));
|
|
904
|
+
const title = textParam(raw, 'title')
|
|
905
|
+
?? textParam(raw, 'fileName')
|
|
906
|
+
?? textParam(raw, 'filename')
|
|
907
|
+
?? textParam(raw, 'alt');
|
|
908
|
+
const mimeType = normalizeMimeType(resolved.mimeType);
|
|
909
|
+
const fileName = title ?? defaultOutboundFileName(type, mimeType, resolved.artifactId);
|
|
910
|
+
out.push({ artifactId: resolved.artifactId, fileName, mimeType, source: resolved.source });
|
|
911
|
+
}
|
|
912
|
+
for (const mediaUrl of extractStructuredMediaUrls(message)) {
|
|
913
|
+
const resolved = resolveOutboundMediaUrlSource(mediaUrl, message);
|
|
914
|
+
if (!resolved) {
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
const mimeType = normalizeMimeType(resolved.mimeType);
|
|
918
|
+
const fileName = structuredMediaFileName(message, mimeType, resolved.artifactId);
|
|
919
|
+
out.push({ artifactId: resolved.artifactId, fileName, mimeType, source: resolved.source });
|
|
920
|
+
}
|
|
921
|
+
return out;
|
|
922
|
+
}
|
|
923
|
+
function isOutboundMediaBlock(block) {
|
|
924
|
+
const type = textParam(block, 'type')?.toLowerCase();
|
|
925
|
+
if (type === 'image' || type === 'input_image' || type === 'image_url'
|
|
926
|
+
|| type === 'audio' || type === 'input_audio'
|
|
927
|
+
|| type === 'file' || type === 'input_file') {
|
|
928
|
+
return true;
|
|
929
|
+
}
|
|
930
|
+
return Boolean(block.url || block.openUrl || block.data || block.source || block.image_url || block.audio_url);
|
|
931
|
+
}
|
|
932
|
+
function resolveOutboundBlockSource(block) {
|
|
933
|
+
const source = isRecord(block.source) ? block.source : undefined;
|
|
934
|
+
const data = textParam(block, 'data');
|
|
935
|
+
const contentVal = textParam(block, 'content');
|
|
936
|
+
const url = textParam(block, 'url') ?? textParam(block, 'openUrl');
|
|
937
|
+
const imageUrl = mediaUrlValue(block.image_url);
|
|
938
|
+
const audioUrl = textParam(block, 'audio_url');
|
|
939
|
+
const sourceData = source ? textParam(source, 'data') : undefined;
|
|
940
|
+
const sourceUrl = source ? textParam(source, 'url') : undefined;
|
|
941
|
+
const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, contentVal, sourceData]
|
|
942
|
+
.find((value) => typeof value === 'string' && /^data:/i.test(value));
|
|
943
|
+
const mimeType = textParam(block, 'mimeType')
|
|
944
|
+
?? textParam(block, 'media_type')
|
|
945
|
+
?? (source ? (textParam(source, 'media_type') ?? textParam(source, 'mimeType')) : undefined)
|
|
946
|
+
?? (dataUrl ? mimeFromDataUrl(dataUrl) : undefined);
|
|
947
|
+
if (dataUrl) {
|
|
948
|
+
const base64 = base64FromDataUrl(dataUrl);
|
|
949
|
+
if (base64) {
|
|
950
|
+
return { source: { kind: 'data', base64 }, mimeType, artifactId: `sha_${sha256HexOfBase64(base64).slice(0, 24)}` };
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// base64 直接放在显式 source.data(图片/音频块的标准形态)。
|
|
954
|
+
if (sourceData && !/^data:/i.test(sourceData) && isLikelyBase64(sourceData)) {
|
|
955
|
+
return { source: { kind: 'data', base64: sourceData }, mimeType, artifactId: `sha_${sha256HexOfBase64(sourceData).slice(0, 24)}` };
|
|
956
|
+
}
|
|
957
|
+
const managed = [url, sourceUrl, imageUrl, audioUrl]
|
|
958
|
+
.find((value) => typeof value === 'string' && isManagedMediaUrl(value));
|
|
959
|
+
if (managed) {
|
|
960
|
+
return { source: { kind: 'managed', url: managed }, mimeType, artifactId: managedMediaArtifactId(managed) };
|
|
961
|
+
}
|
|
962
|
+
return undefined;
|
|
963
|
+
}
|
|
964
|
+
function resolveOutboundMediaUrlSource(mediaUrl, record) {
|
|
965
|
+
const mimeType = textParam(record, 'mimeType')
|
|
966
|
+
?? textParam(record, 'media_type')
|
|
967
|
+
?? textParam(record, 'mime_type');
|
|
968
|
+
if (/^data:/i.test(mediaUrl)) {
|
|
969
|
+
const base64 = base64FromDataUrl(mediaUrl);
|
|
970
|
+
if (!base64) {
|
|
971
|
+
return undefined;
|
|
972
|
+
}
|
|
973
|
+
return {
|
|
974
|
+
source: { kind: 'data', base64 },
|
|
975
|
+
mimeType: mimeType ?? mimeFromDataUrl(mediaUrl),
|
|
976
|
+
artifactId: `sha_${sha256HexOfBase64(base64).slice(0, 24)}`,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
if (isManagedMediaUrl(mediaUrl)) {
|
|
980
|
+
return {
|
|
981
|
+
source: { kind: 'managed', url: mediaUrl },
|
|
982
|
+
mimeType,
|
|
983
|
+
artifactId: managedMediaArtifactId(mediaUrl),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
return undefined;
|
|
987
|
+
}
|
|
988
|
+
function extractStructuredMediaUrls(message) {
|
|
989
|
+
const raw = [];
|
|
990
|
+
for (const key of ['media', 'mediaUrl', 'media_url', 'fileUrl', 'file_url']) {
|
|
991
|
+
const value = textParam(message, key);
|
|
992
|
+
if (value) {
|
|
993
|
+
raw.push(value);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
for (const key of ['mediaUrls', 'media_urls']) {
|
|
997
|
+
const value = message[key];
|
|
998
|
+
if (!Array.isArray(value)) {
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
for (const item of value) {
|
|
1002
|
+
if (typeof item === 'string' && item.trim()) {
|
|
1003
|
+
raw.push(item.trim());
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return raw;
|
|
1008
|
+
}
|
|
1009
|
+
function structuredMediaFileName(record, mimeType, artifactId) {
|
|
1010
|
+
return textParam(record, 'fileName')
|
|
1011
|
+
?? textParam(record, 'filename')
|
|
1012
|
+
?? textParam(record, 'name')
|
|
1013
|
+
?? textParam(record, 'title')
|
|
1014
|
+
?? defaultOutboundFileName(mediaTypeForMime(mimeType), mimeType, artifactId);
|
|
1015
|
+
}
|
|
1016
|
+
function mediaTypeForMime(mimeType) {
|
|
1017
|
+
if (mimeType.startsWith('image/')) {
|
|
1018
|
+
return 'image';
|
|
1019
|
+
}
|
|
1020
|
+
if (mimeType.startsWith('audio/')) {
|
|
1021
|
+
return 'audio';
|
|
1022
|
+
}
|
|
1023
|
+
return 'file';
|
|
1024
|
+
}
|
|
1025
|
+
function normalizeOutboundMediaType(value) {
|
|
1026
|
+
const normalized = value?.trim().toLowerCase();
|
|
1027
|
+
if (normalized === 'image' || normalized === 'input_image' || normalized === 'image_url') {
|
|
1028
|
+
return 'image';
|
|
1029
|
+
}
|
|
1030
|
+
if (normalized === 'audio' || normalized === 'input_audio') {
|
|
1031
|
+
return 'audio';
|
|
1032
|
+
}
|
|
1033
|
+
return 'file';
|
|
1034
|
+
}
|
|
1035
|
+
function defaultOutboundFileName(type, mimeType, artifactId) {
|
|
1036
|
+
const subtype = mimeType.includes('/') ? mimeType.split('/')[1] ?? '' : '';
|
|
1037
|
+
const cleaned = subtype.replace(/\+.*$/, '');
|
|
1038
|
+
const ext = cleaned && /^[a-z0-9.-]+$/.test(cleaned)
|
|
1039
|
+
? `.${cleaned === 'jpeg' ? 'jpg' : cleaned}`
|
|
1040
|
+
: (type === 'image' ? '.png' : type === 'audio' ? '.mp3' : '.bin');
|
|
1041
|
+
return `${type}-${artifactId}${ext}`;
|
|
1042
|
+
}
|
|
1043
|
+
function mediaUrlValue(value) {
|
|
1044
|
+
if (typeof value === 'string') {
|
|
1045
|
+
return value.length > 0 ? value : undefined;
|
|
1046
|
+
}
|
|
1047
|
+
if (isRecord(value)) {
|
|
1048
|
+
return textParam(value, 'url');
|
|
1049
|
+
}
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
function base64FromDataUrl(value) {
|
|
1053
|
+
const trimmed = value.trim();
|
|
1054
|
+
const commaIndex = trimmed.indexOf(',');
|
|
1055
|
+
if (commaIndex < 0 || trimmed.slice(0, 5).toLowerCase() !== 'data:') {
|
|
1056
|
+
return undefined;
|
|
1057
|
+
}
|
|
1058
|
+
const meta = trimmed.slice(0, commaIndex);
|
|
1059
|
+
if (!/;base64$/i.test(meta)) {
|
|
1060
|
+
return undefined;
|
|
1061
|
+
}
|
|
1062
|
+
const payload = trimmed.slice(commaIndex + 1).trim();
|
|
1063
|
+
return payload.length > 0 ? payload : undefined;
|
|
1064
|
+
}
|
|
1065
|
+
function mimeFromDataUrl(value) {
|
|
1066
|
+
const match = /^data:([^;,]+)(?:;[^,]*)?,/i.exec(value.trim());
|
|
1067
|
+
return match?.[1]?.toLowerCase();
|
|
1068
|
+
}
|
|
1069
|
+
function isManagedMediaUrl(value) {
|
|
1070
|
+
try {
|
|
1071
|
+
const parsed = new URL(value, 'http://localhost');
|
|
1072
|
+
return parsed.pathname.startsWith(MANAGED_MEDIA_PATH_PREFIX);
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function managedMediaArtifactId(value) {
|
|
1079
|
+
try {
|
|
1080
|
+
const parsed = new URL(value, 'http://localhost');
|
|
1081
|
+
// /api/chat/media/outgoing/<sessionKey>/<attachmentId>/<variant>
|
|
1082
|
+
const segments = parsed.pathname.slice(MANAGED_MEDIA_PATH_PREFIX.length).split('/').filter(Boolean);
|
|
1083
|
+
const attachmentId = segments[1];
|
|
1084
|
+
if (attachmentId) {
|
|
1085
|
+
return attachmentId;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
// fall through to hashing the raw url
|
|
1090
|
+
}
|
|
1091
|
+
return `url_${createHash('sha256').update(value).digest('hex').slice(0, 24)}`;
|
|
1092
|
+
}
|
|
1093
|
+
function isLikelyBase64(value) {
|
|
1094
|
+
const trimmed = value.trim();
|
|
1095
|
+
if (trimmed.length < 8) {
|
|
1096
|
+
return false;
|
|
1097
|
+
}
|
|
1098
|
+
return /^[A-Za-z0-9+/]+={0,2}$/.test(trimmed);
|
|
1099
|
+
}
|
|
1100
|
+
function sha256HexOfBase64(base64) {
|
|
1101
|
+
return createHash('sha256').update(Buffer.from(base64, 'base64')).digest('hex');
|
|
1102
|
+
}
|
|
1103
|
+
function collectMessageText(message) {
|
|
1104
|
+
if (!isRecord(message)) {
|
|
1105
|
+
return '';
|
|
1106
|
+
}
|
|
1107
|
+
const content = message.content;
|
|
1108
|
+
if (typeof content === 'string') {
|
|
1109
|
+
return content;
|
|
1110
|
+
}
|
|
1111
|
+
if (!Array.isArray(content)) {
|
|
1112
|
+
return '';
|
|
1113
|
+
}
|
|
1114
|
+
return content
|
|
1115
|
+
.map((block) => (isRecord(block) && typeof block.text === 'string' ? block.text : ''))
|
|
1116
|
+
.filter((text) => text.length > 0)
|
|
1117
|
+
.join('\n');
|
|
1118
|
+
}
|
|
1119
|
+
// 从 assistant final 文本里抽取候选本地文件路径:markdown 链接目标 + 防御性的 MEDIA: 行。
|
|
1120
|
+
function parseFileCandidatesFromText(text) {
|
|
1121
|
+
if (!text) {
|
|
1122
|
+
return [];
|
|
1123
|
+
}
|
|
1124
|
+
const raw = [];
|
|
1125
|
+
const mdLink = /\[[^\]]*\]\(\s*(<[^>]+>|[^)\s]+)/g;
|
|
1126
|
+
let match;
|
|
1127
|
+
while ((match = mdLink.exec(text)) !== null) {
|
|
1128
|
+
let target = match[1].trim();
|
|
1129
|
+
if (target.startsWith('<') && target.endsWith('>')) {
|
|
1130
|
+
target = target.slice(1, -1);
|
|
1131
|
+
}
|
|
1132
|
+
raw.push(target);
|
|
1133
|
+
}
|
|
1134
|
+
const mediaLine = /^\s*MEDIA:\s*(\S+)/gim;
|
|
1135
|
+
while ((match = mediaLine.exec(text)) !== null) {
|
|
1136
|
+
raw.push(match[1].trim());
|
|
1137
|
+
}
|
|
1138
|
+
return raw.map(decodeFileTarget).filter(isLocalPathCandidate);
|
|
1139
|
+
}
|
|
1140
|
+
function extractStructuredLocalFileCandidates(message) {
|
|
1141
|
+
if (!isRecord(message)) {
|
|
1142
|
+
return [];
|
|
1143
|
+
}
|
|
1144
|
+
const raw = [];
|
|
1145
|
+
for (const key of ['path', 'filePath', 'file_path', 'media', 'fileUrl', 'file_url']) {
|
|
1146
|
+
const value = textParam(message, key);
|
|
1147
|
+
if (value) {
|
|
1148
|
+
raw.push(value);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
for (const mediaUrl of extractStructuredMediaUrls(message)) {
|
|
1152
|
+
raw.push(mediaUrl);
|
|
1153
|
+
}
|
|
1154
|
+
return raw.map(decodeFileTarget).filter(isLocalPathCandidate);
|
|
1155
|
+
}
|
|
1156
|
+
function decodeFileTarget(target) {
|
|
1157
|
+
try {
|
|
1158
|
+
return decodeURIComponent(target);
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
return target;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
// 排除带协议的 URL(http/https/data/mailto…)、协议相对链接、页内锚点;只留本地路径候选。
|
|
1165
|
+
function isLocalPathCandidate(target) {
|
|
1166
|
+
if (!target) {
|
|
1167
|
+
return false;
|
|
1168
|
+
}
|
|
1169
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) {
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
if (target.startsWith('//') || target.startsWith('#')) {
|
|
1173
|
+
return false;
|
|
1174
|
+
}
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
function isInsideRoots(real, roots) {
|
|
1178
|
+
return roots.some((root) => real === root || real.startsWith(root + pathSep));
|
|
1179
|
+
}
|
|
1180
|
+
function mimeForFileName(fileName) {
|
|
1181
|
+
const dot = fileName.lastIndexOf('.');
|
|
1182
|
+
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : '';
|
|
1183
|
+
return FILE_EXT_MIME[ext] ?? DEFAULT_MIME_TYPE;
|
|
1184
|
+
}
|
|
1185
|
+
function readOpenClawWorkspaceDir(configPath) {
|
|
1186
|
+
try {
|
|
1187
|
+
const path = configPath ?? process.env.OPENCLAW_CONFIG ?? joinPath(homedir(), '.openclaw', 'openclaw.json');
|
|
1188
|
+
const cfg = JSON.parse(readFileSync(path, 'utf8'));
|
|
1189
|
+
const agents = isRecord(cfg.agents) ? cfg.agents : undefined;
|
|
1190
|
+
const defaults = agents && isRecord(agents.defaults) ? agents.defaults : undefined;
|
|
1191
|
+
const workspace = defaults ? defaults.workspace : undefined;
|
|
1192
|
+
return typeof workspace === 'string' && workspace.trim() ? workspace.trim() : undefined;
|
|
1193
|
+
}
|
|
1194
|
+
catch {
|
|
1195
|
+
return undefined;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
function textParam(params, key) {
|
|
1199
|
+
const value = params[key];
|
|
1200
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
1201
|
+
return value;
|
|
1202
|
+
}
|
|
1203
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1204
|
+
return String(value);
|
|
1205
|
+
}
|
|
1206
|
+
return undefined;
|
|
1207
|
+
}
|
|
1208
|
+
function longParam(params, key) {
|
|
1209
|
+
const value = params[key];
|
|
1210
|
+
if (typeof value === 'number' && Number.isSafeInteger(value)) {
|
|
1211
|
+
return value;
|
|
1212
|
+
}
|
|
1213
|
+
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
|
1214
|
+
const parsed = Number(value);
|
|
1215
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
1216
|
+
}
|
|
1217
|
+
return undefined;
|
|
1218
|
+
}
|
|
1219
|
+
function isRecord(value) {
|
|
1220
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1221
|
+
}
|
|
1222
|
+
//# sourceMappingURL=openclaw-chat-runtime.js.map
|