@cloudrise/openclaw-channel-rocketchat 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudrise/openclaw-channel-rocketchat",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Rocket.Chat channel plugin for OpenClaw (Cloudrise)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -225,3 +225,114 @@ export async function sendRocketChatTyping(
225
225
  body: JSON.stringify({ roomId, typing: Boolean(isTyping) }),
226
226
  });
227
227
  }
228
+
229
+ export type RocketChatUploadOpts = {
230
+ roomId: string;
231
+ file: Buffer;
232
+ fileName: string;
233
+ mimeType?: string;
234
+ description?: string;
235
+ tmid?: string;
236
+ };
237
+
238
+ export type RocketChatUploadResult = {
239
+ _id: string;
240
+ rid: string;
241
+ ts: string;
242
+ };
243
+
244
+ /**
245
+ * Upload a file to a Rocket.Chat room.
246
+ * RC 8.x uses a two-step process:
247
+ * 1. POST /api/v1/rooms.media/{roomId} → returns file._id
248
+ * 2. POST /api/v1/chat.sendMessage with file reference
249
+ */
250
+ export async function uploadRocketChatFile(
251
+ client: RocketChatClient,
252
+ opts: RocketChatUploadOpts
253
+ ): Promise<RocketChatUploadResult> {
254
+ // Step 1: Upload file to rooms.media
255
+ const uploadUrl = `${client.baseUrl}/api/v1/rooms.media/${opts.roomId}`;
256
+
257
+ // Build FormData manually for Node.js
258
+ const boundary = `----OpenClawBoundary${Date.now()}`;
259
+ const parts: Buffer[] = [];
260
+
261
+ // Add file part
262
+ const fileHeader = [
263
+ `--${boundary}`,
264
+ `Content-Disposition: form-data; name="file"; filename="${opts.fileName}"`,
265
+ `Content-Type: ${opts.mimeType ?? "application/octet-stream"}`,
266
+ "",
267
+ "",
268
+ ].join("\r\n");
269
+ parts.push(Buffer.from(fileHeader));
270
+ parts.push(opts.file);
271
+ parts.push(Buffer.from("\r\n"));
272
+
273
+ // End boundary
274
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
275
+
276
+ const uploadBody = Buffer.concat(parts);
277
+
278
+ const uploadRes = await client.fetch(uploadUrl, {
279
+ method: "POST",
280
+ headers: {
281
+ "X-Auth-Token": client.authToken,
282
+ "X-User-Id": client.userId,
283
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
284
+ "Content-Length": String(uploadBody.length),
285
+ },
286
+ body: uploadBody,
287
+ });
288
+
289
+ if (!uploadRes.ok) {
290
+ const text = await uploadRes.text().catch(() => "");
291
+ throw new Error(`Rocket.Chat media upload error ${uploadRes.status}: ${text}`);
292
+ }
293
+
294
+ const uploadData = await uploadRes.json() as {
295
+ file: { _id: string; url: string };
296
+ success: boolean
297
+ };
298
+
299
+ if (!uploadData.success || !uploadData.file?._id) {
300
+ throw new Error("Rocket.Chat media upload failed: no file ID returned");
301
+ }
302
+
303
+ // Step 2: Send message with file reference
304
+ const messagePayload = {
305
+ message: {
306
+ rid: opts.roomId,
307
+ msg: opts.description ?? "",
308
+ file: { _id: uploadData.file._id },
309
+ ...(opts.tmid && { tmid: opts.tmid }),
310
+ },
311
+ };
312
+
313
+ const sendRes = await client.fetch(`${client.baseUrl}/api/v1/chat.sendMessage`, {
314
+ method: "POST",
315
+ headers: {
316
+ "X-Auth-Token": client.authToken,
317
+ "X-User-Id": client.userId,
318
+ "Content-Type": "application/json",
319
+ },
320
+ body: JSON.stringify(messagePayload),
321
+ });
322
+
323
+ if (!sendRes.ok) {
324
+ const text = await sendRes.text().catch(() => "");
325
+ throw new Error(`Rocket.Chat send message error ${sendRes.status}: ${text}`);
326
+ }
327
+
328
+ const sendData = await sendRes.json() as {
329
+ message: { _id: string; rid: string; ts: string };
330
+ success: boolean
331
+ };
332
+
333
+ return {
334
+ _id: sendData.message._id,
335
+ rid: sendData.message.rid,
336
+ ts: sendData.message.ts,
337
+ };
338
+ }
@@ -2,6 +2,9 @@
2
2
  * Rocket.Chat message sending
3
3
  */
4
4
 
