From 706744cc9e040570b2ee03dd716a081d5e0cbd2a Mon Sep 17 00:00:00 2001 From: dodaa08 Date: Tue, 16 Jun 2026 00:12:13 +0530 Subject: [PATCH] Addded auth window --- openclaw.plugin.json | 8 +- package.json | 2 + src/cli/config-updater.ts | 65 +++++++++++++++ src/cli/credential-store.ts | 60 ++++++++++++++ src/cli/setup.ts | 152 ++++++++++++++++++++++++++++++++++++ src/client.ts | 97 ++++++++++++++++++++++- src/index.ts | 7 +- src/plugin.ts | 18 +++-- src/types/types.ts | 29 +++++++ 9 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 src/cli/config-updater.ts create mode 100644 src/cli/credential-store.ts create mode 100644 src/cli/setup.ts diff --git a/openclaw.plugin.json b/openclaw.plugin.json index e16f5f7..09adc19 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -62,17 +62,13 @@ "transcribeAudio": { "type": "boolean", "default": true }, "agent": { "type": "string", "minLength": 1 } }, - "required": ["enabled", "serverUrl", "auth"] + "required": ["enabled", "serverUrl"] } } } }, "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" } } } @@ -132,7 +128,7 @@ "transcribeAudio": { "type": "boolean", "default": true }, "agent": { "type": "string", "minLength": 1 } }, - "required": ["enabled", "serverUrl", "auth"] + "required": ["enabled", "serverUrl"] } } } diff --git a/package.json b/package.json index 79a57c8..3cc5438 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", + "setup": "node --import tsx src/cli/setup.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { @@ -28,6 +29,7 @@ "devDependencies": { "@types/node": "^25.9.1", "ts-node": "^10.9.2", + "tsx": "^4.22.4", "typescript": "^6.0.3" }, "dependencies": { diff --git a/src/cli/config-updater.ts b/src/cli/config-updater.ts new file mode 100644 index 0000000..33df2c7 --- /dev/null +++ b/src/cli/config-updater.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import type { AuthCredentials } from "../types/types.js"; + +const OC_CONFIG_PATH = resolve(homedir(), ".openclaw", "openclaw.json"); + +type OcConfig = Record; + +function readConfig(): OcConfig { + if (!existsSync(OC_CONFIG_PATH)) return {}; + return JSON.parse(readFileSync(OC_CONFIG_PATH, "utf-8")); +} + +function writeConfig(cfg: OcConfig): void { + const tmp = OC_CONFIG_PATH + ".tmp"; + writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); + renameSync(tmp, OC_CONFIG_PATH); +} + +export function updateConfig(opts: { + pluginPath: string; + pluginId: string; + accountId: string; + serverUrl: string; + transport?: { mode: string; pollIntervalMs?: number }; + mentionNames?: string[]; + auth: AuthCredentials; +}) { + const cfg = readConfig() as Record; + + if (!cfg.plugins) cfg.plugins = {}; + if (!cfg.plugins.load) cfg.plugins.load = {}; + if (!cfg.plugins.load.paths) cfg.plugins.load.paths = []; + if (!cfg.plugins.load.paths.includes(opts.pluginPath)) { + cfg.plugins.load.paths.push(opts.pluginPath); + } + if (!cfg.plugins.allow) cfg.plugins.allow = []; + if (!cfg.plugins.allow.includes(opts.pluginId)) { + cfg.plugins.allow.push(opts.pluginId); + } + + if (!cfg.channels) cfg.channels = {}; + if (!cfg.channels.rocketchat) cfg.channels.rocketchat = {}; + if (!cfg.channels.rocketchat.accounts) cfg.channels.rocketchat.accounts = {}; + + cfg.channels.rocketchat.accounts[opts.accountId] = { + enabled: true, + serverUrl: opts.serverUrl, + auth: { mode: "token", userId: opts.auth.userId, accessToken: opts.auth.accessToken }, + transport: opts.transport ?? { mode: "polling", pollIntervalMs: 5000 }, + mentionNames: opts.mentionNames ?? [], + }; + + writeConfig(cfg); +} + +export function removeAccount(accountId: string): boolean { + const cfg = readConfig() as Record; + const accounts = cfg.channels?.rocketchat?.accounts; + if (!accounts || !accounts[accountId]) return false; + delete accounts[accountId]; + writeConfig(cfg); + return true; +} diff --git a/src/cli/credential-store.ts b/src/cli/credential-store.ts new file mode 100644 index 0000000..c67977f --- /dev/null +++ b/src/cli/credential-store.ts @@ -0,0 +1,60 @@ +import { + existsSync, mkdirSync, readFileSync, readdirSync, + writeFileSync, unlinkSync, chmodSync, renameSync +} from "node:fs"; +import { join, resolve } from "node:path"; +import { homedir } from "node:os"; +import type { AccountCredentials } from "../types/types.js"; + +function getBaseDir(): string { + const home = process.env.OPENCLAW_HOME?.trim() ?? homedir(); + return resolve(home, ".openclaw", "credentials", "rocketchat"); +} + +function filePath(accountId: string): string { + return join(getBaseDir(), `${accountId}.json`); +} + +function ensureDir(): void { + const dir = getBaseDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +export function exists(accountId: string): boolean { + return existsSync(filePath(accountId)); +} + +export async function read(accountId: string): Promise { + const path = filePath(accountId); + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, "utf-8"); + return JSON.parse(raw) as AccountCredentials; + } catch { + return null; + } +} + +export async function write(accountId: string, data: AccountCredentials): Promise { + ensureDir(); + const path = filePath(accountId); + const tmp = path + ".tmp"; + writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8"); + chmodSync(tmp, 0o600); + renameSync(tmp, path); +} + +export async function remove(accountId: string): Promise { + const path = filePath(accountId); + if (!existsSync(path)) return false; + unlinkSync(path); + return true; +} + +export async function list(): Promise { + const dir = getBaseDir(); + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.replace(/\.json$/, "")); +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts new file mode 100644 index 0000000..aec2455 --- /dev/null +++ b/src/cli/setup.ts @@ -0,0 +1,152 @@ +import { createInterface } from "node:readline"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loginAs, createBotUser, getUserByUsername, createDirectMessage, sendMessage } from "../client.js"; +import * as store from "./credential-store.js"; +import { updateConfig } from "./config-updater.js"; +import type { RCLoginResult } from "../types/types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_PATH = resolve(__dirname, "..", ".."); + +interface PrevConfig { + rcUrl?: string; + bot?: { username?: string; name?: string; email?: string } | undefined; +} + +let prev: PrevConfig = {}; + +function prompt(question: string, fallback?: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const suffix = fallback ? ` [${fallback}]` : ""; + rl.question(` ${question}${suffix}: `, (answer) => { + rl.close(); + resolve(answer.trim() || fallback || ""); + }); + }); +} + +function info(msg: string) { console.log(` ${msg}`); } +function ok(msg: string) { console.log(` \u2705 ${msg}`); } +function fail(msg: string) { console.log(` \u274c ${msg}`); } + +function heading(n: number, title: string) { + console.log(`\n\u2500\u2500 Step ${n}: ${title}`); +} + +async function main() { + console.log(`\n OpenClaw Rocket.Chat Setup\n`); + + const prevConfig = await store.read("main"); + if (prevConfig) { + if (prevConfig.bot) { + prev.bot = { username: prevConfig.bot.username }; + } + info("Existing credentials found for account 'main'."); + const answer = await prompt("Re-run setup? (y/N)", "N"); + if (answer.toLowerCase() !== "y") { + info("Aborted."); + process.exit(0); + } + } + + heading(1, "Rocket.Chat Connection"); + const rcUrl = await prompt("Rocket.Chat URL", "http://localhost:3000"); + const adminUser = await prompt("Admin username"); + const adminPass = await prompt("Admin password"); + + info("Logging in..."); + let adminAuth: RCLoginResult; + try { + adminAuth = await loginAs(rcUrl, adminUser, adminPass); + ok(`Logged in as ${adminUser}`); + } catch (e: any) { + fail(`Login failed: ${e.message}`); + process.exit(1); + } + + heading(2, "Bot User"); + const botUsername = await prompt("Bot username", "rocketbot"); + if (!botUsername) { fail("Bot username is required"); process.exit(1); } + const botName = await prompt("Bot display name", botUsername); + const botEmail = await prompt("Bot email", `${botUsername.toLowerCase()}@openclaw.local`); + const botPassword = await prompt("Bot password"); + + if (!botPassword) { fail("Password is required"); process.exit(1); } + + info("Checking if bot already exists..."); + let botUser: { _id: string; username: string; name: string }; + const existing = await getUserByUsername(rcUrl, adminAuth, botUsername); + + if (existing) { + ok(`Bot "${botUsername}" already exists (${existing._id}) -- reusing`); + botUser = existing; + } else { + info("Creating bot..."); + try { + botUser = await createBotUser(rcUrl, adminAuth, { + username: botUsername, name: botName, password: botPassword, email: botEmail, + }); + ok(`Created bot: ${botUser.username} (${botUser._id})`); + } catch (e: any) { + fail(`Failed: ${e.message}`); + process.exit(1); + } + } + + info("Getting bot auth token..."); + let botAuth: RCLoginResult; + try { + botAuth = await loginAs(rcUrl, botUsername, botPassword); + ok("Bot token obtained"); + } catch (e: any) { + fail(`Bot login failed: ${e.message}`); + process.exit(1); + } + + heading(3, "Welcome Message"); + let dmRoomId = ""; + try { + info("Creating DM channel..."); + dmRoomId = await createDirectMessage(rcUrl, adminAuth, botUsername); + await sendMessage(rcUrl, botAuth, dmRoomId, "OpenClaw is connected! Send me a message to get started."); + ok(`Welcome message sent to @${botUsername}`); + } catch (e: any) { + info(`Welcome message skipped: ${e.message}`); + } + + heading(4, "Save & Configure"); + await store.write("main", { + accountId: "main", + auth: { mode: "token", userId: botAuth.userId, accessToken: botAuth.authToken }, + bot: { username: botUsername, userId: botUser._id }, + createdAt: new Date().toISOString(), + }); + ok("Saved credentials to ~/.openclaw/credentials/rocketchat/main.json"); + + try { + updateConfig({ + pluginPath: PLUGIN_PATH, + pluginId: "rocketchat", + accountId: "main", + serverUrl: rcUrl, + transport: { mode: "polling", pollIntervalMs: 5000 }, + mentionNames: [botUsername], + auth: { mode: "token", userId: botAuth.userId, accessToken: botAuth.authToken }, + }); + ok("Updated ~/.openclaw/openclaw.json (plugin paths + channel config)"); + } catch (e: any) { + info(`Skipped openclaw.json update: ${e.message}`); + } + + console.log(`\n\u2500\u2500 Done! \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`); + console.log(` + Next steps: + 1. Restart OpenClaw: openclaw restart + 2. Message @${botUsername} in Rocket.Chat + `); +} + +main().catch((e) => { console.error("\nSetup failed:", e.message ?? e); process.exit(1); }); diff --git a/src/client.ts b/src/client.ts index 09fcea7..f593e4a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,9 @@ import type { RocketChatSubscriptionRecord, RocketChatMessageRecord, RocketChatClientOptions, - JsonObject + JsonObject, + RCLoginResult, + RCUser, } from "./types/types.js"; export class RocketChatClientError extends Error { @@ -190,3 +192,96 @@ function getRetryAfterMs(response: Response, payload: JsonObject): number { return 30_000; } + +// ── Admin / Setup API ─────────────────────────────────────────── + +type RCFetchOpts = { + method?: string; + body?: Record; + userId?: string; + authToken?: string; +}; + +async function adminFetch(baseUrl: string, path: string, opts: RCFetchOpts = {}): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (opts.userId && opts.authToken) { + headers["X-Auth-Token"] = opts.authToken; + headers["X-User-Id"] = opts.userId; + } + const res = await fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, { + method: opts.method ?? "POST", + headers, + ...(opts.body ? { body: JSON.stringify(opts.body) } : {}), + }); + const json = (await res.json()) as JsonObject; + if (!res.ok || json.success === false) { + const msg = getErrorMessage(json, res.statusText); + throw new RocketChatClientError(`RC API ${path} failed: ${msg}`); + } + return json; +} + +export async function loginAs(baseUrl: string, user: string, password: string): Promise { + 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 }; +} + +export async function createBotUser( + baseUrl: string, + auth: RCLoginResult, + opts: { username: string; name: string; password: string; email: string } +): Promise { + const json = await adminFetch(baseUrl, "/api/v1/users.create", { + userId: auth.userId, + authToken: auth.authToken, + body: { + username: opts.username, + name: opts.name, + password: opts.password, + email: opts.email, + roles: ["bot", "user"], + verified: true, + requirePasswordChange: false, + sendWelcomeEmail: false, + }, + }); + const user = json.user as RCUser; + return { _id: user._id, username: user.username, name: user.name }; +} + +export async function getUserByUsername( + baseUrl: string, + auth: RCLoginResult, + username: string, +): Promise { + try { + const json = await adminFetch(baseUrl, `/api/v1/users.info?username=${encodeURIComponent(username)}`, { + method: "GET", + userId: auth.userId, + authToken: auth.authToken, + }); + const user = json.user as RCUser; + return { _id: user._id, username: user.username, name: user.name }; + } catch { + return null; + } +} + +export async function createDirectMessage(baseUrl: string, auth: RCLoginResult, username: string): Promise { + const json = await adminFetch(baseUrl, "/api/v1/im.create", { + userId: auth.userId, + authToken: auth.authToken, + body: { username }, + }); + const room = json.room as { _id: string }; + return room._id; +} + +export async function sendMessage(baseUrl: string, auth: RCLoginResult, roomId: string, text: string): Promise { + await adminFetch(baseUrl, "/api/v1/chat.postMessage", { + userId: auth.userId, + authToken: auth.authToken, + body: { roomId, text }, + }); +} diff --git a/src/index.ts b/src/index.ts index e0d282a..ca44ba3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import type { GatewayApi } from "./types/types.js"; import { rocketchatPlugin, startGateway, listAccountIds, resolveAccount } from "./plugin.js"; +import * as store from "./cli/credential-store.js"; export function register(api: GatewayApi) { api.registerChannel?.({ plugin: rocketchatPlugin }); @@ -20,8 +21,10 @@ export default { listAccountIds, resolveAccount, isConfigured(account: unknown) { - const a = account as { serverUrl?: string; auth?: unknown } | null | undefined; - return Boolean(a?.serverUrl && a.auth); + const a = account as { serverUrl?: string; accountId?: string } | null | undefined; + if (!a?.serverUrl) return false; + if (a.accountId && store.exists(a.accountId)) return true; + return Boolean((a as any).auth); } }, register, diff --git a/src/plugin.ts b/src/plugin.ts index 79d65dd..1171cc2 100644 --- a/src/plugin.ts +++ b/src/plugin.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 * as store from "./cli/credential-store.js"; import type { InboundEvent } from "./types/types.js"; import { shouldHandleInboundEvent } from "./channel.js"; import { dispatchInboundEventWithChannelRuntime } from "./inbound-dispatch.js"; @@ -35,7 +36,9 @@ export function listAccountIds(cfg: OpenClawConfig): string[] { } function isConfigured(account: Partial | null | undefined): boolean { - return Boolean(account?.serverUrl && account.auth); + if (!account?.serverUrl) return false; + if (account.accountId && store.exists(account.accountId)) return true; + return Boolean(account.auth); } export async function startGateway(ctx: GatewayContext): Promise { @@ -46,7 +49,8 @@ export async function startGateway(ctx: GatewayContext): Promise { } const log = logger; - const auth = account.auth as { mode: "token"; userId: string; accessToken: string }; + const stored = await store.read(account.accountId).catch(() => null); + const auth = stored?.auth ?? account.auth as { mode: "token"; userId: string; accessToken: string }; const client = new RocketChatClient({ serverUrl: account.serverUrl, auth, @@ -315,10 +319,12 @@ export const rocketchatPlugin = { if (!account) throw new Error(`Unknown Rocket.Chat account: ${params.accountId}`); const entry = activeClients.get(account.accountId); - const client = entry?.client ?? new RocketChatClient({ - serverUrl: account.serverUrl, - auth: account.auth as { mode: "token"; userId: string; accessToken: string }, - }); + let client = entry?.client ?? null; + if (!client) { + const stored = await store.read(account.accountId).catch(() => null); + const auth = stored?.auth ?? account.auth as { mode: "token"; userId: string; accessToken: string }; + client = new RocketChatClient({ serverUrl: account.serverUrl, auth }); + } const tmidOptions = params.replyToId ? { tmid: params.replyToId } : undefined; const messageId = await client.postMessage(params.to, params.text, tmidOptions); return { ok: true, messageId, channel: "rocketchat" }; diff --git a/src/types/types.ts b/src/types/types.ts index 4380e21..533a329 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -173,3 +173,32 @@ export type GatewayApi = { registerGatewayMethod(name: string, handler: (ctx: unknown) => Promise): void; registerChannel?(args: { plugin: unknown }): void; }; + +export type AuthCredentials = { + mode: "token"; + userId: string; + accessToken: string; +}; + +export type AccountCredentials = { + accountId: string; + auth: AuthCredentials; + bot?: { + username: string; + userId: string; + }; + createdAt: string; +}; + +export type RCLoginResult = { + userId: string; + authToken: string; +}; + +export type RCUser = { + _id: string; + username: string; + name: string; +}; + +