@cloudrise/openclaw-channel-rocketchat 0.1.10 → 0.1.12

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,7 +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
+ - **File attachments**: receives images, PDFs, documents, audio uploaded to Rocket.Chat and passes them to the vision model.
149
149
  - **Model prefix**: honors `messages.responsePrefix` (e.g. `({model}) `) so replies can include the model name.
150
150
 
151
151
  ## Model switching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudrise/openclaw-channel-rocketchat",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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,
@@ -70,63 +75,83 @@ function chatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "chann
70
75
  return "channel";
71
76
  }
72
77
 
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
78
  /**
87
- * Extract image URLs from Rocket.Chat message attachments/files.
79
+ * Extract file URLs from Rocket.Chat message attachments/files.
88
80
  * Returns full URLs that can be fetched with auth headers.
81
+ * Supports all file types - OpenClaw's media understanding handles type detection.
89
82
  */
90
- function extractImageUrls(
83
+ function extractFileUrls(
91
84
  msg: IncomingMessage,
92
85
  baseUrl: string
93
- ): Array<{ url: string; mimeType?: string }> {
94
- const images: Array<{ url: string; mimeType?: string }> = [];
86
+ ): Array<{ url: string; mimeType?: string; fileName?: string }> {
87
+ const files: Array<{ url: string; mimeType?: string; fileName?: string }> = [];
88
+
89
+ // From file/files (used for direct uploads) - check these first as they're more reliable
90
+ const fileList = msg.files ?? (msg.file ? [msg.file] : []);
91
+ for (const f of fileList) {
92
+ if (f._id && f.name) {
93
+ // Rocket.Chat file-upload URL pattern
94
+ const url = `${baseUrl}/file-upload/${f._id}/${encodeURIComponent(f.name)}`;
95
+ files.push({ url, mimeType: f.type, fileName: decodeURIComponent(f.name) });
96
+ }
97
+ }
95
98
 
96
- // From attachments array (used for image_url references)
97
- if (msg.attachments?.length) {
99
+ // From attachments array (fallback for image_url references if no files found)
100
+ if (files.length === 0 && msg.attachments?.length) {
98
101
  for (const att of msg.attachments) {
99
102
  if (att.image_url) {
100
103
  const url = att.image_url.startsWith("http")
101
104
  ? att.image_url
102
105
  : `${baseUrl}${att.image_url.startsWith("/") ? "" : "/"}${att.image_url}`;
103
- images.push({ url, mimeType: att.image_type });
106
+ files.push({ url, mimeType: att.image_type, fileName: att.title });
104
107
  }
105
108
  }
106
109
  }
107
110
 
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
- }
111
+ return files;
112
+ }
113
+
114
+ type FetchedFile = {
115
+ path: string;
116
+ mimeType: string;
117
+ cleanup: () => Promise<void>;
118
+ };
117
119
 
118
- return images;
120
+ /** Map common MIME types to file extensions */
121
+ function getExtensionFromMime(mimeType: string): string {
122
+ const mime = mimeType.toLowerCase().split(";")[0].trim();
123
+ const map: Record<string, string> = {
124
+ "image/png": ".png",
125
+ "image/jpeg": ".jpg",
126
+ "image/gif": ".gif",
127
+ "image/webp": ".webp",
128
+ "application/pdf": ".pdf",
129
+ "text/plain": ".txt",
130
+ "text/markdown": ".md",
131
+ "text/csv": ".csv",
132
+ "application/json": ".json",
133
+ "application/xml": ".xml",
134
+ "text/xml": ".xml",
135
+ "audio/mpeg": ".mp3",
136
+ "audio/wav": ".wav",
137
+ "audio/ogg": ".ogg",
138
+ "video/mp4": ".mp4",
139
+ "video/webm": ".webm",
140
+ };
141
+ return map[mime] ?? ".bin";
119
142
  }
120
143
 
121
144
  /**
122
- * Fetch an image from Rocket.Chat and return as base64 data URL.
145
+ * Fetch a file from Rocket.Chat and save to a temp file.
146
+ * Returns the file path (OpenClaw expects file paths, not data URLs).
123
147
  */
124
- async function fetchImageAsDataUrl(
148
+ async function fetchFileToTemp(
125
149
  url: string,
126
150
  authToken: string,
127
151
  userId: string,
128
- mimeType?: string
129
- ): Promise<string | null> {
152
+ mimeType?: string,
153
+ fileName?: string
154
+ ): Promise<FetchedFile | null> {
130
155
  try {
131
156
  const res = await fetch(url, {
132
157
  headers: {
@@ -136,12 +161,26 @@ async function fetchImageAsDataUrl(
136
161
  });
137
162
  if (!res.ok) return null;
138
163
 
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}`;
164
+ const contentType = mimeType ?? res.headers.get("content-type") ?? "application/octet-stream";
165
+ const buffer = Buffer.from(await res.arrayBuffer());
166
+
167
+ // Determine extension from filename or mime type
168
+ let ext = getExtensionFromMime(contentType);
169
+ if (fileName) {
170
+ const fnExt = path.extname(fileName);
171
+ if (fnExt) ext = fnExt;
172
+ }
173
+
174
+ const tempPath = path.join(os.tmpdir(), `openclaw-rc-${crypto.randomUUID()}${ext}`);
175
+ await fs.writeFile(tempPath, buffer, { mode: 0o600 });
176
+
177
+ return {
178
+ path: tempPath,
179
+ mimeType: contentType,
180
+ cleanup: async () => {
181
+ await fs.unlink(tempPath).catch(() => {});
182
+ },
183
+ };
145
184
  } catch {
146
185
  return null;
147
186
  }
@@ -370,16 +409,21 @@ async function handleIncomingMessage(
370
409
  ? msg.ts.$date
371
410
  : Date.parse(String(msg.ts));
372
411
 
373
- // Extract image attachments (if any)
412
+ // Extract file attachments (images, PDFs, documents, etc.)
374
413
  const baseUrl = account.baseUrl;
375
414
  const authToken = account.authToken;
376
415
  const userId = account.userId;
377
- const imageRefs = extractImageUrls(msg, baseUrl);
416
+
417
+ // DEBUG: Log raw message to see what DDP sends
418
+ logger.debug?.(`[RC DEBUG] Raw message: file=${JSON.stringify(msg.file)} files=${JSON.stringify(msg.files)} attachments=${JSON.stringify(msg.attachments?.slice(0, 2))}`);
419
+
420
+ const fileRefs = extractFileUrls(msg, baseUrl);
421
+ logger.debug?.(`[RC DEBUG] Extracted ${fileRefs.length} file refs`);
378
422
 
379
423
  let rawBody = msg.msg.trim();
380
424
 
381
- // Allow messages with only images (no text)
382
- if (!rawBody && imageRefs.length === 0) return;
425
+ // Allow messages with only file attachments (no text)
426
+ if (!rawBody && fileRefs.length === 0) return;
383
427
 
384
428
  // Optional per-message overrides
385
429
  // - !thread -> force reply in thread
@@ -392,8 +436,8 @@ async function handleIncomingMessage(
392
436
  forcedReplyMode = "channel";
393
437
  rawBody = rawBody.replace(/^!channel\b\s*/i, "").trim();
394
438
  }
395
- // Skip if no text and no images
396
- if (!rawBody && imageRefs.length === 0) return;
439
+ // Skip if no text and no file attachments
440
+ if (!rawBody && fileRefs.length === 0) return;
397
441
 
398
442
  // Determine reply mode
399
443
  const baseReplyMode: "thread" | "channel" | "auto" =
@@ -474,8 +518,8 @@ async function handleIncomingMessage(
474
518
  : undefined;
475
519
 
476
520
  // 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]" : "");
521
+ // For attachment-only messages, use a placeholder so the agent knows there's content
522
+ const effectiveRawBody = rawBody || (fileRefs.length > 0 ? "[attachment]" : "");
479
523
  const body = core.channel?.reply?.formatAgentEnvelope?.({
480
524
  channel: "Rocket.Chat",
481
525
  from: fromLabel,
@@ -485,34 +529,73 @@ async function handleIncomingMessage(
485
529
  body: effectiveRawBody,
486
530
  }) ?? effectiveRawBody;
487
531
 
488
- // Rocket.Chat NOTE: Messages starting with "/" are treated as Rocket.Chat slash-commands.
489
- // To make model switching usable from chat, we support an alternate syntax:
490
- // --model qwen3
491
- // --qwen3
492
- // which we normalize into OpenClaw inline directives.
493
- const commandBody = rawBody
532
+
533
+ // Inline directives (e.g. /model <ref>) should be parsed from the raw inbound text.
534
+ // Rocket.Chat users often type one-line directives like: "/model qwen3 hello".
535
+ // We split a leading directive into:
536
+ // - BodyForCommands: directive-only (so OpenClaw applies it)
537
+ // - BodyForAgent: the full envelope (so the agent retains context)
538
+ const normalizedRawBody = rawBody
539
+ // Shorthands (Rocket.Chat doesn't reliably allow leading `/...`)
540
+ .replace(/^\s*--opus\b/i, "/model opus")
541
+ .replace(/^\s*-opus\b/i, "/model opus")
542
+ .replace(/^\s*--oss\b/i, "/model oss")
543
+ .replace(/^\s*-oss\b/i, "/model oss")
494
544
  .replace(/^\s*--model\b/i, "/model")
545
+ .replace(/^\s*-model\b/i, "/model")
546
+ // Generic: `--foo` => `/foo` (we intentionally do NOT do this for single-dash to avoid
547
+ // accidental triggers on markdown bullets / negative numbers).
495
548
  .replace(/^\s*--/, "/");
496
549
 
497
- // Fetch images as data URLs (authenticated fetch required for Rocket.Chat uploads)
498
- let mediaUrls: string[] | undefined;
499
- if (imageRefs.length > 0) {
550
+ const bodyForCommands = (() => {
551
+ const t = normalizedRawBody.trim();
552
+ if (!t.startsWith("/")) return normalizedRawBody;
553
+
554
+ // IMPORTANT: Rocket.Chat users can't reliably send leading `/...` (Rocket.Chat treats it as an internal slash command).
555
+ // We allow `--...` as a stand-in for `/...`.
556
+ //
557
+ // For directives like `/think high`, `/verbose full`, `/elevated ask`, etc, we must preserve arguments.
558
+ // So: when the message starts with a directive/command, pass the *full first line* to the command parser.
559
+ // (OpenClaw will apply it as a directive-only message if the remaining body is empty.)
560
+ return t.split("\n", 1)[0].trim();
561
+ })();
562
+
563
+ const commandAuthorized = true;
564
+ const bodyForAgent = body;
565
+
566
+ // Fetch files to temp (OpenClaw expects file paths, not data URLs)
567
+ let mediaPaths: string[] | undefined;
568
+ let mediaTypes: string[] | undefined;
569
+ const fileCleanups: Array<() => Promise<void>> = [];
570
+
571
+ if (fileRefs.length > 0) {
500
572
  const fetched = await Promise.all(
501
- imageRefs.map((ref) =>
502
- fetchImageAsDataUrl(ref.url, authToken, userId, ref.mimeType)
573
+ fileRefs.map((ref) =>
574
+ fetchFileToTemp(ref.url, authToken, userId, ref.mimeType, ref.fileName)
503
575
  )
504
576
  );
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`);
577
+ const validFiles = fetched.filter((f): f is FetchedFile => f !== null);
578
+ if (validFiles.length > 0) {
579
+ mediaPaths = validFiles.map((f) => f.path);
580
+ mediaTypes = validFiles.map((f) => f.mimeType);
581
+ fileCleanups.push(...validFiles.map((f) => f.cleanup));
582
+ logger.debug?.(`Fetched ${validFiles.length} file(s) from Rocket.Chat attachments`);
508
583
  }
509
584
  }
510
585
 
511
- // Finalize inbound context
512
586
  const ctxPayload = core.channel?.reply?.finalizeInboundContext?.({
513
587
  Body: body,
588
+ BodyForAgent: bodyForAgent,
514
589
  RawBody: rawBody,
515
- CommandBody: commandBody,
590
+ CommandBody: bodyForCommands,
591
+ // Be explicit: directives (/model, /qwen, etc.) should be parsed from the raw inbound text.
592
+ BodyForCommands: bodyForCommands,
593
+ // Hint to OpenClaw that this is plain text (not a platform-native slash command).
594
+ CommandSource: "text",
595
+
596
+ // Allow inline directives like /model ...
597
+ CommandAuthorized: commandAuthorized,
598
+
516
599
  From: isGroup ? `rocketchat:room:${roomId}` : `rocketchat:${senderId}`,
517
600
  To: `rocketchat:${roomId}`,
518
601
  SessionKey: route.sessionKey,
@@ -529,15 +612,15 @@ async function handleIncomingMessage(
529
612
  OriginatingChannel: "rocketchat",
530
613
  OriginatingTo: `rocketchat:${roomId}`,
531
614
 
532
- // Image attachments (fetched as base64 data URLs)
533
- MediaUrls: mediaUrls?.length ? mediaUrls : undefined,
615
+ // Image attachments (fetched to temp files)
616
+ MediaPaths: mediaPaths?.length ? mediaPaths : undefined,
617
+ MediaTypes: mediaTypes?.length ? mediaTypes : undefined,
534
618
  });
535
619
 
536
620
  if (!ctxPayload) {
537
621
  logger.error?.(`Failed to finalize inbound context for message ${msg._id}`);
538
622
  return;
539
623
  }
540
-
541
624
  // Record inbound session
542
625
  if (storePath) {
543
626
  await core.channel?.session?.recordInboundSession?.({
@@ -628,5 +711,9 @@ async function handleIncomingMessage(
628
711
  });
629
712
  } finally {
630
713
  await stopTyping();
714
+ // Clean up temp files
715
+ for (const cleanup of fileCleanups) {
716
+ await cleanup();
717
+ }
631
718
  }
632
719
  }