Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/openclaw-integration.md
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions apps/meteor/app/openclaw/README.md
Original file line number Diff line number Diff line change
@@ -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

```bash
/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:

```json
POST /api/v1/openclaw.webhook
Content-Type: application/json

{
"token": "<your_auth_token>",
"channel_id": "<room_id>",
"text": "Hello from OpenClaw!",
"thread_id": "<optional_thread_id>"
}
```

## Architecture

```text
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
114 changes: 114 additions & 0 deletions apps/meteor/app/openclaw/server/api/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { timingSafeEqual } from 'crypto';

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';

function safeCompare(a: unknown, b: string): boolean {
if (typeof a !== 'string') {
return false;
}
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(
'openclaw.webhook',
{ authRequired: false },
{
async post() {
openclawLogger.info({ msg: 'Received OpenClaw webhook callback' });

if (settings.get<boolean>('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');
}

const expectedToken = settings.get<string>('OpenClaw_Auth_Token');
const receivedToken = body.token;

if (!expectedToken || !safeCompare(receivedToken, expectedToken)) {
openclawLogger.warn({ msg: 'OpenClaw webhook token validation failed' });
return API.v1.unauthorized('Invalid webhook token');
}

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?.trim()) {
return API.v1.failure('Missing required field: text');
}

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

const botUser = await getOpenClawBotUser();
if (!botUser || !botUser.username) {
openclawLogger.error({ msg: 'OpenClaw bot user not found' });
return API.v1.failure('Bot user not found');
}

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 = {
text,
channel: `#${room._id}`,
...(threadId && { tmid: threadId }),
};

const defaultValues = {
alias,
avatar,
emoji,
channel: `#${room._id}`,
};

try {
const result = await processWebhookMessage(
messagePayload,
{ ...botUser, username: botUser.username },
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}`);
}
},
},
);
3 changes: 3 additions & 0 deletions apps/meteor/app/openclaw/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import './settings';
import './slashCommand';
import './api/webhook';
102 changes: 102 additions & 0 deletions apps/meteor/app/openclaw/server/lib/messageHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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';

export function shouldProcessMessage(message: IMessage): boolean {
if (!isEnabled()) {
return false;
}

if (message.t) {
return false;
}

if (!message.msg || message.msg.trim().length === 0) {
return false;
}

if (message.bot) {
return false;
}

const botUsername = settings.get<string>('OpenClaw_Bot_Username');
if (botUsername && message.u?.username === botUsername) {
return false;
}

return true;
}

export function formatMessagePayload(
message: IMessage,
room: IRoom,
callbackUrl?: string,
): OpenClawAgentPayload {
const respondInThread = settings.get<boolean>('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 }),
};
}

export async function forwardMessageToAgent(
message: IMessage,
room: IRoom,
callbackUrl?: string,
): Promise<string | null> {
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;
}

export async function getOpenClawBotUser() {
const botUsername = settings.get<string>('OpenClaw_Bot_Username') || 'openclaw.bot';

const user = await Users.findOneByUsername(botUsername);
if (user) {
return user;
}

openclawLogger.debug({
msg: 'OpenClaw bot user not found, falling back to rocket.cat',
botUsername,
fallback: 'rocket.cat',
});

return Users.findOneByUsername('rocket.cat');
}

export async function getRoomById(roomId: string): Promise<IRoom | null> {
const room = await Rooms.findOneById(roomId);
return room || null;
}
Loading