@alfe.ai/openclaw-chat 0.6.1 → 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/plugin2.js CHANGED
@@ -1,10 +1,349 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
6
6
  import { randomUUID } from "node:crypto";
7
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
8
+ import { resolveConfig } from "@alfe.ai/config";
7
9
  import { existsSync } from "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 `![x](/etc/passwd)`-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 = resolveConfig();
88
+ cachedClient = new 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 = 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 = join(homedir(), p.slice(1));
121
+ return isAbsolute(p) ? p : 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 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 = basename(localPath);
147
+ const mimeType = mimeFromPath(localPath);
148
+ try {
149
+ const body = await 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(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 `![alt](<local image path>)` 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 ? `![${alt}](${resolved.url})` : 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 @@ import { existsSync } from "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 : 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", ctx.text);
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 = randomUUID();
43
450
  client.notify("agent-message", {
44
451
  conversationId,
45
- text: ctx.text,
452
+ text,
46
453
  sessionKey: conversationId,
47
454
  messageId,
48
- ...mediaUrl ? { mediaUrls: [mediaUrl] } : {}
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
  }
@@ -330,7 +757,7 @@ async function createSession(sessionId, agentId, channel, tenantId, userId) {
330
757
  return session;
331
758
  }
332
759
  const writeLocks = /* @__PURE__ */ new Map();
333
- async function addMessage(sessionId, role, content, senderId, senderName) {
760
+ async function addMessage(sessionId, role, content, senderId, senderName, components) {
334
761
  const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
335
762
  const session = await getSession(sessionId);
336
763
  if (!session) return;
@@ -339,7 +766,8 @@ async function addMessage(sessionId, role, content, senderId, senderName) {
339
766
  content,
340
767
  timestamp: Date.now(),
341
768
  ...senderId ? { senderId } : {},
342
- ...senderName ? { senderName } : {}
769
+ ...senderName ? { senderName } : {},
770
+ ...components?.length ? { components } : {}
343
771
  });
344
772
  await saveSession(session);
345
773
  }).finally(() => {
@@ -984,13 +1412,13 @@ function defineTool(def) {
984
1412
  }
985
1413
  };
986
1414
  }
987
- function buildA2ATools(getChatClient, log) {
1415
+ function buildA2ATools(getChatClient, log, present) {
988
1416
  const requireClient = () => {
989
1417
  const client = getChatClient();
990
1418
  if (!client) throw new Error("chat service not connected yet — try again in a moment");
991
1419
  return client;
992
1420
  };
993
- return [
1421
+ const tools = [
994
1422
  defineTool({
995
1423
  name: "list_agents",
996
1424
  description: "List other agents in your organization that you can communicate with.",
@@ -1066,6 +1494,81 @@ function buildA2ATools(getChatClient, log) {
1066
1494
  }
1067
1495
  })
1068
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;
1069
1572
  }
1070
1573
  //#endregion
1071
1574
  //#region src/attachment-url.ts
@@ -1798,6 +2301,7 @@ async function handleAgentRequest(request, log) {
1798
2301
  const userLabel = displayName ?? userId ?? senderId;
1799
2302
  const conversationLabel = conversationType === "group" ? shortConvId ? `[${channelLabel}] Group (${shortConvId})` : `[${channelLabel}] Group` : shortConvId ? `[${channelLabel}] ${userLabel} (${shortConvId})` : `[${channelLabel}] ${userLabel}`;
1800
2303
  let a2aResponseBuffer = "";
2304
+ const outboundMediaCtx = createOutboundMediaContext();
1801
2305
  if (isA2A) resetA2AEndSignal();
1802
2306
  const peer = conversationType === "group" ? {
1803
2307
  kind: "group",
@@ -1863,8 +2367,13 @@ async function handleAgentRequest(request, log) {
1863
2367
  } : {}
1864
2368
  },
1865
2369
  deliver: async (payload) => {
1866
- const responseText = stripLeakedToolSummary(stripThinkSpans(payload.text ?? ""));
1867
- const mediaUrls = [...payload.mediaUrl ? [payload.mediaUrl] : [], ...payload.mediaUrls ?? []];
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
+ }
1868
2377
  await addMessage(sessionId, "assistant", responseText);
1869
2378
  if (isA2A) a2aResponseBuffer += (a2aResponseBuffer && responseText ? "\n\n" : "") + responseText;
1870
2379
  chatClient?.notify("agent-message", {
@@ -1872,7 +2381,10 @@ async function handleAgentRequest(request, log) {
1872
2381
  text: responseText,
1873
2382
  sessionKey: runGate.sessionKey ?? legacySessionKey,
1874
2383
  messageId: chatMessageId ?? request.id,
1875
- ...mediaUrls.length ? { mediaUrls } : {}
2384
+ ...media.mediaUrls.length ? {
2385
+ mediaUrls: media.mediaUrls,
2386
+ attachments: media.attachments
2387
+ } : {}
1876
2388
  });
1877
2389
  },
1878
2390
  onRecordError: (err) => {
@@ -1974,7 +2486,8 @@ async function handleSessionsGet(request, log) {
1974
2486
  id: `msg-${String(m.timestamp)}`,
1975
2487
  role: m.role,
1976
2488
  content: m.content,
1977
- timestamp: m.timestamp
2489
+ timestamp: m.timestamp,
2490
+ ...m.components?.length ? { components: m.components } : {}
1978
2491
  })),
1979
2492
  activity: await resolveHistoryActivity(session)
1980
2493
  });
@@ -1991,19 +2504,30 @@ const plugin = {
1991
2504
  version: pkg.version,
1992
2505
  activate(api) {
1993
2506
  const log = api.logger;
1994
- const alfeChannel = createAlfeChannelPlugin({
2507
+ const outboundDeps = {
1995
2508
  getChatClient: () => chatClient,
2509
+ log,
1996
2510
  listSessions,
1997
2511
  getSession,
1998
2512
  createSession,
1999
2513
  addMessage
2000
- });
2514
+ };
2515
+ const alfeChannel = createAlfeChannelPlugin(outboundDeps);
2001
2516
  api.registerChannel(alfeChannel);
2002
2517
  log.info(`Registered channel: ${alfeChannel.id}`);
2003
2518
  if (typeof api.registerTool === "function") {
2004
- const a2aTools = buildA2ATools(() => chatClient, log);
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);
2005
2529
  for (const tool of a2aTools) api.registerTool(tool);
2006
- log.info(`Registered ${String(a2aTools.length)} agent-to-agent tools`);
2530
+ log.info(`Registered ${String(a2aTools.length)} agent tools`);
2007
2531
  }
2008
2532
  const pluginConfig = (((api.config ?? {}).plugins?.entries)?.["@alfe.ai/openclaw-chat"] ?? {}).config ?? {};
2009
2533
  const startChatService = () => {
@@ -2114,7 +2638,8 @@ const plugin = {
2114
2638
  id: `msg-${String(m.timestamp)}`,
2115
2639
  role: m.role,
2116
2640
  content: m.content,
2117
- timestamp: m.timestamp
2641
+ timestamp: m.timestamp,
2642
+ ...m.components?.length ? { components: m.components } : {}
2118
2643
  })),
2119
2644
  activity: await resolveHistoryActivity(session)
2120
2645
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -27,7 +27,7 @@
27
27
  "openclaw.plugin.json"
28
28
  ],
29
29
  "dependencies": {
30
- "@alfe.ai/agent-api-client": "^0.6.0",
30
+ "@alfe.ai/agent-api-client": "^0.7.0",
31
31
  "@alfe.ai/chat": "^0.0.14",
32
32
  "@alfe.ai/config": "^0.3.0"
33
33
  },