Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { InboundAttachment, InboundAttachmentKind, AttachmentRecord } from "./types/types.js";

const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "mov", "mkv", "webm", "avi", "m4v"]);
const AUDIO_EXTENSIONS = new Set(["mp3", "m4a", "ogg", "oga", "opus", "wav", "flac", "aac", "amr", "weba"]);
const DOCUMENT_EXTENSIONS = new Set(["pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "txt", "md", "csv", "json"]);
const DOCUMENT_MIME_TYPES = new Set([
"application/pdf",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/json",
"text/csv",
"text/markdown",
"text/plain",
]);

export function getMessageAttachmentInputs(message: {
attachments?: unknown[];
file?: unknown;
files?: unknown[];
}): unknown[] {
const hasId = (r: AttachmentRecord) => typeof r._id === "string" && r._id.length > 0;
const fileRecords = toRecords([
...(message.file ? [message.file] : []),
...(message.files ?? []),
]);
const fileIds = new Set(fileRecords.filter(hasId).map((r) => r._id));
const attachmentRecords = toRecords(message.attachments ?? []).filter(
(r) => !hasId(r) || !fileIds.has(r._id),
);
return [...fileRecords, ...attachmentRecords];
}

export function normalizeInboundAttachments(
inputs: unknown[],
options?: { serverUrl?: string },
): InboundAttachment[] {
return inputs.map((input) => toAttachment(input, options));
}

function toAttachment(input: unknown, options?: { serverUrl?: string }): InboundAttachment {
const record = asRecord(input);
const mimeType = getMime(record);
const url = getUrl(record, options?.serverUrl);
const fileName = getFileName(record, url);
return {
kind: classify(mimeType, fileName),
source: record?._id ? "rocketchat-file" : "rocketchat-attachment",
raw: input,
...(mimeType !== undefined ? { mimeType } : {}),
...(fileName !== undefined ? { fileName } : {}),
...(url !== undefined ? { url } : {}),
...(typeof record?.size === "number" ? { sizeBytes: record.size } : {}),
};
}

function asRecord(input: unknown): AttachmentRecord | null {
return input && typeof input === "object" && !Array.isArray(input) ? input as AttachmentRecord : null;
}

function toRecords(inputs: unknown[]): AttachmentRecord[] {
return inputs.map(asRecord).filter((r): r is AttachmentRecord => r !== null);
}

function getMime(record: AttachmentRecord | null): string | undefined {
const v = record?.type ?? record?.mimeType ?? record?.mimetype ?? record?.contentType;
return typeof v === "string" && v.trim().length > 0 ? v.trim().toLowerCase() : undefined;
}

function getUrl(record: AttachmentRecord | null, serverUrl: string | undefined): string | undefined {
const candidates = [record?.url, record?.title_link, record?.image_url, record?.video_url, record?.audio_url];
const raw = candidates.find((v): v is string => typeof v === "string" && v.length > 0);
return raw ? resolveUrl(raw, serverUrl) : undefined;
}

function getFileName(record: AttachmentRecord | null, url: string | undefined): string | undefined {
const name = [record?.title, record?.name, record?.filename].find(
(v): v is string => typeof v === "string" && v.trim().length > 0,
);
if (name) return name.trim();
if (!url) return undefined;
try {
const seg = new URL(url).pathname.split("/").filter(Boolean).at(-1);
return seg ? decodeURIComponent(seg) : undefined;
} catch { return undefined; }
}

function classify(mimeType: string | undefined, fileName: string | undefined): InboundAttachmentKind {
if (mimeType?.startsWith("image/")) return "image";
if (mimeType?.startsWith("audio/")) return "audio";
if (mimeType?.startsWith("video/")) return "video";
if (mimeType?.startsWith("text/") || (mimeType && DOCUMENT_MIME_TYPES.has(mimeType))) return "document";
const ext = getExt(fileName);
if (!ext) return "unknown";
if (IMAGE_EXTENSIONS.has(ext)) return "image";
if (AUDIO_EXTENSIONS.has(ext)) return "audio";
if (VIDEO_EXTENSIONS.has(ext)) return "video";
if (DOCUMENT_EXTENSIONS.has(ext)) return "document";
return "unknown";
}

function getExt(fileName: string | undefined): string | undefined {
if (!fileName) return undefined;
const clean = fileName.trim().toLowerCase();
const dot = clean.lastIndexOf(".");
if (dot <= 0 || dot === clean.length - 1) return undefined;
return clean.slice(dot + 1);
}

function resolveUrl(url: string, serverUrl: string | undefined): string {
try { return new URL(url).toString(); } catch { /* relative */ }
if (!serverUrl) return url;
try { return new URL(url, serverUrl).toString(); } catch { return url; }
}
11 changes: 5 additions & 6 deletions src/cli/config-updater.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
import { resolve } from "node:path";
import { homedir } from "node:os";
/** Config updater only writes token auth (CLI setup always resolves to a token) */
type TokenAuth = { mode: "token"; userId: string; accessToken: string };
import type { AuthCredentials, JsonObject } from "../types/types.js";

const OC_CONFIG_PATH = resolve(homedir(), ".openclaw", "openclaw.json");

type OcConfig = Record<string, unknown>;
/** Config updater only writes token auth (CLI setup always resolves to a token) */
type TokenAuth = Extract<AuthCredentials, { mode: "token" }>;

function readConfig(): OcConfig {
function readConfig(): JsonObject {
if (!existsSync(OC_CONFIG_PATH)) return {};
return JSON.parse(readFileSync(OC_CONFIG_PATH, "utf-8"));
}

function writeConfig(cfg: OcConfig): void {
function writeConfig(cfg: JsonObject): void {
const tmp = OC_CONFIG_PATH + ".tmp";
writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
renameSync(tmp, OC_CONFIG_PATH);
Expand Down Expand Up @@ -55,4 +55,3 @@ export function updateConfig(opts: {

writeConfig(cfg);
}

107 changes: 107 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";

import type {
PluginAccountConfig,
RocketChatIdentity,
Expand Down Expand Up @@ -28,6 +33,7 @@ export class RocketChatClient {
private readonly serverUrl: string;
private readonly auth: PluginAccountConfig["auth"];
private readonly fetchFn: typeof fetch;
private readonly mediaDir: string;
private identity: RocketChatIdentity | null = null;
private resolvedUserId: string | null = null;
private resolvedAuthToken: string | null = null;
Expand All @@ -36,6 +42,7 @@ export class RocketChatClient {
this.serverUrl = options.serverUrl.replace(/\/+$/, "");
this.auth = options.auth;
this.fetchFn = options.fetch ?? globalThis.fetch;
this.mediaDir = resolveMediaDir();

if (this.auth.mode === "token") {
this.resolvedUserId = this.auth.userId;
Expand Down Expand Up @@ -124,6 +131,84 @@ export class RocketChatClient {
});
}

async downloadAttachmentToTempFile(
url: string,
options?: { fileName?: string },
): Promise<string> {
await this.ensureInitialized();
const requestUrl = resolveUrl_relative(url, this.serverUrl);
const response = await this.fetchFn(requestUrl, {
method: "GET",
headers: {
Accept: "*/*",
"X-User-Id": this.resolvedUserId!,
"X-Auth-Token": this.resolvedAuthToken!,
},
});
if (!response.ok) {
throw new RocketChatClientError(`attachment download failed: ${response.statusText}`);
}
const inboundDir = join(this.mediaDir, "inbound");
await mkdir(inboundDir, { recursive: true });
const ext = guessExt(url, options?.fileName);
const safeName = (options?.fileName ?? "attachment").replace(/[^a-zA-Z0-9._-]/g, "_");
const filePath = join(inboundDir, `${safeName}---${randomUUID().slice(0, 12)}${ext ? `.${ext}` : ""}`);
const bytes = Buffer.from(await response.arrayBuffer());
await writeFile(filePath, bytes);
return filePath;
}

async uploadAttachment(
roomId: string,
filePath: string,
text?: string,
options?: { tmid?: string },
): Promise<string> {
await this.ensureInitialized();
const fileName = basename(filePath);
const fileBytes = await readFile(filePath);
const formData = new FormData();
if (text?.trim()) formData.append("msg", text.trim());
if (options?.tmid) formData.append("tmid", options.tmid);
formData.append("file", new Blob([fileBytes]), fileName);
const uploadResponse = await this.fetchFn(
new URL(`/api/v1/rooms.media/${encodeURIComponent(roomId)}`, this.serverUrl).toString(),
{
method: "POST",
headers: {
"X-User-Id": this.resolvedUserId!,
"X-Auth-Token": this.resolvedAuthToken!,
},
body: formData,
},
);
if (!uploadResponse.ok) {
throw new RocketChatClientError(`attachment upload failed: ${uploadResponse.statusText}`);
}
const uploadPayload = await this.parseJsonResponse(uploadResponse);
const file = asObject(uploadPayload.file ?? {});
const fileId = getString(file, "_id");
const confirmResponse = await this.fetchFn(
new URL(
`/api/v1/rooms.mediaConfirm/${encodeURIComponent(roomId)}/${encodeURIComponent(fileId)}`,
this.serverUrl,
).toString(),
{
method: "POST",
headers: {
"X-User-Id": this.resolvedUserId!,
"X-Auth-Token": this.resolvedAuthToken!,
},
},
);
if (!confirmResponse.ok) {
throw new RocketChatClientError(`attachment confirm failed: ${confirmResponse.statusText}`);
}
const confirmPayload = await this.parseJsonResponse(confirmResponse);
const message = asObject(confirmPayload.message ?? {});
return getString(message, "_id");
}

private async ensureInitialized(): Promise<void> {
if (!this.resolvedUserId || !this.resolvedAuthToken) {
await this.initialize();
Expand Down Expand Up @@ -230,3 +315,25 @@ function getRetryAfterMs(response: Response, payload: JsonObject): number {
return 30_000;
}

function resolveMediaDir(): string {
const explicit = process.env.OPENCLAW_STATE_DIR?.trim();
if (explicit) return join(explicit, "media");
const home = process.env.OPENCLAW_HOME?.trim();
if (home) return join(home, ".openclaw", "media");
return join(homedir(), ".openclaw", "media");
}

function resolveUrl_relative(url: string, base: string | undefined): string {
try { return new URL(url).toString(); } catch { /* relative */ }
if (!base) return url;
try { return new URL(url, base.endsWith("/") ? base : base + "/").toString(); } catch { return url; }
}

function guessExt(url: string | undefined, fileName: string | undefined): string | undefined {
const name = fileName || url || "";
const parts = name.split(".");
if (parts.length < 2) return undefined;
const ext = parts.pop()!.split("?").shift()!.split("#").shift()!.toLowerCase();
return ext || undefined;
}

19 changes: 16 additions & 3 deletions src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from "node:path";
import { RocketChatClient, RocketChatRateLimitError } from "./client.js";
import { parsePluginConfig } from "./config.js";
import { FileCheckpointStore } from "./checkpoint-store.js";
import { getMessageAttachmentInputs, normalizeInboundAttachments } from "./attachments.js";
import type { InboundEvent } from "./types/types.js";
import { shouldHandleInboundEvent } from "./channel.js";
import { dispatchInboundEventWithChannelRuntime } from "./inbound-dispatch.js";
Expand Down Expand Up @@ -115,7 +116,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
continue;
}

const event = toInboundEvent(account.accountId, sub, msg);
const event = toInboundEvent(account.accountId, sub, msg, account.serverUrl);

if (!shouldHandleInboundEvent(event, { botUserId: identity.userId, mentionNames })) {
continue;
Expand All @@ -141,10 +142,20 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
accountId: account.accountId,
event,
channelRuntime,
client,
deliver: async (payload, info) => {
if (info.kind === "final") {
await client.reactToMessage(event.messageId, ":white_check_mark:").catch((err) => log.error(`[rocketchat:${account.accountId}] reaction failed: ${err instanceof Error ? err.message : String(err)}`));
await client.postMessage(event.roomId, payload.text ?? "", replyTmid ? { tmid: replyTmid } : undefined);
if (payload.attachmentPath) {
try {
await client.uploadAttachment(event.roomId, payload.attachmentPath, payload.text, replyTmid ? { tmid: replyTmid } : undefined);
} catch (err) {
log.error(`[rocketchat:${account.accountId}] upload failed: ${err instanceof Error ? err.message : String(err)}`);
await client.postMessage(event.roomId, payload.text ?? "", replyTmid ? { tmid: replyTmid } : undefined);
}
} else {
await client.postMessage(event.roomId, payload.text ?? "", replyTmid ? { tmid: replyTmid } : undefined);
}
}
},
onRecordError: (error) => {
Expand Down Expand Up @@ -240,7 +251,7 @@ function shouldSkipMessage(
): boolean {
if (!msg._id) return true;
if (msg.t) return true;
if ((!msg.msg || msg.msg.trim().length === 0)) return true;
if ((!msg.msg || msg.msg.trim().length === 0) && getMessageAttachmentInputs(msg).length === 0) return true;
if (msg.u?._id === botUserId) return true;
if (seenIds.has(msg._id)) return true;
return false;
Expand All @@ -250,6 +261,7 @@ function toInboundEvent(
accountId: string,
sub: import("./types/types.js").RocketChatSubscriptionRecord,
msg: import("./types/types.js").RocketChatMessageRecord,
serverUrl?: string,
): InboundEvent {
return {
accountId,
Expand All @@ -261,6 +273,7 @@ function toInboundEvent(
senderName: msg.u?.username ?? msg.u?.name ?? "",
text: msg.msg ?? "",
mentions: (msg.mentions ?? []).map((m) => m.username ?? m.name ?? "").filter(Boolean),
attachments: normalizeInboundAttachments(getMessageAttachmentInputs(msg), serverUrl ? { serverUrl } : undefined),
sentAt: msg.ts ?? new Date(0).toISOString(),
raw: msg,
};
Expand Down
Loading