@cloudrise/openclaw-channel-rocketchat 0.1.9 → 0.1.10

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/README.md CHANGED
@@ -145,6 +145,7 @@ Then restart the gateway.
145
145
 
146
146
  ## Features
147
147
 
148
+ - **Image attachments**: receives images uploaded to Rocket.Chat and passes them to the vision model.
148
149
  - **Model prefix**: honors `messages.responsePrefix` (e.g. `({model}) `) so replies can include the model name.
149
150
 
150
151
  ## Model switching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudrise/openclaw-channel-rocketchat",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Rocket.Chat channel plugin for OpenClaw (Cloudrise)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,7 @@ import {
22
22
  type RocketChatRoom,
23
23
  type RocketChatClient,
24
24
  } from "./client.js";
25
- import { RocketChatRealtime, type IncomingMessage } from "./realtime.js";
25
+ import { RocketChatRealtime, type IncomingMessage, type RocketChatAttachment, type RocketChatFile } from "./realtime.js";
26
26
  import { sendMessageRocketChat } from "./send.js";
27
27
 
28
28
  export type MonitorRocketChatOpts = {
@@ -70,6 +70,83 @@ function chatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "chann
70
70
  return "channel";
71
71
  }
72
72
 
73
+ /** Image MIME types we can send to vision models */
74
+ const IMAGE_MIME_TYPES = new Set([
75
+ "image/jpeg",
76
+ "image/png",
77
+ "image/gif",
78
+ "image/webp",
79
+ ]);
80
+
81
+ function isImageMime(mime?: string): boolean {
82
+ if (!mime) return false;
83
+ return IMAGE_MIME_TYPES.has(mime.toLowerCase().split(";")[0].trim());
84
+ }
85
+
86
+ /**
87
+ * Extract image URLs from Rocket.Chat message attachments/files.
88
+ * Returns full URLs that can be fetched with auth headers.
89
+ */
90
+ function extractImageUrls(
91
+ msg: IncomingMessage,
92
+ baseUrl: string
93
+ ): Array<{ url: string; mimeType?: string }> {
94
+ const images: Array<{ url: string; mimeType?: string }> = [];
95
+
96
+ // From attachments array (used for image_url references)
97
+ if (msg.attachments?.length) {
98
+ for (const att of msg.attachments) {
99
+ if (att.image_url) {
100
+ const url = att.image_url.startsWith("http")
101
+ ? att.image_url
102
+ : `${baseUrl}${att.image_url.startsWith("/") ? "" : "/"}${att.image_url}`;
103
+ images.push({ url, mimeType: att.image_type });
104
+ }
105
+ }
106
+ }
107
+
108
+ // From file/files (used for direct uploads)
109
+ const files = msg.files ?? (msg.file ? [msg.file] : []);
110
+ for (const f of files) {
111
+ if (f._id && f.name && isImageMime(f.type)) {
112
+ // Rocket.Chat file-upload URL pattern
113
+ const url = `${baseUrl}/file-upload/${f._id}/${encodeURIComponent(f.name)}`;
114
+ images.push({ url, mimeType: f.type });
115
+ }
116
+ }
117
+
118
+ return images;
119
+ }
120
+
121
+ /**
122
+ * Fetch an image from Rocket.Chat and return as base64 data URL.
123
+ */
124
+ async function fetchImageAsDataUrl(
125
+ url: string,
126
+ authToken: string,
127
+ userId: string,
128
+ mimeType?: string
129
+ ): Promise<string | null> {
130
+ try {
131
+ const res = await fetch(url, {
132
+ headers: {
133
+ "X-Auth-Token": authToken,
134
+ "X-User-Id": userId,
135
+ },
136
+ });
137
+ if (!res.ok) return null;
138
+
139
+ const contentType = mimeType ?? res.headers.get("content-type") ?? "image/png";
140
+ if (!isImageMime(contentType)) return null;
141
+
142
+ const buffer = await res.arrayBuffer();
143
+ const base64 = Buffer.from(buffer).toString("base64");
144
+ return `data:${contentType};base64,${base64}`;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
73
150
  export async function monitorRocketChatProvider(
74
151
  opts: MonitorRocketChatOpts
75
152
  ): Promise<() => void> {
@@ -293,8 +370,16 @@ async function handleIncomingMessage(
293
370
  ? msg.ts.$date
294
371
  : Date.parse(String(msg.ts));
295
372
 
373
+ // Extract image attachments (if any)
374
+ const baseUrl = account.baseUrl;
375
+ const authToken = account.authToken;
376
+ const userId = account.userId;
377
+ const imageRefs = extractImageUrls(msg, baseUrl);
378
+
296
379
  let rawBody = msg.msg.trim();
297
- if (!rawBody) return;
380
+
381
+ // Allow messages with only images (no text)
382
+ if (!rawBody && imageRefs.length === 0) return;
298
383
 
299
384
  // Optional per-message overrides
300
385
  // - !thread -> force reply in thread
@@ -307,7 +392,8 @@ async function handleIncomingMessage(
307
392
  forcedReplyMode = "channel";
308
393
  rawBody = rawBody.replace(/^!channel\b\s*/i, "").trim();
309
394
  }
310
- if (!rawBody) return;
395
+ // Skip if no text and no images
396
+ if (!rawBody && imageRefs.length === 0) return;
311
397
 
312
398
  // Determine reply mode
313
399
  const baseReplyMode: "thread" | "channel" | "auto" =
@@ -388,14 +474,16 @@ async function handleIncomingMessage(
388
474
  : undefined;
389
475
 
390
476
  // Format the envelope body
477
+ // For image-only messages, use a placeholder so the agent knows there's content
478
+ const effectiveRawBody = rawBody || (imageRefs.length > 0 ? "[image]" : "");
391
479
  const body = core.channel?.reply?.formatAgentEnvelope?.({
392
480
  channel: "Rocket.Chat",
393
481
  from: fromLabel,
394
482
  timestamp: ts,
395
483
  previousTimestamp,
396
484
  envelope: envelopeOptions,
397
- body: rawBody,
398
- }) ?? rawBody;
485
+ body: effectiveRawBody,
486
+ }) ?? effectiveRawBody;
399
487
 
400
488
  // Rocket.Chat NOTE: Messages starting with "/" are treated as Rocket.Chat slash-commands.
401
489
  // To make model switching usable from chat, we support an alternate syntax:
@@ -406,6 +494,20 @@ async function handleIncomingMessage(
406
494
  .replace(/^\s*--model\b/i, "/model")
407
495
  .replace(/^\s*--/, "/");
408
496
 
497
+ // Fetch images as data URLs (authenticated fetch required for Rocket.Chat uploads)
498
+ let mediaUrls: string[] | undefined;
499
+ if (imageRefs.length > 0) {
500
+ const fetched = await Promise.all(
501
+ imageRefs.map((ref) =>
502
+ fetchImageAsDataUrl(ref.url, authToken, userId, ref.mimeType)
503
+ )
504
+ );
505
+ mediaUrls = fetched.filter((u): u is string => u !== null);
506
+ if (mediaUrls.length > 0) {
507
+ logger.debug?.(`Fetched ${mediaUrls.length} image(s) from Rocket.Chat attachments`);
508
+ }
509
+ }
510
+
409
511
  // Finalize inbound context
410
512
  const ctxPayload = core.channel?.reply?.finalizeInboundContext?.({
411
513
  Body: body,
@@ -426,6 +528,9 @@ async function handleIncomingMessage(
426
528
  Timestamp: ts,
427
529
  OriginatingChannel: "rocketchat",
428
530
  OriginatingTo: `rocketchat:${roomId}`,
531
+
532
+ // Image attachments (fetched as base64 data URLs)
533
+ MediaUrls: mediaUrls?.length ? mediaUrls : undefined,
429
534
  });
430
535
 
431
536
  if (!ctxPayload) {
@@ -33,6 +33,24 @@ export type RealtimeOpts = {
33
33
  logger?: { debug?: (msg: string) => void; info?: (msg: string) => void };
34
34
  };
35
35
 
36
+ export type RocketChatAttachment = {
37
+ title?: string;
38
+ title_link?: string;
39
+ image_url?: string;
40
+ audio_url?: string;
41
+ video_url?: string;
42
+ type?: string;
43
+ image_type?: string;
44
+ image_size?: number;
45
+ };
46
+
47
+ export type RocketChatFile = {
48
+ _id: string;
49
+ name: string;
50
+ type?: string;
51
+ size?: number;
52
+ };
53
+
36
54
  export type IncomingMessage = {
37
55
  _id: string;
38
56
  rid: string;
@@ -41,12 +59,9 @@ export type IncomingMessage = {
41
59
  u: { _id: string; username: string; name?: string };
42
60
  tmid?: string;
43
61
  t?: string;
44
- attachments?: Array<{
45
- title?: string;
46
- image_url?: string;
47
- audio_url?: string;
48
- video_url?: string;
49
- }>;
62
+ attachments?: RocketChatAttachment[];
63
+ file?: RocketChatFile;
64
+ files?: RocketChatFile[];
50
65
  };
51
66
 
52
67
  export class RocketChatRealtime {