@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 +1 -1
- package/package.json +1 -1
- package/src/rocketchat/monitor.ts +154 -67
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ Then restart the gateway.
|
|
|
145
145
|
|
|
146
146
|
## Features
|
|
147
147
|
|
|
148
|
-
- **
|
|
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
|
@@ -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
|
|
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
|
|
83
|
+
function extractFileUrls(
|
|
91
84
|
msg: IncomingMessage,
|
|
92
85
|
baseUrl: string
|
|
93
|
-
): Array<{ url: string; mimeType?: string }> {
|
|
94
|
-
const
|
|
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 (
|
|
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
|
-
|
|
106
|
+
files.push({ url, mimeType: att.image_type, fileName: att.title });
|
|
104
107
|
}
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type FetchedFile = {
|
|
115
|
+
path: string;
|
|
116
|
+
mimeType: string;
|
|
117
|
+
cleanup: () => Promise<void>;
|
|
118
|
+
};
|
|
117
119
|
|
|
118
|
-
|
|
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
|
|
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
|
|
148
|
+
async function fetchFileToTemp(
|
|
125
149
|
url: string,
|
|
126
150
|
authToken: string,
|
|
127
151
|
userId: string,
|
|
128
|
-
mimeType?: string
|
|
129
|
-
|
|
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") ?? "
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
382
|
-
if (!rawBody &&
|
|
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
|
|
396
|
-
if (!rawBody &&
|
|
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
|
|
478
|
-
const effectiveRawBody = rawBody || (
|
|
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
|
-
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
502
|
-
|
|
573
|
+
fileRefs.map((ref) =>
|
|
574
|
+
fetchFileToTemp(ref.url, authToken, userId, ref.mimeType, ref.fileName)
|
|
503
575
|
)
|
|
504
576
|
);
|
|
505
|
-
|
|
506
|
-
if (
|
|
507
|
-
|
|
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:
|
|
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
|
|
533
|
-
|
|
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
|
}
|