From 1c5d9ce5288fc4e39cad6d5f783aab14b31ed21e Mon Sep 17 00:00:00 2001 From: dodaa08 Date: Sat, 27 Jun 2026 17:50:20 +0530 Subject: [PATCH] feat: media and image support --- src/attachments.ts | 118 ++++++++++++++++++++++++++++++++++ src/cli/config-updater.ts | 11 ++-- src/client.ts | 107 +++++++++++++++++++++++++++++++ src/gateway.ts | 19 +++++- src/inbound-dispatch.ts | 66 +++++++++++++++++-- src/index.ts | 33 ++++------ src/plugin.ts | 131 ++++++++++++++++++++------------------ src/types/types.ts | 34 ++++++++++ 8 files changed, 424 insertions(+), 95 deletions(-) create mode 100644 src/attachments.ts diff --git a/src/attachments.ts b/src/attachments.ts new file mode 100644 index 0000000..dd9f8ef --- /dev/null +++ b/src/attachments.ts @@ -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; } +} diff --git a/src/cli/config-updater.ts b/src/cli/config-updater.ts index a75d712..548b152 100644 --- a/src/cli/config-updater.ts +++ b/src/cli/config-updater.ts @@ -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; +/** Config updater only writes token auth (CLI setup always resolves to a token) */ +type TokenAuth = Extract; -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); @@ -55,4 +55,3 @@ export function updateConfig(opts: { writeConfig(cfg); } - diff --git a/src/client.ts b/src/client.ts index d2c1f6f..e0e26fb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, @@ -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; @@ -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; @@ -124,6 +131,84 @@ export class RocketChatClient { }); } + async downloadAttachmentToTempFile( + url: string, + options?: { fileName?: string }, + ): Promise { + 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 { + 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 { if (!this.resolvedUserId || !this.resolvedAuthToken) { await this.initialize(); @@ -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; +} + diff --git a/src/gateway.ts b/src/gateway.ts index 308db60..9961085 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -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"; @@ -115,7 +116,7 @@ export async function startGateway(ctx: GatewayContext): Promise { 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; @@ -141,10 +142,20 @@ export async function startGateway(ctx: GatewayContext): Promise { 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) => { @@ -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; @@ -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, @@ -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, }; diff --git a/src/inbound-dispatch.ts b/src/inbound-dispatch.ts index 85cba65..57b5842 100644 --- a/src/inbound-dispatch.ts +++ b/src/inbound-dispatch.ts @@ -1,4 +1,5 @@ -import type { InboundEvent, OpenClawConfigLike, OutboundReplyPayload, ReplyDeliverInfo, ChannelRuntimeLike } from "./types/types.js"; +import type { InboundEvent, OpenClawConfigLike, OutboundReplyPayload, ReplyDeliverInfo, ChannelRuntimeLike, InboundAttachment } from "./types/types.js"; +import type { RocketChatClient } from "./client.js"; export async function dispatchInboundEventWithChannelRuntime(params: { cfg: OpenClawConfigLike; @@ -8,6 +9,7 @@ export async function dispatchInboundEventWithChannelRuntime(params: { deliver(payload: OutboundReplyPayload, info: ReplyDeliverInfo): Promise; onRecordError(err: unknown): void; onDispatchError(err: unknown, info: ReplyDeliverInfo): void; + client?: RocketChatClient; }): Promise { const route = params.channelRuntime.routing.resolveAgentRoute({ cfg: params.cfg, @@ -62,6 +64,7 @@ export async function dispatchInboundEventWithChannelRuntime(params: { Timestamp: timestamp, OriginatingChannel: "rocketchat", OriginatingTo: to, + ...(await buildMediaContext(params.event.attachments, params.client)), }); await params.channelRuntime.session.recordInboundSession({ @@ -95,18 +98,20 @@ function normalizeOutboundReplyPayload(payload: unknown): OutboundReplyPayload { } const record = payload as Record; - const mediaUrls = Array.isArray(record.mediaUrls) - ? record.mediaUrls.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : undefined; const text = typeof record.text === "string" ? record.text : undefined; const mediaUrl = typeof record.mediaUrl === "string" ? record.mediaUrl : undefined; + const mediaUrls = Array.isArray(record.mediaUrls) + ? record.mediaUrls.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : undefined; + const attachmentPath = typeof record.attachmentPath === "string" ? record.attachmentPath : undefined; const replyToId = typeof record.replyToId === "string" ? record.replyToId : undefined; return { ...(text ? { text } : {}), ...(mediaUrl ? { mediaUrl } : {}), ...(mediaUrls && mediaUrls.length > 0 ? { mediaUrls } : {}), + ...(attachmentPath ? { attachmentPath } : {}), ...(replyToId ? { replyToId } : {}), }; } @@ -126,6 +131,59 @@ function buildRecipientAddress(event: InboundEvent): string { return `rocketchat:${event.roomId}`; } +async function buildMediaContext( + attachments: InboundAttachment[], + client?: RocketChatClient, +): Promise> { + if (attachments.length === 0) return {}; + + const mediaUrls: string[] = []; + const mediaPaths: string[] = []; + const mediaTypes: string[] = []; + + for (const attachment of attachments) { + if (attachment.source === "rocketchat-file" && attachment.url && client && isPrivateUrl(attachment.url)) { + try { + const filePath = await client.downloadAttachmentToTempFile(attachment.url, attachment.fileName ? { fileName: attachment.fileName } : undefined); + mediaPaths.push(filePath); + if (attachment.mimeType) mediaTypes.push(attachment.mimeType); + continue; + } catch { + // download failed — fall through to URL injection + } + } + + if (attachment.url) { + mediaUrls.push(attachment.url); + if (attachment.mimeType) mediaTypes.push(attachment.mimeType); + } + } + + return { + ...(mediaUrls.length > 0 ? { MediaUrl: mediaUrls[0], MediaUrls: mediaUrls } : {}), + ...(mediaPaths.length > 0 ? { MediaPath: mediaPaths[0], MediaPaths: mediaPaths } : {}), + ...(mediaTypes.length > 0 ? { MediaType: mediaTypes[0], MediaTypes: mediaTypes } : {}), + }; +} + +function isPrivateUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase(); + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname.endsWith(".local") || + hostname.startsWith("10.") || + hostname.startsWith("192.168.") || + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || + hostname.endsWith(".internal") + ); + } catch { + return true; // unparseable URLs — treat as private, download + } +} + function toEpochMs(value: string): number | undefined { const timestamp = Date.parse(value); return Number.isNaN(timestamp) ? undefined : timestamp; diff --git a/src/index.ts b/src/index.ts index bd1bee7..7bbe6ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,21 @@ -import type { GatewayApi } from "./types/types.js"; -import { rocketchatPlugin, startGateway, listAccountIds, resolveAccount, isConfigured } from "./plugin.js"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core"; +import type { OpenClawPluginApi, OpenClawPluginDefinition } from "openclaw/plugin-sdk/core"; +import { rocketchatPlugin, startGateway } from "./plugin.js"; -export function register(api: GatewayApi) { - api.registerChannel?.({ plugin: rocketchatPlugin }); -} +export { startGateway } from "./plugin.js"; -export function activate(api: GatewayApi) { - api.registerGatewayMethod("rocketchat.gateway.startAccount", (ctx) => { - return startGateway(ctx as Parameters[0]); - }); -} - -export default { +const _entry = defineChannelPluginEntry({ id: "rocketchat", name: "Rocket.Chat", description: "Rocket.Chat channel plugin with REST polling outbound/inbound", plugin: rocketchatPlugin, - config: { - listAccountIds, - resolveAccount, - isConfigured, + registerFull: (api: OpenClawPluginApi) => { + api.registerGatewayMethod("rocketchat.gateway.startAccount", (ctx) => { + return startGateway(ctx as unknown as Parameters[0]); + }); }, - register, - activate, -}; +}); + +const entry: OpenClawPluginDefinition & { channelPlugin: typeof rocketchatPlugin } = _entry; + +export default entry; diff --git a/src/plugin.ts b/src/plugin.ts index 4883463..d65c6f5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,79 +1,84 @@ +import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { RocketChatClient } from "./client.js"; import { startGateway, resolveAccount, listAccountIds, isConfigured, activeClients } from "./gateway.js"; -import type { OpenClawConfig } from "./types/types.js"; +import type { ResolvedAccount } from "./types/types.js"; export { startGateway, resolveAccount, listAccountIds, isConfigured }; -export const rocketchatPlugin = { - id: "rocketchat", - meta: { +export const rocketchatPlugin = createChatChannelPlugin({ + base: { id: "rocketchat", - label: "Rocket.Chat", - selectionLabel: "Rocket.Chat", - blurb: "Rocket.Chat channel plugin with REST polling outbound/inbound", - aliases: ["rc"], - }, - capabilities: { chatTypes: ["direct", "group", "channel"] }, - config: { - listAccountIds, - resolveAccount, - isConfigured, - }, - threading: { - topLevelReplyToMode: "reply" as const, - }, - messaging: { - targetPrefixes: ["rocketchat", "channel", "user", "@"], - normalizeTarget: (target: string): string | undefined => { - const trimmed = target?.trim(); - if (!trimmed) return undefined; - return trimmed.replace(/^rocketchat:(?:channel:|user:)?/i, "").replace(/^channel:/i, ""); + meta: { + id: "rocketchat", + label: "Rocket.Chat", + selectionLabel: "Rocket.Chat", + docsPath: "https://rocket.chat/docs", + blurb: "Rocket.Chat channel plugin with REST polling outbound/inbound", + aliases: ["rc"], }, - targetResolver: { - looksLikeId: (id: string): boolean => { - const trimmed = id?.trim(); - if (!trimmed) return false; - return /^[a-z0-9_]{4,32}$/i.test(trimmed) || /^rocketchat:/i.test(trimmed) || /^channel:/i.test(trimmed) || /^user:/i.test(trimmed) || /^@/.test(trimmed); + capabilities: { chatTypes: ["direct", "group", "channel"] }, + config: { + listAccountIds: listAccountIds as (cfg: OpenClawConfig) => string[], + resolveAccount: resolveAccount as (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount, + isConfigured: ((account: unknown, _cfg: OpenClawConfig): boolean => isConfigured(account as Parameters[0])) as (account: ResolvedAccount, cfg: OpenClawConfig) => boolean, + }, + messaging: { + targetPrefixes: ["rocketchat", "channel", "user", "@"], + normalizeTarget: (target: string): string | undefined => { + const trimmed = target?.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/^rocketchat:(?:channel:|user:)?/i, "").replace(/^channel:/i, ""); + }, + targetResolver: { + looksLikeId: (id: string): boolean => { + const trimmed = id?.trim(); + if (!trimmed) return false; + return /^[a-z0-9_]{4,32}$/i.test(trimmed) || /^rocketchat:/i.test(trimmed) || /^channel:/i.test(trimmed) || /^user:/i.test(trimmed) || /^@/.test(trimmed); + }, + hint: "", }, - hint: "", + }, + gateway: { + startAccount: (ctx: unknown) => startGateway(ctx as Parameters[0]), }, }, + threading: { + topLevelReplyToMode: "reply", + }, outbound: { - deliveryMode: "direct" as const, - resolveTarget: ({ to }: { to: string }) => { - const trimmed = to?.trim(); - if (!trimmed) return { ok: false as const, error: new Error("Rocket.Chat send requires a target id") }; - const normalized = trimmed.replace(/^rocketchat:(?:channel:|user:)?/i, "").replace(/^channel:/i, ""); - return { ok: true as const, to: normalized }; + base: { + deliveryMode: "direct", + resolveTarget: (params) => { + const to = params?.to?.trim(); + if (!to) return { ok: false as const, error: new Error("Rocket.Chat send requires a target id") }; + const normalized = to.replace(/^rocketchat:(?:channel:|user:)?/i, "").replace(/^channel:/i, ""); + return { ok: true as const, to: normalized }; + }, }, - sendText: async (params: { - cfg?: unknown; - accountId?: string; - to: string; - text: string; - replyToId?: string; - }): Promise<{ ok: boolean; messageId: string; channel: string }> => { - let account = resolveAccount(params.cfg ?? {}, params.accountId); - if (!account) { - const accounts = listAccountIds(params.cfg as OpenClawConfig); - if (accounts.length > 0) { - account = resolveAccount(params.cfg ?? {}, accounts[0]); + attachedResults: { + channel: "rocketchat", + sendText: async (ctx) => { + const accountId = ctx.accountId ?? undefined; + let account = resolveAccount(ctx.cfg as Parameters[0], accountId); + if (!account) { + const accounts = listAccountIds(ctx.cfg as Parameters[0]); + if (accounts.length > 0) { + account = resolveAccount(ctx.cfg as Parameters[0], accounts[0]); + } } - } - if (!account) throw new Error(`Unknown Rocket.Chat account: ${params.accountId}`); + if (!account) throw new Error(`Unknown Rocket.Chat account: ${ctx.accountId}`); - const entry = activeClients.get(account.accountId); - let client = entry?.client ?? null; - if (!client) { - client = new RocketChatClient({ serverUrl: account.serverUrl, auth: account.auth }); - } - const tmidOptions = params.replyToId ? { tmid: params.replyToId } : undefined; - const messageId = await client.postMessage(params.to, params.text, tmidOptions); - entry?.wakeup?.(); - return { ok: true, messageId, channel: "rocketchat" }; + const entry = activeClients.get(account.accountId); + let client = entry?.client ?? null; + if (!client) { + client = new RocketChatClient({ serverUrl: account.serverUrl, auth: account.auth }); + } + const tmidOptions = ctx.replyToId ? { tmid: ctx.replyToId } : undefined; + const messageId = await client.postMessage(ctx.to, ctx.text, tmidOptions); + entry?.wakeup?.(); + return { ok: true, messageId }; + }, }, }, - gateway: { - startAccount: startGateway, - }, -}; +}); diff --git a/src/types/types.ts b/src/types/types.ts index f00c43b..5ba0695 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,6 +1,18 @@ import type { PluginAccountConfig } from "../config.js"; export type { PluginConfig, PluginAccountConfig } from "../config.js"; +export type InboundAttachmentKind = "image" | "audio" | "document" | "video" | "unknown"; + +export type InboundAttachment = { + kind: InboundAttachmentKind; + mimeType?: string; + fileName?: string; + url?: string; + sizeBytes?: number; + source: "rocketchat-attachment" | "rocketchat-file"; + raw: unknown; +}; + export type RocketChatIdentity = { userId: string; authToken: string; @@ -34,6 +46,9 @@ export type RocketChatMessageRecord = { username?: string; name?: string; }>; + attachments?: unknown[]; + file?: unknown; + files?: unknown[]; }; export type RocketChatClientOptions = { @@ -72,6 +87,7 @@ export type InboundEvent = { senderName: string; text: string; mentions: string[]; + attachments: InboundAttachment[]; sentAt: string; raw: RocketChatMessageRecord; }; @@ -105,6 +121,7 @@ export type OutboundReplyPayload = { text?: string; mediaUrl?: string; mediaUrls?: string[]; + attachmentPath?: string; replyToId?: string; }; @@ -199,4 +216,21 @@ export type RCUser = { name: string; }; +export type AttachmentRecord = { + _id?: string; + title?: string; + title_link?: string; + url?: string; + image_url?: string; + video_url?: string; + audio_url?: string; + type?: string; + mimeType?: string; + mimetype?: string; + contentType?: string; + name?: string; + filename?: string; + size?: number; +}; +