@clawling/clawchat-plugin-openclaw 2026.8.14-2 → 2026.8.22-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/media-runtime.js +256 -24
- package/dist/src/outbound.js +8 -4
- package/dist/src/protocol-types.js +18 -0
- package/dist/src/reply-dispatcher.js +13 -9
- package/dist/src/runtime.js +30 -3
- package/dist/src/storage.js +32 -0
- package/dist/src/ws-client.js +5 -0
- package/package.json +1 -1
- package/src/media-runtime.ts +333 -27
- package/src/outbound.ts +12 -4
- package/src/protocol-types.ts +37 -0
- package/src/reply-dispatcher.ts +17 -11
- package/src/runtime.ts +36 -2
- package/src/storage.ts +38 -0
- package/src/ws-client.ts +4 -0
- package/dist/src/friend-request-turn.js +0 -83
|
@@ -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();
|
|
@@ -8,6 +8,7 @@ export const EVENT = {
|
|
|
8
8
|
MESSAGE_ERROR: "message.error",
|
|
9
9
|
MESSAGE_REPLY: "message.reply",
|
|
10
10
|
MESSAGE_REACTION: "message.reaction",
|
|
11
|
+
MESSAGE_RECALL: "message.recall",
|
|
11
12
|
MESSAGE_CREATED: "message.created",
|
|
12
13
|
MESSAGE_ADD: "message.add",
|
|
13
14
|
MESSAGE_DONE: "message.done",
|
|
@@ -90,3 +91,20 @@ export class StateError extends Error {
|
|
|
90
91
|
export function isBusinessDispatchEvent(event) {
|
|
91
92
|
return event === EVENT.MESSAGE_SEND || event === EVENT.MESSAGE_REPLY;
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Pull the id to delete out of a `message.recall` payload.
|
|
96
|
+
*
|
|
97
|
+
* Exists as a pure function, apart from the socket, for the same reason the
|
|
98
|
+
* mistake it guards is worth guarding: `payload.message_id` is right there and
|
|
99
|
+
* looks like the answer, but it is the `rcl:` slot. Deleting by it removes
|
|
100
|
+
* nothing and reads exactly like working code.
|
|
101
|
+
*
|
|
102
|
+
* Answers `null` for anything unusable, so a recall we cannot resolve deletes
|
|
103
|
+
* nothing rather than something adjacent.
|
|
104
|
+
*/
|
|
105
|
+
export function recallTargetMessageId(payload) {
|
|
106
|
+
if (!payload || typeof payload !== "object")
|
|
107
|
+
return null;
|
|
108
|
+
const target = payload.target_message_id;
|
|
109
|
+
return typeof target === "string" && target.length > 0 ? target : null;
|
|
110
|
+
}
|
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, } from "./protocol-types.js";
|
|
1
|
+
import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
|
|
2
2
|
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
3
3
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
4
4
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
@@ -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) {
|
|
@@ -2907,6 +2907,33 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2907
2907
|
await handleInboundEnvelope(env);
|
|
2908
2908
|
})();
|
|
2909
2909
|
});
|
|
2910
|
+
// `message.recall` — the sender withdrew a message. Erase our own ledger row
|
|
2911
|
+
// so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
|
|
2912
|
+
// predicate, no retention) can never read it back into a turn.
|
|
2913
|
+
//
|
|
2914
|
+
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
2915
|
+
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
2916
|
+
//
|
|
2917
|
+
// Synchronous and fire-and-forget on purpose. There is no ack to send (the
|
|
2918
|
+
// frame is BestEffort and non-ackable), and no tombstone to write: a Kafka
|
|
2919
|
+
// redelivery of the original can resurrect the row, which is an accepted
|
|
2920
|
+
// known limitation rather than a fourth reimplementation of the mobile
|
|
2921
|
+
// client's tombstone table.
|
|
2922
|
+
client.on("message:recall", (env) => {
|
|
2923
|
+
const target = recallTargetMessageId(env.payload);
|
|
2924
|
+
if (!target) {
|
|
2925
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`);
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
const removed = store?.deleteMessagesByMessageId?.({
|
|
2929
|
+
platform: "openclaw",
|
|
2930
|
+
accountId,
|
|
2931
|
+
messageId: target,
|
|
2932
|
+
});
|
|
2933
|
+
const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
|
|
2934
|
+
const logFn = typeof removed === "number" ? log?.info : log?.error;
|
|
2935
|
+
logFn?.(`[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`);
|
|
2936
|
+
});
|
|
2910
2937
|
// Initial pull of per-group agent settings at startup (best-effort; errors are
|
|
2911
2938
|
// logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
|
|
2912
2939
|
// rotating `account.token` out from under the already-constructed WS client.
|
package/dist/src/storage.js
CHANGED
|
@@ -443,6 +443,38 @@ export class ClawChatStore {
|
|
|
443
443
|
.run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
|
|
444
444
|
});
|
|
445
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Erase every ledger row for one `message_id` — the local half of a
|
|
448
|
+
* server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
|
|
449
|
+
*
|
|
450
|
+
* **The only DELETE in this store, and deliberately so.** There is no prune
|
|
451
|
+
* and no retention on `clawchat_messages`: without this, a message recalled
|
|
452
|
+
* ninety seconds after it was sent stays one @-mention away from being read
|
|
453
|
+
* back aloud under "Prior group context" for as long as the group is quiet.
|
|
454
|
+
*
|
|
455
|
+
* Scoped to `(platform, account_id)` so one account's recall cannot reach
|
|
456
|
+
* another's rows, and matched on `message_id`, which seeks via the
|
|
457
|
+
* single-column `idx_clawchat_messages_message_id` before the `platform` /
|
|
458
|
+
* `account_id` filter is applied — so this is a lookup, not a table scan.
|
|
459
|
+
*
|
|
460
|
+
* Answers the number of rows removed. `0` is a normal outcome, not a failure:
|
|
461
|
+
* the row may never have been stored (a message that arrived before this
|
|
462
|
+
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
463
|
+
* store is unavailable.
|
|
464
|
+
*/
|
|
465
|
+
deleteMessagesByMessageId(input) {
|
|
466
|
+
// An empty id would match every row whose message_id is '' — deleting
|
|
467
|
+
// everything the store never managed to key. Refuse rather than guess.
|
|
468
|
+
if (!input.messageId)
|
|
469
|
+
return 0;
|
|
470
|
+
return this.write(() => {
|
|
471
|
+
const result = this.requireDb()
|
|
472
|
+
.prepare(`DELETE FROM clawchat_messages
|
|
473
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
474
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
475
|
+
return Number(result.changes);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
446
478
|
startConnection(input) {
|
|
447
479
|
return this.write(() => {
|
|
448
480
|
const now = input.connectStartedAt ?? Date.now();
|
package/dist/src/ws-client.js
CHANGED
|
@@ -558,6 +558,11 @@ export class ClawChatClient extends EventEmitter {
|
|
|
558
558
|
return this.onMessageError(env);
|
|
559
559
|
if (isBusinessDispatchEvent(env.event))
|
|
560
560
|
this.emit("message", env);
|
|
561
|
+
// Deliberately NOT a business dispatch event: `message` feeds the turn
|
|
562
|
+
// pipeline, and a recall has no body to turn into a prompt. It is an
|
|
563
|
+
// instruction to delete, so it gets its own emitter and its own consumer.
|
|
564
|
+
if (env.event === EVENT.MESSAGE_RECALL)
|
|
565
|
+
this.emit("message:recall", env);
|
|
561
566
|
if (env.event === EVENT.MESSAGE_CREATED)
|
|
562
567
|
this.emit("message:created", env);
|
|
563
568
|
if (env.event === EVENT.MESSAGE_ADD)
|
package/package.json
CHANGED
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/protocol-types.ts
CHANGED
|
@@ -8,6 +8,7 @@ export const EVENT = {
|
|
|
8
8
|
MESSAGE_ERROR: "message.error",
|
|
9
9
|
MESSAGE_REPLY: "message.reply",
|
|
10
10
|
MESSAGE_REACTION: "message.reaction",
|
|
11
|
+
MESSAGE_RECALL: "message.recall",
|
|
11
12
|
MESSAGE_CREATED: "message.created",
|
|
12
13
|
MESSAGE_ADD: "message.add",
|
|
13
14
|
MESSAGE_DONE: "message.done",
|
|
@@ -361,3 +362,39 @@ export class StateError extends Error {
|
|
|
361
362
|
export function isBusinessDispatchEvent(event: string): boolean {
|
|
362
363
|
return event === EVENT.MESSAGE_SEND || event === EVENT.MESSAGE_REPLY;
|
|
363
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* `message.recall` downlink payload (docs/client-integration.md §9.8).
|
|
368
|
+
*
|
|
369
|
+
* **Ids only, never content.** The frame relays an instruction to delete, not
|
|
370
|
+
* a copy of what is being deleted — which is what makes it compatible with the
|
|
371
|
+
* feature's "no trace anywhere" requirement.
|
|
372
|
+
*/
|
|
373
|
+
export interface MessageRecallPayload {
|
|
374
|
+
/**
|
|
375
|
+
* The server's deterministic slot id, `rcl:<target_message_id>`. Present so
|
|
376
|
+
* the marker can occupy one durable inbox row per recalled message. It is
|
|
377
|
+
* NOT the id to delete.
|
|
378
|
+
*/
|
|
379
|
+
message_id: string;
|
|
380
|
+
|
|
381
|
+
/** The id of the message being erased. This is the one to delete by. */
|
|
382
|
+
target_message_id: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Pull the id to delete out of a `message.recall` payload.
|
|
387
|
+
*
|
|
388
|
+
* Exists as a pure function, apart from the socket, for the same reason the
|
|
389
|
+
* mistake it guards is worth guarding: `payload.message_id` is right there and
|
|
390
|
+
* looks like the answer, but it is the `rcl:` slot. Deleting by it removes
|
|
391
|
+
* nothing and reads exactly like working code.
|
|
392
|
+
*
|
|
393
|
+
* Answers `null` for anything unusable, so a recall we cannot resolve deletes
|
|
394
|
+
* nothing rather than something adjacent.
|
|
395
|
+
*/
|
|
396
|
+
export function recallTargetMessageId(payload: unknown): string | null {
|
|
397
|
+
if (!payload || typeof payload !== "object") return null;
|
|
398
|
+
const target = (payload as { target_message_id?: unknown }).target_message_id;
|
|
399
|
+
return typeof target === "string" && target.length > 0 ? target : null;
|
|
400
|
+
}
|
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
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
StateError,
|
|
6
6
|
TransportError,
|
|
7
7
|
EVENT,
|
|
8
|
+
recallTargetMessageId,
|
|
8
9
|
type Envelope,
|
|
9
10
|
type Transport,
|
|
10
11
|
} from "./protocol-types.ts";
|
|
@@ -52,7 +53,7 @@ import {
|
|
|
52
53
|
resolveManagedSkillsDir,
|
|
53
54
|
runSkillUpdateCheck,
|
|
54
55
|
} from "./skill-update.ts";
|
|
55
|
-
import { fetchInboundMedia } from "./media-runtime.ts";
|
|
56
|
+
import { CLAWCHAT_MEDIA_MAX_BYTES, fetchInboundMedia } from "./media-runtime.ts";
|
|
56
57
|
import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
|
|
57
58
|
import { runWithTerminalClawChatSendScope } from "./terminal-send.ts";
|
|
58
59
|
import {
|
|
@@ -123,6 +124,7 @@ type RuntimeConnectionStore = Pick<
|
|
|
123
124
|
| "getActivationConversation"
|
|
124
125
|
| "getLastResolvedDeviceId"
|
|
125
126
|
| "listRecentGroupMessages"
|
|
127
|
+
| "deleteMessagesByMessageId"
|
|
126
128
|
| "upsertOwnerProfile"
|
|
127
129
|
>
|
|
128
130
|
>;
|
|
@@ -2907,7 +2909,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
2907
2909
|
? await fetchInboundMedia(turn.mediaItems, {
|
|
2908
2910
|
runtime,
|
|
2909
2911
|
log,
|
|
2910
|
-
maxBytes:
|
|
2912
|
+
maxBytes: CLAWCHAT_MEDIA_MAX_BYTES,
|
|
2911
2913
|
})
|
|
2912
2914
|
: [];
|
|
2913
2915
|
if (inboundPaths.length > 0) {
|
|
@@ -3447,6 +3449,38 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3447
3449
|
})();
|
|
3448
3450
|
});
|
|
3449
3451
|
|
|
3452
|
+
// `message.recall` — the sender withdrew a message. Erase our own ledger row
|
|
3453
|
+
// so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
|
|
3454
|
+
// predicate, no retention) can never read it back into a turn.
|
|
3455
|
+
//
|
|
3456
|
+
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
3457
|
+
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
3458
|
+
//
|
|
3459
|
+
// Synchronous and fire-and-forget on purpose. There is no ack to send (the
|
|
3460
|
+
// frame is BestEffort and non-ackable), and no tombstone to write: a Kafka
|
|
3461
|
+
// redelivery of the original can resurrect the row, which is an accepted
|
|
3462
|
+
// known limitation rather than a fourth reimplementation of the mobile
|
|
3463
|
+
// client's tombstone table.
|
|
3464
|
+
client.on("message:recall", (env: Envelope) => {
|
|
3465
|
+
const target = recallTargetMessageId(env.payload);
|
|
3466
|
+
if (!target) {
|
|
3467
|
+
log?.info?.(
|
|
3468
|
+
`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`,
|
|
3469
|
+
);
|
|
3470
|
+
return;
|
|
3471
|
+
}
|
|
3472
|
+
const removed = store?.deleteMessagesByMessageId?.({
|
|
3473
|
+
platform: "openclaw",
|
|
3474
|
+
accountId,
|
|
3475
|
+
messageId: target,
|
|
3476
|
+
});
|
|
3477
|
+
const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
|
|
3478
|
+
const logFn = typeof removed === "number" ? log?.info : log?.error;
|
|
3479
|
+
logFn?.(
|
|
3480
|
+
`[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`,
|
|
3481
|
+
);
|
|
3482
|
+
});
|
|
3483
|
+
|
|
3450
3484
|
// Initial pull of per-group agent settings at startup (best-effort; errors are
|
|
3451
3485
|
// logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
|
|
3452
3486
|
// rotating `account.token` out from under the already-constructed WS client.
|
package/src/storage.ts
CHANGED
|
@@ -802,6 +802,44 @@ export class ClawChatStore {
|
|
|
802
802
|
});
|
|
803
803
|
}
|
|
804
804
|
|
|
805
|
+
/**
|
|
806
|
+
* Erase every ledger row for one `message_id` — the local half of a
|
|
807
|
+
* server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
|
|
808
|
+
*
|
|
809
|
+
* **The only DELETE in this store, and deliberately so.** There is no prune
|
|
810
|
+
* and no retention on `clawchat_messages`: without this, a message recalled
|
|
811
|
+
* ninety seconds after it was sent stays one @-mention away from being read
|
|
812
|
+
* back aloud under "Prior group context" for as long as the group is quiet.
|
|
813
|
+
*
|
|
814
|
+
* Scoped to `(platform, account_id)` so one account's recall cannot reach
|
|
815
|
+
* another's rows, and matched on `message_id`, which seeks via the
|
|
816
|
+
* single-column `idx_clawchat_messages_message_id` before the `platform` /
|
|
817
|
+
* `account_id` filter is applied — so this is a lookup, not a table scan.
|
|
818
|
+
*
|
|
819
|
+
* Answers the number of rows removed. `0` is a normal outcome, not a failure:
|
|
820
|
+
* the row may never have been stored (a message that arrived before this
|
|
821
|
+
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
822
|
+
* store is unavailable.
|
|
823
|
+
*/
|
|
824
|
+
deleteMessagesByMessageId(input: {
|
|
825
|
+
platform: string;
|
|
826
|
+
accountId: string;
|
|
827
|
+
messageId: string;
|
|
828
|
+
}): number | null {
|
|
829
|
+
// An empty id would match every row whose message_id is '' — deleting
|
|
830
|
+
// everything the store never managed to key. Refuse rather than guess.
|
|
831
|
+
if (!input.messageId) return 0;
|
|
832
|
+
return this.write(() => {
|
|
833
|
+
const result = this.requireDb()
|
|
834
|
+
.prepare(
|
|
835
|
+
`DELETE FROM clawchat_messages
|
|
836
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`,
|
|
837
|
+
)
|
|
838
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
839
|
+
return Number(result.changes);
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
805
843
|
startConnection(input: StartConnectionInput): number | null {
|
|
806
844
|
return this.write(() => {
|
|
807
845
|
const now = input.connectStartedAt ?? Date.now();
|
package/src/ws-client.ts
CHANGED
|
@@ -651,6 +651,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
651
651
|
if (env.event === EVENT.MESSAGE_ACK) return this.onAck(env as Envelope<MessageAckPayload>);
|
|
652
652
|
if (env.event === EVENT.MESSAGE_ERROR) return this.onMessageError(env as Envelope<MessageErrorPayload>);
|
|
653
653
|
if (isBusinessDispatchEvent(env.event)) this.emit("message", env);
|
|
654
|
+
// Deliberately NOT a business dispatch event: `message` feeds the turn
|
|
655
|
+
// pipeline, and a recall has no body to turn into a prompt. It is an
|
|
656
|
+
// instruction to delete, so it gets its own emitter and its own consumer.
|
|
657
|
+
if (env.event === EVENT.MESSAGE_RECALL) this.emit("message:recall", env);
|
|
654
658
|
if (env.event === EVENT.MESSAGE_CREATED) this.emit("message:created", env);
|
|
655
659
|
if (env.event === EVENT.MESSAGE_ADD) this.emit("message:add", env);
|
|
656
660
|
if (env.event === EVENT.MESSAGE_DONE) this.emit("message:done", env);
|
|
@@ -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
|
-
}
|