@alfe.ai/openclaw-chat 0.6.0 → 0.7.0
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/index.d.cts +1 -90
- package/dist/index.d.ts +1 -90
- package/dist/plugin.d.cts +139 -2
- package/dist/plugin.d.ts +139 -2
- package/dist/plugin2.cjs +546 -17
- package/dist/plugin2.js +547 -18
- package/package.json +2 -2
package/dist/plugin2.cjs
CHANGED
|
@@ -4,7 +4,346 @@ let node_path = require("node:path");
|
|
|
4
4
|
let node_os = require("node:os");
|
|
5
5
|
let _alfe_ai_chat = require("@alfe.ai/chat");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
|
+
let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
|
|
8
|
+
let _alfe_ai_config = require("@alfe.ai/config");
|
|
7
9
|
let node_fs = require("node:fs");
|
|
10
|
+
//#region src/outbound-media.ts
|
|
11
|
+
/**
|
|
12
|
+
* Outbound media upload — agent → user.
|
|
13
|
+
*
|
|
14
|
+
* OpenClaw approves LOCAL file paths in outbound media (`MEDIA:` tokens →
|
|
15
|
+
* `payload.mediaUrl(s)`, and inline markdown images which the runtime never
|
|
16
|
+
* extracts for this channel). Forwarding those strings verbatim ships a VM
|
|
17
|
+
* path to the browser, which resolves it against the chat origin → broken
|
|
18
|
+
* image. This module uploads local refs to S3 through the EXISTING chat
|
|
19
|
+
* attachment machinery (`AgentApiClient.presignAttachments` →
|
|
20
|
+
* `POST /agent/chat/attachments/presign` → presigned PUT) and hands back
|
|
21
|
+
* hosted download URLs plus rich metadata.
|
|
22
|
+
*
|
|
23
|
+
* Key constraints (all load-bearing):
|
|
24
|
+
* - `deliver` fires PER STREAMING BLOCK plus a terminal frame, so the same
|
|
25
|
+
* media ref recurs within one turn. Every resolution goes through a
|
|
26
|
+
* per-turn `OutboundMediaContext` cache — one presign+PUT per distinct
|
|
27
|
+
* path per turn, failures cached too, failure annotations emitted once.
|
|
28
|
+
* - The upload runs inside the per-agent single-flight turn queue; an
|
|
29
|
+
* unbounded PUT would head-of-line block every later turn. Every PUT is
|
|
30
|
+
* bounded by an AbortController timeout.
|
|
31
|
+
* - The size cap mirrors the presign endpoint's 25 MB / 10-files limits —
|
|
32
|
+
* NOT the plugin's 50 MB inbound download cap — so oversize files fail
|
|
33
|
+
* fast locally instead of as an opaque presign 400.
|
|
34
|
+
* - The presigned PUT is signed with `ContentType`; the raw fetch MUST send
|
|
35
|
+
* a matching `Content-Type` header. (This raw fetch to a presigned S3 URL
|
|
36
|
+
* is the sanctioned exception to the no-raw-fetch rule.)
|
|
37
|
+
* - `resolveConfig()` throws on self-hosted agents without env/config.toml
|
|
38
|
+
* (and on unknown token prefixes). With no client, local refs resolve to
|
|
39
|
+
* FAILURES (dropped + fallback note) — never passthrough: a local path
|
|
40
|
+
* was never deliverable, and shipping it would leak filesystem structure.
|
|
41
|
+
* Remote http(s) refs are unaffected. The unavailability is memoized for
|
|
42
|
+
* the process lifetime (config appears via `alfe setup` + restart).
|
|
43
|
+
*/
|
|
44
|
+
const MAX_OUTBOUND_MEDIA_SIZE = 25 * 1024 * 1024;
|
|
45
|
+
const UPLOAD_TIMEOUT_MS = 3e4;
|
|
46
|
+
const EXTENSION_MIME = {
|
|
47
|
+
png: "image/png",
|
|
48
|
+
jpg: "image/jpeg",
|
|
49
|
+
jpeg: "image/jpeg",
|
|
50
|
+
gif: "image/gif",
|
|
51
|
+
webp: "image/webp",
|
|
52
|
+
svg: "image/svg+xml",
|
|
53
|
+
mp4: "video/mp4",
|
|
54
|
+
mov: "video/quicktime",
|
|
55
|
+
webm: "video/webm",
|
|
56
|
+
mp3: "audio/mpeg",
|
|
57
|
+
wav: "audio/wav",
|
|
58
|
+
ogg: "audio/ogg",
|
|
59
|
+
m4a: "audio/mp4",
|
|
60
|
+
pdf: "application/pdf",
|
|
61
|
+
txt: "text/plain",
|
|
62
|
+
md: "text/markdown",
|
|
63
|
+
csv: "text/csv",
|
|
64
|
+
json: "application/json"
|
|
65
|
+
};
|
|
66
|
+
/** Extensions the markdown-image rewrite will upload. Restricting to image
|
|
67
|
+
* types keeps ``-style refs from shipping arbitrary VM
|
|
68
|
+
* files to the attachment bucket. */
|
|
69
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
70
|
+
"png",
|
|
71
|
+
"jpg",
|
|
72
|
+
"jpeg",
|
|
73
|
+
"gif",
|
|
74
|
+
"webp",
|
|
75
|
+
"svg"
|
|
76
|
+
]);
|
|
77
|
+
function createOutboundMediaContext() {
|
|
78
|
+
return {
|
|
79
|
+
uploads: /* @__PURE__ */ new Map(),
|
|
80
|
+
annotatedFailures: /* @__PURE__ */ new Set()
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
let cachedClient;
|
|
84
|
+
function getUploadClient(log) {
|
|
85
|
+
if (cachedClient !== void 0) return cachedClient;
|
|
86
|
+
try {
|
|
87
|
+
const config = (0, _alfe_ai_config.resolveConfig)();
|
|
88
|
+
cachedClient = new _alfe_ai_agent_api_client.AgentApiClient({
|
|
89
|
+
apiUrl: config.apiUrl,
|
|
90
|
+
apiKey: config.apiKey
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
log.warn(`Outbound media upload unavailable (no resolvable Alfe config): ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
cachedClient = null;
|
|
95
|
+
}
|
|
96
|
+
return cachedClient;
|
|
97
|
+
}
|
|
98
|
+
function mimeFromPath(p) {
|
|
99
|
+
const ext = extensionOf(p);
|
|
100
|
+
return (ext ? EXTENSION_MIME[ext] : void 0) ?? "application/octet-stream";
|
|
101
|
+
}
|
|
102
|
+
function extensionOf(p) {
|
|
103
|
+
const name = (0, node_path.basename)(p);
|
|
104
|
+
return name.includes(".") ? name.split(".").at(-1)?.toLowerCase() : void 0;
|
|
105
|
+
}
|
|
106
|
+
function isRemoteUrl(ref) {
|
|
107
|
+
return /^https?:\/\//i.test(ref);
|
|
108
|
+
}
|
|
109
|
+
/** Non-http(s) URI schemes (data:, blob:, media://inbound claim-checks…) —
|
|
110
|
+
* not local paths, nothing we can upload. Windows drive letters (C:\) are
|
|
111
|
+
* paths, not schemes. */
|
|
112
|
+
function hasNonPathScheme(ref) {
|
|
113
|
+
if (/^file:\/\//i.test(ref)) return false;
|
|
114
|
+
if (/^[a-zA-Z]:[\\/]/.test(ref)) return false;
|
|
115
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(ref);
|
|
116
|
+
}
|
|
117
|
+
/** file:// strip, ~ expansion, relative → cwd resolution. */
|
|
118
|
+
function resolveLocalMediaPath(ref) {
|
|
119
|
+
let p = ref.replace(/^file:\/\//i, "");
|
|
120
|
+
if (p === "~" || p.startsWith("~/")) p = (0, node_path.join)((0, node_os.homedir)(), p.slice(1));
|
|
121
|
+
return (0, node_path.isAbsolute)(p) ? p : (0, node_path.resolve)(process.cwd(), p);
|
|
122
|
+
}
|
|
123
|
+
async function uploadLocalFile(localPath, log, deps) {
|
|
124
|
+
const client = deps.getClient ? deps.getClient() : getUploadClient(log);
|
|
125
|
+
if (!client) return null;
|
|
126
|
+
let size;
|
|
127
|
+
try {
|
|
128
|
+
const st = await (0, node_fs_promises.stat)(localPath);
|
|
129
|
+
if (!st.isFile()) {
|
|
130
|
+
log.warn(`Outbound media ref is not a file — skipping: ${localPath}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
size = st.size;
|
|
134
|
+
} catch {
|
|
135
|
+
log.warn(`Outbound media file not found — skipping: ${localPath}`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (size <= 0) {
|
|
139
|
+
log.warn(`Outbound media file is empty — skipping: ${localPath}`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (size > 26214400) {
|
|
143
|
+
log.warn(`Outbound media exceeds ${String(MAX_OUTBOUND_MEDIA_SIZE)} bytes (${String(size)}) — skipping: ${localPath}`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const filename = (0, node_path.basename)(localPath);
|
|
147
|
+
const mimeType = mimeFromPath(localPath);
|
|
148
|
+
try {
|
|
149
|
+
const body = await (0, node_fs_promises.readFile)(localPath);
|
|
150
|
+
const { attachments } = await client.presignAttachments([{
|
|
151
|
+
filename,
|
|
152
|
+
mimeType,
|
|
153
|
+
size
|
|
154
|
+
}]);
|
|
155
|
+
const presigned = attachments.at(0);
|
|
156
|
+
if (!presigned) {
|
|
157
|
+
log.warn(`Presign returned no attachment for ${filename}`);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const timeout = setTimeout(() => {
|
|
163
|
+
controller.abort();
|
|
164
|
+
}, UPLOAD_TIMEOUT_MS);
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetchFn(presigned.uploadUrl, {
|
|
167
|
+
method: "PUT",
|
|
168
|
+
headers: { "Content-Type": mimeType },
|
|
169
|
+
body,
|
|
170
|
+
signal: controller.signal
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
log.warn(`Outbound media PUT failed (${String(res.status)}) for ${filename}`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
}
|
|
179
|
+
log.info(`Uploaded outbound media: ${filename} (${String(size)} bytes)`);
|
|
180
|
+
return {
|
|
181
|
+
url: presigned.downloadUrl,
|
|
182
|
+
filename,
|
|
183
|
+
mimeType,
|
|
184
|
+
size
|
|
185
|
+
};
|
|
186
|
+
} catch (err) {
|
|
187
|
+
log.warn(`Outbound media upload failed for ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Resolve one outbound media ref. Remote http(s) URLs pass through; local
|
|
193
|
+
* paths upload (deduped per turn via `ctx`); non-path schemes and failures
|
|
194
|
+
* resolve to null.
|
|
195
|
+
*/
|
|
196
|
+
function resolveOutboundMediaRef(ref, ctx, log, deps = {}) {
|
|
197
|
+
const trimmed = ref.trim();
|
|
198
|
+
if (!trimmed) return Promise.resolve(null);
|
|
199
|
+
if (isRemoteUrl(trimmed)) return Promise.resolve({ url: trimmed });
|
|
200
|
+
if (hasNonPathScheme(trimmed)) {
|
|
201
|
+
log.warn(`Outbound media ref has unsupported scheme — skipping: ${trimmed.slice(0, 120)}`);
|
|
202
|
+
return Promise.resolve(null);
|
|
203
|
+
}
|
|
204
|
+
const localPath = resolveLocalMediaPath(trimmed);
|
|
205
|
+
const cached = ctx.uploads.get(localPath);
|
|
206
|
+
if (cached) return cached;
|
|
207
|
+
const promise = ctx.uploads.size >= 10 ? (() => {
|
|
208
|
+
log.warn(`Outbound media per-turn cap (${String(10)}) reached — skipping: ${localPath}`);
|
|
209
|
+
return Promise.resolve(null);
|
|
210
|
+
})() : uploadLocalFile(localPath, log, deps);
|
|
211
|
+
ctx.uploads.set(localPath, promise);
|
|
212
|
+
return promise;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a turn's `mediaUrl(s)` refs into hosted URLs + rich metadata.
|
|
216
|
+
* `newFailures` lists local refs that failed and haven't been annotated
|
|
217
|
+
* yet this turn — append fallback text for them so a media-only message
|
|
218
|
+
* doesn't silently vanish.
|
|
219
|
+
*/
|
|
220
|
+
async function resolveOutboundMedia(refs, ctx, log, deps = {}) {
|
|
221
|
+
const mediaUrls = [];
|
|
222
|
+
const attachments = [];
|
|
223
|
+
const newFailures = [];
|
|
224
|
+
for (const ref of refs) {
|
|
225
|
+
const trimmed = ref.trim();
|
|
226
|
+
if (!trimmed) continue;
|
|
227
|
+
const resolved = await resolveOutboundMediaRef(trimmed, ctx, log, deps);
|
|
228
|
+
if (resolved) {
|
|
229
|
+
mediaUrls.push(resolved.url);
|
|
230
|
+
attachments.push(resolved);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (isRemoteUrl(trimmed) || hasNonPathScheme(trimmed)) continue;
|
|
234
|
+
const localPath = resolveLocalMediaPath(trimmed);
|
|
235
|
+
if (!ctx.annotatedFailures.has(localPath)) {
|
|
236
|
+
ctx.annotatedFailures.add(localPath);
|
|
237
|
+
newFailures.push((0, node_path.basename)(localPath));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
mediaUrls,
|
|
242
|
+
attachments,
|
|
243
|
+
newFailures
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** Formats the once-per-turn fallback note for failed uploads. */
|
|
247
|
+
function formatMediaFailureNote(failures) {
|
|
248
|
+
return failures.map((f) => `[attachment failed to upload: ${f}]`).join("\n");
|
|
249
|
+
}
|
|
250
|
+
/** Split into code (fenced blocks + inline spans) and plain segments so the
|
|
251
|
+
* rewrite never touches code. Fence detection is line-anchored ```/~~~,
|
|
252
|
+
* matching OpenClaw's own parser precedent. Offset-based slicing so
|
|
253
|
+
* rejoining segments with '' reproduces the input byte-for-byte. */
|
|
254
|
+
function splitCodeSegments(text) {
|
|
255
|
+
const fenceSpans = [];
|
|
256
|
+
let inFence = false;
|
|
257
|
+
let fenceStart = 0;
|
|
258
|
+
let offset = 0;
|
|
259
|
+
for (const line of text.split("\n")) {
|
|
260
|
+
if (/^[ \t]*(`{3,}|~{3,})/.test(line)) if (!inFence) {
|
|
261
|
+
inFence = true;
|
|
262
|
+
fenceStart = offset;
|
|
263
|
+
} else {
|
|
264
|
+
fenceSpans.push({
|
|
265
|
+
start: fenceStart,
|
|
266
|
+
end: offset + line.length
|
|
267
|
+
});
|
|
268
|
+
inFence = false;
|
|
269
|
+
}
|
|
270
|
+
offset += line.length + 1;
|
|
271
|
+
}
|
|
272
|
+
if (inFence) fenceSpans.push({
|
|
273
|
+
start: fenceStart,
|
|
274
|
+
end: text.length
|
|
275
|
+
});
|
|
276
|
+
const segments = [];
|
|
277
|
+
let cursor = 0;
|
|
278
|
+
for (const span of fenceSpans) {
|
|
279
|
+
if (span.start > cursor) segments.push({
|
|
280
|
+
code: false,
|
|
281
|
+
text: text.slice(cursor, span.start)
|
|
282
|
+
});
|
|
283
|
+
segments.push({
|
|
284
|
+
code: true,
|
|
285
|
+
text: text.slice(span.start, span.end)
|
|
286
|
+
});
|
|
287
|
+
cursor = span.end;
|
|
288
|
+
}
|
|
289
|
+
if (cursor < text.length) segments.push({
|
|
290
|
+
code: false,
|
|
291
|
+
text: text.slice(cursor)
|
|
292
|
+
});
|
|
293
|
+
const result = [];
|
|
294
|
+
for (const seg of segments) {
|
|
295
|
+
if (seg.code) {
|
|
296
|
+
result.push(seg);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
for (const part of seg.text.split(/(`[^`\n]+`)/)) {
|
|
300
|
+
if (!part) continue;
|
|
301
|
+
result.push({
|
|
302
|
+
code: part.startsWith("`") && part.endsWith("`") && part.length > 2,
|
|
303
|
+
text: part
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
|
|
310
|
+
/**
|
|
311
|
+
* Rewrite `` to hosted URLs. Per-image: a failed
|
|
312
|
+
* upload leaves that image's text untouched (a truncated mid-stream path
|
|
313
|
+
* fails `stat` and is left alone; the terminal frame carries the full text
|
|
314
|
+
* and rewrites correctly). Only image extensions are uploaded.
|
|
315
|
+
*/
|
|
316
|
+
async function rewriteLocalMarkdownImages(text, ctx, log, deps = {}) {
|
|
317
|
+
if (!text.includes("![")) return text;
|
|
318
|
+
const segments = splitCodeSegments(text);
|
|
319
|
+
return (await Promise.all(segments.map(async (seg) => {
|
|
320
|
+
if (seg.code || !seg.text.includes("![")) return seg.text;
|
|
321
|
+
const matches = [...seg.text.matchAll(MARKDOWN_IMAGE_RE)];
|
|
322
|
+
if (matches.length === 0) return seg.text;
|
|
323
|
+
let out = "";
|
|
324
|
+
let cursor = 0;
|
|
325
|
+
for (const match of matches) {
|
|
326
|
+
const [full, alt, src] = match;
|
|
327
|
+
const start = match.index;
|
|
328
|
+
out += seg.text.slice(cursor, start);
|
|
329
|
+
cursor = start + full.length;
|
|
330
|
+
if (isRemoteUrl(src) || hasNonPathScheme(src)) {
|
|
331
|
+
out += full;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const ext = extensionOf(src.split("?")[0]);
|
|
335
|
+
if (!ext || !IMAGE_EXTENSIONS.has(ext)) {
|
|
336
|
+
out += full;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const resolved = await resolveOutboundMediaRef(src, ctx, log, deps);
|
|
340
|
+
out += resolved ? `` : full;
|
|
341
|
+
}
|
|
342
|
+
out += seg.text.slice(cursor);
|
|
343
|
+
return out;
|
|
344
|
+
}))).join("");
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
8
347
|
//#region src/alfe-channel.ts
|
|
9
348
|
/**
|
|
10
349
|
* Alfe channel plugin definition — registers 'alfe' as an OpenClaw channel.
|
|
@@ -19,11 +358,78 @@ let node_fs = require("node:fs");
|
|
|
19
358
|
*
|
|
20
359
|
* Config section: channels.alfe in openclaw.yaml
|
|
21
360
|
*/
|
|
361
|
+
const noopLog = {
|
|
362
|
+
info: () => void 0,
|
|
363
|
+
warn: () => void 0,
|
|
364
|
+
error: () => void 0
|
|
365
|
+
};
|
|
366
|
+
function isSafeComponentUrl(raw) {
|
|
367
|
+
let u;
|
|
368
|
+
try {
|
|
369
|
+
u = new URL(raw);
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
if (u.protocol !== "https:") return false;
|
|
374
|
+
const host = u.hostname.toLowerCase();
|
|
375
|
+
return host === "alfe.ai" || host.endsWith(".alfe.ai");
|
|
376
|
+
}
|
|
377
|
+
const MAX_COMPONENTS_PER_MESSAGE = 10;
|
|
378
|
+
const MAX_COMPONENT_LABEL_CHARS = 120;
|
|
379
|
+
/**
|
|
380
|
+
* Validate + normalize agent-supplied components down to the persisted wire
|
|
381
|
+
* shape. Drops anything malformed or unsafe (unsafe URL, missing fields,
|
|
382
|
+
* over-cap). Returns at most MAX_COMPONENTS_PER_MESSAGE. Phase 1 renders
|
|
383
|
+
* `link_button`; `quick_reply` is accepted for forward-compat persistence.
|
|
384
|
+
*/
|
|
385
|
+
function sanitizeComponents(raw) {
|
|
386
|
+
if (!Array.isArray(raw)) return [];
|
|
387
|
+
const out = [];
|
|
388
|
+
for (const item of raw) {
|
|
389
|
+
if (out.length >= MAX_COMPONENTS_PER_MESSAGE) break;
|
|
390
|
+
if (!item || typeof item !== "object") continue;
|
|
391
|
+
const c = item;
|
|
392
|
+
const label = typeof c.label === "string" ? c.label.slice(0, MAX_COMPONENT_LABEL_CHARS) : "";
|
|
393
|
+
if (!label) continue;
|
|
394
|
+
const id = typeof c.id === "string" && c.id ? c.id : (0, node_crypto.randomUUID)();
|
|
395
|
+
const style = c.style === "primary" || c.style === "secondary" || c.style === "danger" ? c.style : void 0;
|
|
396
|
+
if (c.type === "link_button") {
|
|
397
|
+
if (typeof c.url !== "string" || !isSafeComponentUrl(c.url)) continue;
|
|
398
|
+
const target = c.target === "same-tab" || c.target === "new-tab" || c.target === "popup" ? c.target : void 0;
|
|
399
|
+
out.push({
|
|
400
|
+
type: "link_button",
|
|
401
|
+
id,
|
|
402
|
+
label,
|
|
403
|
+
url: c.url,
|
|
404
|
+
...target ? { target } : {},
|
|
405
|
+
...style ? { style } : {}
|
|
406
|
+
});
|
|
407
|
+
} else if (c.type === "quick_reply") {
|
|
408
|
+
if (typeof c.value !== "string" || !c.value) continue;
|
|
409
|
+
out.push({
|
|
410
|
+
type: "quick_reply",
|
|
411
|
+
id,
|
|
412
|
+
label,
|
|
413
|
+
value: c.value,
|
|
414
|
+
...style ? { style } : {}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
22
420
|
const CHANNEL_ID = "alfe";
|
|
23
421
|
const DEFAULT_ACCOUNT_ID = "default";
|
|
24
|
-
async function sendViaChat(deps, ctx, mediaUrl) {
|
|
422
|
+
async function sendViaChat(deps, ctx, mediaUrl, components) {
|
|
25
423
|
const client = deps.getChatClient();
|
|
26
424
|
if (!client) throw new Error("Chat service not connected — cannot deliver");
|
|
425
|
+
const log = deps.log ?? noopLog;
|
|
426
|
+
const mediaCtx = createOutboundMediaContext();
|
|
427
|
+
let text = await rewriteLocalMarkdownImages(ctx.text, mediaCtx, log);
|
|
428
|
+
const media = await resolveOutboundMedia(mediaUrl ? [mediaUrl] : [], mediaCtx, log);
|
|
429
|
+
if (media.newFailures.length) {
|
|
430
|
+
if (!text.trim() && media.mediaUrls.length === 0 && !components?.length) throw new Error(`media_upload_failed: ${media.newFailures.join(", ")}`);
|
|
431
|
+
text = text ? `${text}\n\n${formatMediaFailureNote(media.newFailures)}` : formatMediaFailureNote(media.newFailures);
|
|
432
|
+
}
|
|
27
433
|
let conversationId;
|
|
28
434
|
if (ctx.to.startsWith("conv:")) {
|
|
29
435
|
conversationId = ctx.to.slice(5);
|
|
@@ -38,14 +444,19 @@ async function sendViaChat(deps, ctx, mediaUrl) {
|
|
|
38
444
|
await deps.createSession(conversationId, "", "alfe", void 0, userId);
|
|
39
445
|
}
|
|
40
446
|
}
|
|
41
|
-
await deps.addMessage(conversationId, "assistant",
|
|
447
|
+
if (components?.length) await deps.addMessage(conversationId, "assistant", text, void 0, void 0, components);
|
|
448
|
+
else await deps.addMessage(conversationId, "assistant", text);
|
|
42
449
|
const messageId = (0, node_crypto.randomUUID)();
|
|
43
450
|
client.notify("agent-message", {
|
|
44
451
|
conversationId,
|
|
45
|
-
text
|
|
452
|
+
text,
|
|
46
453
|
sessionKey: conversationId,
|
|
47
454
|
messageId,
|
|
48
|
-
...
|
|
455
|
+
...media.mediaUrls.length ? {
|
|
456
|
+
mediaUrls: media.mediaUrls,
|
|
457
|
+
attachments: media.attachments
|
|
458
|
+
} : {},
|
|
459
|
+
...components?.length ? { components } : {}
|
|
49
460
|
});
|
|
50
461
|
return {
|
|
51
462
|
channel: "alfe",
|
|
@@ -54,6 +465,22 @@ async function sendViaChat(deps, ctx, mediaUrl) {
|
|
|
54
465
|
timestamp: Date.now()
|
|
55
466
|
};
|
|
56
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Present interactive components (link buttons, quick replies) into a
|
|
470
|
+
* conversation — the outbound path for the `chat_present_components` tool.
|
|
471
|
+
* `to` is a `conv:{id}` or `user:{id}` target (same vocabulary as the channel's
|
|
472
|
+
* `resolveTarget`). Persists + broadcasts through the same `sendViaChat` path
|
|
473
|
+
* as a normal reply, so the components ride the standard `message` frame and
|
|
474
|
+
* are replayed on backfill. Components are sanitized (unsafe URLs dropped)
|
|
475
|
+
* before delivery.
|
|
476
|
+
*/
|
|
477
|
+
async function presentComponentsViaChat(deps, args) {
|
|
478
|
+
if (args.components.length === 0) throw new Error("At least one valid component is required");
|
|
479
|
+
return await sendViaChat(deps, {
|
|
480
|
+
to: args.to,
|
|
481
|
+
text: args.text ?? ""
|
|
482
|
+
}, void 0, args.components);
|
|
483
|
+
}
|
|
57
484
|
function getChannelSection(cfg) {
|
|
58
485
|
return cfg.channels?.alfe ?? {};
|
|
59
486
|
}
|
|
@@ -250,6 +677,10 @@ function extractChannelMode(conversationId, fallback = "chat") {
|
|
|
250
677
|
* Each session file contains metadata and the full message history.
|
|
251
678
|
* Sessions are written on every message to ensure durability.
|
|
252
679
|
* Old sessions are cleaned up automatically (30-day TTL, 1000 max).
|
|
680
|
+
*
|
|
681
|
+
* NOTE: the storage path and SessionData shape are a read contract —
|
|
682
|
+
* packages/openclaw-memory-cloud/src/session-backfill.ts reads these files
|
|
683
|
+
* directly (the memory plugin can't depend on this package). Keep in sync.
|
|
253
684
|
*/
|
|
254
685
|
const SESSIONS_DIR = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "sessions", "chat");
|
|
255
686
|
const MAX_SESSIONS = 1e3;
|
|
@@ -326,7 +757,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
|
|
|
326
757
|
return session;
|
|
327
758
|
}
|
|
328
759
|
const writeLocks = /* @__PURE__ */ new Map();
|
|
329
|
-
async function addMessage(sessionId, role, content, senderId, senderName) {
|
|
760
|
+
async function addMessage(sessionId, role, content, senderId, senderName, components) {
|
|
330
761
|
const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
331
762
|
const session = await getSession(sessionId);
|
|
332
763
|
if (!session) return;
|
|
@@ -335,7 +766,8 @@ async function addMessage(sessionId, role, content, senderId, senderName) {
|
|
|
335
766
|
content,
|
|
336
767
|
timestamp: Date.now(),
|
|
337
768
|
...senderId ? { senderId } : {},
|
|
338
|
-
...senderName ? { senderName } : {}
|
|
769
|
+
...senderName ? { senderName } : {},
|
|
770
|
+
...components?.length ? { components } : {}
|
|
339
771
|
});
|
|
340
772
|
await saveSession(session);
|
|
341
773
|
}).finally(() => {
|
|
@@ -980,13 +1412,13 @@ function defineTool(def) {
|
|
|
980
1412
|
}
|
|
981
1413
|
};
|
|
982
1414
|
}
|
|
983
|
-
function buildA2ATools(getChatClient, log) {
|
|
1415
|
+
function buildA2ATools(getChatClient, log, present) {
|
|
984
1416
|
const requireClient = () => {
|
|
985
1417
|
const client = getChatClient();
|
|
986
1418
|
if (!client) throw new Error("chat service not connected yet — try again in a moment");
|
|
987
1419
|
return client;
|
|
988
1420
|
};
|
|
989
|
-
|
|
1421
|
+
const tools = [
|
|
990
1422
|
defineTool({
|
|
991
1423
|
name: "list_agents",
|
|
992
1424
|
description: "List other agents in your organization that you can communicate with.",
|
|
@@ -1062,6 +1494,81 @@ function buildA2ATools(getChatClient, log) {
|
|
|
1062
1494
|
}
|
|
1063
1495
|
})
|
|
1064
1496
|
];
|
|
1497
|
+
if (present) tools.push(defineTool({
|
|
1498
|
+
name: "chat_present_components",
|
|
1499
|
+
description: "Attach interactive UI components (e.g. a link button) to a chat message shown to the user. Provide the target conversation and, optionally, accompanying text shown above the components. Link URLs must be https on an Alfe-owned host. Components always degrade to a plain text link on clients that do not support them, so include the key info in `text` too.",
|
|
1500
|
+
parameters: {
|
|
1501
|
+
type: "object",
|
|
1502
|
+
properties: {
|
|
1503
|
+
to: {
|
|
1504
|
+
type: "string",
|
|
1505
|
+
description: "Target conversation: \"conv:{conversationId}\" or \"user:{userId}\"."
|
|
1506
|
+
},
|
|
1507
|
+
text: {
|
|
1508
|
+
type: "string",
|
|
1509
|
+
description: "Optional message text rendered above the components."
|
|
1510
|
+
},
|
|
1511
|
+
components: {
|
|
1512
|
+
type: "array",
|
|
1513
|
+
description: "Interactive components to attach (at least one).",
|
|
1514
|
+
items: {
|
|
1515
|
+
type: "object",
|
|
1516
|
+
properties: {
|
|
1517
|
+
type: {
|
|
1518
|
+
type: "string",
|
|
1519
|
+
enum: ["link_button"],
|
|
1520
|
+
description: "Component kind. Phase 1 supports link_button."
|
|
1521
|
+
},
|
|
1522
|
+
label: {
|
|
1523
|
+
type: "string",
|
|
1524
|
+
description: "Button text."
|
|
1525
|
+
},
|
|
1526
|
+
url: {
|
|
1527
|
+
type: "string",
|
|
1528
|
+
description: "https URL on an Alfe-owned host to open when clicked."
|
|
1529
|
+
},
|
|
1530
|
+
target: {
|
|
1531
|
+
type: "string",
|
|
1532
|
+
enum: [
|
|
1533
|
+
"same-tab",
|
|
1534
|
+
"new-tab",
|
|
1535
|
+
"popup"
|
|
1536
|
+
],
|
|
1537
|
+
description: "How the URL opens (default same-tab)."
|
|
1538
|
+
},
|
|
1539
|
+
style: {
|
|
1540
|
+
type: "string",
|
|
1541
|
+
enum: [
|
|
1542
|
+
"primary",
|
|
1543
|
+
"secondary",
|
|
1544
|
+
"danger"
|
|
1545
|
+
],
|
|
1546
|
+
description: "Visual style hint."
|
|
1547
|
+
}
|
|
1548
|
+
},
|
|
1549
|
+
required: [
|
|
1550
|
+
"type",
|
|
1551
|
+
"label",
|
|
1552
|
+
"url"
|
|
1553
|
+
]
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
},
|
|
1557
|
+
required: ["to", "components"]
|
|
1558
|
+
},
|
|
1559
|
+
handler: async (params) => {
|
|
1560
|
+
const to = params.to;
|
|
1561
|
+
if (!to || !to.startsWith("conv:") && !to.startsWith("user:")) throw new Error("`to` must be \"conv:{conversationId}\" or \"user:{userId}\"");
|
|
1562
|
+
const text = params.text;
|
|
1563
|
+
log.info("chat_present_components tool called");
|
|
1564
|
+
return await present({
|
|
1565
|
+
to,
|
|
1566
|
+
text,
|
|
1567
|
+
components: params.components
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
}));
|
|
1571
|
+
return tools;
|
|
1065
1572
|
}
|
|
1066
1573
|
//#endregion
|
|
1067
1574
|
//#region src/attachment-url.ts
|
|
@@ -1794,6 +2301,7 @@ async function handleAgentRequest(request, log) {
|
|
|
1794
2301
|
const userLabel = displayName ?? userId ?? senderId;
|
|
1795
2302
|
const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
|
|
1796
2303
|
let a2aResponseBuffer = "";
|
|
2304
|
+
const outboundMediaCtx = createOutboundMediaContext();
|
|
1797
2305
|
if (isA2A) resetA2AEndSignal();
|
|
1798
2306
|
const peer = conversationType === "group" ? {
|
|
1799
2307
|
kind: "group",
|
|
@@ -1859,8 +2367,13 @@ async function handleAgentRequest(request, log) {
|
|
|
1859
2367
|
} : {}
|
|
1860
2368
|
},
|
|
1861
2369
|
deliver: async (payload) => {
|
|
1862
|
-
|
|
1863
|
-
|
|
2370
|
+
let responseText = stripLeakedToolSummary(stripThinkSpans(payload.text ?? ""));
|
|
2371
|
+
responseText = await rewriteLocalMarkdownImages(responseText, outboundMediaCtx, log);
|
|
2372
|
+
const media = await resolveOutboundMedia([...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []], outboundMediaCtx, log);
|
|
2373
|
+
if (media.newFailures.length) {
|
|
2374
|
+
const note = formatMediaFailureNote(media.newFailures);
|
|
2375
|
+
responseText = responseText ? `${responseText}\n\n${note}` : note;
|
|
2376
|
+
}
|
|
1864
2377
|
await addMessage(sessionId, "assistant", responseText);
|
|
1865
2378
|
if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
|
|
1866
2379
|
chatClient?.notify("agent-message", {
|
|
@@ -1868,7 +2381,10 @@ async function handleAgentRequest(request, log) {
|
|
|
1868
2381
|
text: responseText,
|
|
1869
2382
|
sessionKey: runGate.sessionKey ?? legacySessionKey,
|
|
1870
2383
|
messageId: chatMessageId ?? request.id,
|
|
1871
|
-
...mediaUrls.length ? {
|
|
2384
|
+
...media.mediaUrls.length ? {
|
|
2385
|
+
mediaUrls: media.mediaUrls,
|
|
2386
|
+
attachments: media.attachments
|
|
2387
|
+
} : {}
|
|
1872
2388
|
});
|
|
1873
2389
|
},
|
|
1874
2390
|
onRecordError: (err) => {
|
|
@@ -1970,7 +2486,8 @@ async function handleSessionsGet(request, log) {
|
|
|
1970
2486
|
id: `msg-${String(m.timestamp)}`,
|
|
1971
2487
|
role: m.role,
|
|
1972
2488
|
content: m.content,
|
|
1973
|
-
timestamp: m.timestamp
|
|
2489
|
+
timestamp: m.timestamp,
|
|
2490
|
+
...m.components?.length ? { components: m.components } : {}
|
|
1974
2491
|
})),
|
|
1975
2492
|
activity: await resolveHistoryActivity(session)
|
|
1976
2493
|
});
|
|
@@ -1987,19 +2504,30 @@ const plugin = {
|
|
|
1987
2504
|
version: pkg.version,
|
|
1988
2505
|
activate(api) {
|
|
1989
2506
|
const log = api.logger;
|
|
1990
|
-
const
|
|
2507
|
+
const outboundDeps = {
|
|
1991
2508
|
getChatClient: () => chatClient,
|
|
2509
|
+
log,
|
|
1992
2510
|
listSessions,
|
|
1993
2511
|
getSession,
|
|
1994
2512
|
createSession,
|
|
1995
2513
|
addMessage
|
|
1996
|
-
}
|
|
2514
|
+
};
|
|
2515
|
+
const alfeChannel = createAlfeChannelPlugin(outboundDeps);
|
|
1997
2516
|
api.registerChannel(alfeChannel);
|
|
1998
2517
|
log.info(`Registered channel: ${alfeChannel.id}`);
|
|
1999
2518
|
if (typeof api.registerTool === "function") {
|
|
2000
|
-
const
|
|
2519
|
+
const present = async (args) => {
|
|
2520
|
+
const clean = sanitizeComponents(args.components);
|
|
2521
|
+
if (clean.length === 0) throw new Error("No valid components: link_button requires a `label` and an https `url` on an Alfe-owned host");
|
|
2522
|
+
return presentComponentsViaChat(outboundDeps, {
|
|
2523
|
+
to: args.to,
|
|
2524
|
+
text: args.text,
|
|
2525
|
+
components: clean
|
|
2526
|
+
});
|
|
2527
|
+
};
|
|
2528
|
+
const a2aTools = buildA2ATools(() => chatClient, log, present);
|
|
2001
2529
|
for (const tool of a2aTools) api.registerTool(tool);
|
|
2002
|
-
log.info(`Registered ${String(a2aTools.length)} agent
|
|
2530
|
+
log.info(`Registered ${String(a2aTools.length)} agent tools`);
|
|
2003
2531
|
}
|
|
2004
2532
|
const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
|
|
2005
2533
|
const startChatService = () => {
|
|
@@ -2110,7 +2638,8 @@ const plugin = {
|
|
|
2110
2638
|
id: `msg-${String(m.timestamp)}`,
|
|
2111
2639
|
role: m.role,
|
|
2112
2640
|
content: m.content,
|
|
2113
|
-
timestamp: m.timestamp
|
|
2641
|
+
timestamp: m.timestamp,
|
|
2642
|
+
...m.components?.length ? { components: m.components } : {}
|
|
2114
2643
|
})),
|
|
2115
2644
|
activity: await resolveHistoryActivity(session)
|
|
2116
2645
|
};
|