@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 +1 -0
- package/package.json +1 -1
- package/src/rocketchat/monitor.ts +195 -15
- package/src/rocketchat/realtime.ts +21 -6
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
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
398
|
-
}) ??
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
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
|
-
|
|
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:
|
|
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?:
|
|
45
|
-
|
|
46
|
-
|
|
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 {
|