@clawling/clawchat-plugin-openclaw 2026.8.14-1 → 2026.8.20-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/dist/src/api-client.js +6 -1
- package/dist/src/config.js +24 -2
- package/dist/src/media-runtime.js +256 -24
- package/dist/src/outbound.js +8 -4
- package/dist/src/reply-dispatcher.js +13 -9
- package/dist/src/runtime.js +2 -2
- package/package.json +1 -1
- package/src/api-client.ts +6 -1
- package/src/config.ts +29 -2
- package/src/media-runtime.ts +333 -27
- package/src/outbound.ts +12 -4
- package/src/reply-dispatcher.ts +17 -11
- package/src/runtime.ts +2 -2
- package/dist/src/friend-request-turn.js +0 -83
package/dist/src/api-client.js
CHANGED
|
@@ -153,8 +153,13 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
153
153
|
// `X-Device-Id` is sent on every request so the server can correlate
|
|
154
154
|
// activity back to this plugin instance. Callers may override via
|
|
155
155
|
// `extra` (e.g. tests) but the default is the channel id.
|
|
156
|
+
//
|
|
157
|
+
// A blank token gets NO Authorization header at all: `Bearer ` with an
|
|
158
|
+
// empty credential is a zero-segment JWT that the backend logs as
|
|
159
|
+
// malformed-token 401 noise (the unauthenticated plugin-report call sent
|
|
160
|
+
// one on every gateway entry).
|
|
156
161
|
return {
|
|
157
|
-
authorization: `Bearer ${opts.token}
|
|
162
|
+
...(opts.token.trim() ? { authorization: `Bearer ${opts.token}` } : {}),
|
|
158
163
|
"x-device-id": CHANNEL_ID,
|
|
159
164
|
...extra,
|
|
160
165
|
};
|
package/dist/src/config.js
CHANGED
|
@@ -221,6 +221,28 @@ function decodeJwtStringClaim(token, claim) {
|
|
|
221
221
|
* wins; the mismatch is logged. Also derives userId from `sub` when nothing is
|
|
222
222
|
* configured, mirroring the `oid` fallback above.
|
|
223
223
|
*/
|
|
224
|
+
/**
|
|
225
|
+
* Reject an ownerUserId that is an unrendered provisioning-template
|
|
226
|
+
* placeholder — treat it as absent (fall through to the next source) instead
|
|
227
|
+
* of using it verbatim.
|
|
228
|
+
*
|
|
229
|
+
* 2026-08 production incident: a provisioner shipped the literal `usr_owner`
|
|
230
|
+
* (this repo's own test-fixture value) un-rendered; every affected agent
|
|
231
|
+
* passed the connect gate and hammered prod with `GET /v1/users/usr_owner`
|
|
232
|
+
* plus malformed-JWT retries for days. Dev/mock ids stay loose on purpose —
|
|
233
|
+
* only the known fixture literal and unmistakable template syntax
|
|
234
|
+
* ({{…}}, ${…}, <…>, %…%) are rejected.
|
|
235
|
+
*/
|
|
236
|
+
function rejectPlaceholderOwnerUserId(value, source) {
|
|
237
|
+
if (!value)
|
|
238
|
+
return "";
|
|
239
|
+
const looksUnrendered = value === "usr_owner" || /[{}<>%]|\$\{/.test(value);
|
|
240
|
+
if (looksUnrendered) {
|
|
241
|
+
console.warn(`clawchat ownerUserId from ${source} looks like an unrendered template placeholder ("${value}"); ignoring it. Fix the provisioning template — without a real owner id (or an oid claim in the token) the agent stays in activation-wait instead of hammering the backend.`);
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
224
246
|
function resolveUserId(token, configured) {
|
|
225
247
|
const tokenSub = decodeJwtStringClaim(token, "sub");
|
|
226
248
|
if (!tokenSub)
|
|
@@ -336,8 +358,8 @@ export function resolveOpenclawClawlingAccount(cfg, env = process.env) {
|
|
|
336
358
|
const token = readOptionalString(channel.token) || readEnvString(env, CLAWCHAT_TOKEN_ENV);
|
|
337
359
|
const agentId = readOptionalString(channel.agentId) || readEnvString(env, CLAWCHAT_AGENT_ID_ENV);
|
|
338
360
|
const userId = resolveUserId(token, readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV));
|
|
339
|
-
const ownerUserId = readOptionalString(channel.ownerUserId) ||
|
|
340
|
-
readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV) ||
|
|
361
|
+
const ownerUserId = rejectPlaceholderOwnerUserId(readOptionalString(channel.ownerUserId), "channel ownerUserId") ||
|
|
362
|
+
rejectPlaceholderOwnerUserId(readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV), CLAWCHAT_OWNER_USER_ID_ENV) ||
|
|
341
363
|
// Fall back to the access token's `oid` (owner) claim. A provisioner may
|
|
342
364
|
// inject token + userId but omit ownerUserId; without it the connect gate
|
|
343
365
|
// (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildOutboundMediaLoadOptions, } from "openclaw/plugin-sdk/media-runtime";
|
|
2
|
+
import { ClawlingApiError } from "./api-types.js";
|
|
2
3
|
export function inferMediaKindFromMime(mime) {
|
|
3
4
|
if (!mime)
|
|
4
5
|
return "file";
|
|
@@ -10,7 +11,178 @@ export function inferMediaKindFromMime(mime) {
|
|
|
10
11
|
return "video";
|
|
11
12
|
return "file";
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Single-file ceiling of the upstream msghub media service — its
|
|
16
|
+
* `media.max_size_bytes` is `104857600` in every shipped upstream config, and a
|
|
17
|
+
* larger body is rejected with HTTP 413 / business code `41301`. The plugin
|
|
18
|
+
* mirrors the server capability rather than under-reporting it: a lower local
|
|
19
|
+
* cap silently fails attachments the service would accept.
|
|
20
|
+
*/
|
|
21
|
+
export const CLAWCHAT_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
22
|
+
const DEFAULT_MEDIA_MAX_BYTES = CLAWCHAT_MEDIA_MAX_BYTES;
|
|
23
|
+
/** Three attempts per attachment = the initial upload plus two backoff retries. */
|
|
24
|
+
export const DEFAULT_UPLOAD_RETRY_POLICY = {
|
|
25
|
+
maxAttempts: 3,
|
|
26
|
+
baseDelayMs: 300,
|
|
27
|
+
maxDelayMs: 2_000,
|
|
28
|
+
};
|
|
29
|
+
/** Bounded exponential backoff for the wait AFTER 1-based `attempt`. */
|
|
30
|
+
export function uploadRetryDelayMs(attempt, policy = DEFAULT_UPLOAD_RETRY_POLICY) {
|
|
31
|
+
return Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempt - 1), policy.maxDelayMs);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Media-service business codes that reject THIS payload on its merits:
|
|
35
|
+
* missing file part, empty upload, bad token, too large, disallowed MIME.
|
|
36
|
+
* Re-sending identical bytes produces an identical rejection, so these never
|
|
37
|
+
* retry regardless of status.
|
|
38
|
+
*/
|
|
39
|
+
const PERMANENT_BUSINESS_CODES = new Set([40001, 40002, 40101, 41301, 41501]);
|
|
40
|
+
/**
|
|
41
|
+
* Socket/DNS/timeout signatures for throws that never made it through the API
|
|
42
|
+
* client's own wrapping (e.g. an `AbortError` surfaced by the host fetch).
|
|
43
|
+
*/
|
|
44
|
+
const TRANSIENT_ERROR_PATTERN = /(econnreset|econnrefused|econnaborted|etimedout|epipe|ehostunreach|enetunreach|enetdown|eai_again|enotfound|socket hang up|network error|fetch failed|timed out|timeout|aborted)/i;
|
|
45
|
+
function isRetryableHttpStatus(status) {
|
|
46
|
+
// 408 request timeout, 425 too early, 429 rate limited, plus every 5xx.
|
|
47
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
48
|
+
}
|
|
49
|
+
const API_ERROR_KINDS = ["auth", "api", "transport", "validation"];
|
|
50
|
+
function asClawlingApiError(err) {
|
|
51
|
+
if (err instanceof ClawlingApiError)
|
|
52
|
+
return err;
|
|
53
|
+
// Structural fallback: a duck-typed error from a mocked client still carries
|
|
54
|
+
// the diagnostics we must log, and misreading it as `unknown` would lose them.
|
|
55
|
+
if (err instanceof Error &&
|
|
56
|
+
API_ERROR_KINDS.includes(err.kind)) {
|
|
57
|
+
return err;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function isRetryableFailure(detail) {
|
|
62
|
+
// Local pre-flight failures and credential rejections do not get better by
|
|
63
|
+
// trying again with the same bytes and the same token.
|
|
64
|
+
if (detail.kind === "validation" || detail.kind === "auth")
|
|
65
|
+
return false;
|
|
66
|
+
if (typeof detail.code === "number" && PERMANENT_BUSINESS_CODES.has(detail.code))
|
|
67
|
+
return false;
|
|
68
|
+
if (typeof detail.status === "number")
|
|
69
|
+
return isRetryableHttpStatus(detail.status);
|
|
70
|
+
// `transport` without a status is a failure before any response existed —
|
|
71
|
+
// DNS, connection reset, timeout: the transient case this retry loop is for.
|
|
72
|
+
if (detail.kind === "transport")
|
|
73
|
+
return true;
|
|
74
|
+
// `api` without a status is a server-declared business rejection.
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/** Classify a thrown upload error into structured diagnostics + a retry verdict. */
|
|
78
|
+
export function classifyUploadError(err) {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
const api = asClawlingApiError(err);
|
|
81
|
+
if (!api) {
|
|
82
|
+
return { kind: "unknown", message, retryable: TRANSIENT_ERROR_PATTERN.test(message) };
|
|
83
|
+
}
|
|
84
|
+
const detail = {
|
|
85
|
+
kind: api.kind,
|
|
86
|
+
message,
|
|
87
|
+
retryable: false,
|
|
88
|
+
...(typeof api.meta?.status === "number" ? { status: api.meta.status } : {}),
|
|
89
|
+
...(typeof api.meta?.code === "number" ? { code: api.meta.code } : {}),
|
|
90
|
+
...(api.meta?.path ? { path: api.meta.path } : {}),
|
|
91
|
+
...(api.meta?.data !== undefined ? { data: api.meta.data } : {}),
|
|
92
|
+
};
|
|
93
|
+
detail.retryable = isRetryableFailure(detail);
|
|
94
|
+
return detail;
|
|
95
|
+
}
|
|
96
|
+
const BEARER_PATTERN = /\b(bearer)\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
97
|
+
const JWT_PATTERN = /\beyJ[A-Za-z0-9._~+/=-]{10,}/g;
|
|
98
|
+
const SECRET_FIELD_PATTERN = /("?\b(?:authorization|access[_-]?token|refresh[_-]?token|token|api[_-]?key|apikey|password|passwd|secret|signature|x-amz-signature|x-amz-credential)\b"?\s*[:=]\s*)"?[^"',\s&}]+"?/gi;
|
|
99
|
+
/**
|
|
100
|
+
* Strip anything credential-shaped out of a diagnostic string. Upload failures
|
|
101
|
+
* are logged with the server's `msg`/`data` verbatim, and neither is under this
|
|
102
|
+
* plugin's control — a backend that echoes a header or a presigned URL must not
|
|
103
|
+
* turn a log line into a token leak.
|
|
104
|
+
*/
|
|
105
|
+
export function redactMediaDiagnostics(text) {
|
|
106
|
+
return text
|
|
107
|
+
.replace(BEARER_PATTERN, "$1 [redacted]")
|
|
108
|
+
.replace(SECRET_FIELD_PATTERN, '$1"[redacted]"')
|
|
109
|
+
.replace(JWT_PATTERN, "[redacted]");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Reduce an outbound media source to something safe to log: presigned HTTP(S)
|
|
113
|
+
* URLs carry their credential in the query string, so only origin + path
|
|
114
|
+
* survive. Local paths are kept (they are the only way to identify the
|
|
115
|
+
* attachment) but never their contents.
|
|
116
|
+
*/
|
|
117
|
+
export function sanitizeMediaSource(source) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = new URL(source);
|
|
120
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
121
|
+
const trimmed = `${parsed.origin}${parsed.pathname}`;
|
|
122
|
+
return parsed.search || parsed.hash ? `${trimmed} (query redacted)` : trimmed;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Not a URL — a local path. Fall through to plain redaction.
|
|
127
|
+
}
|
|
128
|
+
return redactMediaDiagnostics(source);
|
|
129
|
+
}
|
|
130
|
+
function serializeDiagnosticValue(value, limit = 300) {
|
|
131
|
+
let text;
|
|
132
|
+
try {
|
|
133
|
+
text = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
text = "[unserializable]";
|
|
137
|
+
}
|
|
138
|
+
const redacted = redactMediaDiagnostics(text);
|
|
139
|
+
return redacted.length > limit ? `${redacted.slice(0, limit)}…` : redacted;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Render a failure as `key=value` diagnostics: error kind, HTTP status,
|
|
143
|
+
* business code, request path, response data, plus file name / size / MIME.
|
|
144
|
+
* Everything passes through {@link redactMediaDiagnostics} first — no token,
|
|
145
|
+
* `Authorization` header, or local file content ever reaches a log line.
|
|
146
|
+
*/
|
|
147
|
+
export function formatOutboundMediaFailure(failure) {
|
|
148
|
+
const { detail } = failure;
|
|
149
|
+
return [
|
|
150
|
+
`source=${failure.source}`,
|
|
151
|
+
...(failure.fileName ? [`file=${serializeDiagnosticValue(failure.fileName, 120)}`] : []),
|
|
152
|
+
...(typeof failure.size === "number" ? [`size=${failure.size}`] : []),
|
|
153
|
+
...(failure.mime ? [`mime=${failure.mime}`] : []),
|
|
154
|
+
`stage=${failure.stage}`,
|
|
155
|
+
`attempts=${failure.attempts}`,
|
|
156
|
+
`retryable=${detail.retryable}`,
|
|
157
|
+
`kind=${detail.kind}`,
|
|
158
|
+
...(typeof detail.status === "number" ? [`status=${detail.status}`] : []),
|
|
159
|
+
...(typeof detail.code === "number" ? [`code=${detail.code}`] : []),
|
|
160
|
+
...(detail.path ? [`path=${detail.path}`] : []),
|
|
161
|
+
...(detail.data !== undefined ? [`data=${serializeDiagnosticValue(detail.data)}`] : []),
|
|
162
|
+
`error=${serializeDiagnosticValue(detail.message)}`,
|
|
163
|
+
].join(" ");
|
|
164
|
+
}
|
|
165
|
+
function buildFailure(input) {
|
|
166
|
+
const failure = { ...input, summary: "" };
|
|
167
|
+
failure.summary = formatOutboundMediaFailure(failure);
|
|
168
|
+
return failure;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build the send-blocking error message for a batch that lost attachments.
|
|
172
|
+
*
|
|
173
|
+
* Both call sites (the `sendMedia` adapter and the reply dispatcher) reject the
|
|
174
|
+
* whole send on a shortfall, so this is the only text the agent sees: it must
|
|
175
|
+
* name every failed attachment with its reason instead of a generic
|
|
176
|
+
* "upload failed" that leaves the operator with nothing to act on.
|
|
177
|
+
*/
|
|
178
|
+
export function describeOutboundMediaShortfall(result, requested) {
|
|
179
|
+
const missing = Math.max(requested - result.fragments.length, result.failures.length);
|
|
180
|
+
const outcome = result.fragments.length > 0 ? "partial success" : "all failed";
|
|
181
|
+
const reasons = result.failures.map((f, i) => `[${i + 1}] ${f.summary}`).join(" | ");
|
|
182
|
+
return (`clawchat-plugin-openclaw failed to upload ${missing}/${requested} outbound media ` +
|
|
183
|
+
`attachment(s) (${outcome}, ${result.fragments.length}/${requested} uploaded); ` +
|
|
184
|
+
`message not sent${reasons ? `: ${reasons}` : ""}`);
|
|
185
|
+
}
|
|
14
186
|
/**
|
|
15
187
|
* Fetch each remote URL via the shared media runtime, persist to a local
|
|
16
188
|
* cache, and return the list of local paths.
|
|
@@ -33,53 +205,113 @@ export async function fetchInboundMedia(items, ctx) {
|
|
|
33
205
|
paths.push(saved.path);
|
|
34
206
|
}
|
|
35
207
|
catch (err) {
|
|
36
|
-
ctx.log?.info?.(`clawchat-plugin-openclaw inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`);
|
|
208
|
+
ctx.log?.info?.(`clawchat-plugin-openclaw inbound media skipped: ${sanitizeMediaSource(item.url)} (${redactMediaDiagnostics(err instanceof Error ? err.message : String(err))})`);
|
|
37
209
|
}
|
|
38
210
|
}
|
|
39
211
|
return paths;
|
|
40
212
|
}
|
|
41
213
|
/**
|
|
42
214
|
* Upload each URL (remote or local path) to /media/upload via the api
|
|
43
|
-
* client and return
|
|
215
|
+
* client and return fragments ready to splice into `body.fragments`, plus a
|
|
216
|
+
* structured failure record for every attachment that did not make it.
|
|
44
217
|
*
|
|
45
218
|
* Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
|
|
46
219
|
* enforcement and media-loading policy stay aligned with the current
|
|
47
220
|
* OpenClaw runtime instead of a directly imported helper.
|
|
48
221
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
222
|
+
* Transient upload faults (network/timeout, HTTP 5xx, 429/408) are retried
|
|
223
|
+
* with bounded exponential backoff per {@link DEFAULT_UPLOAD_RETRY_POLICY};
|
|
224
|
+
* local validation, auth, and definitive business rejections fail on the first
|
|
225
|
+
* attempt. One attachment giving up never aborts the batch — every remaining
|
|
226
|
+
* URL is still loaded and uploaded, and its fragment comes back — so the
|
|
227
|
+
* caller can distinguish partial success from a total failure and report why.
|
|
52
228
|
*/
|
|
53
229
|
export async function uploadOutboundMedia(urls, ctx) {
|
|
230
|
+
const result = { fragments: [], failures: [] };
|
|
54
231
|
if (urls.length === 0)
|
|
55
|
-
return
|
|
232
|
+
return result;
|
|
56
233
|
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
57
|
-
const
|
|
234
|
+
const policy = { ...DEFAULT_UPLOAD_RETRY_POLICY, ...(ctx.retry ?? {}) };
|
|
235
|
+
const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
58
236
|
for (const url of urls) {
|
|
237
|
+
const source = sanitizeMediaSource(url);
|
|
238
|
+
let loaded;
|
|
59
239
|
try {
|
|
60
|
-
|
|
240
|
+
loaded = await ctx.runtime.media.loadWebMedia(url, buildOutboundMediaLoadOptions({
|
|
61
241
|
maxBytes,
|
|
62
242
|
...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
|
|
63
243
|
...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}),
|
|
64
244
|
...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
|
|
65
245
|
}));
|
|
66
|
-
const uploaded = await ctx.apiClient.uploadMedia({
|
|
67
|
-
buffer: loaded.buffer,
|
|
68
|
-
filename: loaded.fileName ?? "upload.bin",
|
|
69
|
-
mime: loaded.contentType,
|
|
70
|
-
});
|
|
71
|
-
const fragment = {
|
|
72
|
-
kind: uploaded.kind,
|
|
73
|
-
url: uploaded.url,
|
|
74
|
-
name: uploaded.name,
|
|
75
|
-
mime: uploaded.mime,
|
|
76
|
-
size: uploaded.size,
|
|
77
|
-
};
|
|
78
|
-
out.push(fragment);
|
|
79
246
|
}
|
|
80
247
|
catch (err) {
|
|
81
|
-
|
|
248
|
+
// Loading is host policy plus a one-shot read (missing path, outside the
|
|
249
|
+
// allowed roots, over `maxBytes`). Nothing here is transient, so it is
|
|
250
|
+
// never retried — and the bytes never reached the media service.
|
|
251
|
+
const failure = buildFailure({
|
|
252
|
+
source,
|
|
253
|
+
stage: "load",
|
|
254
|
+
attempts: 1,
|
|
255
|
+
detail: { ...classifyUploadError(err), retryable: false },
|
|
256
|
+
});
|
|
257
|
+
result.failures.push(failure);
|
|
258
|
+
ctx.log?.error?.(`clawchat-plugin-openclaw outbound media load failed: ${failure.summary}`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const fileName = loaded.fileName ?? "upload.bin";
|
|
262
|
+
const size = loaded.buffer.byteLength;
|
|
263
|
+
const mime = loaded.contentType;
|
|
264
|
+
let pending;
|
|
265
|
+
let attempts = 0;
|
|
266
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
|
|
267
|
+
attempts = attempt;
|
|
268
|
+
try {
|
|
269
|
+
const uploaded = await ctx.apiClient.uploadMedia({
|
|
270
|
+
buffer: loaded.buffer,
|
|
271
|
+
filename: fileName,
|
|
272
|
+
...(mime ? { mime } : {}),
|
|
273
|
+
});
|
|
274
|
+
result.fragments.push({
|
|
275
|
+
kind: uploaded.kind,
|
|
276
|
+
url: uploaded.url,
|
|
277
|
+
name: uploaded.name,
|
|
278
|
+
mime: uploaded.mime,
|
|
279
|
+
size: uploaded.size,
|
|
280
|
+
});
|
|
281
|
+
pending = undefined;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
pending = classifyUploadError(err);
|
|
286
|
+
if (!pending.retryable || attempt >= policy.maxAttempts)
|
|
287
|
+
break;
|
|
288
|
+
const delayMs = uploadRetryDelayMs(attempt, policy);
|
|
289
|
+
const attemptFailure = buildFailure({
|
|
290
|
+
source,
|
|
291
|
+
stage: "upload",
|
|
292
|
+
attempts: attempt,
|
|
293
|
+
fileName,
|
|
294
|
+
size,
|
|
295
|
+
...(mime ? { mime } : {}),
|
|
296
|
+
detail: pending,
|
|
297
|
+
});
|
|
298
|
+
ctx.log?.info?.(`clawchat-plugin-openclaw outbound media upload retry ${attempt}/${policy.maxAttempts - 1} in ${delayMs}ms: ${attemptFailure.summary}`);
|
|
299
|
+
await sleep(delayMs);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (pending) {
|
|
303
|
+
const failure = buildFailure({
|
|
304
|
+
source,
|
|
305
|
+
stage: "upload",
|
|
306
|
+
attempts,
|
|
307
|
+
fileName,
|
|
308
|
+
size,
|
|
309
|
+
...(mime ? { mime } : {}),
|
|
310
|
+
detail: pending,
|
|
311
|
+
});
|
|
312
|
+
result.failures.push(failure);
|
|
313
|
+
ctx.log?.error?.(`clawchat-plugin-openclaw outbound media upload failed: ${failure.summary}`);
|
|
82
314
|
}
|
|
83
315
|
}
|
|
84
|
-
return
|
|
316
|
+
return result;
|
|
85
317
|
}
|
package/dist/src/outbound.js
CHANGED
|
@@ -6,7 +6,7 @@ import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
|
|
|
6
6
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
7
7
|
import { CHANNEL_ID, resolveOpenclawClawlingAccount } from "./config.js";
|
|
8
8
|
import { applyTextMentionLabels, buildMentionMessageFragments, normalizeMentionTargets, textToFragments, } from "./message-mapper.js";
|
|
9
|
-
import { uploadOutboundMedia } from "./media-runtime.js";
|
|
9
|
+
import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
|
|
10
10
|
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
11
11
|
import { stripNoReplyTokens } from "./no-reply.js";
|
|
12
12
|
import { getOpenclawClawlingClient, getOpenclawClawlingRuntime, waitForOpenclawClawlingClient, } from "./runtime.js";
|
|
@@ -723,16 +723,20 @@ export const openclawClawlingOutbound = {
|
|
|
723
723
|
token: account.token,
|
|
724
724
|
userId: account.userId,
|
|
725
725
|
});
|
|
726
|
-
const
|
|
726
|
+
const uploadResult = await uploadOutboundMedia([mediaUrl.trim()], {
|
|
727
727
|
apiClient,
|
|
728
728
|
runtime,
|
|
729
729
|
...(mediaAccess ? { mediaAccess } : {}),
|
|
730
730
|
...(mediaLocalRoots ? { mediaLocalRoots } : {}),
|
|
731
731
|
...(mediaReadFile ? { mediaReadFile } : {}),
|
|
732
732
|
});
|
|
733
|
-
|
|
734
|
-
|
|
733
|
+
// Single-attachment path, so "shortfall" is always a total failure here;
|
|
734
|
+
// the message still carries the structured, redacted reason instead of a
|
|
735
|
+
// bare "failed to upload" the operator cannot act on.
|
|
736
|
+
if (uploadResult.fragments.length === 0) {
|
|
737
|
+
throw new Error(describeOutboundMediaShortfall(uploadResult, 1));
|
|
735
738
|
}
|
|
739
|
+
const mediaFragments = uploadResult.fragments;
|
|
736
740
|
const target = parseOpenclawRecipient(to);
|
|
737
741
|
const messageId = mintOutboundMessageId(account);
|
|
738
742
|
const claimText = (text ?? "").trim();
|
|
@@ -2,7 +2,7 @@ import { interactiveReplyToPresentation, renderMessagePresentationFallbackText,
|
|
|
2
2
|
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
|
|
3
3
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
4
4
|
import { effectiveOutputVisibility, } from "./config.js";
|
|
5
|
-
import { uploadOutboundMedia } from "./media-runtime.js";
|
|
5
|
+
import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
|
|
6
6
|
import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
|
|
7
7
|
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
8
8
|
import { consumeTerminalClawChatSend } from "./terminal-send.js";
|
|
@@ -293,15 +293,19 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
293
293
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw outbound media skipped: baseUrl not configured`);
|
|
294
294
|
return [];
|
|
295
295
|
}
|
|
296
|
-
const
|
|
297
|
-
if (fragments.length < urls.length) {
|
|
298
|
-
// uploadOutboundMedia
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
296
|
+
const result = await uploadOutboundMedia(urls, { apiClient, runtime, log });
|
|
297
|
+
if (result.fragments.length < urls.length) {
|
|
298
|
+
// uploadOutboundMedia never aborts the batch: every URL is attempted and
|
|
299
|
+
// the successful fragments still come back. A shortfall — partial success
|
|
300
|
+
// or total failure alike — means at least one attachment never reached
|
|
301
|
+
// the media service, so the whole send is rejected (parity with the
|
|
302
|
+
// adapter path in outbound.ts and with the Hermes plugin) rather than
|
|
303
|
+
// letting a text-only frame go out reading as a successful send. The
|
|
304
|
+
// thrown message carries each failure's structured, redacted diagnostics
|
|
305
|
+
// so the agent is not left with a reasonless generic error.
|
|
306
|
+
throw new Error(describeOutboundMediaShortfall(result, urls.length));
|
|
303
307
|
}
|
|
304
|
-
return fragments;
|
|
308
|
+
return result.fragments;
|
|
305
309
|
}
|
|
306
310
|
// ----- Reply state ------------------------------------------------------
|
|
307
311
|
let reasoningText = "";
|
package/dist/src/runtime.js
CHANGED
|
@@ -16,7 +16,7 @@ import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effec
|
|
|
16
16
|
import { isValidChatId } from "./ws-client.js";
|
|
17
17
|
import { dispatchOpenclawClawlingInbound } from "./inbound.js";
|
|
18
18
|
import { PendingConsentStore, cleanupTombstonedManagedSkills, handleOwnerConsentReply, managedSkillExists, readLocalSkillState, resolveBundledSkillsDir, resolveManagedSkillsDir, runSkillUpdateCheck, } from "./skill-update.js";
|
|
19
|
-
import { fetchInboundMedia } from "./media-runtime.js";
|
|
19
|
+
import { CLAWCHAT_MEDIA_MAX_BYTES, fetchInboundMedia } from "./media-runtime.js";
|
|
20
20
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
|
|
21
21
|
import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
|
|
22
22
|
import { flushAlignedOutboundQueue, getAlignedOutboundQueueSize, sendOpenclawClawlingText, setAlignedOutboundLogContext, } from "./outbound.js";
|
|
@@ -2440,7 +2440,7 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2440
2440
|
? await fetchInboundMedia(turn.mediaItems, {
|
|
2441
2441
|
runtime,
|
|
2442
2442
|
log,
|
|
2443
|
-
maxBytes:
|
|
2443
|
+
maxBytes: CLAWCHAT_MEDIA_MAX_BYTES,
|
|
2444
2444
|
})
|
|
2445
2445
|
: [];
|
|
2446
2446
|
if (inboundPaths.length > 0) {
|
package/package.json
CHANGED
package/src/api-client.ts
CHANGED
|
@@ -337,8 +337,13 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
337
337
|
// `X-Device-Id` is sent on every request so the server can correlate
|
|
338
338
|
// activity back to this plugin instance. Callers may override via
|
|
339
339
|
// `extra` (e.g. tests) but the default is the channel id.
|
|
340
|
+
//
|
|
341
|
+
// A blank token gets NO Authorization header at all: `Bearer ` with an
|
|
342
|
+
// empty credential is a zero-segment JWT that the backend logs as
|
|
343
|
+
// malformed-token 401 noise (the unauthenticated plugin-report call sent
|
|
344
|
+
// one on every gateway entry).
|
|
340
345
|
return {
|
|
341
|
-
authorization: `Bearer ${opts.token}
|
|
346
|
+
...(opts.token.trim() ? { authorization: `Bearer ${opts.token}` } : {}),
|
|
342
347
|
"x-device-id": CHANNEL_ID,
|
|
343
348
|
...extra,
|
|
344
349
|
};
|
package/src/config.ts
CHANGED
|
@@ -349,6 +349,30 @@ function decodeJwtStringClaim(token: string, claim: string): string {
|
|
|
349
349
|
* wins; the mismatch is logged. Also derives userId from `sub` when nothing is
|
|
350
350
|
* configured, mirroring the `oid` fallback above.
|
|
351
351
|
*/
|
|
352
|
+
/**
|
|
353
|
+
* Reject an ownerUserId that is an unrendered provisioning-template
|
|
354
|
+
* placeholder — treat it as absent (fall through to the next source) instead
|
|
355
|
+
* of using it verbatim.
|
|
356
|
+
*
|
|
357
|
+
* 2026-08 production incident: a provisioner shipped the literal `usr_owner`
|
|
358
|
+
* (this repo's own test-fixture value) un-rendered; every affected agent
|
|
359
|
+
* passed the connect gate and hammered prod with `GET /v1/users/usr_owner`
|
|
360
|
+
* plus malformed-JWT retries for days. Dev/mock ids stay loose on purpose —
|
|
361
|
+
* only the known fixture literal and unmistakable template syntax
|
|
362
|
+
* ({{…}}, ${…}, <…>, %…%) are rejected.
|
|
363
|
+
*/
|
|
364
|
+
function rejectPlaceholderOwnerUserId(value: string, source: string): string {
|
|
365
|
+
if (!value) return "";
|
|
366
|
+
const looksUnrendered = value === "usr_owner" || /[{}<>%]|\$\{/.test(value);
|
|
367
|
+
if (looksUnrendered) {
|
|
368
|
+
console.warn(
|
|
369
|
+
`clawchat ownerUserId from ${source} looks like an unrendered template placeholder ("${value}"); ignoring it. Fix the provisioning template — without a real owner id (or an oid claim in the token) the agent stays in activation-wait instead of hammering the backend.`,
|
|
370
|
+
);
|
|
371
|
+
return "";
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
|
|
352
376
|
function resolveUserId(token: string, configured: string): string {
|
|
353
377
|
const tokenSub = decodeJwtStringClaim(token, "sub");
|
|
354
378
|
if (!tokenSub) return configured;
|
|
@@ -498,8 +522,11 @@ export function resolveOpenclawClawlingAccount(
|
|
|
498
522
|
readOptionalString(channel.userId) || readEnvString(env, CLAWCHAT_USER_ID_ENV),
|
|
499
523
|
);
|
|
500
524
|
const ownerUserId =
|
|
501
|
-
readOptionalString(channel.ownerUserId) ||
|
|
502
|
-
|
|
525
|
+
rejectPlaceholderOwnerUserId(readOptionalString(channel.ownerUserId), "channel ownerUserId") ||
|
|
526
|
+
rejectPlaceholderOwnerUserId(
|
|
527
|
+
readEnvString(env, CLAWCHAT_OWNER_USER_ID_ENV),
|
|
528
|
+
CLAWCHAT_OWNER_USER_ID_ENV,
|
|
529
|
+
) ||
|
|
503
530
|
// Fall back to the access token's `oid` (owner) claim. A provisioner may
|
|
504
531
|
// inject token + userId but omit ownerUserId; without it the connect gate
|
|
505
532
|
// (hasOpenclawClawlingConnectCredentials) never passes and the plugin sits
|
package/src/media-runtime.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type OutboundMediaReadFile,
|
|
6
6
|
} from "openclaw/plugin-sdk/media-runtime";
|
|
7
7
|
import type { OpenclawClawlingApiClient } from "./api-client.ts";
|
|
8
|
+
import { ClawlingApiError, type ClawlingApiErrorKind } from "./api-types.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Local structural superset of the protocol media fragment narrow types.
|
|
@@ -50,6 +51,10 @@ export interface UploadOutboundCtx {
|
|
|
50
51
|
mediaLocalRoots?: readonly string[] | "any";
|
|
51
52
|
/** Host-provided read bridge for sandbox/allowed local media paths. */
|
|
52
53
|
mediaReadFile?: OutboundMediaReadFile;
|
|
54
|
+
/** Overrides for the transient-failure retry budget. Merged over the default policy. */
|
|
55
|
+
retry?: Partial<UploadRetryPolicy>;
|
|
56
|
+
/** Test seam for the backoff wait. Defaults to `setTimeout`. */
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kind"] {
|
|
@@ -60,7 +65,250 @@ export function inferMediaKindFromMime(mime: string | undefined): MediaItem["kin
|
|
|
60
65
|
return "file";
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Single-file ceiling of the upstream msghub media service — its
|
|
70
|
+
* `media.max_size_bytes` is `104857600` in every shipped upstream config, and a
|
|
71
|
+
* larger body is rejected with HTTP 413 / business code `41301`. The plugin
|
|
72
|
+
* mirrors the server capability rather than under-reporting it: a lower local
|
|
73
|
+
* cap silently fails attachments the service would accept.
|
|
74
|
+
*/
|
|
75
|
+
export const CLAWCHAT_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
|
|
76
|
+
|
|
77
|
+
const DEFAULT_MEDIA_MAX_BYTES = CLAWCHAT_MEDIA_MAX_BYTES;
|
|
78
|
+
|
|
79
|
+
export interface UploadRetryPolicy {
|
|
80
|
+
/** Total attempts per attachment, including the first one. */
|
|
81
|
+
maxAttempts: number;
|
|
82
|
+
/** Wait after the first failed attempt; doubled per further attempt. */
|
|
83
|
+
baseDelayMs: number;
|
|
84
|
+
/** Upper bound on any single wait, so the backoff stays bounded. */
|
|
85
|
+
maxDelayMs: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Three attempts per attachment = the initial upload plus two backoff retries. */
|
|
89
|
+
export const DEFAULT_UPLOAD_RETRY_POLICY: UploadRetryPolicy = {
|
|
90
|
+
maxAttempts: 3,
|
|
91
|
+
baseDelayMs: 300,
|
|
92
|
+
maxDelayMs: 2_000,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Bounded exponential backoff for the wait AFTER 1-based `attempt`. */
|
|
96
|
+
export function uploadRetryDelayMs(
|
|
97
|
+
attempt: number,
|
|
98
|
+
policy: UploadRetryPolicy = DEFAULT_UPLOAD_RETRY_POLICY,
|
|
99
|
+
): number {
|
|
100
|
+
return Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempt - 1), policy.maxDelayMs);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Structured, credential-free diagnostics for one failed upload attempt. */
|
|
104
|
+
export interface UploadErrorDetail {
|
|
105
|
+
/** `ClawlingApiError.kind`, or `"unknown"` for a non-API throw. */
|
|
106
|
+
kind: ClawlingApiErrorKind | "unknown";
|
|
107
|
+
/** HTTP status, when the failure carried a response. */
|
|
108
|
+
status?: number;
|
|
109
|
+
/** Business `code` from the `{ code, msg, data }` envelope. */
|
|
110
|
+
code?: number;
|
|
111
|
+
/** Request path the failure came from (e.g. `/media/upload`). */
|
|
112
|
+
path?: string;
|
|
113
|
+
/** Response `data` payload, when the envelope carried one. */
|
|
114
|
+
data?: unknown;
|
|
115
|
+
message: string;
|
|
116
|
+
retryable: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Media-service business codes that reject THIS payload on its merits:
|
|
121
|
+
* missing file part, empty upload, bad token, too large, disallowed MIME.
|
|
122
|
+
* Re-sending identical bytes produces an identical rejection, so these never
|
|
123
|
+
* retry regardless of status.
|
|
124
|
+
*/
|
|
125
|
+
const PERMANENT_BUSINESS_CODES = new Set([40001, 40002, 40101, 41301, 41501]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Socket/DNS/timeout signatures for throws that never made it through the API
|
|
129
|
+
* client's own wrapping (e.g. an `AbortError` surfaced by the host fetch).
|
|
130
|
+
*/
|
|
131
|
+
const TRANSIENT_ERROR_PATTERN =
|
|
132
|
+
/(econnreset|econnrefused|econnaborted|etimedout|epipe|ehostunreach|enetunreach|enetdown|eai_again|enotfound|socket hang up|network error|fetch failed|timed out|timeout|aborted)/i;
|
|
133
|
+
|
|
134
|
+
function isRetryableHttpStatus(status: number): boolean {
|
|
135
|
+
// 408 request timeout, 425 too early, 429 rate limited, plus every 5xx.
|
|
136
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const API_ERROR_KINDS: readonly string[] = ["auth", "api", "transport", "validation"];
|
|
140
|
+
|
|
141
|
+
function asClawlingApiError(err: unknown): ClawlingApiError | undefined {
|
|
142
|
+
if (err instanceof ClawlingApiError) return err;
|
|
143
|
+
// Structural fallback: a duck-typed error from a mocked client still carries
|
|
144
|
+
// the diagnostics we must log, and misreading it as `unknown` would lose them.
|
|
145
|
+
if (
|
|
146
|
+
err instanceof Error &&
|
|
147
|
+
API_ERROR_KINDS.includes((err as { kind?: unknown }).kind as string)
|
|
148
|
+
) {
|
|
149
|
+
return err as ClawlingApiError;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isRetryableFailure(detail: UploadErrorDetail): boolean {
|
|
155
|
+
// Local pre-flight failures and credential rejections do not get better by
|
|
156
|
+
// trying again with the same bytes and the same token.
|
|
157
|
+
if (detail.kind === "validation" || detail.kind === "auth") return false;
|
|
158
|
+
if (typeof detail.code === "number" && PERMANENT_BUSINESS_CODES.has(detail.code)) return false;
|
|
159
|
+
if (typeof detail.status === "number") return isRetryableHttpStatus(detail.status);
|
|
160
|
+
// `transport` without a status is a failure before any response existed —
|
|
161
|
+
// DNS, connection reset, timeout: the transient case this retry loop is for.
|
|
162
|
+
if (detail.kind === "transport") return true;
|
|
163
|
+
// `api` without a status is a server-declared business rejection.
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Classify a thrown upload error into structured diagnostics + a retry verdict. */
|
|
168
|
+
export function classifyUploadError(err: unknown): UploadErrorDetail {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
const api = asClawlingApiError(err);
|
|
171
|
+
if (!api) {
|
|
172
|
+
return { kind: "unknown", message, retryable: TRANSIENT_ERROR_PATTERN.test(message) };
|
|
173
|
+
}
|
|
174
|
+
const detail: UploadErrorDetail = {
|
|
175
|
+
kind: api.kind,
|
|
176
|
+
message,
|
|
177
|
+
retryable: false,
|
|
178
|
+
...(typeof api.meta?.status === "number" ? { status: api.meta.status } : {}),
|
|
179
|
+
...(typeof api.meta?.code === "number" ? { code: api.meta.code } : {}),
|
|
180
|
+
...(api.meta?.path ? { path: api.meta.path } : {}),
|
|
181
|
+
...(api.meta?.data !== undefined ? { data: api.meta.data } : {}),
|
|
182
|
+
};
|
|
183
|
+
detail.retryable = isRetryableFailure(detail);
|
|
184
|
+
return detail;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const BEARER_PATTERN = /\b(bearer)\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
188
|
+
const JWT_PATTERN = /\beyJ[A-Za-z0-9._~+/=-]{10,}/g;
|
|
189
|
+
const SECRET_FIELD_PATTERN =
|
|
190
|
+
/("?\b(?:authorization|access[_-]?token|refresh[_-]?token|token|api[_-]?key|apikey|password|passwd|secret|signature|x-amz-signature|x-amz-credential)\b"?\s*[:=]\s*)"?[^"',\s&}]+"?/gi;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Strip anything credential-shaped out of a diagnostic string. Upload failures
|
|
194
|
+
* are logged with the server's `msg`/`data` verbatim, and neither is under this
|
|
195
|
+
* plugin's control — a backend that echoes a header or a presigned URL must not
|
|
196
|
+
* turn a log line into a token leak.
|
|
197
|
+
*/
|
|
198
|
+
export function redactMediaDiagnostics(text: string): string {
|
|
199
|
+
return text
|
|
200
|
+
.replace(BEARER_PATTERN, "$1 [redacted]")
|
|
201
|
+
.replace(SECRET_FIELD_PATTERN, '$1"[redacted]"')
|
|
202
|
+
.replace(JWT_PATTERN, "[redacted]");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reduce an outbound media source to something safe to log: presigned HTTP(S)
|
|
207
|
+
* URLs carry their credential in the query string, so only origin + path
|
|
208
|
+
* survive. Local paths are kept (they are the only way to identify the
|
|
209
|
+
* attachment) but never their contents.
|
|
210
|
+
*/
|
|
211
|
+
export function sanitizeMediaSource(source: string): string {
|
|
212
|
+
try {
|
|
213
|
+
const parsed = new URL(source);
|
|
214
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
215
|
+
const trimmed = `${parsed.origin}${parsed.pathname}`;
|
|
216
|
+
return parsed.search || parsed.hash ? `${trimmed} (query redacted)` : trimmed;
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Not a URL — a local path. Fall through to plain redaction.
|
|
220
|
+
}
|
|
221
|
+
return redactMediaDiagnostics(source);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function serializeDiagnosticValue(value: unknown, limit = 300): string {
|
|
225
|
+
let text: string;
|
|
226
|
+
try {
|
|
227
|
+
text = typeof value === "string" ? value : (JSON.stringify(value) ?? String(value));
|
|
228
|
+
} catch {
|
|
229
|
+
text = "[unserializable]";
|
|
230
|
+
}
|
|
231
|
+
const redacted = redactMediaDiagnostics(text);
|
|
232
|
+
return redacted.length > limit ? `${redacted.slice(0, limit)}…` : redacted;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Which step of the outbound pipeline gave up on this attachment. */
|
|
236
|
+
export type OutboundMediaFailureStage = "load" | "upload";
|
|
237
|
+
|
|
238
|
+
export interface OutboundMediaFailure {
|
|
239
|
+
/** Redacted source URL / local path the attachment came from. */
|
|
240
|
+
source: string;
|
|
241
|
+
stage: OutboundMediaFailureStage;
|
|
242
|
+
/** Attempts actually made, including the first. */
|
|
243
|
+
attempts: number;
|
|
244
|
+
fileName?: string;
|
|
245
|
+
/** Byte length of the loaded payload. */
|
|
246
|
+
size?: number;
|
|
247
|
+
mime?: string;
|
|
248
|
+
detail: UploadErrorDetail;
|
|
249
|
+
/** One-line redacted diagnostic — the same text written to the error log. */
|
|
250
|
+
summary: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface OutboundMediaUploadResult {
|
|
254
|
+
/** Fragments for every attachment that uploaded successfully. */
|
|
255
|
+
fragments: ClawlingMediaFragment[];
|
|
256
|
+
/** One entry per attachment that did not, in request order. */
|
|
257
|
+
failures: OutboundMediaFailure[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Render a failure as `key=value` diagnostics: error kind, HTTP status,
|
|
262
|
+
* business code, request path, response data, plus file name / size / MIME.
|
|
263
|
+
* Everything passes through {@link redactMediaDiagnostics} first — no token,
|
|
264
|
+
* `Authorization` header, or local file content ever reaches a log line.
|
|
265
|
+
*/
|
|
266
|
+
export function formatOutboundMediaFailure(failure: OutboundMediaFailure): string {
|
|
267
|
+
const { detail } = failure;
|
|
268
|
+
return [
|
|
269
|
+
`source=${failure.source}`,
|
|
270
|
+
...(failure.fileName ? [`file=${serializeDiagnosticValue(failure.fileName, 120)}`] : []),
|
|
271
|
+
...(typeof failure.size === "number" ? [`size=${failure.size}`] : []),
|
|
272
|
+
...(failure.mime ? [`mime=${failure.mime}`] : []),
|
|
273
|
+
`stage=${failure.stage}`,
|
|
274
|
+
`attempts=${failure.attempts}`,
|
|
275
|
+
`retryable=${detail.retryable}`,
|
|
276
|
+
`kind=${detail.kind}`,
|
|
277
|
+
...(typeof detail.status === "number" ? [`status=${detail.status}`] : []),
|
|
278
|
+
...(typeof detail.code === "number" ? [`code=${detail.code}`] : []),
|
|
279
|
+
...(detail.path ? [`path=${detail.path}`] : []),
|
|
280
|
+
...(detail.data !== undefined ? [`data=${serializeDiagnosticValue(detail.data)}`] : []),
|
|
281
|
+
`error=${serializeDiagnosticValue(detail.message)}`,
|
|
282
|
+
].join(" ");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildFailure(input: Omit<OutboundMediaFailure, "summary">): OutboundMediaFailure {
|
|
286
|
+
const failure: OutboundMediaFailure = { ...input, summary: "" };
|
|
287
|
+
failure.summary = formatOutboundMediaFailure(failure);
|
|
288
|
+
return failure;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Build the send-blocking error message for a batch that lost attachments.
|
|
293
|
+
*
|
|
294
|
+
* Both call sites (the `sendMedia` adapter and the reply dispatcher) reject the
|
|
295
|
+
* whole send on a shortfall, so this is the only text the agent sees: it must
|
|
296
|
+
* name every failed attachment with its reason instead of a generic
|
|
297
|
+
* "upload failed" that leaves the operator with nothing to act on.
|
|
298
|
+
*/
|
|
299
|
+
export function describeOutboundMediaShortfall(
|
|
300
|
+
result: OutboundMediaUploadResult,
|
|
301
|
+
requested: number,
|
|
302
|
+
): string {
|
|
303
|
+
const missing = Math.max(requested - result.fragments.length, result.failures.length);
|
|
304
|
+
const outcome = result.fragments.length > 0 ? "partial success" : "all failed";
|
|
305
|
+
const reasons = result.failures.map((f, i) => `[${i + 1}] ${f.summary}`).join(" | ");
|
|
306
|
+
return (
|
|
307
|
+
`clawchat-plugin-openclaw failed to upload ${missing}/${requested} outbound media ` +
|
|
308
|
+
`attachment(s) (${outcome}, ${result.fragments.length}/${requested} uploaded); ` +
|
|
309
|
+
`message not sent${reasons ? `: ${reasons}` : ""}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
64
312
|
|
|
65
313
|
/**
|
|
66
314
|
* Fetch each remote URL via the shared media runtime, persist to a local
|
|
@@ -92,7 +340,7 @@ export async function fetchInboundMedia(
|
|
|
92
340
|
paths.push(saved.path);
|
|
93
341
|
} catch (err) {
|
|
94
342
|
ctx.log?.info?.(
|
|
95
|
-
`clawchat-plugin-openclaw inbound media skipped: ${item.url} (${err instanceof Error ? err.message : String(err)})`,
|
|
343
|
+
`clawchat-plugin-openclaw inbound media skipped: ${sanitizeMediaSource(item.url)} (${redactMediaDiagnostics(err instanceof Error ? err.message : String(err))})`,
|
|
96
344
|
);
|
|
97
345
|
}
|
|
98
346
|
}
|
|
@@ -101,26 +349,34 @@ export async function fetchInboundMedia(
|
|
|
101
349
|
|
|
102
350
|
/**
|
|
103
351
|
* Upload each URL (remote or local path) to /media/upload via the api
|
|
104
|
-
* client and return
|
|
352
|
+
* client and return fragments ready to splice into `body.fragments`, plus a
|
|
353
|
+
* structured failure record for every attachment that did not make it.
|
|
105
354
|
*
|
|
106
355
|
* Uses the host runtime's `runtime.media.loadWebMedia`, so local-root
|
|
107
356
|
* enforcement and media-loading policy stay aligned with the current
|
|
108
357
|
* OpenClaw runtime instead of a directly imported helper.
|
|
109
358
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
359
|
+
* Transient upload faults (network/timeout, HTTP 5xx, 429/408) are retried
|
|
360
|
+
* with bounded exponential backoff per {@link DEFAULT_UPLOAD_RETRY_POLICY};
|
|
361
|
+
* local validation, auth, and definitive business rejections fail on the first
|
|
362
|
+
* attempt. One attachment giving up never aborts the batch — every remaining
|
|
363
|
+
* URL is still loaded and uploaded, and its fragment comes back — so the
|
|
364
|
+
* caller can distinguish partial success from a total failure and report why.
|
|
113
365
|
*/
|
|
114
366
|
export async function uploadOutboundMedia(
|
|
115
367
|
urls: string[],
|
|
116
368
|
ctx: UploadOutboundCtx,
|
|
117
|
-
): Promise<
|
|
118
|
-
|
|
369
|
+
): Promise<OutboundMediaUploadResult> {
|
|
370
|
+
const result: OutboundMediaUploadResult = { fragments: [], failures: [] };
|
|
371
|
+
if (urls.length === 0) return result;
|
|
119
372
|
const maxBytes = ctx.maxBytes ?? DEFAULT_MEDIA_MAX_BYTES;
|
|
120
|
-
const
|
|
373
|
+
const policy: UploadRetryPolicy = { ...DEFAULT_UPLOAD_RETRY_POLICY, ...(ctx.retry ?? {}) };
|
|
374
|
+
const sleep = ctx.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
121
375
|
for (const url of urls) {
|
|
376
|
+
const source = sanitizeMediaSource(url);
|
|
377
|
+
let loaded: Awaited<ReturnType<PluginRuntime["media"]["loadWebMedia"]>>;
|
|
122
378
|
try {
|
|
123
|
-
|
|
379
|
+
loaded = await ctx.runtime.media.loadWebMedia(
|
|
124
380
|
url,
|
|
125
381
|
buildOutboundMediaLoadOptions({
|
|
126
382
|
maxBytes,
|
|
@@ -129,24 +385,74 @@ export async function uploadOutboundMedia(
|
|
|
129
385
|
...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
|
|
130
386
|
}),
|
|
131
387
|
);
|
|
132
|
-
const uploaded = await ctx.apiClient.uploadMedia({
|
|
133
|
-
buffer: loaded.buffer,
|
|
134
|
-
filename: loaded.fileName ?? "upload.bin",
|
|
135
|
-
mime: loaded.contentType,
|
|
136
|
-
});
|
|
137
|
-
const fragment: ClawlingMediaFragment = {
|
|
138
|
-
kind: uploaded.kind,
|
|
139
|
-
url: uploaded.url,
|
|
140
|
-
name: uploaded.name,
|
|
141
|
-
mime: uploaded.mime,
|
|
142
|
-
size: uploaded.size,
|
|
143
|
-
};
|
|
144
|
-
out.push(fragment);
|
|
145
388
|
} catch (err) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
389
|
+
// Loading is host policy plus a one-shot read (missing path, outside the
|
|
390
|
+
// allowed roots, over `maxBytes`). Nothing here is transient, so it is
|
|
391
|
+
// never retried — and the bytes never reached the media service.
|
|
392
|
+
const failure = buildFailure({
|
|
393
|
+
source,
|
|
394
|
+
stage: "load",
|
|
395
|
+
attempts: 1,
|
|
396
|
+
detail: { ...classifyUploadError(err), retryable: false },
|
|
397
|
+
});
|
|
398
|
+
result.failures.push(failure);
|
|
399
|
+
ctx.log?.error?.(`clawchat-plugin-openclaw outbound media load failed: ${failure.summary}`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
const fileName = loaded.fileName ?? "upload.bin";
|
|
403
|
+
const size = loaded.buffer.byteLength;
|
|
404
|
+
const mime = loaded.contentType;
|
|
405
|
+
let pending: UploadErrorDetail | undefined;
|
|
406
|
+
let attempts = 0;
|
|
407
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
|
|
408
|
+
attempts = attempt;
|
|
409
|
+
try {
|
|
410
|
+
const uploaded = await ctx.apiClient.uploadMedia({
|
|
411
|
+
buffer: loaded.buffer,
|
|
412
|
+
filename: fileName,
|
|
413
|
+
...(mime ? { mime } : {}),
|
|
414
|
+
});
|
|
415
|
+
result.fragments.push({
|
|
416
|
+
kind: uploaded.kind,
|
|
417
|
+
url: uploaded.url,
|
|
418
|
+
name: uploaded.name,
|
|
419
|
+
mime: uploaded.mime,
|
|
420
|
+
size: uploaded.size,
|
|
421
|
+
});
|
|
422
|
+
pending = undefined;
|
|
423
|
+
break;
|
|
424
|
+
} catch (err) {
|
|
425
|
+
pending = classifyUploadError(err);
|
|
426
|
+
if (!pending.retryable || attempt >= policy.maxAttempts) break;
|
|
427
|
+
const delayMs = uploadRetryDelayMs(attempt, policy);
|
|
428
|
+
const attemptFailure = buildFailure({
|
|
429
|
+
source,
|
|
430
|
+
stage: "upload",
|
|
431
|
+
attempts: attempt,
|
|
432
|
+
fileName,
|
|
433
|
+
size,
|
|
434
|
+
...(mime ? { mime } : {}),
|
|
435
|
+
detail: pending,
|
|
436
|
+
});
|
|
437
|
+
ctx.log?.info?.(
|
|
438
|
+
`clawchat-plugin-openclaw outbound media upload retry ${attempt}/${policy.maxAttempts - 1} in ${delayMs}ms: ${attemptFailure.summary}`,
|
|
439
|
+
);
|
|
440
|
+
await sleep(delayMs);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (pending) {
|
|
444
|
+
const failure = buildFailure({
|
|
445
|
+
source,
|
|
446
|
+
stage: "upload",
|
|
447
|
+
attempts,
|
|
448
|
+
fileName,
|
|
449
|
+
size,
|
|
450
|
+
...(mime ? { mime } : {}),
|
|
451
|
+
detail: pending,
|
|
452
|
+
});
|
|
453
|
+
result.failures.push(failure);
|
|
454
|
+
ctx.log?.error?.(`clawchat-plugin-openclaw outbound media upload failed: ${failure.summary}`);
|
|
149
455
|
}
|
|
150
456
|
}
|
|
151
|
-
return
|
|
457
|
+
return result;
|
|
152
458
|
}
|
package/src/outbound.ts
CHANGED
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
textToFragments,
|
|
17
17
|
type MentionTarget,
|
|
18
18
|
} from "./message-mapper.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
describeOutboundMediaShortfall,
|
|
21
|
+
uploadOutboundMedia,
|
|
22
|
+
type ClawlingMediaFragment,
|
|
23
|
+
} from "./media-runtime.ts";
|
|
20
24
|
import { isClawChatNoopResponseText } from "./profile-prompt.ts";
|
|
21
25
|
import { stripNoReplyTokens } from "./no-reply.ts";
|
|
22
26
|
import {
|
|
@@ -947,16 +951,20 @@ export const openclawClawlingOutbound: ChannelOutboundAdapter = {
|
|
|
947
951
|
token: account.token,
|
|
948
952
|
userId: account.userId,
|
|
949
953
|
});
|
|
950
|
-
const
|
|
954
|
+
const uploadResult = await uploadOutboundMedia([mediaUrl.trim()], {
|
|
951
955
|
apiClient,
|
|
952
956
|
runtime,
|
|
953
957
|
...(mediaAccess ? { mediaAccess } : {}),
|
|
954
958
|
...(mediaLocalRoots ? { mediaLocalRoots } : {}),
|
|
955
959
|
...(mediaReadFile ? { mediaReadFile } : {}),
|
|
956
960
|
});
|
|
957
|
-
|
|
958
|
-
|
|
961
|
+
// Single-attachment path, so "shortfall" is always a total failure here;
|
|
962
|
+
// the message still carries the structured, redacted reason instead of a
|
|
963
|
+
// bare "failed to upload" the operator cannot act on.
|
|
964
|
+
if (uploadResult.fragments.length === 0) {
|
|
965
|
+
throw new Error(describeOutboundMediaShortfall(uploadResult, 1));
|
|
959
966
|
}
|
|
967
|
+
const mediaFragments = uploadResult.fragments;
|
|
960
968
|
const target = parseOpenclawRecipient(to);
|
|
961
969
|
const messageId = mintOutboundMessageId(account);
|
|
962
970
|
const claimText = (text ?? "").trim();
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
effectiveOutputVisibility,
|
|
18
18
|
type ResolvedOpenclawClawlingAccount,
|
|
19
19
|
} from "./config.ts";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
describeOutboundMediaShortfall,
|
|
22
|
+
uploadOutboundMedia,
|
|
23
|
+
type ClawlingMediaFragment,
|
|
24
|
+
} from "./media-runtime.ts";
|
|
21
25
|
import {
|
|
22
26
|
mintMessageId,
|
|
23
27
|
sendOpenclawClawlingText,
|
|
@@ -439,17 +443,19 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
439
443
|
);
|
|
440
444
|
return [];
|
|
441
445
|
}
|
|
442
|
-
const
|
|
443
|
-
if (fragments.length < urls.length) {
|
|
444
|
-
// uploadOutboundMedia
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
446
|
+
const result = await uploadOutboundMedia(urls, { apiClient, runtime, log });
|
|
447
|
+
if (result.fragments.length < urls.length) {
|
|
448
|
+
// uploadOutboundMedia never aborts the batch: every URL is attempted and
|
|
449
|
+
// the successful fragments still come back. A shortfall — partial success
|
|
450
|
+
// or total failure alike — means at least one attachment never reached
|
|
451
|
+
// the media service, so the whole send is rejected (parity with the
|
|
452
|
+
// adapter path in outbound.ts and with the Hermes plugin) rather than
|
|
453
|
+
// letting a text-only frame go out reading as a successful send. The
|
|
454
|
+
// thrown message carries each failure's structured, redacted diagnostics
|
|
455
|
+
// so the agent is not left with a reasonless generic error.
|
|
456
|
+
throw new Error(describeOutboundMediaShortfall(result, urls.length));
|
|
451
457
|
}
|
|
452
|
-
return fragments;
|
|
458
|
+
return result.fragments;
|
|
453
459
|
}
|
|
454
460
|
|
|
455
461
|
// ----- Reply state ------------------------------------------------------
|
package/src/runtime.ts
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
resolveManagedSkillsDir,
|
|
53
53
|
runSkillUpdateCheck,
|
|
54
54
|
} from "./skill-update.ts";
|
|
55
|
-
import { fetchInboundMedia } from "./media-runtime.ts";
|
|
55
|
+
import { CLAWCHAT_MEDIA_MAX_BYTES, fetchInboundMedia } from "./media-runtime.ts";
|
|
56
56
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
|
|
57
57
|
import { runWithTerminalClawChatSendScope } from "./terminal-send.ts";
|
|
58
58
|
import {
|
|
@@ -2907,7 +2907,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2907
2907
|
? await fetchInboundMedia(turn.mediaItems, {
|
|
2908
2908
|
runtime,
|
|
2909
2909
|
log,
|
|
2910
|
-
maxBytes:
|
|
2910
|
+
maxBytes: CLAWCHAT_MEDIA_MAX_BYTES,
|
|
2911
2911
|
})
|
|
2912
2912
|
: [];
|
|
2913
2913
|
if (inboundPaths.length > 0) {
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Synthetic reasoning-turn builder for `friend.request` notify.signal events.
|
|
3
|
-
*
|
|
4
|
-
* When the server fires a `friend.request` signal the runtime dispatches one
|
|
5
|
-
* deduped synthetic `message.send` envelope so the agent can reason about the
|
|
6
|
-
* pending request. The prompt text varies by the current "friend.add" permission
|
|
7
|
-
* policy state: a deny policy yields a decline/inform prompt; ask or allow
|
|
8
|
-
* yields a prompt that instructs the agent to review the request via the
|
|
9
|
-
* `clawchat_list_friend_requests` tool.
|
|
10
|
-
*/
|
|
11
|
-
import { EVENT } from "./protocol-types.js";
|
|
12
|
-
/**
|
|
13
|
-
* Returns canned prompt text for a friend-request reasoning turn based on the
|
|
14
|
-
* current "friend.add" permission policy state.
|
|
15
|
-
*
|
|
16
|
-
* - `deny` → inform the agent it should decline; no accept instruction.
|
|
17
|
-
* - `ask` / `allow` → instruct the agent to review the pending request.
|
|
18
|
-
*
|
|
19
|
-
* The requester's user id is embedded so the agent does not go looking up the
|
|
20
|
-
* synthetic sender ("clawchat-friend-request" is not a real user).
|
|
21
|
-
*/
|
|
22
|
-
export function friendRequestPromptFor(state, requesterUserId) {
|
|
23
|
-
const arrived = requesterUserId
|
|
24
|
-
? `A new friend request has arrived from user ${requesterUserId}.`
|
|
25
|
-
: "A new friend request has arrived.";
|
|
26
|
-
if (state === "deny") {
|
|
27
|
-
return [
|
|
28
|
-
arrived,
|
|
29
|
-
"Your current friend-add policy is set to deny.",
|
|
30
|
-
"Do not add this contact.",
|
|
31
|
-
"You may inform the requester that you cannot add them at this time.",
|
|
32
|
-
].join(" ");
|
|
33
|
-
}
|
|
34
|
-
return [
|
|
35
|
-
arrived,
|
|
36
|
-
"Please review the pending request by calling `clawchat_list_friend_requests`",
|
|
37
|
-
"and decide whether to accept it.",
|
|
38
|
-
].join(" ");
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Builds a synthetic `message.send` envelope that triggers one agent reasoning
|
|
42
|
-
* turn for a pending friend request. The envelope shape mirrors the
|
|
43
|
-
* activation-bootstrap envelope; it targets the owner's direct conversation so
|
|
44
|
-
* the agent has context about who it is reasoning for.
|
|
45
|
-
*/
|
|
46
|
-
export function buildFriendRequestEnvelope(params) {
|
|
47
|
-
const { account, state, entityId, ownerConversationId } = params;
|
|
48
|
-
const text = friendRequestPromptFor(state, entityId);
|
|
49
|
-
const now = Date.now();
|
|
50
|
-
// Fall back to ownerUserId only when no activation conversation is recorded:
|
|
51
|
-
// the turn still runs (the agent can act via tools, e.g. accept the request),
|
|
52
|
-
// but its in-chat replies will not be deliverable until activation records
|
|
53
|
-
// the owner conversation.
|
|
54
|
-
return {
|
|
55
|
-
version: "2",
|
|
56
|
-
event: EVENT.MESSAGE_SEND,
|
|
57
|
-
trace_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
58
|
-
emitted_at: now,
|
|
59
|
-
chat_id: ownerConversationId ?? account.ownerUserId,
|
|
60
|
-
chat_type: "direct",
|
|
61
|
-
to: { id: account.userId, type: "direct" },
|
|
62
|
-
sender: {
|
|
63
|
-
id: "clawchat-friend-request",
|
|
64
|
-
type: "direct",
|
|
65
|
-
nick_name: "ClawChat",
|
|
66
|
-
},
|
|
67
|
-
payload: {
|
|
68
|
-
message_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
69
|
-
message_mode: "normal",
|
|
70
|
-
message: {
|
|
71
|
-
body: { fragments: [{ kind: "text", text }] },
|
|
72
|
-
context: { mentions: [], reply: null },
|
|
73
|
-
streaming: {
|
|
74
|
-
status: "static",
|
|
75
|
-
sequence: 0,
|
|
76
|
-
mutation_policy: "sealed",
|
|
77
|
-
started_at: null,
|
|
78
|
-
completed_at: null,
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|