5
+ import * as fs from "node:fs/promises";
6
+ import * as path from "node:path";
7
+
5
8
  import { resolveRocketChatAccount } from "./accounts.js";
6
9
  import {
7
10
  createRocketChatClient,
@@ -10,6 +13,7 @@ import {
10
13
  fetchRocketChatUserByUsername,
11
14
  normalizeRocketChatBaseUrl,
12
15
  postRocketChatMessage,
16
+ uploadRocketChatFile,
13
17
  type RocketChatUser,
14
18
  } from "./client.js";
15
19
  import { getRocketChatRuntime } from "../runtime.js";
@@ -47,6 +51,32 @@ function isHttpUrl(value: string): boolean {
47
51
  return /^https?:\/\//i.test(value);
48
52
  }
49
53
 
54
+ function isLocalPath(value: string): boolean {
55
+ // Check if it's an absolute path or relative path (not a URL)
56
+ return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || /^[A-Za-z]:\\/.test(value);
57
+ }
58
+
59
+ /** Map file extensions to MIME types */
60
+ function getMimeFromExt(filePath: string): string {
61
+ const ext = path.extname(filePath).toLowerCase();
62
+ const map: Record<string, string> = {
63
+ ".png": "image/png",
64
+ ".jpg": "image/jpeg",
65
+ ".jpeg": "image/jpeg",
66
+ ".gif": "image/gif",
67
+ ".webp": "image/webp",
68
+ ".pdf": "application/pdf",
69
+ ".txt": "text/plain",
70
+ ".md": "text/markdown",
71
+ ".json": "application/json",
72
+ ".mp3": "audio/mpeg",
73
+ ".wav": "audio/wav",
74
+ ".mp4": "video/mp4",
75
+ ".webm": "video/webm",
76
+ };
77
+ return map[ext] ?? "application/octet-stream";
78
+ }
79
+
50
80
  function parseRocketChatTarget(raw: string): RocketChatTarget {
51
81
  const trimmed = raw.trim();
52
82
  if (!trimmed) throw new Error("Recipient is required for Rocket.Chat sends");
@@ -165,7 +195,46 @@ export async function sendMessageRocketChat(
165
195
  let message = text?.trim() ?? "";
166
196
  const mediaUrl = opts.mediaUrl?.trim();
167
197
 
168
- // For media, we just append the URL since Rocket.Chat will unfurl it
198
+ // Resolve room ID for uploads (channels need special handling)
199
+ const isChannel = target.kind === "channel";
200
+ const uploadRoomId = isChannel ? roomId : roomId;
201
+
202
+ // Handle local file uploads
203
+ if (mediaUrl && isLocalPath(mediaUrl)) {
204
+ try {
205
+ const fileBuffer = await fs.readFile(mediaUrl);
206
+ const fileName = path.basename(mediaUrl);
207
+ const mimeType = getMimeFromExt(mediaUrl);
208
+
209
+ logger?.debug?.(`Uploading file to Rocket.Chat: ${fileName} (${mimeType}, ${fileBuffer.length} bytes)`);
210
+
211
+ const upload = await uploadRocketChatFile(client, {
212
+ roomId: uploadRoomId,
213
+ file: fileBuffer,
214
+ fileName,
215
+ mimeType,
216
+ description: message || undefined,
217
+ tmid: opts.replyToId,
218
+ });
219
+
220
+ core?.channel?.activity?.record?.({
221
+ channel: "rocketchat",
222
+ accountId: account.accountId,
223
+ direction: "outbound",
224
+ });
225
+
226
+ return {
227
+ messageId: upload._id ?? "unknown",
228
+ roomId: upload.rid ?? uploadRoomId,
229
+ };
230
+ } catch (err) {
231
+ logger?.error?.(`Failed to upload file to Rocket.Chat: ${String(err)}`);
232
+ // Fall through to send as text message with path (degraded experience)
233
+ message = normalizeMessage(message, `[File: ${mediaUrl}]`);
234
+ }
235
+ }
236
+
237
+ // For HTTP URLs, append to message (Rocket.Chat will unfurl)
169
238
  if (mediaUrl && isHttpUrl(mediaUrl)) {
170
239
  message = normalizeMessage(message, mediaUrl);
171
240
  }
@@ -174,7 +243,6 @@ export async function sendMessageRocketChat(
174
243
  throw new Error("Rocket.Chat message is empty");
175
244
  }
176
245
 
177
- const isChannel = target.kind === "channel";
178
246
  const post = await postRocketChatMessage(client, {
179
247
  roomId: isChannel ? undefined : roomId,
180
248
  channel: isChannel ? `#${target.name}` : undefined,