@cloudrise/openclaw-channel-rocketchat 0.1.0 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -0
- package/README.md +325 -7
- package/package.json +2 -1
- package/src/rocketchat/monitor.ts +131 -10
- package/src/rocketchat/realtime.ts +21 -6
- package/test-chad.mjs +20 -76
- package/test-chad2.mjs +20 -108
- package/test-realtime.mjs +20 -65
package/.env.example
ADDED
package/README.md
CHANGED
|
@@ -1,20 +1,338 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenClaw Rocket.Chat Channel Plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@cloudrise/openclaw-channel-rocketchat)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Neutral, self-host friendly Rocket.Chat channel plugin for **OpenClaw** (Cloudrise-maintained).
|
|
4
7
|
|
|
5
8
|
- **Inbound:** Rocket.Chat Realtime (DDP/WebSocket) subscribe to `stream-room-messages`
|
|
6
9
|
- **Outbound:** Rocket.Chat REST `chat.postMessage`
|
|
7
10
|
|
|
11
|
+
## Upgrade / rename notice
|
|
12
|
+
|
|
13
|
+
If you were using the old Clawdbot-era package:
|
|
14
|
+
|
|
15
|
+
- Old: `@cloudrise/clawdbot-channel-rocketchat`
|
|
16
|
+
- New: `@cloudrise/openclaw-channel-rocketchat`
|
|
17
|
+
|
|
18
|
+
## Authors
|
|
19
|
+
|
|
20
|
+
- Chad (AI assistant running in OpenClaw) — primary implementer
|
|
21
|
+
- Marshal Morse — project owner, requirements, infrastructure, and testing
|
|
22
|
+
|
|
23
|
+
## Quickstart (5–10 minutes)
|
|
24
|
+
|
|
25
|
+
1) **Create a Rocket.Chat bot user** (or a dedicated user account) and obtain:
|
|
26
|
+
- `userId`
|
|
27
|
+
- `authToken` (treat like a password)
|
|
28
|
+
|
|
29
|
+
2) **Add the bot user to the rooms** you want it to monitor (channels/private groups). For DMs, ensure users can message the bot.
|
|
30
|
+
|
|
31
|
+
3) **Install + enable the plugin in OpenClaw**
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
plugins:
|
|
35
|
+
installs:
|
|
36
|
+
rocketchat:
|
|
37
|
+
source: npm
|
|
38
|
+
spec: "@cloudrise/openclaw-channel-rocketchat"
|
|
39
|
+
entries:
|
|
40
|
+
rocketchat:
|
|
41
|
+
enabled: true
|
|
42
|
+
|
|
43
|
+
channels:
|
|
44
|
+
rocketchat:
|
|
45
|
+
baseUrl: "https://chat.example.com"
|
|
46
|
+
userId: "<ROCKETCHAT_USER_ID>"
|
|
47
|
+
authToken: "<ROCKETCHAT_AUTH_TOKEN>"
|
|
48
|
+
|
|
49
|
+
# Optional: keep noise down
|
|
50
|
+
replyMode: auto
|
|
51
|
+
rooms:
|
|
52
|
+
GENERAL:
|
|
53
|
+
requireMention: true
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
4) **Restart the gateway**.
|
|
57
|
+
|
|
58
|
+
5) **Test** by @mentioning the bot in a room it’s a member of.
|
|
59
|
+
|
|
60
|
+
### Example chat commands (reply to a room + model switching)
|
|
61
|
+
|
|
62
|
+
In Rocket.Chat you can send a normal message, or you can switch the session’s model first.
|
|
63
|
+
|
|
64
|
+
**Switch model, then ask a question**:
|
|
65
|
+
|
|
66
|
+
Rocket.Chat treats messages starting with `/` as Rocket.Chat slash-commands.
|
|
67
|
+
So for model switching, either:
|
|
68
|
+
|
|
69
|
+
- put the directive *after* an @mention (works on most servers/clients), or
|
|
70
|
+
- use the plugin’s alternate `--model` / `--<alias>` syntax.
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
# Option A: use /model after an @mention
|
|
74
|
+
@Chad /model qwen3
|
|
75
|
+
@Chad write a 5-line summary of our incident in plain English
|
|
76
|
+
|
|
77
|
+
# Option B: alternate syntax (avoids Rocket.Chat /commands)
|
|
78
|
+
@Chad --model qwen3
|
|
79
|
+
@Chad write a 5-line summary of our incident in plain English
|
|
80
|
+
|
|
81
|
+
# Option C: shorthand alias form
|
|
82
|
+
@Chad --qwen3
|
|
83
|
+
@Chad write a 5-line summary of our incident in plain English
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Example output** (with `messages.responsePrefix: "({model}) "` enabled):
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
(mlx-qwen/mlx-community/Qwen3-14B-4bit) Here’s a 5-line summary...
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Send a one-off message to a specific Rocket.Chat room** (from the gateway host):
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
openclaw message send --channel rocketchat --to room:GENERAL --message "Hello from OpenClaw"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Send using a specific model for that one message**:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
openclaw message send --channel rocketchat --to room:GENERAL --message "/model qwen3 Hello from Qwen3"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
8
107
|
## Install
|
|
9
108
|
|
|
109
|
+
### Install from npm
|
|
110
|
+
|
|
10
111
|
```bash
|
|
11
|
-
npm
|
|
112
|
+
npm install @cloudrise/openclaw-channel-rocketchat
|
|
12
113
|
```
|
|
13
114
|
|
|
14
|
-
|
|
115
|
+
### Configure OpenClaw to load the plugin
|
|
15
116
|
|
|
16
|
-
|
|
117
|
+
You need to tell OpenClaw to load the installed plugin.
|
|
17
118
|
|
|
18
|
-
|
|
19
|
-
|
|
119
|
+
**Option A (recommended): install via `plugins.installs` (npm source)**
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
plugins:
|
|
123
|
+
installs:
|
|
124
|
+
rocketchat:
|
|
125
|
+
source: npm
|
|
126
|
+
spec: "@cloudrise/openclaw-channel-rocketchat"
|
|
127
|
+
entries:
|
|
128
|
+
rocketchat:
|
|
129
|
+
enabled: true
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Option B: load from a local path**
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
plugins:
|
|
136
|
+
load:
|
|
137
|
+
paths:
|
|
138
|
+
- /absolute/path/to/node_modules/@cloudrise/openclaw-channel-rocketchat
|
|
139
|
+
entries:
|
|
140
|
+
rocketchat:
|
|
141
|
+
enabled: true
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then restart the gateway.
|
|
145
|
+
|
|
146
|
+
## Features
|
|
147
|
+
|
|
148
|
+
- **Image attachments**: receives images uploaded to Rocket.Chat and passes them to the vision model.
|
|
149
|
+
- **Model prefix**: honors `messages.responsePrefix` (e.g. `({model}) `) so replies can include the model name.
|
|
150
|
+
|
|
151
|
+
## Model switching
|
|
152
|
+
|
|
153
|
+
There are two parts:
|
|
154
|
+
|
|
155
|
+
1) **Switching models in chat** (temporary, per-session) via `/model ...`
|
|
156
|
+
2) **Defining short aliases** like `qwen3` so you don’t have to type the full `provider/model`
|
|
157
|
+
|
|
158
|
+
### Switching models in chat (`/model`)
|
|
159
|
+
|
|
160
|
+
In any chat where OpenClaw slash-commands are enabled, you can switch the current session’s model:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
/model
|
|
164
|
+
/model list
|
|
165
|
+
/model status
|
|
166
|
+
/model openai/gpt-5.2
|
|
167
|
+
/model qwen3
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Tip: on Rocket.Chat you’ll often be writing something like:
|
|
171
|
+
|
|
172
|
+
```text
|
|
173
|
+
@Chad /model qwen3
|
|
174
|
+
@Chad what do you think about ...
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Model aliases (shortcuts like `qwen3`)
|
|
178
|
+
|
|
179
|
+
OpenClaw supports **model aliases** so you can type a short name (like `qwen3`) instead of a full `provider/model` ref.
|
|
180
|
+
|
|
181
|
+
**Option A: define aliases in config**
|
|
182
|
+
|
|
183
|
+
Aliases come from `agents.defaults.models.<modelId>.alias`.
|
|
184
|
+
|
|
185
|
+
```yaml
|
|
186
|
+
agents:
|
|
187
|
+
defaults:
|
|
188
|
+
models:
|
|
189
|
+
"mlx-qwen/mlx-community/qwen3-14b-4bit":
|
|
190
|
+
alias: qwen3
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Option B: use the CLI**
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
openclaw models aliases add qwen3 mlx-qwen/mlx-community/Qwen3-14B-4bit
|
|
197
|
+
openclaw models aliases list
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Notes:
|
|
201
|
+
- Model refs are normalized to lowercase.
|
|
202
|
+
- If you define the same alias in config and via CLI, your config value wins.
|
|
203
|
+
|
|
204
|
+
## Configuration
|
|
205
|
+
|
|
206
|
+
> Use the room **rid** (e.g. `GENERAL`) for per-room settings.
|
|
207
|
+
|
|
208
|
+
### Minimal (single account)
|
|
209
|
+
|
|
210
|
+
```yaml
|
|
211
|
+
channels:
|
|
212
|
+
rocketchat:
|
|
213
|
+
baseUrl: "https://chat.example.com"
|
|
214
|
+
userId: "<ROCKETCHAT_USER_ID>"
|
|
215
|
+
authToken: "<ROCKETCHAT_AUTH_TOKEN>"
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Multiple accounts / multiple Rocket.Chat servers
|
|
219
|
+
|
|
220
|
+
You can configure multiple Rocket.Chat “accounts” under `channels.rocketchat.accounts` and choose which one to use via `accountId` when sending.
|
|
221
|
+
|
|
222
|
+
```yaml
|
|
223
|
+
channels:
|
|
224
|
+
rocketchat:
|
|
225
|
+
accounts:
|
|
226
|
+
prod:
|
|
227
|
+
name: "Prod RC"
|
|
228
|
+
baseUrl: "https://chat.example.com"
|
|
229
|
+
userId: "<PROD_USER_ID>"
|
|
230
|
+
authToken: "<PROD_AUTH_TOKEN>"
|
|
231
|
+
|
|
232
|
+
staging:
|
|
233
|
+
name: "Staging RC"
|
|
234
|
+
baseUrl: "https://chat-staging.example.com"
|
|
235
|
+
userId: "<STAGING_USER_ID>"
|
|
236
|
+
authToken: "<STAGING_AUTH_TOKEN>"
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Notes:
|
|
240
|
+
- The legacy single-account format (top-level `baseUrl/userId/authToken`) still works and is treated as `accountId: default`.
|
|
241
|
+
- Per-room settings live under each account (e.g. `channels.rocketchat.accounts.prod.rooms`).
|
|
242
|
+
|
|
243
|
+
### Reply routing (thread vs channel)
|
|
244
|
+
|
|
245
|
+
```yaml
|
|
246
|
+
channels:
|
|
247
|
+
rocketchat:
|
|
248
|
+
# thread | channel | auto
|
|
249
|
+
replyMode: auto
|
|
250
|
+
|
|
251
|
+
rooms:
|
|
252
|
+
GENERAL:
|
|
253
|
+
requireMention: false
|
|
254
|
+
# Optional per-room override
|
|
255
|
+
# replyMode: channel
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Auto rules** (deterministic):
|
|
259
|
+
- If the inbound message is already in a thread (`tmid` exists) → reply in that thread
|
|
260
|
+
- Else if the inbound message is “long” (≥280 chars or contains a newline) → reply in a thread
|
|
261
|
+
- Else → reply in channel
|
|
262
|
+
|
|
263
|
+
### Per-message overrides
|
|
264
|
+
|
|
265
|
+
Prefix your message:
|
|
266
|
+
- `!thread ...` → force the reply to be posted as a thread reply
|
|
267
|
+
- `!channel ...` → force the reply to be posted in the channel
|
|
268
|
+
|
|
269
|
+
(The prefix is stripped before the message is sent to the agent.)
|
|
270
|
+
|
|
271
|
+
### Typing indicator
|
|
272
|
+
|
|
273
|
+
```yaml
|
|
274
|
+
channels:
|
|
275
|
+
rocketchat:
|
|
276
|
+
# Delay (ms) before emitting typing indicator
|
|
277
|
+
typingDelayMs: 500
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
(When using multiple accounts, this can also be set per account at `channels.rocketchat.accounts.<accountId>.typingDelayMs`.)
|
|
281
|
+
|
|
282
|
+
Typing indicators are emitted via DDP `stream-notify-room` using `<RID>/user-activity`.
|
|
283
|
+
- Channel replies emit typing without `tmid` → shows under channel composer
|
|
284
|
+
- Thread replies include `{ tmid: ... }` → shows under thread composer
|
|
285
|
+
|
|
286
|
+
## Development
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
git clone git@github.com:cloudrise-network/openclaw-channel-rocketchat.git
|
|
290
|
+
cd openclaw-channel-rocketchat
|
|
291
|
+
npm install
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Local smoke tests (uses env vars; see `.env.example`):
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
# REST send
|
|
298
|
+
node test-chad.mjs
|
|
299
|
+
|
|
300
|
+
# Realtime receive
|
|
301
|
+
node test-realtime.mjs
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Packaging + publishing (no secrets)
|
|
305
|
+
|
|
306
|
+
Before publishing:
|
|
307
|
+
|
|
308
|
+
1) Run a quick secret scan (at minimum):
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
grep -RIn --exclude-dir=node_modules --exclude=package-lock.json -E "npm_[A-Za-z0-9]+|ghp_[A-Za-z0-9]+|xox[baprs]-|authToken\s*[:=]\s*\"" .
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
2) Bump version in `package.json`.
|
|
315
|
+
|
|
316
|
+
3) Verify the tarball:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
npm pack
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
4) Publish:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
npm publish
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
(There is also a GitHub Actions workflow in `.github/workflows/publish.yml`.)
|
|
329
|
+
|
|
330
|
+
## Security
|
|
331
|
+
|
|
332
|
+
Treat Rocket.Chat `authToken` like a password.
|
|
333
|
+
|
|
334
|
+
This repository is intended to be publishable (no secrets committed).
|
|
335
|
+
|
|
336
|
+
## License
|
|
20
337
|
|
|
338
|
+
MIT
|
package/package.json
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
RuntimeEnv,
|
|
9
9
|
} from "openclaw/plugin-sdk";
|
|
10
10
|
|
|
11
|
+
import { createReplyPrefixContext } from "openclaw/plugin-sdk";
|
|
12
|
+
|
|
11
13
|
import { getRocketChatRuntime } from "../runtime.js";
|
|
12
14
|
import { resolveRocketChatAccount, type ResolvedRocketChatAccount } from "./accounts.js";
|
|
13
15
|
import {
|
|
@@ -20,7 +22,7 @@ import {
|
|
|
20
22
|
type RocketChatRoom,
|
|
21
23
|
type RocketChatClient,
|
|
22
24
|
} from "./client.js";
|
|
23
|
-
import { RocketChatRealtime, type IncomingMessage } from "./realtime.js";
|
|
25
|
+
import { RocketChatRealtime, type IncomingMessage, type RocketChatAttachment, type RocketChatFile } from "./realtime.js";
|
|
24
26
|
import { sendMessageRocketChat } from "./send.js";
|
|
25
27
|
|
|
26
28
|
export type MonitorRocketChatOpts = {
|
|
@@ -68,17 +70,92 @@ function chatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "chann
|
|
|
68
70
|
return "channel";
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/** Image MIME types we can send to vision models */
|
|
74
|
+
const IMAGE_MIME_TYPES = new Set([
|
|
75
|
+
"image/jpeg",
|
|
76
|
+
"image/png",
|
|
77
|
+
"image/gif",
|
|
78
|
+
"image/webp",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
function isImageMime(mime?: string): boolean {
|
|
82
|
+
if (!mime) return false;
|
|
83
|
+
return IMAGE_MIME_TYPES.has(mime.toLowerCase().split(";")[0].trim());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract image URLs from Rocket.Chat message attachments/files.
|
|
88
|
+
* Returns full URLs that can be fetched with auth headers.
|
|
89
|
+
*/
|
|
90
|
+
function extractImageUrls(
|
|
91
|
+
msg: IncomingMessage,
|
|
92
|
+
baseUrl: string
|
|
93
|
+
): Array<{ url: string; mimeType?: string }> {
|
|
94
|
+
const images: Array<{ url: string; mimeType?: string }> = [];
|
|
95
|
+
|
|
96
|
+
// From attachments array (used for image_url references)
|
|
97
|
+
if (msg.attachments?.length) {
|
|
98
|
+
for (const att of msg.attachments) {
|
|
99
|
+
if (att.image_url) {
|
|
100
|
+
const url = att.image_url.startsWith("http")
|
|
101
|
+
? att.image_url
|
|
102
|
+
: `${baseUrl}${att.image_url.startsWith("/") ? "" : "/"}${att.image_url}`;
|
|
103
|
+
images.push({ url, mimeType: att.image_type });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// From file/files (used for direct uploads)
|
|
109
|
+
const files = msg.files ?? (msg.file ? [msg.file] : []);
|
|
110
|
+
for (const f of files) {
|
|
111
|
+
if (f._id && f.name && isImageMime(f.type)) {
|
|
112
|
+
// Rocket.Chat file-upload URL pattern
|
|
113
|
+
const url = `${baseUrl}/file-upload/${f._id}/${encodeURIComponent(f.name)}`;
|
|
114
|
+
images.push({ url, mimeType: f.type });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return images;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fetch an image from Rocket.Chat and return as base64 data URL.
|
|
123
|
+
*/
|
|
124
|
+
async function fetchImageAsDataUrl(
|
|
125
|
+
url: string,
|
|
126
|
+
authToken: string,
|
|
127
|
+
userId: string,
|
|
128
|
+
mimeType?: string
|
|
129
|
+
): Promise<string | null> {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(url, {
|
|
132
|
+
headers: {
|
|
133
|
+
"X-Auth-Token": authToken,
|
|
134
|
+
"X-User-Id": userId,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
if (!res.ok) return null;
|
|
138
|
+
|
|
139
|
+
const contentType = mimeType ?? res.headers.get("content-type") ?? "image/png";
|
|
140
|
+
if (!isImageMime(contentType)) return null;
|
|
141
|
+
|
|
142
|
+
const buffer = await res.arrayBuffer();
|
|
143
|
+
const base64 = Buffer.from(buffer).toString("base64");
|
|
144
|
+
return `data:${contentType};base64,${base64}`;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
71
150
|
export async function monitorRocketChatProvider(
|
|
72
151
|
opts: MonitorRocketChatOpts
|
|
73
152
|
): Promise<() => void> {
|
|
74
|
-
console.log("[ROCKETCHAT DEBUG] monitorRocketChatProvider called!");
|
|
75
153
|
const core = getRocketChatRuntime();
|
|
76
154
|
const logger = core?.logging?.getChildLogger?.({ module: "rocketchat" }) ?? {
|
|
77
155
|
info: console.log,
|
|
78
156
|
debug: console.log,
|
|
79
157
|
error: console.error,
|
|
80
158
|
};
|
|
81
|
-
console.log("[ROCKETCHAT DEBUG] Got logger, getting config...");
|
|
82
159
|
const cfg = opts.config ?? core?.config?.loadConfig?.() ?? {};
|
|
83
160
|
|
|
84
161
|
const account = resolveRocketChatAccount({
|
|
@@ -229,9 +306,7 @@ export async function monitorRocketChatProvider(
|
|
|
229
306
|
});
|
|
230
307
|
|
|
231
308
|
// Connect and subscribe to all rooms
|
|
232
|
-
console.log("[ROCKETCHAT DEBUG] About to connect realtime...");
|
|
233
309
|
await realtime.connect();
|
|
234
|
-
console.log("[ROCKETCHAT DEBUG] Realtime connected!");
|
|
235
310
|
|
|
236
311
|
// Subscribe to current rooms
|
|
237
312
|
await refreshSubscriptions();
|
|
@@ -295,8 +370,16 @@ async function handleIncomingMessage(
|
|
|
295
370
|
? msg.ts.$date
|
|
296
371
|
: Date.parse(String(msg.ts));
|
|
297
372
|
|
|
373
|
+
// Extract image attachments (if any)
|
|
374
|
+
const baseUrl = account.baseUrl;
|
|
375
|
+
const authToken = account.authToken;
|
|
376
|
+
const userId = account.userId;
|
|
377
|
+
const imageRefs = extractImageUrls(msg, baseUrl);
|
|
378
|
+
|
|
298
379
|
let rawBody = msg.msg.trim();
|
|
299
|
-
|
|
380
|
+
|
|
381
|
+
// Allow messages with only images (no text)
|
|
382
|
+
if (!rawBody && imageRefs.length === 0) return;
|
|
300
383
|
|
|
301
384
|
// Optional per-message overrides
|
|
302
385
|
// - !thread -> force reply in thread
|
|
@@ -309,7 +392,8 @@ async function handleIncomingMessage(
|
|
|
309
392
|
forcedReplyMode = "channel";
|
|
310
393
|
rawBody = rawBody.replace(/^!channel\b\s*/i, "").trim();
|
|
311
394
|
}
|
|
312
|
-
if
|
|
395
|
+
// Skip if no text and no images
|
|
396
|
+
if (!rawBody && imageRefs.length === 0) return;
|
|
313
397
|
|
|
314
398
|
// Determine reply mode
|
|
315
399
|
const baseReplyMode: "thread" | "channel" | "auto" =
|
|
@@ -390,20 +474,45 @@ async function handleIncomingMessage(
|
|
|
390
474
|
: undefined;
|
|
391
475
|
|
|
392
476
|
// Format the envelope body
|
|
477
|
+
// For image-only messages, use a placeholder so the agent knows there's content
|
|
478
|
+
const effectiveRawBody = rawBody || (imageRefs.length > 0 ? "[image]" : "");
|
|
393
479
|
const body = core.channel?.reply?.formatAgentEnvelope?.({
|
|
394
480
|
channel: "Rocket.Chat",
|
|
395
481
|
from: fromLabel,
|
|
396
482
|
timestamp: ts,
|
|
397
483
|
previousTimestamp,
|
|
398
484
|
envelope: envelopeOptions,
|
|
399
|
-
body:
|
|
400
|
-
}) ??
|
|
485
|
+
body: effectiveRawBody,
|
|
486
|
+
}) ?? effectiveRawBody;
|
|
487
|
+
|
|
488
|
+
// Rocket.Chat NOTE: Messages starting with "/" are treated as Rocket.Chat slash-commands.
|
|
489
|
+
// To make model switching usable from chat, we support an alternate syntax:
|
|
490
|
+
// --model qwen3
|
|
491
|
+
// --qwen3
|
|
492
|
+
// which we normalize into OpenClaw inline directives.
|
|
493
|
+
const commandBody = rawBody
|
|
494
|
+
.replace(/^\s*--model\b/i, "/model")
|
|
495
|
+
.replace(/^\s*--/, "/");
|
|
496
|
+
|
|
497
|
+
// Fetch images as data URLs (authenticated fetch required for Rocket.Chat uploads)
|
|
498
|
+
let mediaUrls: string[] | undefined;
|
|
499
|
+
if (imageRefs.length > 0) {
|
|
500
|
+
const fetched = await Promise.all(
|
|
501
|
+
imageRefs.map((ref) =>
|
|
502
|
+
fetchImageAsDataUrl(ref.url, authToken, userId, ref.mimeType)
|
|
503
|
+
)
|
|
504
|
+
);
|
|
505
|
+
mediaUrls = fetched.filter((u): u is string => u !== null);
|
|
506
|
+
if (mediaUrls.length > 0) {
|
|
507
|
+
logger.debug?.(`Fetched ${mediaUrls.length} image(s) from Rocket.Chat attachments`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
401
510
|
|
|
402
511
|
// Finalize inbound context
|
|
403
512
|
const ctxPayload = core.channel?.reply?.finalizeInboundContext?.({
|
|
404
513
|
Body: body,
|
|
405
514
|
RawBody: rawBody,
|
|
406
|
-
CommandBody:
|
|
515
|
+
CommandBody: commandBody,
|
|
407
516
|
From: isGroup ? `rocketchat:room:${roomId}` : `rocketchat:${senderId}`,
|
|
408
517
|
To: `rocketchat:${roomId}`,
|
|
409
518
|
SessionKey: route.sessionKey,
|
|
@@ -419,6 +528,9 @@ async function handleIncomingMessage(
|
|
|
419
528
|
Timestamp: ts,
|
|
420
529
|
OriginatingChannel: "rocketchat",
|
|
421
530
|
OriginatingTo: `rocketchat:${roomId}`,
|
|
531
|
+
|
|
532
|
+
// Image attachments (fetched as base64 data URLs)
|
|
533
|
+
MediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
|
422
534
|
});
|
|
423
535
|
|
|
424
536
|
if (!ctxPayload) {
|
|
@@ -484,10 +596,16 @@ async function handleIncomingMessage(
|
|
|
484
596
|
startTypingAfterDelay();
|
|
485
597
|
|
|
486
598
|
try {
|
|
599
|
+
// Wire up responsePrefix support (e.g. messages.responsePrefix: "({model}) ")
|
|
600
|
+
// so Rocket.Chat replies can include the selected model name.
|
|
601
|
+
const prefix = createReplyPrefixContext({ cfg, agentId: route.agentId });
|
|
602
|
+
|
|
487
603
|
await core.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher?.({
|
|
488
604
|
ctx: ctxPayload,
|
|
489
605
|
cfg,
|
|
490
606
|
dispatcherOptions: {
|
|
607
|
+
responsePrefix: prefix.responsePrefix,
|
|
608
|
+
responsePrefixContextProvider: prefix.responsePrefixContextProvider,
|
|
491
609
|
deliver: async (payload) => {
|
|
492
610
|
const text = (payload as { text?: string }).text ?? "";
|
|
493
611
|
if (!text.trim()) return;
|
|
@@ -504,6 +622,9 @@ async function handleIncomingMessage(
|
|
|
504
622
|
logger.error?.(`Rocket.Chat ${info.kind} reply failed: ${String(err)}`);
|
|
505
623
|
},
|
|
506
624
|
},
|
|
625
|
+
replyOptions: {
|
|
626
|
+
onModelSelected: prefix.onModelSelected,
|
|
627
|
+
},
|
|
507
628
|
});
|
|
508
629
|
} finally {
|
|
509
630
|
await stopTyping();
|
|
@@ -33,6 +33,24 @@ export type RealtimeOpts = {
|
|
|
33
33
|
logger?: { debug?: (msg: string) => void; info?: (msg: string) => void };
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
export type RocketChatAttachment = {
|
|
37
|
+
title?: string;
|
|
38
|
+
title_link?: string;
|
|
39
|
+
image_url?: string;
|
|
40
|
+
audio_url?: string;
|
|
41
|
+
video_url?: string;
|
|
42
|
+
type?: string;
|
|
43
|
+
image_type?: string;
|
|
44
|
+
image_size?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type RocketChatFile = {
|
|
48
|
+
_id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
type?: string;
|
|
51
|
+
size?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
36
54
|
export type IncomingMessage = {
|
|
37
55
|
_id: string;
|
|
38
56
|
rid: string;
|
|
@@ -41,12 +59,9 @@ export type IncomingMessage = {
|
|
|
41
59
|
u: { _id: string; username: string; name?: string };
|
|
42
60
|
tmid?: string;
|
|
43
61
|
t?: string;
|
|
44
|
-
attachments?:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
audio_url?: string;
|
|
48
|
-
video_url?: string;
|
|
49
|
-
}>;
|
|
62
|
+
attachments?: RocketChatAttachment[];
|
|
63
|
+
file?: RocketChatFile;
|
|
64
|
+
files?: RocketChatFile[];
|
|
50
65
|
};
|
|
51
66
|
|
|
52
67
|
export class RocketChatRealtime {
|
package/test-chad.mjs
CHANGED
|
@@ -1,79 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
// Local manual test helper.
|
|
2
|
+
// DO NOT hardcode credentials in this repo.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// export ROCKETCHAT_BASE_URL='https://your-rocketchat'
|
|
6
|
+
// export ROCKETCHAT_AUTH_TOKEN='...'
|
|
7
|
+
// export ROCKETCHAT_USER_ID='...'
|
|
8
|
+
// node ./test-realtime.mjs
|
|
9
|
+
|
|
10
|
+
const baseUrl = process.env.ROCKETCHAT_BASE_URL;
|
|
11
|
+
const authToken = process.env.ROCKETCHAT_AUTH_TOKEN;
|
|
12
|
+
const userId = process.env.ROCKETCHAT_USER_ID;
|
|
13
|
+
|
|
14
|
+
if (!baseUrl || !authToken || !userId) {
|
|
15
|
+
throw new Error('Missing env: ROCKETCHAT_BASE_URL, ROCKETCHAT_AUTH_TOKEN, ROCKETCHAT_USER_ID');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
ws.on("message", (data) => {
|
|
24
|
-
const msg = JSON.parse(data.toString());
|
|
25
|
-
console.log("<<< RECV:", JSON.stringify(msg).slice(0, 200));
|
|
26
|
-
|
|
27
|
-
if (msg.msg === "connected") {
|
|
28
|
-
console.log("DDP connected, logging in as Chad...");
|
|
29
|
-
send({
|
|
30
|
-
msg: "method",
|
|
31
|
-
method: "login",
|
|
32
|
-
id: String(++msgId),
|
|
33
|
-
params: [{ resume: authToken }]
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (msg.msg === "result" && msg.id === "1") {
|
|
38
|
-
if (msg.error) {
|
|
39
|
-
console.error("LOGIN FAILED:", msg.error);
|
|
40
|
-
ws.close();
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
console.log("Logged in as Chad! Subscribing to GENERAL...");
|
|
44
|
-
send({
|
|
45
|
-
msg: "sub",
|
|
46
|
-
id: String(++msgId),
|
|
47
|
-
name: "stream-room-messages",
|
|
48
|
-
params: ["GENERAL", false]
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (msg.msg === "ping") {
|
|
53
|
-
send({ msg: "pong" });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (msg.msg === "changed") {
|
|
57
|
-
console.log("\n*** MESSAGE RECEIVED ***");
|
|
58
|
-
console.log(JSON.stringify(msg, null, 2));
|
|
59
|
-
console.log("************************\n");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (msg.msg === "nosub") {
|
|
63
|
-
console.error("SUBSCRIPTION FAILED:", msg);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
ws.on("error", (err) => {
|
|
68
|
-
console.error("WebSocket Error:", err.message);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
ws.on("close", (code, reason) => {
|
|
72
|
-
console.log("Connection closed:", code, reason?.toString());
|
|
73
|
-
});
|
|
18
|
+
console.log('Base URL:', baseUrl);
|
|
19
|
+
console.log('User ID:', userId);
|
|
20
|
+
console.log('Auth token set:', authToken ? 'yes' : 'no');
|
|
74
21
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
console.log("Test complete, closing...");
|
|
78
|
-
ws.close();
|
|
79
|
-
}, 60000);
|
|
22
|
+
// TODO: call into the library / run your test logic here.
|
|
23
|
+
console.log('TODO: implement test logic');
|
package/test-chad2.mjs
CHANGED
|
@@ -1,111 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
// Local manual test helper.
|
|
2
|
+
// DO NOT hardcode credentials in this repo.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// export ROCKETCHAT_BASE_URL='https://your-rocketchat'
|
|
6
|
+
// export ROCKETCHAT_AUTH_TOKEN='...'
|
|
7
|
+
// export ROCKETCHAT_USER_ID='...'
|
|
8
|
+
// node ./test-realtime.mjs
|
|
9
|
+
|
|
10
|
+
const baseUrl = process.env.ROCKETCHAT_BASE_URL;
|
|
11
|
+
const authToken = process.env.ROCKETCHAT_AUTH_TOKEN;
|
|
12
|
+
const userId = process.env.ROCKETCHAT_USER_ID;
|
|
13
|
+
|
|
14
|
+
if (!baseUrl || !authToken || !userId) {
|
|
15
|
+
throw new Error('Missing env: ROCKETCHAT_BASE_URL, ROCKETCHAT_AUTH_TOKEN, ROCKETCHAT_USER_ID');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
ws.on("message", (data) => {
|
|
24
|
-
const msg = JSON.parse(data.toString());
|
|
25
|
-
const preview = JSON.stringify(msg).slice(0, 300);
|
|
26
|
-
console.log("<<< RECV:", preview);
|
|
27
|
-
|
|
28
|
-
if (msg.msg === "connected") {
|
|
29
|
-
console.log("\n=== DDP connected, logging in as Chad... ===\n");
|
|
30
|
-
send({
|
|
31
|
-
msg: "method",
|
|
32
|
-
method: "login",
|
|
33
|
-
id: String(++msgId),
|
|
34
|
-
params: [{ resume: authToken }]
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (msg.msg === "result" && msg.id === "1") {
|
|
39
|
-
if (msg.error) {
|
|
40
|
-
console.error("LOGIN FAILED:", msg.error);
|
|
41
|
-
ws.close();
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
console.log("\n=== Logged in! Trying multiple subscription formats... ===\n");
|
|
45
|
-
|
|
46
|
-
// Try format 1: just roomId and boolean
|
|
47
|
-
send({
|
|
48
|
-
msg: "sub",
|
|
49
|
-
id: String(++msgId),
|
|
50
|
-
name: "stream-room-messages",
|
|
51
|
-
params: ["GENERAL", false]
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// Try format 2: with event name
|
|
55
|
-
send({
|
|
56
|
-
msg: "sub",
|
|
57
|
-
id: String(++msgId),
|
|
58
|
-
name: "stream-room-messages",
|
|
59
|
-
params: ["GENERAL", { useCollection: false, args: [{ visitorToken: null }] }]
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// Try format 3: stream-notify-user for notifications
|
|
63
|
-
send({
|
|
64
|
-
msg: "sub",
|
|
65
|
-
id: String(++msgId),
|
|
66
|
-
name: "stream-notify-user",
|
|
67
|
-
params: [`${userId}/notification`, false]
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Try format 4: stream-notify-room
|
|
71
|
-
send({
|
|
72
|
-
msg: "sub",
|
|
73
|
-
id: String(++msgId),
|
|
74
|
-
name: "stream-notify-room",
|
|
75
|
-
params: ["GENERAL/deleteMessage", false]
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (msg.msg === "ping") {
|
|
80
|
-
send({ msg: "pong" });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (msg.msg === "changed") {
|
|
84
|
-
console.log("\n**************************************************");
|
|
85
|
-
console.log("*** MESSAGE/EVENT RECEIVED ***");
|
|
86
|
-
console.log(JSON.stringify(msg, null, 2));
|
|
87
|
-
console.log("**************************************************\n");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (msg.msg === "nosub") {
|
|
91
|
-
console.error("SUBSCRIPTION FAILED:", JSON.stringify(msg));
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (msg.msg === "ready") {
|
|
95
|
-
console.log("=== Subscription ready:", msg.subs, "===");
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
ws.on("error", (err) => {
|
|
100
|
-
console.error("WebSocket Error:", err.message);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
ws.on("close", (code, reason) => {
|
|
104
|
-
console.log("Connection closed:", code, reason?.toString());
|
|
105
|
-
});
|
|
18
|
+
console.log('Base URL:', baseUrl);
|
|
19
|
+
console.log('User ID:', userId);
|
|
20
|
+
console.log('Auth token set:', authToken ? 'yes' : 'no');
|
|
106
21
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
console.log("Test complete, closing...");
|
|
110
|
-
ws.close();
|
|
111
|
-
}, 45000);
|
|
22
|
+
// TODO: call into the library / run your test logic here.
|
|
23
|
+
console.log('TODO: implement test logic');
|
package/test-realtime.mjs
CHANGED
|
@@ -1,68 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
// Local manual test helper.
|
|
2
|
+
// DO NOT hardcode credentials in this repo.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// export ROCKETCHAT_BASE_URL='https://your-rocketchat'
|
|
6
|
+
// export ROCKETCHAT_AUTH_TOKEN='...'
|
|
7
|
+
// export ROCKETCHAT_USER_ID='...'
|
|
8
|
+
// node ./test-realtime.mjs
|
|
9
|
+
|
|
10
|
+
const baseUrl = process.env.ROCKETCHAT_BASE_URL;
|
|
11
|
+
const authToken = process.env.ROCKETCHAT_AUTH_TOKEN;
|
|
12
|
+
const userId = process.env.ROCKETCHAT_USER_ID;
|
|
13
|
+
|
|
14
|
+
if (!baseUrl || !authToken || !userId) {
|
|
15
|
+
throw new Error('Missing env: ROCKETCHAT_BASE_URL, ROCKETCHAT_AUTH_TOKEN, ROCKETCHAT_USER_ID');
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
ws.on("message", (data) => {
|
|
24
|
-
const msg = JSON.parse(data.toString());
|
|
25
|
-
console.log("<<< RECV:", JSON.stringify(msg));
|
|
26
|
-
|
|
27
|
-
if (msg.msg === "connected") {
|
|
28
|
-
console.log("DDP connected, logging in...");
|
|
29
|
-
send({
|
|
30
|
-
msg: "method",
|
|
31
|
-
method: "login",
|
|
32
|
-
id: String(++msgId),
|
|
33
|
-
params: [{ resume: authToken }]
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (msg.msg === "result" && msg.id === "1") {
|
|
38
|
-
console.log("Logged in! Subscribing to GENERAL...");
|
|
39
|
-
send({
|
|
40
|
-
msg: "sub",
|
|
41
|
-
id: String(++msgId),
|
|
42
|
-
name: "stream-room-messages",
|
|
43
|
-
params: ["GENERAL", false]
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (msg.msg === "ping") {
|
|
48
|
-
send({ msg: "pong" });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (msg.msg === "changed") {
|
|
52
|
-
console.log("*** MESSAGE RECEIVED ***", msg);
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
ws.on("error", (err) => {
|
|
57
|
-
console.error("Error:", err);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
ws.on("close", (code, reason) => {
|
|
61
|
-
console.log("Closed:", code, reason?.toString());
|
|
62
|
-
});
|
|
18
|
+
console.log('Base URL:', baseUrl);
|
|
19
|
+
console.log('User ID:', userId);
|
|
20
|
+
console.log('Auth token set:', authToken ? 'yes' : 'no');
|
|
63
21
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
console.log("Test complete, closing...");
|
|
67
|
-
ws.close();
|
|
68
|
-
}, 60000);
|
|
22
|
+
// TODO: call into the library / run your test logic here.
|
|
23
|
+
console.log('TODO: implement test logic');
|