Skip to content
Open
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
143 changes: 143 additions & 0 deletions src/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { resolveUrl, getExt } from "./utils.js";
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),
);

const hasUrl = (r: AttachmentRecord) =>
typeof r.url === "string" ||
typeof r.title_link === "string" ||
typeof r.image_url === "string" ||
typeof r.video_url === "string" ||
typeof r.audio_url === "string";

const merged: AttachmentRecord[] = [];
const paired = new Set<number>();

for (const fileRec of fileRecords) {
const matchIdx = attachmentRecords.findIndex((att, i) =>
!paired.has(i) && (
(fileRec._id && att._id && fileRec._id === att._id) ||
(!hasId(att) && hasUrl(att))
),
);
if (matchIdx !== -1) {
paired.add(matchIdx);
const att = attachmentRecords[matchIdx]!;
const m = { ...att } as AttachmentRecord;
if (fileRec._id) m._id = fileRec._id;
if (fileRec.type) m.type = fileRec.type;
if (fileRec.name) m.name = fileRec.name;
if (typeof fileRec.size === "number") m.size = fileRec.size;
merged.push(m);
} else {
merged.push(fileRec);
}
}

for (let i = 0; i < attachmentRecords.length; i++) {
if (!paired.has(i)) merged.push(attachmentRecords[i]!);
}

return merged;
}

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";
}


35 changes: 23 additions & 12 deletions src/cli/admin-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { RocketChatClientError } from "../client.js";
import { getErrorMessage } from "../utils.js";
import type { RCLoginResult, RCUser, JsonObject } from "../types/types.js";

function extractRecord(json: JsonObject, field: string): Record<string, unknown> {
const value = json[field];
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new RocketChatClientError(`RC API response missing or invalid "${field}"`);
}
return value as Record<string, unknown>;
}

function extractString(obj: Record<string, unknown>, key: string): string {
const v = obj[key];
if (typeof v !== "string" || v.length === 0) {
throw new RocketChatClientError(`RC API response missing or invalid "${key}"`);
}
return v;
}

type RCFetchOpts = {
method?: string;
body?: Record<string, unknown>;
Expand All @@ -27,16 +44,10 @@ async function adminFetch(baseUrl: string, path: string, opts: RCFetchOpts = {})
return json;
}

function getErrorMessage(payload: JsonObject, fallback: string): string {
if (typeof payload.error === "string" && payload.error.length > 0) return payload.error;
if (typeof payload.message === "string" && payload.message.length > 0) return payload.message;
return fallback;
}

export async function loginAs(baseUrl: string, user: string, password: string): Promise<RCLoginResult> {
const json = await adminFetch(baseUrl, "/api/v1/login", { body: { user, password } });
const data = json.data as { userId: string; authToken: string };
return { userId: data.userId, authToken: data.authToken };
const data = extractRecord(json, "data");
return { userId: extractString(data, "userId"), authToken: extractString(data, "authToken") };
}

export async function createBotUser(
Expand All @@ -58,8 +69,8 @@ export async function createBotUser(
sendWelcomeEmail: false,
},
});
const user = json.user as RCUser;
return { _id: user._id, username: user.username, name: user.name };
const userRecord = extractRecord(json, "user");
return { _id: extractString(userRecord, "_id"), username: extractString(userRecord, "username"), name: extractString(userRecord, "name") };
}

export async function getUserByUsername(
Expand All @@ -86,8 +97,8 @@ export async function createDirectMessage(baseUrl: string, auth: RCLoginResult,
authToken: auth.authToken,
body: { username },
});
const room = json.room as { _id: string };
return room._id;
const room = extractRecord(json, "room");
return extractString(room, "_id");
}

export async function sendMessage(baseUrl: string, auth: RCLoginResult, roomId: string, text: string): Promise<void> {
Expand Down
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";

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);
}

Loading