From a78d72e7223d723aa844b32576dad9c95c751785 Mon Sep 17 00:00:00 2001 From: dodaa08 Date: Mon, 8 Jun 2026 00:14:10 +0530 Subject: [PATCH] Added all rocket chat rest api's and plugin core --- .gitignore | 1 + openclaw.plugin.json | 200 ++++++++++------ package.json | 7 +- src/client.ts | 527 +++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 77 +++++-- src/index.ts | 228 +++---------------- src/plugin.ts | 151 +++++++++++++ src/types/types.ts | 89 +++++++- 8 files changed, 983 insertions(+), 297 deletions(-) create mode 100644 src/client.ts create mode 100644 src/plugin.ts diff --git a/.gitignore b/.gitignore index bc0a348..8e8eab9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ package-lock.json node_modules dist *.txt +*.mjs \ No newline at end of file diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 89c89c4..adff473 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -1,72 +1,140 @@ { - "id": "rocketchat", - "name": "RocketChat Webhook", - "version": "0.1.0", - "description": "Rocket.Chat integration for OpenClaw", - "type": "channel", - "channels": [ - "rocketchat" - ], - "configSchema": { - "RC_URL": { - "type": "string", - "description": "Rocket.Chat server URL", - "default": "http://localhost:3000" - }, - "RC_AUTH_TOKEN": { - "type": "string", - "description": "Rocket.Chat bot auth token", - "secret": true - }, - "RC_USER_ID": { - "type": "string", - "description": "Rocket.Chat bot user ID" - }, - "DEFAULT_ROOM": { - "type": "string", - "description": "Default room ID to send messages to", - "default": "GENERAL" - }, - "RC_WEBHOOK_SECRET": { - "type": "string", - "description": "Secret token to validate incoming webhooks", - "secret": true - } - }, - "channelConfigs": { - "rocketchat": { - "schema": { - "RC_URL": { - "type": "string", - "description": "Rocket.Chat server URL", - "default": "http://localhost:3000" - }, - "RC_AUTH_TOKEN": { - "type": "string", - "description": "Rocket.Chat bot auth token", - "secret": true + "id": "rocketchat", + "name": "Rocket.Chat", + "description": "Rocket.Chat channel plugin with REST polling outbound/inbound", + "version": "1.0.0", + "kind": "channel", + "channels": ["rocketchat"], + "channelConfigs": { + "rocketchat": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "accounts": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "serverUrl": { "type": "string", "minLength": 1 }, + "auth": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "const": "token" }, + "userId": { "type": "string", "minLength": 1 }, + "accessToken": { "type": "string", "minLength": 1 } + }, + "required": ["mode", "userId", "accessToken"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "const": "password" }, + "username": { "type": "string", "minLength": 1 }, + "password": { "type": "string", "minLength": 1 } + }, + "required": ["mode", "username", "password"] + } + ] }, - "RC_USER_ID": { - "type": "string", - "description": "Rocket.Chat bot user ID" + "transport": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "const": "polling" }, + "pollIntervalMs": { "type": "integer", "minimum": 1000 } + }, + "required": ["mode"] + } + ] }, - "DEFAULT_ROOM": { - "type": "string", - "description": "Default room ID to send messages to", - "default": "GENERAL" + "mentionNames": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "forceThread": { "type": "boolean", "default": true }, + "agent": { "type": "string", "minLength": 1 } + }, + "required": ["enabled", "serverUrl", "auth"] + } + } + } + }, + "uiHints": { + "accounts.*.serverUrl": { "label": "Server URL", "placeholder": "https://chat.example.com" }, + "accounts.*.auth.userId": { "label": "User ID" }, + "accounts.*.auth.accessToken": { "label": "Access Token", "sensitive": true }, + "accounts.*.auth.username": { "label": "Username" }, + "accounts.*.auth.password": { "label": "Password", "sensitive": true }, + "accounts.*.transport.pollIntervalMs": { "label": "Polling Interval (ms)", "placeholder": "3000" } + } + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "accounts": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "serverUrl": { "type": "string", "minLength": 1 }, + "auth": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "const": "token" }, + "userId": { "type": "string", "minLength": 1 }, + "accessToken": { "type": "string", "minLength": 1 } + }, + "required": ["mode", "userId", "accessToken"] }, - "RC_WEBHOOK_SECRET": { - "type": "string", - "description": "Secret token to validate incoming webhooks", - "secret": true + { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "const": "password" }, + "username": { "type": "string", "minLength": 1 }, + "password": { "type": "string", "minLength": 1 } + }, + "required": ["mode", "username", "password"] } - } + ] + }, + "transport": { + "type": "object", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { "const": "polling" }, + "pollIntervalMs": { "type": "integer", "minimum": 1000 } + }, + "required": ["mode"] + } + ] + }, + "mentionNames": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "forceThread": { "type": "boolean", "default": true }, + "agent": { "type": "string", "minLength": 1 } + }, + "required": ["enabled", "serverUrl", "auth"] } - }, - "configuration": { - "channels": [ - "rocketchat" - ] - }, - "entry": "dist/index.js" -} \ No newline at end of file + } + } + } +} diff --git a/package.json b/package.json index b078ad4..d77f889 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "openclaw": { "extensions": [ - "./dist/index.js" + "./src/index.ts" ] }, "type": "module", @@ -31,6 +31,7 @@ "typescript": "^6.0.3" }, "dependencies": { - "dotenv": "^17.4.2" + "dotenv": "^17.4.2", + "zod": "^4.4.3" } -} \ No newline at end of file +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..39b0bdc --- /dev/null +++ b/src/client.ts @@ -0,0 +1,527 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, parse } from "node:path"; +import { randomUUID } from "node:crypto"; + +import type { + PluginAccountConfig, + RocketChatIdentity, + RocketChatSubscriptionRecord, + RoomInfo, + RocketChatAttachmentRecord, + RocketChatFileRecord, + RocketChatMessageRecord, + RocketChatClientOptions, + JsonObject +} from "./types/types.js"; + +export class RocketChatClientError extends Error { + constructor(message: string) { + super(message); + this.name = "RocketChatClientError"; + } +} + +export class RocketChatRateLimitError extends RocketChatClientError { + readonly retryAfterMs: number; + + constructor(message: string, options: { retryAfterMs: number }) { + super(message); + this.name = "RocketChatRateLimitError"; + this.retryAfterMs = options.retryAfterMs; + } +} + +export class RocketChatClient { + private readonly serverUrl: string; + private readonly auth: PluginAccountConfig["auth"]; + private readonly mediaDir: string; + private readonly fetchImpl: typeof fetch; + private identity: RocketChatIdentity | null = null; + + constructor(options: RocketChatClientOptions) { + this.serverUrl = options.serverUrl.replace(/\/+$/, ""); + this.auth = options.auth; + this.mediaDir = options.mediaDir?.trim() || tmpdir(); + this.fetchImpl = options.fetch ?? fetch; + } + + async initialize(): Promise { + if (this.identity) { + return this.identity; + } + + this.identity = + this.auth.mode === "password" ? await this.loginWithPassword() : await this.verifyToken(); + + return this.identity; + } + + async listSubscriptions(updatedSince: string | null): Promise { + await this.initialize(); + const url = new URL("/api/v1/subscriptions.get", this.serverUrl); + if (updatedSince) { + url.searchParams.set("updatedSince", updatedSince); + } + + const payload = await this.requestJson(url, { + method: "GET" + }); + + return Array.isArray(payload.update) ? payload.update : []; + } + + async listRooms(): Promise { + const subscriptions = await this.listSubscriptions(null); + return subscriptions.map((sub) => ({ + id: sub.rid, + name: sub.fname || sub.name || sub.rid, + type: mapSubscriptionType(sub.t) + })); + } + + async syncMessages( + roomId: string, + updatedSince: string | null + ): Promise { + await this.initialize(); + const url = new URL("/api/v1/chat.syncMessages", this.serverUrl); + url.searchParams.set("roomId", roomId); + if (updatedSince) { + url.searchParams.set("lastUpdate", updatedSince); + } + + const payload = await this.requestJson(url, { + method: "GET" + }); + + const result = asObject(payload.result ?? {}); + return Array.isArray(result.updated) ? result.updated : []; + } + + async postMessage(roomId: string, text: string, options?: { tmid?: string }): Promise { + await this.initialize(); + const body: Record = { roomId, text }; + if (options?.tmid) { + body.tmid = options.tmid; + } + const payload = await this.requestJson(new URL("/api/v1/chat.postMessage", this.serverUrl), { + method: "POST", + body: JSON.stringify(body) + }); + + const message = asObject(payload.message); + return getString(message, "_id"); + } + + async updateMessage(roomId: string, messageId: string, text: string): Promise { + await this.initialize(); + await this.requestJson(new URL("/api/v1/chat.update", this.serverUrl), { + method: "POST", + body: JSON.stringify({ + roomId, + msgId: messageId, + text + }) + }); + } + + async getMessage(messageId: string): Promise<{ + id: string; + text: string; + username: string; + ts: string; + tmid: string | null; + } | null> { + await this.initialize(); + try { + const url = new URL("/api/v1/chat.getMessage", this.serverUrl); + url.searchParams.set("msgId", messageId); + const payload = await this.requestJson(url, { method: "GET" }); + const message = asOptionalObject(payload.message); + if (!message) { + return null; + } + const user = asOptionalObject(message.u) ?? {}; + const id = getOptionalString(message, "_id"); + if (!id) { + return null; + } + return { + id, + text: getOptionalString(message, "msg") ?? "", + username: getOptionalString(user, "username") ?? "(unknown)", + ts: getOptionalString(message, "ts") ?? "", + tmid: getOptionalString(message, "tmid") + }; + } catch (error) { + return null; + } + } + + async getThreadMessages( + tmid: string, + count: number + ): Promise> { + await this.initialize(); + try { + const url = new URL("/api/v1/chat.getThreadMessages", this.serverUrl); + url.searchParams.set("tmid", tmid); + url.searchParams.set("count", String(count)); + const payload = await this.requestJson(url, { method: "GET" }); + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const parsed: Array<{ id: string; text: string; username: string; ts: string }> = []; + for (const raw of messages) { + const m = asOptionalObject(raw); + if (!m) continue; + const id = getOptionalString(m, "_id"); + if (!id) continue; + const user = asOptionalObject(m.u) ?? {}; + parsed.push({ + id, + text: getOptionalString(m, "msg") ?? "", + username: getOptionalString(user, "username") ?? "(unknown)", + ts: getOptionalString(m, "ts") ?? "" + }); + } + return parsed.reverse(); + } catch (error) { + return []; + } + } + + async deleteMessage(roomId: string, messageId: string): Promise { + await this.initialize(); + await this.requestJson(new URL("/api/v1/chat.delete", this.serverUrl), { + method: "POST", + body: JSON.stringify({ + roomId, + msgId: messageId, + asUser: true + }) + }); + } + + async downloadAttachmentToTempFile( + url: string, + options?: { fileName?: string } + ): Promise { + await this.initialize(); + const requestUrl = resolveRequestUrl(url, this.serverUrl); + + const response = await this.fetchImpl(requestUrl, { + method: "GET", + headers: { + Accept: "*/*", + ...this.authHeaders() + } + }); + + if (!response.ok) { + throw new RocketChatClientError(`Rocket.Chat attachment download failed: ${response.statusText}`); + } + + const inboundDir = join(this.mediaDir, "inbound"); + await mkdir(inboundDir, { recursive: true }); + const filePath = join( + inboundDir, + buildStoredAttachmentFileName(resolveAttachmentFileName(requestUrl, options?.fileName)) + ); + 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.initialize(); + + 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.fetchImpl( + new URL(`/api/v1/rooms.media/${encodeURIComponent(roomId)}`, this.serverUrl).toString(), + { + method: "POST", + headers: this.authHeaders(), + body: formData + } + ); + + if (!uploadResponse.ok) { + throw new RocketChatClientError(`Rocket.Chat attachment upload failed: ${uploadResponse.statusText}`); + } + + const uploadPayload = await this.parseJsonResponse(uploadResponse); + const file = asOptionalObject(uploadPayload.file); + if (!file) { + throw new RocketChatClientError("Rocket.Chat attachment upload response missing file id"); + } + + const fileId = getString(file, "_id"); + const confirmResponse = await this.fetchImpl( + new URL( + `/api/v1/rooms.mediaConfirm/${encodeURIComponent(roomId)}/${encodeURIComponent(fileId)}`, + this.serverUrl + ).toString(), + { + method: "POST", + headers: this.authHeaders() + } + ); + + if (!confirmResponse.ok) { + throw new RocketChatClientError( + `Rocket.Chat attachment confirm failed: ${confirmResponse.statusText}` + ); + } + + const confirmPayload = await this.parseJsonResponse(confirmResponse); + const message = asOptionalObject(confirmPayload.message); + if (message) { + return getString(message, "_id"); + } + + throw new RocketChatClientError("Rocket.Chat attachment confirm response missing message id"); + } + + private async loginWithPassword(): Promise { + if (this.auth.mode !== "password") { + throw new RocketChatClientError("Password login requested for a token-auth client"); + } + + const response = await this.fetchImpl(new URL("/api/v1/login", this.serverUrl), { + method: "POST", + headers: this.baseHeaders(), + body: JSON.stringify({ + user: this.auth.username, + password: this.auth.password + }) + }); + const payload = await this.parseJsonResponse(response); + const data = asObject(payload.data); + const me = asObject(data.me); + + return { + userId: getString(data, "userId"), + authToken: getString(data, "authToken"), + username: getString(me, "username"), + displayName: getOptionalString(me, "name") ?? getString(me, "username") + }; + } + + private async verifyToken(): Promise { + if (this.auth.mode !== "token") { + throw new RocketChatClientError("Token verification requested for a password-auth client"); + } + + const payload = await this.requestJson(new URL("/api/v1/me", this.serverUrl), { + method: "GET" + }); + const user = asObject(payload.user ?? payload.me ?? payload); + + return { + userId: this.auth.userId, + authToken: this.auth.accessToken, + username: getString(user, "username"), + displayName: getOptionalString(user, "name") ?? getString(user, "username") + }; + } + + private async requestJson(url: URL, init: RequestInit): Promise { + const response = await this.fetchImpl(url.toString(), { + ...init, + headers: { + ...this.baseHeaders(), + ...this.authHeaders(), + ...(init.headers ?? {}) + } + }); + + return this.parseJsonResponse(response); + } + + private async parseJsonResponse(response: Response): Promise { + const payload = (await response.json()) as JsonObject; + + if (response.status === 429 || payload.errorType === "error-too-many-requests") { + throw new RocketChatRateLimitError(getErrorMessage(payload, "Rocket.Chat API rate limited"), { + retryAfterMs: getRetryAfterMs(response, payload) + }); + } + + if (!response.ok) { + throw new RocketChatClientError(getErrorMessage(payload, response.statusText)); + } + + if (payload.success === false || payload.status === "error") { + throw new RocketChatClientError(getErrorMessage(payload, "Rocket.Chat API request failed")); + } + + return payload; + } + + private baseHeaders(): Record { + return { + "Content-Type": "application/json", + Accept: "application/json" + }; + } + + private authHeaders(): Record { + if (this.auth.mode === "token") { + return { + "X-User-Id": this.auth.userId, + "X-Auth-Token": this.auth.accessToken + }; + } + + if (!this.identity) { + throw new RocketChatClientError("Client is not authenticated"); + } + + return { + "X-User-Id": this.identity.userId, + "X-Auth-Token": this.identity.authToken + }; + } +} + +function asObject(value: unknown): JsonObject { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as JsonObject; + } + + throw new RocketChatClientError("Rocket.Chat API returned an invalid payload"); +} + +function asOptionalObject(value: unknown): JsonObject | null { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as JsonObject; + } + + return null; +} + +function getString(object: JsonObject, key: string): string { + const value = object[key]; + if (typeof value === "string" && value.length > 0) { + return value; + } + + throw new RocketChatClientError(`Rocket.Chat API payload missing "${key}"`); +} + +function getOptionalString(object: JsonObject, key: string): string | null { + const value = object[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +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; +} + +function getRetryAfterMs(response: Response, payload: JsonObject): number { + const retryAfterHeader = response.headers.get("Retry-After"); + if (retryAfterHeader) { + const retryAfterSeconds = Number.parseInt(retryAfterHeader, 10); + if (Number.isInteger(retryAfterSeconds) && retryAfterSeconds > 0) { + return retryAfterSeconds * 1000; + } + } + + const message = getErrorMessage(payload, ""); + const match : any = message.match(/wait\s+(\d+)\s+seconds/i); + if (match) { + const retryAfterSeconds = Number.parseInt(match[1], 10); + if (Number.isInteger(retryAfterSeconds) && retryAfterSeconds > 0) { + return retryAfterSeconds * 1000; + } + } + + return 30_000; +} + +function resolveAttachmentFileName(url: string, fileName: string | undefined): string { + const preferredName = fileName?.trim(); + if (preferredName) { + return sanitizeFileName(preferredName); + } + + try { + const pathName = new URL(url).pathname; + const candidate = pathName.split("/").filter(Boolean).at(-1); + if (candidate) { + return sanitizeFileName(decodeURIComponent(candidate)); + } + } catch { + return "attachment"; + } + + return "attachment"; +} + +function resolveRequestUrl(url: string, serverUrl: string): string { + try { + return new URL(url).toString(); + } catch { + return new URL(url, serverUrl).toString(); + } +} + +function buildStoredAttachmentFileName(fileName: string): string { + const parsed = parse(fileName); + const baseName = sanitizeFileName(parsed.name); + const extension = sanitizeExtension(parsed.ext); + + if (!baseName) { + return `${randomUUID()}${extension}`; + } + + return `${baseName}---${randomUUID()}${extension}`; +} + +function sanitizeFileName(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]+/g, "-") || "attachment"; +} + +function sanitizeExtension(value: string): string { + if (!value) { + return ""; + } + + return value.replace(/[^a-zA-Z0-9.]+/g, ""); +} + +function mapSubscriptionType(type: string | undefined): RoomInfo["type"] { + if (type === "d") { + return "direct"; + } + + if (type === "p") { + return "group"; + } + + return "channel"; +} diff --git a/src/config.ts b/src/config.ts index b23a57f..83a9a93 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,23 +1,60 @@ -import * as dotenv from 'dotenv'; -import type { RocketChatConfig } from './types/types.js'; - -dotenv.config(); - -export function getConfig(): RocketChatConfig { - const config = { - url: process.env.RC_URL || "http://localhost:3000", - authToken: process.env.RC_AUTH_TOKEN || "", - userId: process.env.RC_USER_ID || "", - defaultRoom: process.env.DEFAULT_ROOM || "GENERAL", - webhookSecret: process.env.RC_WEBHOOK_SECRET || "", - }; - - if (!config.authToken) { - console.warn("[RC Config] Warning: RC_AUTH_TOKEN is not set."); - } - if (!config.userId) { - console.warn("[RC Config] Warning: RC_USER_ID is not set."); +import { z } from "zod"; + +const tokenAuthSchema = z.object({ + mode: z.literal("token"), + userId: z.string().min(1), + accessToken: z.string().min(1) +}).strict(); + +const passwordAuthSchema = z.object({ + mode: z.literal("password"), + username: z.string().min(1), + password: z.string().min(1) +}).strict(); + +const transportSchema = z.preprocess( + (value) => value ?? { mode: "polling" }, + z.object({ + mode: z.literal("polling"), + pollIntervalMs: z.number().int().min(1000).default(3000) + }).strict() +); + +const accountSchema = z.object({ + enabled: z.boolean(), + serverUrl: z.string().min(1), + auth: z.union([tokenAuthSchema, passwordAuthSchema]), + transport: transportSchema, + mentionNames: z.array(z.string().min(1)).default([]), + forceThread: z.boolean().default(true), + agent: z.string().min(1).optional(), + transcribeAudio: z.boolean().default(true) +}).strict(); + +const pluginConfigSchema = z.object({ + accounts: z.record(z.string().min(1), accountSchema) +}).strict(); + +export type PluginConfig = z.infer; +export type PluginAccountConfig = PluginConfig["accounts"][string]; + +function substituteEnvVars(value: unknown, env: NodeJS.ProcessEnv = process.env): unknown { + if (typeof value === "string") { + return value.replace(/\$\{([A-Z0-9_]+)\}/gi, (_match, name) => env[name] ?? ""); + } + if (Array.isArray(value)) { + return value.map((item) => substituteEnvVars(item, env)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = substituteEnvVars(v, env); } + return out; + } + return value; +} - return config; +export function parsePluginConfig(input: unknown): PluginConfig { + return pluginConfigSchema.parse(substituteEnvVars(input)); } diff --git a/src/index.ts b/src/index.ts index c68f48c..a6d6ae2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,207 +1,33 @@ -import { getConfig } from "./config.js"; -const configvars = getConfig(); +import { rocketchatPlugin, startGateway, listAccountIds, resolveAccount } from "./plugin.js"; -export default function register(api: any): void { - const logger = api.logger || { - info: (msg: string) => console.log(`[RC] ${msg}`), - error: (msg: string) => console.error(`[RC] ${msg}`), - }; +type GatewayApi = { + registerGatewayMethod(name: string, handler: (ctx: unknown) => Promise): void; + registerChannel?(args: { plugin: unknown }): void; +}; - const config = { - url: configvars.url || "http://localhost:3000", - authToken: configvars.authToken || "", - userId: configvars.userId || "", - defaultRoom: configvars.defaultRoom || "GENERAL", - webhookSecret: configvars.webhookSecret || "", - }; - - if (!config.webhookSecret) { - console.warn("[RC Config] Warning: RC_WEBHOOK_SECRET is not set — webhook auth disabled."); - } - - - logger.info("Initializing Unified Rocket.Chat Plugin..."); - logger.info(`[Config] RC_URL: ${config.url}`); - logger.info(`[Config] RC_USER_ID: ${config.userId || "NOT SET"}`); - logger.info(`[Config] RC_AUTH_TOKEN: ${config.authToken ? config.authToken.slice(0, 6) + "..." : "NOT SET"}`); - logger.info(`[Config] DEFAULT_ROOM: ${config.defaultRoom || "NOT SET"}`); - - api.registerChannel({ - plugin: { - id: "rocketchat", - meta: { - id: "rocketchat", - label: "Rocket.Chat", - selectionLabel: "Rocket.Chat", - blurb: "Unified Rocket.Chat Plugin with Inbound Webhook and Outbound REST", - aliases: ["rc"], - }, - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: (_cfg: any) => ["default"], - resolveAccount: (_cfg: any, accountId?: string) => ({ - accountId: accountId || "default", - }), - }, - outbound: { - deliveryMode: "direct" as const, - resolveTarget: ({ to }: { to: string }) => { - const target = (to && to.trim()) ? to.trim() : config.defaultRoom; - return { ok: true, to: target }; - }, - sendText: async (ctx: { to: string; text: string; accountId?: string; threadId?: string | number | null }) => { - try { - const room = ctx.to || config.defaultRoom; - const payload: any = { rid: room, msg: ctx.text }; - if (ctx.threadId) { - payload.tmid = String(ctx.threadId); - } - - const res = await fetch(`${config.url}/api/v1/chat.sendMessage`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Auth-Token": config.authToken, - "X-User-Id": config.userId, - }, - body: JSON.stringify({ message: payload }), - }); - - if (!res.ok) { - const body = await res.text(); - logger.error(`Outbound failed: ${res.status} ${body}`); - return { ok: false, channel: "rocketchat" }; - } - - return { ok: true, channel: "rocketchat" }; - } catch (err) { - logger.error(`Outbound error: ${(err as Error).message}`); - return { ok: false, channel: "rocketchat" }; - } - }, - }, - gateway: { - startAccount: async (ctx: any) => { - ctx.setStatus({ accountId: ctx.account?.accountId ?? "default", state: "connected" }); - return new Promise(() => { }); - }, - }, - }, - }); - - if (api.registerHttpRoute) { - api.registerHttpRoute({ - method: "POST", - path: "/rocketchat/webhook", - auth: "plugin", - handler: async (req: any, res: any) => { - try { - let body: any = (req.body && Object.keys(req.body).length > 0) ? req.body : null; -if (!body) { - const rawBody = await new Promise((resolve, reject) => { - let data = ""; - req.on("data", (chunk: any) => { data += chunk; }); - req.on("end", () => resolve(data)); - req.on("error", reject); - }); - try { body = rawBody ? JSON.parse(rawBody) : {}; } catch { body = {}; } +export function register(api: GatewayApi) { + api.registerChannel?.({ plugin: rocketchatPlugin }); } - logger.info("[Webhook] Incoming payload received"); - logger.info(`[Webhook] user_id: ${body.user_id}`); - logger.info(`[Webhook] user_name: ${body.user_name}`); - logger.info(`[Webhook] channel_id: ${body.channel_id}`); - logger.info(`[Webhook] channel_name: ${body.channel_name}`); - logger.info(`[Webhook] text: ${body.text}`); - logger.info(`[Webhook] message_id: ${body.message_id}`); - logger.info(`[Webhook] bot: ${body.bot}`); - logger.info(`[Webhook] tmid: ${body.tmid}`); - - // ignore bot self-messages - if (body.bot || body.user_id === config.userId) { - logger.info("[Webhook] Skipping bot message"); - res.statusCode = 200; - res.end(JSON.stringify({ success: true })); - return; - } - - // dispatch into OpenClaw - await api.scheduleSessionTurn({ - channel: "rocketchat", - accountId: "default", - to: body.channel_id || config.defaultRoom, - from: body.user_name, - text: body.text ?? "", - threadId: body.tmid ?? null, - messageId: body.message_id, - }); - - - // New API per sdk-channel-inbound docs. Use this once scheduleSessionTurn - // is confirmed removed or broken. Replace approach 1 with this block. - // - // await api.runtime.channel.inbound.run({ - // channel: "rocketchat", - // accountId: "default", - // raw: body, - // adapter: { - // // ingest: normalize the raw RC webhook payload into OpenClaw's - // // inbound message shape expected by the agent layer. - // ingest: (raw: any) => ({ - // id: raw.message_id ?? `${Date.now()}`, - // rawText: raw.text ?? "", - // textForAgent: raw.text ?? "", - // textForCommands: raw.text ?? "", - // from: raw.user_name, - // to: raw.channel_id || config.defaultRoom, - // threadId: raw.tmid ?? null, - // raw, - // }), - // // resolveTurn: assemble the full turn context for the agent — - // // routing, session store path, reply target, and delivery fn. - // // Signature and required fields TBD from channel-ingress docs: - // // https://docs.openclaw.ai/plugins/sdk-channel-ingress - // resolveTurn: (input: any) => { - // const room = body.channel_id || config.defaultRoom; - // return { - // // TODO: fill once ingress API shape is confirmed - // delivery: { - // deliver: async (payload: any) => { - // const text = payload.text ?? payload.message ?? ""; - // const sendRes = await fetch(`${config.url}/api/v1/chat.sendMessage`, { - // method: "POST", - // headers: { - // "Content-Type": "application/json", - // "X-Auth-Token": config.authToken, - // "X-User-Id": config.userId, - // }, - // body: JSON.stringify({ message: { rid: room, msg: text } }), - // }); - // if (!sendRes.ok) { - // const errBody = await sendRes.text().catch(() => ""); - // logger.error(`[Delivery] Failed: ${sendRes.status} ${errBody}`); - // } - // }, - // }, - // }; - // }, - // }, - // }); - - - logger.info("[Webhook] Dispatched to OpenClaw via scheduleSessionTurn"); - - res.statusCode = 200; - res.end(JSON.stringify({ success: true })); - } catch (err) { - logger.error(`[Webhook] Error: ${(err as Error).message}`); - res.statusCode = 500; - res.end(JSON.stringify({ error: "Internal Server Error" })); - } -}, - }); - logger.info("Registered Inbound Webhook at /rocketchat/webhook"); +export function activate(api: GatewayApi) { + api.registerGatewayMethod("rocketchat.gateway.startAccount", (ctx) => { + return startGateway(ctx as Parameters[0]); + }); } - logger.info("Rocket.Chat Unified Plugin initialization complete."); -} +export default { + id: "rocketchat", + name: "Rocket.Chat", + description: "Rocket.Chat channel plugin with REST polling outbound/inbound", + plugin: rocketchatPlugin, + config: { + listAccountIds, + resolveAccount, + isConfigured(account: unknown) { + const a = account as { serverUrl?: string; auth?: unknown } | null | undefined; + return Boolean(a?.serverUrl && a.auth); + } + }, + register, + activate, +}; diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..f3830ce --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,151 @@ +import { RocketChatClient } from "./client.js"; +import { parsePluginConfig, type PluginConfig, type PluginAccountConfig } from "./config.js"; + +export type ResolvedAccount = PluginAccountConfig & { + accountId: string; +}; + +export type OpenClawConfig = { + session?: { store?: string }; + channels?: { rocketchat?: unknown }; +}; + +export let logger: any = null; + +export function resolveAccount(cfg: unknown, accountId?: string): ResolvedAccount | null { + const config = parseChannelConfig(cfg as OpenClawConfig); + if (!accountId) return null; + const account = config.accounts[accountId]; + return account ? { ...account, accountId } : null; +} + +export function listAccountIds(cfg: OpenClawConfig): string[] { + return Object.keys(parseChannelConfig(cfg).accounts); +} + +function isConfigured(account: Partial | null | undefined): boolean { + return Boolean(account?.serverUrl && account.auth); +} + +export async function startGateway(ctx: { + cfg: OpenClawConfig; + accountId: string; + abortSignal: AbortSignal; + setStatus?: (s: { accountId: string; state: string }) => void; + log?: { info: (m: string) => void; error: (m: string) => void }; +}): Promise { + const account = resolveAccount(ctx.cfg ?? {}, ctx.accountId); + if (!account || !account.enabled) { + ctx.setStatus?.({ accountId: ctx.accountId ?? "default", state: "disabled" }); + return; + } + + const client = new RocketChatClient({ + serverUrl: account.serverUrl, + auth: account.auth, + }); + await client.initialize(); + ctx.setStatus?.({ accountId: account.accountId, state: "connected" }); + + await new Promise((resolve) => { + if (ctx.abortSignal.aborted) return resolve(); + ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +export const rocketchatPlugin = { + id: "rocketchat", + meta: { + id: "rocketchat", + label: "Rocket.Chat", + selectionLabel: "Rocket.Chat", + blurb: "Rocket.Chat channel plugin with REST polling outbound/inbound", + aliases: ["rc"], + }, + capabilities: { chatTypes: ["direct", "group"] }, + 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, ""); + }, + 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: "", + }, + }, + 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 }; + }, + 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]); + } + } + if (!account) throw new Error(`Unknown Rocket.Chat account: ${params.accountId}`); + + const client = new RocketChatClient({ + serverUrl: account.serverUrl, + auth: account.auth, + }); + await client.initialize(); + const tmidOptions = params.replyToId ? { tmid: params.replyToId } : undefined; + const messageId = await client.postMessage(params.to, params.text, tmidOptions); + return { ok: true, messageId, channel: "rocketchat" }; + }, + }, + gateway: { + startAccount: startGateway, + }, +}; + +export const registerPlugin = (api: any) => { + logger = api.logger || { + info: (msg: string) => console.log(`[RC] ${msg}`), + error: (msg: string) => console.error(`[RC] ${msg}`), + }; + + try { + api.registerChannel({ plugin: rocketchatPlugin }); + } catch (error) { + // already registered + } +}; + +function parseChannelConfig(cfg: OpenClawConfig): ReturnType { + const nestedConfig = cfg.channels?.rocketchat; + if (nestedConfig) return parsePluginConfig(nestedConfig); + if (isPluginConfigLike(cfg)) return parsePluginConfig(cfg); + return { accounts: {} }; +} + +function isPluginConfigLike(input: unknown): input is Parameters[0] { + return Boolean(input && typeof input === "object" && "accounts" in input); +} diff --git a/src/types/types.ts b/src/types/types.ts index a77e998..01ffd55 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,7 +1,82 @@ -export interface RocketChatConfig { - url: string; - authToken: string; - userId: string; - defaultRoom: string; - webhookSecret: string; -} +import type { PluginAccountConfig } from "../config.js"; +export type { PluginConfig, PluginAccountConfig } from "../config.js"; + +export type RocketChatIdentity = { + userId: string; + authToken: string; + username: string; + displayName: string; +}; + +export type RocketChatSubscriptionRecord = { + rid: string; + name?: string; + fname?: string; + t?: string; + _updatedAt?: string; + updatedAt?: string; +}; + +export type RoomInfo = { + id: string; + name: string; + type: "direct" | "group" | "channel"; +}; + +export type RocketChatAttachmentRecord = { + title?: string; + title_link?: string; + description?: string; + image_url?: string; + video_url?: string; + audio_url?: string; + type?: string; + mimeType?: string; + mimetype?: string; + contentType?: string; + name?: string; + filename?: string; + size?: number; +}; + +export type RocketChatFileRecord = { + _id?: string; + name?: string; + type?: string; + mimeType?: string; + mimetype?: string; + size?: number; + url?: string; + title_link?: string; +}; + +export type RocketChatMessageRecord = { + _id: string; + rid: string; + msg?: string; + ts?: string; + _updatedAt?: string; + t?: string; + tmid?: string; + u?: { + _id?: string; + username?: string; + name?: string; + }; + mentions?: Array<{ + username?: string; + name?: string; + }>; + attachments?: RocketChatAttachmentRecord[]; + file?: RocketChatFileRecord; + files?: RocketChatFileRecord[]; +}; + +export type RocketChatClientOptions = { + serverUrl: string; + auth: PluginAccountConfig["auth"]; + mediaDir?: string; + fetch?: typeof fetch; +}; + +export type JsonObject = Record;