From d2d25462a7dc8d7d051e57067406bfdfdf87f1b0 Mon Sep 17 00:00:00 2001 From: CHINNU Date: Fri, 20 Mar 2026 11:27:09 +0530 Subject: [PATCH 1/3] feat: Add OpenClaw AI agent integration Adds a new OpenClaw integration module that allows Rocket.Chat users to interact with OpenClaw autonomous AI agents directly from chat channels. Features: - /openclaw slash command to send prompts to the AI agent - Incoming webhook endpoint (/api/v1/openclaw.webhook) for receiving AI responses - Admin settings for API URL, auth token, default LLM model, bot username - Bot message loop prevention and error handling - Thread response support - Full i18n support (20 translation keys) New files: - apps/meteor/app/openclaw/server/ (module with 7 source files) - apps/meteor/app/openclaw/README.md (documentation) - .changeset/openclaw-integration.md Modified files: - apps/meteor/server/importPackages.ts (register module) - packages/i18n/src/locales/en.i18n.json (translations) --- .changeset/openclaw-integration.md | 17 ++ apps/meteor/app/openclaw/README.md | 54 ++++++ .../meteor/app/openclaw/server/api/webhook.ts | 121 +++++++++++++ apps/meteor/app/openclaw/server/index.ts | 3 + .../app/openclaw/server/lib/messageHandler.ts | 124 ++++++++++++++ .../app/openclaw/server/lib/openclawClient.ts | 160 ++++++++++++++++++ apps/meteor/app/openclaw/server/logger.ts | 3 + apps/meteor/app/openclaw/server/settings.ts | 65 +++++++ .../app/openclaw/server/slashCommand.ts | 100 +++++++++++ apps/meteor/server/importPackages.ts | 1 + packages/i18n/src/locales/en.i18n.json | 20 +++ 11 files changed, 668 insertions(+) create mode 100644 .changeset/openclaw-integration.md create mode 100644 apps/meteor/app/openclaw/README.md create mode 100644 apps/meteor/app/openclaw/server/api/webhook.ts create mode 100644 apps/meteor/app/openclaw/server/index.ts create mode 100644 apps/meteor/app/openclaw/server/lib/messageHandler.ts create mode 100644 apps/meteor/app/openclaw/server/lib/openclawClient.ts create mode 100644 apps/meteor/app/openclaw/server/logger.ts create mode 100644 apps/meteor/app/openclaw/server/settings.ts create mode 100644 apps/meteor/app/openclaw/server/slashCommand.ts diff --git a/.changeset/openclaw-integration.md b/.changeset/openclaw-integration.md new file mode 100644 index 0000000000000..ed5774432e1ae --- /dev/null +++ b/.changeset/openclaw-integration.md @@ -0,0 +1,17 @@ +--- +"@rocket.chat/meteor": minor +--- + +feat: Add OpenClaw AI agent integration + +Adds a new OpenClaw integration module that allows Rocket.Chat users to interact with OpenClaw autonomous AI agents directly from chat channels. + +**Features:** +- `/openclaw` slash command to send prompts to the AI agent +- Incoming webhook endpoint (`/api/v1/openclaw.webhook`) for receiving AI responses +- Admin settings for configuring API URL, auth token, default model, bot username, and thread behavior +- Bot message loop prevention and proper error handling + +**Configuration:** +- Navigate to Admin → Settings → OpenClaw to enable and configure the integration +- Requires a running OpenClaw instance with a valid authentication token diff --git a/apps/meteor/app/openclaw/README.md b/apps/meteor/app/openclaw/README.md new file mode 100644 index 0000000000000..8ade470c126b4 --- /dev/null +++ b/apps/meteor/app/openclaw/README.md @@ -0,0 +1,54 @@ +# OpenClaw AI Agent Integration + +This module integrates [OpenClaw](https://openclaw.ai), an open-source autonomous AI agent platform, into Rocket.Chat. + +## Features + +- **`/openclaw` slash command**: Send prompts directly to your OpenClaw AI agent from any channel +- **Webhook endpoint**: OpenClaw can post responses back to Rocket.Chat channels via `/api/v1/openclaw.webhook` +- **Admin settings**: Configure API URL, authentication token, default LLM model, and bot behavior + +## Setup + +1. **Enable the integration**: Go to **Admin → Settings → OpenClaw** and enable it +2. **Set the API URL**: Enter your OpenClaw instance URL (e.g., `http://localhost:3080`) +3. **Set the authentication token**: Enter the shared secret token for webhook authentication +4. **Optional**: Configure the default LLM model, bot username, and thread response behavior + +## Usage + +### Slash command + +``` +/openclaw What is the weather in Berlin today? +``` + +The AI agent will process your prompt and respond in the channel. + +### Webhook (for OpenClaw → Rocket.Chat) + +OpenClaw can POST responses to: + +``` +POST /api/v1/openclaw.webhook +Content-Type: application/json + +{ + "token": "", + "channel_id": "", + "text": "Hello from OpenClaw!", + "thread_id": "" +} +``` + +## Architecture + +``` +Rocket.Chat ──► OpenClaw /hooks/agent (outbound: user messages/commands) +OpenClaw ──► Rocket.Chat /api/v1/openclaw.webhook (inbound: AI responses) +``` + +## Requirements + +- A running OpenClaw instance (self-hosted or cloud) +- A valid authentication token configured on both sides diff --git a/apps/meteor/app/openclaw/server/api/webhook.ts b/apps/meteor/app/openclaw/server/api/webhook.ts new file mode 100644 index 0000000000000..c10d3ff1fbfd7 --- /dev/null +++ b/apps/meteor/app/openclaw/server/api/webhook.ts @@ -0,0 +1,121 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { isPlainObject } from '../../../../lib/utils/isPlainObject'; +import { API } from '../../../api/server/api'; +import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; +import { settings } from '../../../settings/server'; +import { openclawLogger } from '../logger'; +import { getOpenClawBotUser, getRoomById } from '../lib/messageHandler'; + +/** + * Webhook endpoint for receiving responses from the OpenClaw agent. + * + * OpenClaw posts AI responses back to this endpoint, which then delivers + * the response as a message in the appropriate Rocket.Chat channel. + * + * Expected payload: + * { + * "token": "", + * "channel_id": "", + * "text": "", + * "thread_id": "" + * } + */ +API.v1.addRoute( + 'openclaw.webhook', + { authRequired: false }, + { + async post() { + openclawLogger.info({ msg: 'Received OpenClaw webhook callback' }); + + // Check if OpenClaw is enabled + if (settings.get('OpenClaw_Enabled') !== true) { + openclawLogger.warn({ msg: 'OpenClaw webhook received but integration is disabled' }); + return API.v1.failure('OpenClaw integration is disabled'); + } + + const body = this.bodyParams; + if (!isPlainObject(body)) { + return API.v1.failure('Invalid request body'); + } + + // Validate the webhook token + const expectedToken = settings.get('OpenClaw_Auth_Token'); + const receivedToken = body.token as string | undefined; + + if (!expectedToken || !receivedToken || receivedToken !== expectedToken) { + openclawLogger.warn({ msg: 'OpenClaw webhook token validation failed' }); + return API.v1.unauthorized('Invalid webhook token'); + } + + // Validate required fields + const channelId = body.channel_id as string | undefined; + const text = body.text as string | undefined; + + if (!channelId) { + return API.v1.failure('Missing required field: channel_id'); + } + + if (!text || text.trim().length === 0) { + return API.v1.failure('Missing required field: text'); + } + + // Resolve the target room + const room = await getRoomById(channelId); + if (!room) { + openclawLogger.error({ msg: 'Target room not found for OpenClaw webhook', channelId }); + return API.v1.failure('Target room not found'); + } + + // Get the bot user to post as + const botUser = await getOpenClawBotUser(); + if (!botUser || !botUser.username) { + openclawLogger.error({ msg: 'OpenClaw bot user not found' }); + return API.v1.failure('Bot user not found'); + } + + // Build the message payload + const threadId = body.thread_id as string | undefined; + const alias = (body.alias as string) || 'OpenClaw AI'; + const avatar = (body.avatar as string) || ''; + const emoji = (body.emoji as string) || ':robot:'; + + const messagePayload: Record = { + text, + channel: `#${room._id}`, + ...(threadId && { tmid: threadId }), + }; + + const defaultValues = { + alias, + avatar, + emoji, + channel: `#${room._id}`, + }; + + try { + const result = await processWebhookMessage( + messagePayload, + botUser as Parameters[1], + defaultValues, + ); + + if (!result || result.length === 0) { + openclawLogger.error({ msg: 'Failed to process OpenClaw webhook message' }); + return API.v1.failure('Failed to deliver message'); + } + + openclawLogger.info({ + msg: 'OpenClaw webhook message delivered successfully', + roomId: channelId, + messageCount: result.length, + }); + + return API.v1.success({ message: 'Message delivered' }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + openclawLogger.error({ msg: 'Error processing OpenClaw webhook', error: errorMsg }); + return API.v1.failure(`Error delivering message: ${errorMsg}`); + } + }, + }, +); diff --git a/apps/meteor/app/openclaw/server/index.ts b/apps/meteor/app/openclaw/server/index.ts new file mode 100644 index 0000000000000..e1c362895bbf4 --- /dev/null +++ b/apps/meteor/app/openclaw/server/index.ts @@ -0,0 +1,3 @@ +import './settings'; +import './slashCommand'; +import './api/webhook'; diff --git a/apps/meteor/app/openclaw/server/lib/messageHandler.ts b/apps/meteor/app/openclaw/server/lib/messageHandler.ts new file mode 100644 index 0000000000000..9ca267828c6c5 --- /dev/null +++ b/apps/meteor/app/openclaw/server/lib/messageHandler.ts @@ -0,0 +1,124 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { Rooms, Users } from '@rocket.chat/models'; + +import type { OpenClawAgentPayload } from './openclawClient'; +import { sendToAgent, isEnabled } from './openclawClient'; +import { settings } from '../../../settings/server'; +import { openclawLogger } from '../logger'; + +/** + * Determine if a message should be forwarded to OpenClaw. + * Filters out bot messages, system messages, and checks if integration is enabled. + */ +export function shouldProcessMessage(message: IMessage): boolean { + // Skip if not enabled + if (!isEnabled()) { + return false; + } + + // Skip system messages + if (message.t) { + return false; + } + + // Skip empty messages + if (!message.msg || message.msg.trim().length === 0) { + return false; + } + + // Skip messages from bots to prevent infinite loops + if (message.bot) { + return false; + } + + // Skip messages from the OpenClaw bot user + const botUsername = settings.get('OpenClaw_Bot_Username'); + if (botUsername && message.u?.username === botUsername) { + return false; + } + + return true; +} + +/** + * Format a Rocket.Chat message into an OpenClaw agent payload. + */ +export function formatMessagePayload( + message: IMessage, + room: IRoom, + callbackUrl?: string, +): OpenClawAgentPayload { + const respondInThread = settings.get('OpenClaw_Respond_In_Thread'); + + return { + message: message.msg || '', + channel_id: room._id, + channel_name: room.name || room._id, + user_id: message.u._id, + user_name: message.u.username || '', + ...(callbackUrl && { callback_url: callbackUrl }), + ...(respondInThread && message._id && { thread_id: message.tmid || message._id }), + }; +} + +/** + * Process a message and forward it to the OpenClaw agent. + * Returns the agent's response text, or null if no response. + */ +export async function forwardMessageToAgent( + message: IMessage, + room: IRoom, + callbackUrl?: string, +): Promise { + if (!shouldProcessMessage(message)) { + return null; + } + + const payload = formatMessagePayload(message, room, callbackUrl); + + openclawLogger.info({ + msg: 'Forwarding message to OpenClaw agent', + messageId: message._id, + roomId: room._id, + }); + + const result = await sendToAgent(payload); + + if (!result.success) { + openclawLogger.error({ + msg: 'Failed to forward message to OpenClaw', + error: result.error, + }); + return null; + } + + return result.response || result.message || null; +} + +/** + * Get the bot user for posting OpenClaw responses. + */ +export async function getOpenClawBotUser() { + const botUsername = settings.get('OpenClaw_Bot_Username') || 'openclaw.bot'; + + const user = await Users.findOneByUsername(botUsername); + if (user) { + return user; + } + + // Fall back to rocket.cat if the configured bot user doesn't exist + openclawLogger.warn({ + msg: 'OpenClaw bot user not found, falling back to rocket.cat', + botUsername, + }); + + return Users.findOneByUsername('rocket.cat'); +} + +/** + * Resolve a room by ID. + */ +export async function getRoomById(roomId: string): Promise { + const room = await Rooms.findOneById(roomId); + return room || null; +} diff --git a/apps/meteor/app/openclaw/server/lib/openclawClient.ts b/apps/meteor/app/openclaw/server/lib/openclawClient.ts new file mode 100644 index 0000000000000..b63ee396bab56 --- /dev/null +++ b/apps/meteor/app/openclaw/server/lib/openclawClient.ts @@ -0,0 +1,160 @@ +import { serverFetch as fetch } from '@rocket.chat/server-fetch'; + +import { settings } from '../../../settings/server'; +import { openclawLogger } from '../logger'; + +export interface OpenClawAgentPayload { + message: string; + channel_id: string; + channel_name?: string; + user_id: string; + user_name: string; + callback_url?: string; + model?: string; + thread_id?: string; +} + +export interface OpenClawWakePayload { + event: string; + data: Record; +} + +export interface OpenClawResponse { + success: boolean; + message?: string; + response?: string; + error?: string; +} + +function getConfig(): { apiUrl: string; authToken: string; model: string } { + const apiUrl = settings.get('OpenClaw_API_URL'); + const authToken = settings.get('OpenClaw_Auth_Token'); + const model = settings.get('OpenClaw_Default_Model'); + + return { apiUrl, authToken, model }; +} + +function isEnabled(): boolean { + return settings.get('OpenClaw_Enabled') === true; +} + +function validateConfig(): { valid: boolean; error?: string } { + if (!isEnabled()) { + return { valid: false, error: 'OpenClaw integration is not enabled. Enable it in Admin → Settings → OpenClaw.' }; + } + + const { apiUrl, authToken } = getConfig(); + + if (!apiUrl) { + return { valid: false, error: 'OpenClaw API URL is not configured. Set it in Admin → Settings → OpenClaw.' }; + } + + if (!authToken) { + return { valid: false, error: 'OpenClaw authentication token is not configured. Set it in Admin → Settings → OpenClaw.' }; + } + + return { valid: true }; +} + +/** + * Send a message to the OpenClaw agent for processing. + * Posts to the OpenClaw `/hooks/agent` endpoint. + */ +export async function sendToAgent(payload: OpenClawAgentPayload): Promise { + const validation = validateConfig(); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + const { apiUrl, authToken, model } = getConfig(); + const url = `${apiUrl.replace(/\/+$/, '')}/hooks/agent`; + + if (model && !payload.model) { + payload.model = model; + } + + openclawLogger.info({ msg: 'Sending message to OpenClaw agent', url, channel: payload.channel_id }); + openclawLogger.debug({ payload }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(payload), + timeout: 30000, + }); + + const contentType = response.headers.get('content-type') || ''; + let data: Record | null = null; + + if (contentType.includes('application/json')) { + try { + data = (await response.json()) as Record; + } catch { + data = null; + } + } + + if (!response.ok) { + const errorMsg = data?.error || data?.message || `HTTP ${response.status}: ${response.statusText}`; + openclawLogger.error({ msg: 'OpenClaw agent request failed', status: response.status, error: errorMsg }); + return { success: false, error: String(errorMsg) }; + } + + openclawLogger.info({ msg: 'OpenClaw agent response received', status: response.status }); + + return { + success: true, + response: data?.response as string | undefined, + message: data?.message as string | undefined, + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + openclawLogger.error({ msg: 'OpenClaw agent request error', error: errorMsg }); + return { success: false, error: `Failed to connect to OpenClaw: ${errorMsg}` }; + } +} + +/** + * Send a wake event to the OpenClaw agent. + * Posts to the OpenClaw `/hooks/wake` endpoint. + */ +export async function wakeAgent(payload: OpenClawWakePayload): Promise { + const validation = validateConfig(); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + const { apiUrl, authToken } = getConfig(); + const url = `${apiUrl.replace(/\/+$/, '')}/hooks/wake`; + + openclawLogger.info({ msg: 'Sending wake event to OpenClaw', url, event: payload.event }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(payload), + timeout: 10000, + }); + + if (!response.ok) { + openclawLogger.error({ msg: 'OpenClaw wake request failed', status: response.status }); + return { success: false, error: `HTTP ${response.status}: ${response.statusText}` }; + } + + return { success: true }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + openclawLogger.error({ msg: 'OpenClaw wake request error', error: errorMsg }); + return { success: false, error: `Failed to connect to OpenClaw: ${errorMsg}` }; + } +} + +export { isEnabled, validateConfig }; diff --git a/apps/meteor/app/openclaw/server/logger.ts b/apps/meteor/app/openclaw/server/logger.ts new file mode 100644 index 0000000000000..e26852e928a29 --- /dev/null +++ b/apps/meteor/app/openclaw/server/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const openclawLogger = new Logger('OpenClaw'); diff --git a/apps/meteor/app/openclaw/server/settings.ts b/apps/meteor/app/openclaw/server/settings.ts new file mode 100644 index 0000000000000..a1a15ff321276 --- /dev/null +++ b/apps/meteor/app/openclaw/server/settings.ts @@ -0,0 +1,65 @@ +import { settingsRegistry } from '../../settings/server'; + +export const createOpenClawSettings = () => + settingsRegistry.addGroup('OpenClaw', async function () { + await this.with( + { + section: 'General', + i18nLabel: 'OpenClaw_General', + }, + async function () { + await this.add('OpenClaw_Enabled', false, { + type: 'boolean', + i18nLabel: 'OpenClaw_Enabled', + i18nDescription: 'OpenClaw_Enabled_Description', + public: true, + }); + + await this.add('OpenClaw_API_URL', '', { + type: 'string', + i18nLabel: 'OpenClaw_API_URL', + i18nDescription: 'OpenClaw_API_URL_Description', + enableQuery: { _id: 'OpenClaw_Enabled', value: true }, + }); + + await this.add('OpenClaw_Auth_Token', '', { + type: 'string', + i18nLabel: 'OpenClaw_Auth_Token', + i18nDescription: 'OpenClaw_Auth_Token_Description', + enableQuery: { _id: 'OpenClaw_Enabled', value: true }, + secret: true, + }); + }, + ); + + await this.with( + { + section: 'Agent', + i18nLabel: 'OpenClaw_Agent', + }, + async function () { + await this.add('OpenClaw_Default_Model', '', { + type: 'string', + i18nLabel: 'OpenClaw_Default_Model', + i18nDescription: 'OpenClaw_Default_Model_Description', + enableQuery: { _id: 'OpenClaw_Enabled', value: true }, + }); + + await this.add('OpenClaw_Bot_Username', 'openclaw.bot', { + type: 'string', + i18nLabel: 'OpenClaw_Bot_Username', + i18nDescription: 'OpenClaw_Bot_Username_Description', + enableQuery: { _id: 'OpenClaw_Enabled', value: true }, + }); + + await this.add('OpenClaw_Respond_In_Thread', true, { + type: 'boolean', + i18nLabel: 'OpenClaw_Respond_In_Thread', + i18nDescription: 'OpenClaw_Respond_In_Thread_Description', + enableQuery: { _id: 'OpenClaw_Enabled', value: true }, + }); + }, + ); + }); + +void createOpenClawSettings(); diff --git a/apps/meteor/app/openclaw/server/slashCommand.ts b/apps/meteor/app/openclaw/server/slashCommand.ts new file mode 100644 index 0000000000000..26eac5edcfbe3 --- /dev/null +++ b/apps/meteor/app/openclaw/server/slashCommand.ts @@ -0,0 +1,100 @@ +import { api } from '@rocket.chat/core-services'; +import type { SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import { Users } from '@rocket.chat/models'; + +import { forwardMessageToAgent, getRoomById } from './lib/messageHandler'; +import { validateConfig } from './lib/openclawClient'; +import { openclawLogger } from './logger'; +import { i18n } from '../../../server/lib/i18n'; +import { settings } from '../../settings/server'; +import { slashCommands } from '../../utils/server/slashCommand'; + +slashCommands.add({ + command: 'openclaw', + callback: async function OpenClaw({ params, message, userId }: SlashCommandCallbackParams<'openclaw'>): Promise { + const user = await Users.findOneById(userId); + if (!user) { + return; + } + + const userLanguage = user.language || settings.get('language') || 'en'; + + // Validate that OpenClaw is properly configured + const validation = validateConfig(); + if (!validation.valid) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: `:warning: **OpenClaw:** ${validation.error}`, + ...(message.tmid && { tmid: message.tmid }), + }); + return; + } + + // Require a prompt + const prompt = params?.trim(); + if (!prompt) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: i18n.t('OpenClaw_Usage_Hint', { lng: userLanguage }), + ...(message.tmid && { tmid: message.tmid }), + }); + return; + } + + // Notify user that the request is being processed + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: `:hourglass_flowing_sand: ${i18n.t('OpenClaw_Processing', { lng: userLanguage })}`, + ...(message.tmid && { tmid: message.tmid }), + }); + + // Get the room + const room = await getRoomById(message.rid); + if (!room) { + openclawLogger.error({ msg: 'Room not found for slash command', roomId: message.rid }); + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: ':x: **OpenClaw:** Room not found.', + ...(message.tmid && { tmid: message.tmid }), + }); + return; + } + + // Create a synthetic message payload for the agent + const agentMessage = { + ...message, + msg: prompt, + u: { + _id: user._id, + username: user.username || '', + name: user.name || '', + }, + }; + + const responseText = await forwardMessageToAgent(agentMessage, room); + + if (!responseText) { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: `:x: ${i18n.t('OpenClaw_No_Response', { lng: userLanguage })}`, + ...(message.tmid && { tmid: message.tmid }), + }); + return; + } + + // Post the response as a visible message from the bot + const respondInThread = settings.get('OpenClaw_Respond_In_Thread'); + const botUsername = settings.get('OpenClaw_Bot_Username') || 'openclaw.bot'; + + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: responseText, + ...(respondInThread && message.tmid && { tmid: message.tmid }), + }); + + openclawLogger.info({ + msg: 'OpenClaw slash command response delivered', + userId, + roomId: message.rid, + botUsername, + }); + }, + options: { + description: 'OpenClaw_Slash_Description', + params: 'OpenClaw_Slash_Params', + }, +}); diff --git a/apps/meteor/server/importPackages.ts b/apps/meteor/server/importPackages.ts index 49af9b1ca237a..b71445e77ef9a 100644 --- a/apps/meteor/server/importPackages.ts +++ b/apps/meteor/server/importPackages.ts @@ -41,6 +41,7 @@ import '../app/message-mark-as-unread/server'; import '../app/message-pin/server'; import '../app/message-star/server'; import '../app/nextcloud/server'; +import '../app/openclaw/server'; import '../app/oauth2-server-config/server'; import '../app/push-notifications/server'; import '../app/retention-policy/server'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 99f81f9f9c3a8..91786720931eb 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3999,6 +3999,26 @@ "Open_settings": "Open settings", "Open_sidebar": "Open sidebar", "Open_thread": "Open Thread", + "OpenClaw": "OpenClaw", + "OpenClaw_Agent": "Agent Configuration", + "OpenClaw_Auth_Token": "Authentication Token", + "OpenClaw_Auth_Token_Description": "Shared secret token for authenticating with the OpenClaw instance. Must match the token configured in OpenClaw.", + "OpenClaw_API_URL": "API URL", + "OpenClaw_API_URL_Description": "The base URL of your OpenClaw instance (e.g., http://localhost:3080).", + "OpenClaw_Bot_Username": "Bot Username", + "OpenClaw_Bot_Username_Description": "The username that the OpenClaw bot will use to post messages. This user must exist in Rocket.Chat.", + "OpenClaw_Default_Model": "Default LLM Model", + "OpenClaw_Default_Model_Description": "The default language model to use for AI responses (e.g., gpt-4, claude-3, deepseek). Leave empty to use OpenClaw's default.", + "OpenClaw_Enabled": "Enable OpenClaw Integration", + "OpenClaw_Enabled_Description": "Enable the OpenClaw AI agent integration to allow users to interact with AI agents from Rocket.Chat.", + "OpenClaw_General": "General Settings", + "OpenClaw_No_Response": "**OpenClaw:** No response received from the AI agent. Please try again.", + "OpenClaw_Processing": "**OpenClaw:** Processing your request...", + "OpenClaw_Respond_In_Thread": "Respond in Thread", + "OpenClaw_Respond_In_Thread_Description": "When enabled, OpenClaw responses will be posted as thread replies instead of channel messages.", + "OpenClaw_Slash_Description": "Send a prompt to the OpenClaw AI agent", + "OpenClaw_Slash_Params": "your_prompt_here", + "OpenClaw_Usage_Hint": "Usage: `/openclaw ` — Send a prompt to the OpenClaw AI agent.", "Opened": "Opened", "Opened_in_a_new_window": "Opened in a new window.", "Opens_a_channel_group_or_direct_message": "Opens a channel, group or direct message", From 7de5b034658f11839805c5e54e4a1855e4a75311 Mon Sep 17 00:00:00 2001 From: CHINNU Date: Fri, 20 Mar 2026 15:55:27 +0530 Subject: [PATCH 2/3] fix: address CodeRabbit and cubic review issues in OpenClaw integration - Remove unused IMessage import from webhook.ts - Use crypto.timingSafeEqual for webhook token comparison (timing attack prevention) - Fix botUser type assertion in webhook.ts using explicit field spread - validateConfig() now returns stable error codes (NOT_ENABLED, MISSING_API_URL, MISSING_AUTH_TOKEN) instead of hardcoded English strings - Translate validateConfig() error codes via i18n at call boundary in slashCommand.ts - Sanitize openclawLogger.debug payload - no longer logs raw prompt/PII data - Treat malformed JSON as hard failure in sendToAgent() before response.ok check - Change warn -> debug on rocket.cat fallback log in getOpenClawBotUser() - Add fallback field to rocket.cat fallback debug log for observability - Remove unused botUsername variable from slashCommand.ts - Add OpenClaw_Error_Not_Enabled, OpenClaw_Error_Missing_API_URL, OpenClaw_Error_Missing_Auth_Token i18n keys to en.i18n.json - Add language specifiers to README.md fenced code blocks (fixes MD040) - Remove JSDoc blocks and inline comments per project coding guidelines --- apps/meteor/app/openclaw/README.md | 6 +-- .../meteor/app/openclaw/server/api/webhook.ts | 34 +++++----------- .../app/openclaw/server/lib/messageHandler.ts | 26 +----------- .../app/openclaw/server/lib/openclawClient.ts | 40 ++++++++++++------- .../app/openclaw/server/slashCommand.ts | 19 +++++---- packages/i18n/src/locales/en.i18n.json | 3 ++ 6 files changed, 53 insertions(+), 75 deletions(-) diff --git a/apps/meteor/app/openclaw/README.md b/apps/meteor/app/openclaw/README.md index 8ade470c126b4..5dad911dd8bfd 100644 --- a/apps/meteor/app/openclaw/README.md +++ b/apps/meteor/app/openclaw/README.md @@ -19,7 +19,7 @@ This module integrates [OpenClaw](https://openclaw.ai), an open-source autonomou ### Slash command -``` +```bash /openclaw What is the weather in Berlin today? ``` @@ -29,7 +29,7 @@ The AI agent will process your prompt and respond in the channel. OpenClaw can POST responses to: -``` +```json POST /api/v1/openclaw.webhook Content-Type: application/json @@ -43,7 +43,7 @@ Content-Type: application/json ## Architecture -``` +```text Rocket.Chat ──► OpenClaw /hooks/agent (outbound: user messages/commands) OpenClaw ──► Rocket.Chat /api/v1/openclaw.webhook (inbound: AI responses) ``` diff --git a/apps/meteor/app/openclaw/server/api/webhook.ts b/apps/meteor/app/openclaw/server/api/webhook.ts index c10d3ff1fbfd7..f906e8d6fedac 100644 --- a/apps/meteor/app/openclaw/server/api/webhook.ts +++ b/apps/meteor/app/openclaw/server/api/webhook.ts @@ -1,4 +1,5 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import { timingSafeEqual } from 'crypto'; + import { isPlainObject } from '../../../../lib/utils/isPlainObject'; import { API } from '../../../api/server/api'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; @@ -6,20 +7,13 @@ import { settings } from '../../../settings/server'; import { openclawLogger } from '../logger'; import { getOpenClawBotUser, getRoomById } from '../lib/messageHandler'; -/** - * Webhook endpoint for receiving responses from the OpenClaw agent. - * - * OpenClaw posts AI responses back to this endpoint, which then delivers - * the response as a message in the appropriate Rocket.Chat channel. - * - * Expected payload: - * { - * "token": "", - * "channel_id": "", - * "text": "", - * "thread_id": "" - * } - */ +function safeCompare(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + API.v1.addRoute( 'openclaw.webhook', { authRequired: false }, @@ -27,7 +21,6 @@ API.v1.addRoute( async post() { openclawLogger.info({ msg: 'Received OpenClaw webhook callback' }); - // Check if OpenClaw is enabled if (settings.get('OpenClaw_Enabled') !== true) { openclawLogger.warn({ msg: 'OpenClaw webhook received but integration is disabled' }); return API.v1.failure('OpenClaw integration is disabled'); @@ -38,16 +31,14 @@ API.v1.addRoute( return API.v1.failure('Invalid request body'); } - // Validate the webhook token const expectedToken = settings.get('OpenClaw_Auth_Token'); const receivedToken = body.token as string | undefined; - if (!expectedToken || !receivedToken || receivedToken !== expectedToken) { + if (!expectedToken || !receivedToken || !safeCompare(receivedToken, expectedToken)) { openclawLogger.warn({ msg: 'OpenClaw webhook token validation failed' }); return API.v1.unauthorized('Invalid webhook token'); } - // Validate required fields const channelId = body.channel_id as string | undefined; const text = body.text as string | undefined; @@ -59,21 +50,18 @@ API.v1.addRoute( return API.v1.failure('Missing required field: text'); } - // Resolve the target room const room = await getRoomById(channelId); if (!room) { openclawLogger.error({ msg: 'Target room not found for OpenClaw webhook', channelId }); return API.v1.failure('Target room not found'); } - // Get the bot user to post as const botUser = await getOpenClawBotUser(); if (!botUser || !botUser.username) { openclawLogger.error({ msg: 'OpenClaw bot user not found' }); return API.v1.failure('Bot user not found'); } - // Build the message payload const threadId = body.thread_id as string | undefined; const alias = (body.alias as string) || 'OpenClaw AI'; const avatar = (body.avatar as string) || ''; @@ -95,7 +83,7 @@ API.v1.addRoute( try { const result = await processWebhookMessage( messagePayload, - botUser as Parameters[1], + { ...botUser, username: botUser.username }, defaultValues, ); diff --git a/apps/meteor/app/openclaw/server/lib/messageHandler.ts b/apps/meteor/app/openclaw/server/lib/messageHandler.ts index 9ca267828c6c5..f01c0907aded6 100644 --- a/apps/meteor/app/openclaw/server/lib/messageHandler.ts +++ b/apps/meteor/app/openclaw/server/lib/messageHandler.ts @@ -6,32 +6,23 @@ import { sendToAgent, isEnabled } from './openclawClient'; import { settings } from '../../../settings/server'; import { openclawLogger } from '../logger'; -/** - * Determine if a message should be forwarded to OpenClaw. - * Filters out bot messages, system messages, and checks if integration is enabled. - */ export function shouldProcessMessage(message: IMessage): boolean { - // Skip if not enabled if (!isEnabled()) { return false; } - // Skip system messages if (message.t) { return false; } - // Skip empty messages if (!message.msg || message.msg.trim().length === 0) { return false; } - // Skip messages from bots to prevent infinite loops if (message.bot) { return false; } - // Skip messages from the OpenClaw bot user const botUsername = settings.get('OpenClaw_Bot_Username'); if (botUsername && message.u?.username === botUsername) { return false; @@ -40,9 +31,6 @@ export function shouldProcessMessage(message: IMessage): boolean { return true; } -/** - * Format a Rocket.Chat message into an OpenClaw agent payload. - */ export function formatMessagePayload( message: IMessage, room: IRoom, @@ -61,10 +49,6 @@ export function formatMessagePayload( }; } -/** - * Process a message and forward it to the OpenClaw agent. - * Returns the agent's response text, or null if no response. - */ export async function forwardMessageToAgent( message: IMessage, room: IRoom, @@ -95,9 +79,6 @@ export async function forwardMessageToAgent( return result.response || result.message || null; } -/** - * Get the bot user for posting OpenClaw responses. - */ export async function getOpenClawBotUser() { const botUsername = settings.get('OpenClaw_Bot_Username') || 'openclaw.bot'; @@ -106,18 +87,15 @@ export async function getOpenClawBotUser() { return user; } - // Fall back to rocket.cat if the configured bot user doesn't exist - openclawLogger.warn({ + openclawLogger.debug({ msg: 'OpenClaw bot user not found, falling back to rocket.cat', botUsername, + fallback: 'rocket.cat', }); return Users.findOneByUsername('rocket.cat'); } -/** - * Resolve a room by ID. - */ export async function getRoomById(roomId: string): Promise { const room = await Rooms.findOneById(roomId); return room || null; diff --git a/apps/meteor/app/openclaw/server/lib/openclawClient.ts b/apps/meteor/app/openclaw/server/lib/openclawClient.ts index b63ee396bab56..a2e70d11d4b2d 100644 --- a/apps/meteor/app/openclaw/server/lib/openclawClient.ts +++ b/apps/meteor/app/openclaw/server/lib/openclawClient.ts @@ -26,6 +26,8 @@ export interface OpenClawResponse { error?: string; } +export type OpenClawConfigError = 'NOT_ENABLED' | 'MISSING_API_URL' | 'MISSING_AUTH_TOKEN'; + function getConfig(): { apiUrl: string; authToken: string; model: string } { const apiUrl = settings.get('OpenClaw_API_URL'); const authToken = settings.get('OpenClaw_Auth_Token'); @@ -38,28 +40,24 @@ function isEnabled(): boolean { return settings.get('OpenClaw_Enabled') === true; } -function validateConfig(): { valid: boolean; error?: string } { +function validateConfig(): { valid: boolean; error?: OpenClawConfigError } { if (!isEnabled()) { - return { valid: false, error: 'OpenClaw integration is not enabled. Enable it in Admin → Settings → OpenClaw.' }; + return { valid: false, error: 'NOT_ENABLED' }; } const { apiUrl, authToken } = getConfig(); if (!apiUrl) { - return { valid: false, error: 'OpenClaw API URL is not configured. Set it in Admin → Settings → OpenClaw.' }; + return { valid: false, error: 'MISSING_API_URL' }; } if (!authToken) { - return { valid: false, error: 'OpenClaw authentication token is not configured. Set it in Admin → Settings → OpenClaw.' }; + return { valid: false, error: 'MISSING_AUTH_TOKEN' }; } return { valid: true }; } -/** - * Send a message to the OpenClaw agent for processing. - * Posts to the OpenClaw `/hooks/agent` endpoint. - */ export async function sendToAgent(payload: OpenClawAgentPayload): Promise { const validation = validateConfig(); if (!validation.valid) { @@ -74,7 +72,13 @@ export async function sendToAgent(payload: OpenClawAgentPayload): Promise | null = null; + let malformedJson = false; if (contentType.includes('application/json')) { try { data = (await response.json()) as Record; - } catch { - data = null; + } catch (error) { + malformedJson = true; + openclawLogger.error({ + msg: 'OpenClaw agent returned malformed JSON', + status: response.status, + error: error instanceof Error ? error.message : String(error), + }); } } + if (malformedJson) { + return { success: false, error: 'OpenClaw agent returned malformed JSON' }; + } + if (!response.ok) { const errorMsg = data?.error || data?.message || `HTTP ${response.status}: ${response.statusText}`; openclawLogger.error({ msg: 'OpenClaw agent request failed', status: response.status, error: errorMsg }); @@ -118,10 +132,6 @@ export async function sendToAgent(payload: OpenClawAgentPayload): Promise { const validation = validateConfig(); if (!validation.valid) { diff --git a/apps/meteor/app/openclaw/server/slashCommand.ts b/apps/meteor/app/openclaw/server/slashCommand.ts index 26eac5edcfbe3..e0804bc235b07 100644 --- a/apps/meteor/app/openclaw/server/slashCommand.ts +++ b/apps/meteor/app/openclaw/server/slashCommand.ts @@ -9,6 +9,12 @@ import { i18n } from '../../../server/lib/i18n'; import { settings } from '../../settings/server'; import { slashCommands } from '../../utils/server/slashCommand'; +const CONFIG_ERROR_KEY_MAP = { + NOT_ENABLED: 'OpenClaw_Error_Not_Enabled', + MISSING_API_URL: 'OpenClaw_Error_Missing_API_URL', + MISSING_AUTH_TOKEN: 'OpenClaw_Error_Missing_Auth_Token', +} as const; + slashCommands.add({ command: 'openclaw', callback: async function OpenClaw({ params, message, userId }: SlashCommandCallbackParams<'openclaw'>): Promise { @@ -19,17 +25,16 @@ slashCommands.add({ const userLanguage = user.language || settings.get('language') || 'en'; - // Validate that OpenClaw is properly configured const validation = validateConfig(); - if (!validation.valid) { + if (!validation.valid && validation.error) { + const i18nKey = CONFIG_ERROR_KEY_MAP[validation.error] ?? 'OpenClaw_Error_Not_Enabled'; void api.broadcast('notify.ephemeralMessage', userId, message.rid, { - msg: `:warning: **OpenClaw:** ${validation.error}`, + msg: i18n.t(i18nKey, { lng: userLanguage }), ...(message.tmid && { tmid: message.tmid }), }); return; } - // Require a prompt const prompt = params?.trim(); if (!prompt) { void api.broadcast('notify.ephemeralMessage', userId, message.rid, { @@ -39,13 +44,11 @@ slashCommands.add({ return; } - // Notify user that the request is being processed void api.broadcast('notify.ephemeralMessage', userId, message.rid, { msg: `:hourglass_flowing_sand: ${i18n.t('OpenClaw_Processing', { lng: userLanguage })}`, ...(message.tmid && { tmid: message.tmid }), }); - // Get the room const room = await getRoomById(message.rid); if (!room) { openclawLogger.error({ msg: 'Room not found for slash command', roomId: message.rid }); @@ -56,7 +59,6 @@ slashCommands.add({ return; } - // Create a synthetic message payload for the agent const agentMessage = { ...message, msg: prompt, @@ -77,9 +79,7 @@ slashCommands.add({ return; } - // Post the response as a visible message from the bot const respondInThread = settings.get('OpenClaw_Respond_In_Thread'); - const botUsername = settings.get('OpenClaw_Bot_Username') || 'openclaw.bot'; void api.broadcast('notify.ephemeralMessage', userId, message.rid, { msg: responseText, @@ -90,7 +90,6 @@ slashCommands.add({ msg: 'OpenClaw slash command response delivered', userId, roomId: message.rid, - botUsername, }); }, options: { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 91786720931eb..fa64f6ee9e902 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4019,6 +4019,9 @@ "OpenClaw_Slash_Description": "Send a prompt to the OpenClaw AI agent", "OpenClaw_Slash_Params": "your_prompt_here", "OpenClaw_Usage_Hint": "Usage: `/openclaw ` — Send a prompt to the OpenClaw AI agent.", + "OpenClaw_Error_Not_Enabled": ":warning: **OpenClaw:** The integration is not enabled. Enable it in Admin → Settings → OpenClaw.", + "OpenClaw_Error_Missing_API_URL": ":warning: **OpenClaw:** The API URL is not configured. Set it in Admin → Settings → OpenClaw.", + "OpenClaw_Error_Missing_Auth_Token": ":warning: **OpenClaw:** The authentication token is not configured. Set it in Admin → Settings → OpenClaw.", "Opened": "Opened", "Opened_in_a_new_window": "Opened in a new window.", "Opens_a_channel_group_or_direct_message": "Opens a channel, group or direct message", From 7ea572c6ca4a47a593e7dae36b4de3c4c8e139af Mon Sep 17 00:00:00 2001 From: CHINNU Date: Sun, 29 Mar 2026 12:44:20 +0530 Subject: [PATCH 3/3] fix: resolve OpenClaw TS errors and SimpleSAMLphp deprecation warning --- .../meteor/app/openclaw/server/api/webhook.ts | 31 +++++++++++-------- .../app/openclaw/server/lib/openclawClient.ts | 16 +++++----- .../app/openclaw/server/slashCommand.ts | 26 ++++++++++++---- .../saml/config/simplesamlphp/config.php | 2 +- packages/i18n/src/locales/en.i18n.json | 1 + 5 files changed, 47 insertions(+), 29 deletions(-) diff --git a/apps/meteor/app/openclaw/server/api/webhook.ts b/apps/meteor/app/openclaw/server/api/webhook.ts index f906e8d6fedac..633c10491ff9d 100644 --- a/apps/meteor/app/openclaw/server/api/webhook.ts +++ b/apps/meteor/app/openclaw/server/api/webhook.ts @@ -7,11 +7,16 @@ import { settings } from '../../../settings/server'; import { openclawLogger } from '../logger'; import { getOpenClawBotUser, getRoomById } from '../lib/messageHandler'; -function safeCompare(a: string, b: string): boolean { - if (a.length !== b.length) { +function safeCompare(a: unknown, b: string): boolean { + if (typeof a !== 'string') { return false; } - return timingSafeEqual(Buffer.from(a), Buffer.from(b)); + const aBuffer = Buffer.from(a, 'utf8'); + const bBuffer = Buffer.from(b, 'utf8'); + if (aBuffer.length !== bBuffer.length) { + return false; + } + return timingSafeEqual(aBuffer, bBuffer); } API.v1.addRoute( @@ -32,21 +37,21 @@ API.v1.addRoute( } const expectedToken = settings.get('OpenClaw_Auth_Token'); - const receivedToken = body.token as string | undefined; + const receivedToken = body.token; - if (!expectedToken || !receivedToken || !safeCompare(receivedToken, expectedToken)) { + if (!expectedToken || !safeCompare(receivedToken, expectedToken)) { openclawLogger.warn({ msg: 'OpenClaw webhook token validation failed' }); return API.v1.unauthorized('Invalid webhook token'); } - const channelId = body.channel_id as string | undefined; - const text = body.text as string | undefined; + const channelId = typeof body.channel_id === 'string' ? body.channel_id : undefined; + const text = typeof body.text === 'string' ? body.text : undefined; if (!channelId) { return API.v1.failure('Missing required field: channel_id'); } - if (!text || text.trim().length === 0) { + if (!text?.trim()) { return API.v1.failure('Missing required field: text'); } @@ -62,12 +67,12 @@ API.v1.addRoute( return API.v1.failure('Bot user not found'); } - const threadId = body.thread_id as string | undefined; - const alias = (body.alias as string) || 'OpenClaw AI'; - const avatar = (body.avatar as string) || ''; - const emoji = (body.emoji as string) || ':robot:'; + const threadId = typeof body.thread_id === 'string' ? body.thread_id : undefined; + const alias = typeof body.alias === 'string' ? body.alias : 'OpenClaw AI'; + const avatar = typeof body.avatar === 'string' ? body.avatar : ''; + const emoji = typeof body.emoji === 'string' ? body.emoji : ':robot:'; - const messagePayload: Record = { + const messagePayload = { text, channel: `#${room._id}`, ...(threadId && { tmid: threadId }), diff --git a/apps/meteor/app/openclaw/server/lib/openclawClient.ts b/apps/meteor/app/openclaw/server/lib/openclawClient.ts index a2e70d11d4b2d..95731002c2194 100644 --- a/apps/meteor/app/openclaw/server/lib/openclawClient.ts +++ b/apps/meteor/app/openclaw/server/lib/openclawClient.ts @@ -67,17 +67,15 @@ export async function sendToAgent(payload: OpenClawAgentPayload): Promise('OpenClaw_Respond_In_Thread'); - void api.broadcast('notify.ephemeralMessage', userId, message.rid, { - msg: responseText, - ...(respondInThread && message.tmid && { tmid: message.tmid }), - }); + const botUser = await getOpenClawBotUser(); + if (botUser) { + await sendMessage( + botUser, + { + msg: responseText, + rid: message.rid, + ...(respondInThread && message.tmid && { tmid: message.tmid }), + }, + room, + ); + } else { + void api.broadcast('notify.ephemeralMessage', userId, message.rid, { + msg: responseText, + ...(respondInThread && message.tmid && { tmid: message.tmid }), + }); + } openclawLogger.info({ msg: 'OpenClaw slash command response delivered', diff --git a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php index ed9c74b2609f4..b85a79e6b4175 100644 --- a/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php +++ b/apps/meteor/tests/e2e/containers/saml/config/simplesamlphp/config.php @@ -16,7 +16,7 @@ 'technicalcontact_name' => 'Administrator', 'technicalcontact_email' => 'na@example.org', 'timezone' => null, - 'logging.level' => SimpleSAML_Logger::DEBUG, + 'logging.level' => SimpleSAML\Logger::DEBUG, 'logging.handler' => 'errorlog', //'logging.format' => '%date{%b %d %H:%M:%S} %process %level %stat[%trackid] %msg', 'logging.facility' => defined('LOG_LOCAL5') ? constant('LOG_LOCAL5') : LOG_USER, diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index fa64f6ee9e902..e6ab06973549e 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4019,6 +4019,7 @@ "OpenClaw_Slash_Description": "Send a prompt to the OpenClaw AI agent", "OpenClaw_Slash_Params": "your_prompt_here", "OpenClaw_Usage_Hint": "Usage: `/openclaw ` — Send a prompt to the OpenClaw AI agent.", + "OpenClaw_Room_Not_Found": "**OpenClaw:** Room not found.", "OpenClaw_Error_Not_Enabled": ":warning: **OpenClaw:** The integration is not enabled. Enable it in Admin → Settings → OpenClaw.", "OpenClaw_Error_Missing_API_URL": ":warning: **OpenClaw:** The API URL is not configured. Set it in Admin → Settings → OpenClaw.", "OpenClaw_Error_Missing_Auth_Token": ":warning: **OpenClaw:** The authentication token is not configured. Set it in Admin → Settings → OpenClaw.",