@cloudrise/openclaw-channel-rocketchat 0.1.9 → 0.1.11

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.11",
4
4
  "description": "Rocket.Chat channel plugin for OpenClaw (Cloudrise)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,6 +2,11 @@
2
2
  * Rocket.Chat message monitor - handles incoming messages via Realtime API
3
3
  */
4
4
 
5
+ import * as fs from "node:fs/promises";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import * as crypto from "node:crypto";
9
+
5
10
  import type {
6
11
  ChannelAccountSnapshot,
7
12
  OpenclawConfig,
@@ -22,7 +27,7 @@ import {
22
27
  type RocketChatRoom,
23
28
  type RocketChatClient,
24
29
  } from "./client.js";
25
- import { RocketChatRealtime, type IncomingMessage } from "./realtime.js";
30
+ import { RocketChatRealtime, type IncomingMessage, type RocketChatAttachment, type RocketChatFile } from "./realtime.js";
26
31
  import { sendMessageRocketChat } from "./send.js";
27
32
 
28
33
  export type MonitorRocketChatOpts = {
@@ -70,6 +75,105 @@ function chatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "chann
70
75
  return "channel";
71
76
  }
72
77
 
78
+ /** Image MIME types we can send to vision models */
79
+ const IMAGE_MIME_TYPES = new Set([
80
+ "image/jpeg",
81
+ "image/png",
82
+ "image/gif",
83
+ "image/webp",
84
+ ]);
85
+
86
+ function isImageMime(mime?: string): boolean {
87
+ if (!mime) return false;
88
+ return IMAGE_MIME_TYPES.has(mime.toLowerCase().split(";")[0].trim());
89
+ }
90
+
91
+ /**
92
+ * Extract image URLs from Rocket.Chat message attachments/files.
93
+ * Returns full URLs that can be fetched with auth headers.
94
+ */
95
+ function extractImageUrls(
96
+ msg: IncomingMessage,
97
+ baseUrl: string
98
+ ): Array<{ url: string; mimeType?: string }> {
99
+ const images: Array<{ url: string; mimeType?: string }> = [];
100
+
101
+ // From attachments array (used for image_url references)
102
+ if (msg.attachments?.length) {
103
+ for (const att of msg.attachments) {
104
+ if (att.image_url) {
105
+ const url = att.image_url.startsWith("http")
106
+ ? att.image_url
107
+ : `${baseUrl}${att.image_url.startsWith("/") ? "" : "/"}${att.image_url}`;
108
+ images.push({ url, mimeType: att.image_type });
109
+ }
110
+ }
111
+ }
112
+
113
+ // From file/files (used for direct uploads)
114
+ const files = msg.files ?? (msg.file ? [msg.file] : []);
115
+ for (const f of files) {
116
+ if (f._id && f.name && isImageMime(f.type)) {
117
+ // Rocket.Chat file-upload URL pattern
118
+ const url = `${baseUrl}/file-upload/${f._id}/${encodeURIComponent(f.name)}`;
119
+ images.push({ url, mimeType: f.type });
120
+ }
121
+ }
122
+
123
+ return images;
124
+ }
125
+
126
+ type FetchedImage = {
127
+ path: string;
128
+ mimeType: string;
129
+ cleanup: () => Promise<void>;
130
+ };
131
+
132
+ /**
133
+ * Fetch an image from Rocket.Chat and save to a temp file.
134
+ * Returns the file path (OpenClaw expects file paths, not data URLs).
135
+ */
136
+ async function fetchImageToTempFile(
137
+ url: string,
138
+ authToken: string,
139
+ userId: string,
140
+ mimeType?: string
141
+ ): Promise<FetchedImage | null> {
142
+ try {
143
+ const res = await fetch(url, {
144
+ headers: {
145
+ "X-Auth-Token": authToken,
146
+ "X-User-Id": userId,
147
+ },
148
+ });
149
+ if (!res.ok) return null;
150
+
151
+ const contentType = mimeType ?? res.headers.get("content-type") ?? "image/png";
152
+ if (!isImageMime(contentType)) return null;
153
+
154
+ const buffer = Buffer.from(await res.arrayBuffer());
155
+
156
+ // Determine extension from mime type
157
+ const ext = contentType.includes("png") ? ".png"
158
+ : contentType.includes("gif") ? ".gif"
159
+ : contentType.includes("webp") ? ".webp"
160
+ : ".jpg";
161
+
162
+ const tempPath = path.join(os.tmpdir(), `openclaw-rc-${crypto.randomUUID()}${ext}`);
163
+ await fs.writeFile(tempPath, buffer, { mode: 0o600 });
164
+
165
+ return {
166
+ path: tempPath,
167
+ mimeType: contentType,
168
+ cleanup: async () => {
169
+ await fs.unlink(tempPath).catch(() => {});
170
+ },
171
+ };
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
73
177
  export async function monitorRocketChatProvider(
74
178
  opts: MonitorRocketChatOpts
75
179
  ): Promise<() => void> {
@@ -293,8 +397,21 @@ async function handleIncomingMessage(
293
397
  ? msg.ts.$date
294
398
  : Date.parse(String(msg.ts));
295
399
 
400
+ // Extract image attachments (if any)
401
+ const baseUrl = account.baseUrl;
402
+ const authToken = account.authToken;
403
+ const userId = account.userId;
404
+
405
+ // DEBUG: Log raw message to see what DDP sends
406
+ logger.debug?.(`[RC DEBUG] Raw message: file=${JSON.stringify(msg.file)} files=${JSON.stringify(msg.files)} attachments=${JSON.stringify(msg.attachments?.slice(0, 2))}`);
407
+
408
+ const imageRefs = extractImageUrls(msg, baseUrl);
409
+ logger.debug?.(`[RC DEBUG] Extracted ${imageRefs.length} image refs`);
410
+
296
411
  let rawBody = msg.msg.trim();
297
- if (!rawBody) return;
412
+
413
+ // Allow messages with only images (no text)
414
+ if (!rawBody && imageRefs.length === 0) return;
298
415
 
299
416
  // Optional per-message overrides
300
417
  // - !thread -> force reply in thread
@@ -307,7 +424,8 @@ async function handleIncomingMessage(
307
424
  forcedReplyMode = "channel";
308
425
  rawBody = rawBody.replace(/^!channel\b\s*/i, "").trim();
309
426
  }
310
- if (!rawBody) return;
427
+ // Skip if no text and no images
428
+ if (!rawBody && imageRefs.length === 0) return;
311
429
 
312
430
  // Determine reply mode
313
431
  const baseReplyMode: "thread" | "channel" | "auto" =
@@ -388,29 +506,84 @@ async function handleIncomingMessage(
388
506
  : undefined;
389
507
 
390
508
  // Format the envelope body
509
+ // For image-only messages, use a placeholder so the agent knows there's content
510
+ const effectiveRawBody = rawBody || (imageRefs.length > 0 ? "[image]" : "");
391
511
  const body = core.channel?.reply?.formatAgentEnvelope?.({
392
512
  channel: "Rocket.Chat",
393
513
  from: fromLabel,
394
514
  timestamp: ts,
395
515
  previousTimestamp,
396
516
  envelope: envelopeOptions,
397
- body: rawBody,
398
- }) ?? rawBody;
399
-
400
- // Rocket.Chat NOTE: Messages starting with "/" are treated as Rocket.Chat slash-commands.
401
- // To make model switching usable from chat, we support an alternate syntax:
402
- // --model qwen3
403
- // --qwen3
404
- // which we normalize into OpenClaw inline directives.
405
- const commandBody = rawBody
517
+ body: effectiveRawBody,
518
+ }) ?? effectiveRawBody;
519
+
520
+
521
+ // Inline directives (e.g. /model <ref>) should be parsed from the raw inbound text.
522
+ // Rocket.Chat users often type one-line directives like: "/model qwen3 hello".
523
+ // We split a leading directive into:
524
+ // - BodyForCommands: directive-only (so OpenClaw applies it)
525
+ // - BodyForAgent: the full envelope (so the agent retains context)
526
+ const normalizedRawBody = rawBody
527
+ // Shorthands (Rocket.Chat doesn't reliably allow leading `/...`)
528
+ .replace(/^\s*--opus\b/i, "/model opus")
529
+ .replace(/^\s*-opus\b/i, "/model opus")
530
+ .replace(/^\s*--oss\b/i, "/model oss")
531
+ .replace(/^\s*-oss\b/i, "/model oss")
406
532
  .replace(/^\s*--model\b/i, "/model")
533
+ .replace(/^\s*-model\b/i, "/model")
534
+ // Generic: `--foo` => `/foo` (we intentionally do NOT do this for single-dash to avoid
535
+ // accidental triggers on markdown bullets / negative numbers).
407
536
  .replace(/^\s*--/, "/");
408
537
 
409
- // Finalize inbound context
538
+ const bodyForCommands = (() => {
539
+ const t = normalizedRawBody.trim();
540
+ if (!t.startsWith("/")) return normalizedRawBody;
541
+
542
+ // IMPORTANT: Rocket.Chat users can't reliably send leading `/...` (Rocket.Chat treats it as an internal slash command).
543
+ // We allow `--...` as a stand-in for `/...`.
544
+ //
545
+ // For directives like `/think high`, `/verbose full`, `/elevated ask`, etc, we must preserve arguments.
546
+ // So: when the message starts with a directive/command, pass the *full first line* to the command parser.
547
+ // (OpenClaw will apply it as a directive-only message if the remaining body is empty.)
548
+ return t.split("\n", 1)[0].trim();
549
+ })();
550
+
551
+ const commandAuthorized = true;
552
+ const bodyForAgent = body;
553
+
554
+ // Fetch images to temp files (OpenClaw expects file paths, not data URLs)
555
+ let mediaPaths: string[] | undefined;
556
+ let mediaTypes: string[] | undefined;
557
+ const imageCleanups: Array<() => Promise<void>> = [];
558
+
559
+ if (imageRefs.length > 0) {
560
+ const fetched = await Promise.all(
561
+ imageRefs.map((ref) =>
562
+ fetchImageToTempFile(ref.url, authToken, userId, ref.mimeType)
563
+ )
564
+ );
565
+ const validImages = fetched.filter((img): img is FetchedImage => img !== null);
566
+ if (validImages.length > 0) {
567
+ mediaPaths = validImages.map((img) => img.path);
568
+ mediaTypes = validImages.map((img) => img.mimeType);
569
+ imageCleanups.push(...validImages.map((img) => img.cleanup));
570
+ logger.debug?.(`Fetched ${validImages.length} image(s) from Rocket.Chat attachments`);
571
+ }
572
+ }
573
+
410
574
  const ctxPayload = core.channel?.reply?.finalizeInboundContext?.({
411
575
  Body: body,
576
+ BodyForAgent: bodyForAgent,
412
577
  RawBody: rawBody,
413
- CommandBody: commandBody,
578
+ CommandBody: bodyForCommands,
579
+ // Be explicit: directives (/model, /qwen, etc.) should be parsed from the raw inbound text.
580
+ BodyForCommands: bodyForCommands,
581
+ // Hint to OpenClaw that this is plain text (not a platform-native slash command).
582
+ CommandSource: "text",
583
+
584
+ // Allow inline directives like /model ...
585
+ CommandAuthorized: commandAuthorized,
586
+
414
587
  From: isGroup ? `rocketchat:room:${roomId}` : `rocketchat:${senderId}`,
415
588
  To: `rocketchat:${roomId}`,
416
589
  SessionKey: route.sessionKey,
@@ -426,13 +599,16 @@ async function handleIncomingMessage(
426
599
  Timestamp: ts,
427
600
  OriginatingChannel: "rocketchat",
428
601
  OriginatingTo: `rocketchat:${roomId}`,
602
+
603
+ // Image attachments (fetched to temp files)
604
+ MediaPaths: mediaPaths?.length ? mediaPaths : undefined,
605
+ MediaTypes: mediaTypes?.length ? mediaTypes : undefined,
429
606
  });
430
607
 
431
608
  if (!ctxPayload) {
432
609
  logger.error?.(`Failed to finalize inbound context for message ${msg._id}`);
433
610
  return;
434
611
  }
435
-
436
612
  // Record inbound session
437
613
  if (storePath) {
438
614
  await core.channel?.session?.recordInboundSession?.({
@@ -523,5 +699,9 @@ async function handleIncomingMessage(
523
699
  });
524
700
  } finally {
525
701
  await stopTyping();
702
+ // Clean up temp image files
703
+ for (const cleanup of imageCleanups) {
704
+ await cleanup();
705
+ }
526
706
  }
527
707
  }
@@ -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 {