@cloudrise/openclaw-channel-rocketchat 0.4.1 → 0.6.0

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/CHANGELOG.md CHANGED
@@ -1,10 +1,50 @@
1
1
  # Changelog
2
2
 
3
- ## [0.4.1] - 2026-02-15
3
+ ## [0.6.0] - 2026-02-15
4
+
5
+ > ⚠️ **Beta Features**: The approval flows (owner-approval, per-room ACL) in 0.5.0+ are functional but should be considered beta. Additional testing recommended before production use.
6
+
7
+ ### Added
8
+
9
+ - **Per-Room User Access Control**: Fine-grained control over who can interact with the bot in each room
10
+ - `rooms.<roomId>.canInteract` — static list of users/roles who can interact
11
+ - `rooms.<roomId>.roomApprovers` — who can approve others for this room
12
+ - `rooms.<roomId>.responseMode` — "always" or "mention-only" for approved users
13
+ - `rooms.<roomId>.onMentionUnauthorized` — "ignore" or "reply" when unauthorized user @mentions bot
14
+ - **Room-level commands** for roomApprovers:
15
+ - `room-approve @user` — approve user for current room only
16
+ - `room-deny @user` — remove user from room's approved list
17
+ - `room-list` — list approved users for current room
18
+ - **Dynamic room user storage**: `~/.openclaw/credentials/rocketchat-room-users.json`
19
+
20
+ ### Example Config
21
+
22
+ ```yaml
23
+ rooms:
24
+ GENERAL:
25
+ responseMode: "mention-only" # Only respond when @mentioned
26
+ canInteract: # Static approved users
27
+ - "role:admin"
28
+ - "@marshal"
29
+ roomApprovers: # Who can approve others
30
+ - "role:owner"
31
+ - "role:moderator"
32
+ onMentionUnauthorized: "ignore"
33
+ ```
34
+
35
+ ## [0.5.0] - 2026-02-15
36
+
37
+ ### Added
38
+
39
+ - **Channel/Room Approval**: New `groupPolicy: "owner-approval"` for controlling which channels the bot responds in
40
+ - Bot sends "pending approval" message on first message in unapproved channels
41
+ - Approve with `approve room:ROOMID`, deny with `deny room:ROOMID`
42
+ - Separate allowlist for rooms (`groupAllowFrom` config or `rocketchat-rooms-allowFrom.json`)
43
+ - **Bootstrapping documentation**: Clear instructions to pre-approve approvers and main channels to avoid lockout
4
44
 
5
45
  ### Fixed
6
46
 
7
- - **Approver bypass**: Approvers are now correctly allowed through the DM access gate to send approval commands. Previously, approvers would get stuck in the "pending approval" flow when DMing the bot.
47
+ - **Approver bypass**: Approvers are now correctly allowed through both DM and group access gates. Previously, approvers would get stuck in the "pending approval" flow.
8
48
 
9
49
  ## [0.4.0] - 2026-02-15
10
50
 
package/README.md CHANGED
@@ -424,6 +424,146 @@ pending # list pending requests
424
424
 
425
425
  ---
426
426
 
427
+ ### Channel/Room Approval (groupPolicy)
428
+
429
+ Control which channels the bot responds in:
430
+
431
+ ```yaml
432
+ channels:
433
+ rocketchat:
434
+ groupPolicy: "owner-approval" # or "open" | "allowlist" | "disabled"
435
+ ```
436
+
437
+ | Policy | Behavior |
438
+ |--------|----------|
439
+ | `open` | **(Default)** Bot responds in any channel it's added to. |
440
+ | `owner-approval` | Bot sends "pending approval" on first message in new channels. |
441
+ | `allowlist` | Only channels in `groupAllowFrom` receive responses. |
442
+ | `disabled` | Bot ignores all channel messages. |
443
+
444
+ **With `groupPolicy: "owner-approval"`:**
445
+ - When invited to a new channel, first message triggers approval request
446
+ - Approvers receive: `"🔔 Bot invited to #channel-name by @user"`
447
+ - Approve with: `approve room:ROOMID`
448
+
449
+ ---
450
+
451
+ ### 🔑 Auto-Approval (Important!)
452
+
453
+ **Approvers and notify channels are automatically allowed through access gates** — no manual pre-approval needed!
454
+
455
+ | `ownerApproval` Entry | DM Gate | Channel/Group Gate |
456
+ |-----------------------|---------|-------------------|
457
+ | `approvers: ["@user"]` | ✅ Auto-allowed | ✅ Auto-allowed (in any room) |
458
+ | `notifyChannels: ["room:ID"]` | N/A | ✅ Auto-allowed |
459
+
460
+ **Minimal recommended config (no lockout risk):**
461
+
462
+ ```yaml
463
+ channels:
464
+ rocketchat:
465
+ dmPolicy: "owner-approval"
466
+ groupPolicy: "owner-approval"
467
+
468
+ ownerApproval:
469
+ enabled: true
470
+ approvers:
471
+ - "@yourusername" # You can DM the bot + approve in any room
472
+ notifyChannels:
473
+ - "room:YOUR_MAIN_ROOM_ID" # This room is auto-approved
474
+ notifyOnApprove: true
475
+ notifyOnDeny: true
476
+ ```
477
+
478
+ That's it! With this config:
479
+ - ✅ You can DM the bot (you're an approver)
480
+ - ✅ Your main room works (it's a notify channel)
481
+ - ✅ Approval commands work in your main room
482
+ - 🔒 Everyone else needs approval
483
+
484
+ ---
485
+
486
+ ### Manual Pre-Approval (Optional)
487
+
488
+ If you need to pre-approve additional users or rooms that aren't approvers/notifyChannels:
489
+
490
+ **In config:**
491
+ ```yaml
492
+ channels:
493
+ rocketchat:
494
+ allowFrom: # Pre-approved DM users
495
+ - "@alice"
496
+ - "@bob"
497
+ groupAllowFrom: # Pre-approved rooms
498
+ - "room:GENERAL"
499
+ - "#support"
500
+ ```
501
+
502
+ **Or via files:**
503
+ ```bash
504
+ # Pre-approve DM users
505
+ echo '{"version":1,"entries":["alice","bob"]}' > ~/.openclaw/credentials/rocketchat-allowFrom.json
506
+
507
+ # Pre-approve rooms
508
+ echo '{"version":1,"entries":["GENERAL"]}' > ~/.openclaw/credentials/rocketchat-rooms-allowFrom.json
509
+ ```
510
+
511
+ ---
512
+
513
+ ### Per-Room User Access Control
514
+
515
+ Control which users can interact with the bot **within each approved room**:
516
+
517
+ ```yaml
518
+ channels:
519
+ rocketchat:
520
+ rooms:
521
+ GENERAL:
522
+ # Response mode for approved users
523
+ responseMode: "mention-only" # or "always" (default)
524
+
525
+ # Who can interact (static list)
526
+ canInteract:
527
+ - "@alice"
528
+ - "@bob"
529
+ - "role:admin"
530
+ - "role:moderator"
531
+
532
+ # Who can approve/deny users for THIS room
533
+ roomApprovers:
534
+ - "role:owner" # Room owners
535
+ - "role:moderator"
536
+ - "@marshal"
537
+
538
+ # When non-approved user @mentions bot
539
+ onMentionUnauthorized: "ignore" # or "reply" (sends "not authorized")
540
+
541
+ SUPPORT:
542
+ # No restrictions - everyone in the room can interact
543
+ responseMode: "always"
544
+ ```
545
+
546
+ **Room-level commands** (usable by `roomApprovers`):
547
+ ```
548
+ room-approve @alice # Approve alice for THIS room only
549
+ room-deny @alice # Remove alice from this room's approved list
550
+ room-list # Show who's approved in this room
551
+ ```
552
+
553
+ **How it works:**
554
+ 1. Room gets global approval (via `groupPolicy`)
555
+ 2. Per-room user check: is sender in `canInteract`, `roomApprovers`, or dynamically approved?
556
+ 3. If not approved:
557
+ - Silent ignore (unless `onMentionUnauthorized: "reply"`)
558
+ 4. If approved:
559
+ - Check `responseMode` — respond always or only when @mentioned
560
+
561
+ **Storage:** `~/.openclaw/credentials/rocketchat-room-users.json`
562
+
563
+ **Note:** Global approvers (`ownerApproval.approvers`) can interact in ANY room, regardless of per-room settings.
564
+
565
+ ---
566
+
427
567
  ### CLI-Based Pairing
428
568
 
429
569
  If you prefer CLI-based approval:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudrise/openclaw-channel-rocketchat",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Rocket.Chat channel plugin for OpenClaw (Cloudrise)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,20 @@ type RocketChatRoomConfig = {
10
10
  requireMention?: boolean;
11
11
  /** Optional per-room override. Use the room rid (e.g. GENERAL), not the channel name. */
12
12
  replyMode?: RocketChatReplyMode;
13
+
14
+ // === Per-Room User Access Control ===
15
+
16
+ /** Response mode: "always" responds to all approved users, "mention-only" only when @mentioned */
17
+ responseMode?: "always" | "mention-only";
18
+
19
+ /** Who can interact with the bot in this room (static config) */
20
+ canInteract?: string[];
21
+
22
+ /** Who can approve/deny users for THIS room (room-level approvers) */
23
+ roomApprovers?: string[];
24
+
25
+ /** What happens when non-approved user @mentions bot: "ignore" or "reply" with unauthorized message */
26
+ onMentionUnauthorized?: "ignore" | "reply";
13
27
  };
14
28
 
15
29
  export type OwnerApprovalConfig = {
@@ -7,6 +7,11 @@
7
7
  * - approve room:GENERAL
8
8
  * - list pending
9
9
  * - pending
10
+ *
11
+ * Room-level commands:
12
+ * - room-approve @user123
13
+ * - room-deny @user123
14
+ * - room-list
10
15
  */
11
16
 
12
17
  export type ApprovalCommand =
@@ -15,6 +20,12 @@ export type ApprovalCommand =
15
20
  | { action: "list" }
16
21
  | null;
17
22
 
23
+ export type RoomCommand =
24
+ | { action: "room-approve"; targets: string[] }
25
+ | { action: "room-deny"; targets: string[] }
26
+ | { action: "room-list" }
27
+ | null;
28
+
18
29
  /**
19
30
  * Parse an approval command from a message.
20
31
  * Returns null if the message is not a command.
@@ -208,3 +219,98 @@ export function buildDeniedMessage(target: string, type: "dm" | "room"): string
208
219
  return `❌ This room was not approved. Goodbye!`;
209
220
  }
210
221
  }
222
+
223
+ // === Room-Level Commands ===
224
+
225
+ /**
226
+ * Parse a room-level command from a message.
227
+ * These control per-room user access (not global approval).
228
+ *
229
+ * Commands:
230
+ * - room-approve @user
231
+ * - room-deny @user
232
+ * - room-list
233
+ */
234
+ export function parseRoomCommand(text: string): RoomCommand {
235
+ const trimmed = text.trim();
236
+ const lower = trimmed.toLowerCase();
237
+
238
+ // room-list
239
+ if (lower === "room-list" || lower === "room list") {
240
+ return { action: "room-list" };
241
+ }
242
+
243
+ // room-approve @user
244
+ const approveMatch = trimmed.match(/^room[- ]?approve\s+(.+)$/i);
245
+ if (approveMatch) {
246
+ const targets = parseUserTargets(approveMatch[1]);
247
+ if (targets.length > 0) {
248
+ return { action: "room-approve", targets };
249
+ }
250
+ }
251
+
252
+ // room-deny @user
253
+ const denyMatch = trimmed.match(/^room[- ]?deny\s+(.+)$/i);
254
+ if (denyMatch) {
255
+ const targets = parseUserTargets(denyMatch[1]);
256
+ if (targets.length > 0) {
257
+ return { action: "room-deny", targets };
258
+ }
259
+ }
260
+
261
+ // room-remove @user (alias for deny)
262
+ const removeMatch = trimmed.match(/^room[- ]?remove\s+(.+)$/i);
263
+ if (removeMatch) {
264
+ const targets = parseUserTargets(removeMatch[1]);
265
+ if (targets.length > 0) {
266
+ return { action: "room-deny", targets };
267
+ }
268
+ }
269
+
270
+ return null;
271
+ }
272
+
273
+ /**
274
+ * Parse user targets (for room commands - users only, not rooms).
275
+ */
276
+ function parseUserTargets(input: string): string[] {
277
+ const parts = input.split(/[\s,]+/).filter(Boolean);
278
+ const targets: string[] = [];
279
+
280
+ for (const part of parts) {
281
+ const trimmed = part.trim();
282
+ if (!trimmed) continue;
283
+
284
+ // @username
285
+ if (trimmed.startsWith("@")) {
286
+ targets.push(trimmed);
287
+ continue;
288
+ }
289
+
290
+ // Plain username
291
+ targets.push(`@${trimmed}`);
292
+ }
293
+
294
+ return targets;
295
+ }
296
+
297
+ /**
298
+ * Build the "not authorized" message for non-approved users who @mention the bot.
299
+ */
300
+ export function buildNotAuthorizedMessage(): string {
301
+ return `🔒 You're not authorized to interact with me in this room. Ask a room admin to approve you.`;
302
+ }
303
+
304
+ /**
305
+ * Build the room user approved message.
306
+ */
307
+ export function buildRoomUserApprovedMessage(username: string): string {
308
+ return `✅ @${username} can now interact with me in this room.`;
309
+ }
310
+
311
+ /**
312
+ * Build the room user denied/removed message.
313
+ */
314
+ export function buildRoomUserDeniedMessage(username: string): string {
315
+ return `❌ @${username} has been removed from this room's approved users.`;
316
+ }
@@ -149,6 +149,33 @@ export async function fetchRocketChatChannels(
149
149
  return res.channels;
150
150
  }
151
151
 
152
+ /**
153
+ * Fetch a channel's info by name (for resolving room ID).
154
+ */
155
+ export async function fetchRocketChatChannelByName(
156
+ client: RocketChatClient,
157
+ channelName: string
158
+ ): Promise<RocketChatRoom | null> {
159
+ try {
160
+ const res = await rcFetch<{ channel: RocketChatRoom; success: boolean }>(
161
+ client,
162
+ `/api/v1/channels.info?roomName=${encodeURIComponent(channelName)}`
163
+ );
164
+ return res.channel ?? null;
165
+ } catch {
166
+ // Try groups (private channels) if channel lookup fails
167
+ try {
168
+ const res = await rcFetch<{ group: RocketChatRoom; success: boolean }>(
169
+ client,
170
+ `/api/v1/groups.info?roomName=${encodeURIComponent(channelName)}`
171
+ );
172
+ return res.group ?? null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+ }
178
+
152
179
  export type RocketChatSubscription = {
153
180
  _id: string;
154
181
  rid: string;
@@ -33,18 +33,31 @@ import {
33
33
 
34
34
  import {
35
35
  parseApprovalCommand,
36
+ parseRoomCommand,
36
37
  buildApprovalRequestMessage,
37
38
  buildWaitingMessage,
38
39
  buildApprovedMessage,
39
40
  buildDeniedMessage,
41
+ buildNotAuthorizedMessage,
42
+ buildRoomUserApprovedMessage,
43
+ buildRoomUserDeniedMessage,
40
44
  formatApprovalRequest,
41
45
  } from "./approval-commands.js";
42
46
 
47
+ import {
48
+ getRoomApprovedUsers,
49
+ isRoomUserApproved,
50
+ addRoomUser,
51
+ removeRoomUser,
52
+ formatRoomUsersList,
53
+ } from "./room-users.js";
54
+
43
55
  import {
44
56
  isApprover,
45
57
  isNotifyChannel,
46
58
  getDmNotifyTargets,
47
59
  fetchUserRoles,
60
+ fetchUserByUsername,
48
61
  } from "./roles.js";
49
62
 
50
63
  import { getRocketChatRuntime } from "../runtime.js";
@@ -593,6 +606,328 @@ async function handleIncomingMessage(
593
606
  }
594
607
  }
595
608
 
609
+ // === Access Control for Groups/Channels ===
610
+ // Check groupPolicy before processing messages from groups/channels
611
+ if (isGroup) {
612
+ const groupPolicy = account.config.groupPolicy ?? "open"; // Default to "open" for Rocket.Chat
613
+ const roomId = msg.rid;
614
+ const roomName = room?.name ?? room?.fname ?? roomId;
615
+ const senderId = msg.u._id;
616
+ const senderUsername = msg.u.username;
617
+ const senderName = msg.u.name ?? senderUsername;
618
+
619
+ // If disabled, silently drop all group messages
620
+ if (groupPolicy === "disabled") {
621
+ logger.debug?.(`[${account.accountId}] Group message from ${roomName} blocked (groupPolicy=disabled)`);
622
+ return;
623
+ }
624
+
625
+ // If not "open", check if room is allowed
626
+ if (groupPolicy !== "open") {
627
+ // Normalize helper
628
+ const normalizeRoomEntry = (entry: string): string =>
629
+ entry
630
+ .trim()
631
+ .replace(/^(rocketchat|room):/i, "")
632
+ .replace(/^#/, "")
633
+ .toLowerCase();
634
+
635
+ // Read allowed rooms from store + config
636
+ const storeAllowFrom = await readChannelAllowFromStore("rocketchat-rooms").catch(() => []);
637
+ const configAllowFrom = (account.config.groupAllowFrom ?? []).map(String).map(normalizeRoomEntry);
638
+ const allAllowFrom = [...new Set([...storeAllowFrom, ...configAllowFrom])];
639
+
640
+ // Check if room is allowed
641
+ const normalizedRoomId = normalizeRoomEntry(roomId);
642
+ const normalizedRoomName = roomName ? normalizeRoomEntry(roomName) : null;
643
+
644
+ // Auto-allow notifyChannels (they need to receive approval notifications)
645
+ const ownerConfigForCheck = account.config.ownerApproval;
646
+ const notifyChannels = ownerConfigForCheck?.notifyChannels ?? [];
647
+ const isNotifyChannelRoom = isNotifyChannel(roomId, roomName, notifyChannels);
648
+
649
+ const isAllowed = isNotifyChannelRoom || allAllowFrom.some(
650
+ (entry) =>
651
+ entry === "*" ||
652
+ entry === normalizedRoomId ||
653
+ (normalizedRoomName && entry === normalizedRoomName)
654
+ );
655
+
656
+ if (!isAllowed) {
657
+ // If "allowlist" mode, block without approval
658
+ if (groupPolicy === "allowlist") {
659
+ logger.debug?.(`[${account.accountId}] Group message from ${roomName} blocked (groupPolicy=allowlist, not in groupAllowFrom)`);
660
+ return;
661
+ }
662
+
663
+ // If "owner-approval" mode, create approval request
664
+ if (groupPolicy === "owner-approval") {
665
+ const ownerConfig = account.config.ownerApproval;
666
+ if (!ownerConfig?.enabled) {
667
+ logger.debug?.(`[${account.accountId}] Group message from ${roomName} blocked (groupPolicy=owner-approval but ownerApproval not enabled)`);
668
+ return;
669
+ }
670
+
671
+ // Approvers are allowed through in any room
672
+ const approvers = ownerConfig.approvers ?? [];
673
+ if (approvers.length > 0) {
674
+ const restClient = createRocketChatClient({
675
+ baseUrl: account.baseUrl!,
676
+ userId: account.userId!,
677
+ authToken: account.authToken!,
678
+ });
679
+ if (await isApprover(restClient, senderId, senderUsername, approvers)) {
680
+ logger.debug?.(`[${account.accountId}] Group message from ${roomName} allowed (sender ${senderUsername} is approver)`);
681
+ // Fall through to normal message processing
682
+ } else {
683
+ // Not an approver — check for pending room approval
684
+ try {
685
+ const existing = await findPendingApproval(roomId, "room");
686
+ if (existing?.status === "approved") {
687
+ // Room was approved, add to allowed list
688
+ await addToAllowFrom(roomId, "rocketchat-rooms");
689
+ // Fall through to normal message processing
690
+ } else if (!existing) {
691
+ // Create new approval request for this room
692
+ const approval = await createApproval({
693
+ type: "room",
694
+ targetId: roomId,
695
+ targetName: roomName,
696
+ requesterId: senderId,
697
+ requesterName: senderName,
698
+ requesterUsername: senderUsername,
699
+ replyRoomId: roomId,
700
+ timeoutMs: ownerConfig.timeout ? ownerConfig.timeout * 1000 : undefined,
701
+ });
702
+
703
+ // Notify approvers
704
+ const notifyChannels = ownerConfig.notifyChannels ?? [];
705
+ const notifyMessage = buildApprovalRequestMessage({
706
+ type: "room",
707
+ targetId: roomId,
708
+ targetName: roomName,
709
+ requesterName: senderName,
710
+ requesterUsername: senderUsername,
711
+ });
712
+
713
+ for (const channel of notifyChannels) {
714
+ try {
715
+ if (channel.startsWith("@")) {
716
+ await sendMessageRocketChat(`user:${channel.slice(1)}`, notifyMessage, {
717
+ accountId: account.accountId,
718
+ });
719
+ } else if (channel.startsWith("room:")) {
720
+ await sendMessageRocketChat(channel, notifyMessage, {
721
+ accountId: account.accountId,
722
+ });
723
+ } else {
724
+ await sendMessageRocketChat(`room:${channel}`, notifyMessage, {
725
+ accountId: account.accountId,
726
+ });
727
+ }
728
+ } catch (err) {
729
+ logger.error?.(`[${account.accountId}] Failed to send room approval notification to ${channel}: ${String(err)}`);
730
+ }
731
+ }
732
+
733
+ // Notify the room that it's pending approval
734
+ await sendMessageRocketChat(`room:${roomId}`, "⏳ This room is pending approval. The owner has been notified.", {
735
+ accountId: account.accountId,
736
+ });
737
+
738
+ logger.info?.(`[${account.accountId}] Room approval request created for ${roomName}`);
739
+ }
740
+ // If pending, silently drop (already notified)
741
+ return;
742
+ } catch (err) {
743
+ logger.error?.(`[${account.accountId}] Failed to handle room approval flow: ${String(err)}`);
744
+ return;
745
+ }
746
+ }
747
+ }
748
+ }
749
+ }
750
+ }
751
+ }
752
+
753
+ // === Per-Room User Access Control ===
754
+ // After room is approved, check if this specific user can interact
755
+ if (isGroup) {
756
+ const roomId = msg.rid;
757
+ const roomName = room?.name ?? room?.fname ?? roomId;
758
+ const senderId = msg.u._id;
759
+ const senderUsername = msg.u.username;
760
+ const senderName = msg.u.name ?? senderUsername;
761
+
762
+ // Get room-specific config
763
+ const roomConfig = (account.config.rooms?.[roomId] ?? {}) as {
764
+ responseMode?: "always" | "mention-only";
765
+ canInteract?: string[];
766
+ roomApprovers?: string[];
767
+ onMentionUnauthorized?: "ignore" | "reply";
768
+ };
769
+
770
+ // Check if room has per-user access control configured
771
+ const hasRoomAccessControl = roomConfig.canInteract?.length || roomConfig.roomApprovers?.length;
772
+
773
+ if (hasRoomAccessControl) {
774
+ // Check if sender is a room approver (they always have access)
775
+ const roomApprovers = roomConfig.roomApprovers ?? [];
776
+ let isRoomApprover = false;
777
+
778
+ if (roomApprovers.length > 0) {
779
+ const restClient = createRocketChatClient({
780
+ baseUrl: account.baseUrl!,
781
+ userId: account.userId!,
782
+ authToken: account.authToken!,
783
+ });
784
+ isRoomApprover = await isApprover(restClient, senderId, senderUsername, roomApprovers);
785
+ }
786
+
787
+ // Check if sender is in canInteract list (static config)
788
+ const canInteract = roomConfig.canInteract ?? [];
789
+ let isInCanInteract = false;
790
+
791
+ if (canInteract.length > 0) {
792
+ const restClient = createRocketChatClient({
793
+ baseUrl: account.baseUrl!,
794
+ userId: account.userId!,
795
+ authToken: account.authToken!,
796
+ });
797
+ isInCanInteract = await isApprover(restClient, senderId, senderUsername, canInteract);
798
+ }
799
+
800
+ // Check if sender is in dynamic room users list
801
+ const isInRoomUsers = await isRoomUserApproved(roomId, senderId, senderUsername);
802
+
803
+ // Also check if sender is a global approver (they can interact anywhere)
804
+ const globalApprovers = account.config.ownerApproval?.approvers ?? [];
805
+ let isGlobalApprover = false;
806
+ if (globalApprovers.length > 0) {
807
+ const restClient = createRocketChatClient({
808
+ baseUrl: account.baseUrl!,
809
+ userId: account.userId!,
810
+ authToken: account.authToken!,
811
+ });
812
+ isGlobalApprover = await isApprover(restClient, senderId, senderUsername, globalApprovers);
813
+ }
814
+
815
+ const canUserInteract = isRoomApprover || isInCanInteract || isInRoomUsers || isGlobalApprover;
816
+
817
+ // Check if this is a room command from a room approver
818
+ if (isRoomApprover || isGlobalApprover) {
819
+ const roomCommand = parseRoomCommand(msg.msg);
820
+
821
+ if (roomCommand) {
822
+ logger.debug?.(`[${account.accountId}] Room command from ${senderUsername}: ${JSON.stringify(roomCommand)}`);
823
+
824
+ if (roomCommand.action === "room-list") {
825
+ const users = await getRoomApprovedUsers(roomId);
826
+ await sendMessageRocketChat(`room:${roomId}`, formatRoomUsersList(users), {
827
+ accountId: account.accountId,
828
+ });
829
+ return;
830
+ }
831
+
832
+ if (roomCommand.action === "room-approve") {
833
+ const results: string[] = [];
834
+
835
+ for (const target of roomCommand.targets) {
836
+ const username = target.replace(/^@/, "");
837
+
838
+ // Look up user by username
839
+ const restClient = createRocketChatClient({
840
+ baseUrl: account.baseUrl!,
841
+ userId: account.userId!,
842
+ authToken: account.authToken!,
843
+ });
844
+
845
+ try {
846
+ const userInfo = await fetchUserByUsername(restClient, username);
847
+ if (userInfo) {
848
+ const { added, existing } = await addRoomUser({
849
+ roomId,
850
+ userId: userInfo.userId,
851
+ username: userInfo.username,
852
+ approvedBy: senderUsername ?? senderId,
853
+ });
854
+
855
+ if (added) {
856
+ results.push(buildRoomUserApprovedMessage(username));
857
+ } else if (existing) {
858
+ results.push(`ℹ️ @${username} is already approved for this room.`);
859
+ }
860
+ } else {
861
+ results.push(`⚠️ User not found: ${username}`);
862
+ }
863
+ } catch (err) {
864
+ results.push(`⚠️ Failed to approve ${username}: ${String(err)}`);
865
+ }
866
+ }
867
+
868
+ await sendMessageRocketChat(`room:${roomId}`, results.join("\n"), {
869
+ accountId: account.accountId,
870
+ });
871
+ return;
872
+ }
873
+
874
+ if (roomCommand.action === "room-deny") {
875
+ const results: string[] = [];
876
+
877
+ for (const target of roomCommand.targets) {
878
+ const username = target.replace(/^@/, "");
879
+
880
+ const { removed } = await removeRoomUser({
881
+ roomId,
882
+ username,
883
+ });
884
+
885
+ if (removed) {
886
+ results.push(buildRoomUserDeniedMessage(username));
887
+ } else {
888
+ results.push(`⚠️ @${username} was not in this room's approved list.`);
889
+ }
890
+ }
891
+
892
+ await sendMessageRocketChat(`room:${roomId}`, results.join("\n"), {
893
+ accountId: account.accountId,
894
+ });
895
+ return;
896
+ }
897
+ }
898
+ }
899
+
900
+ // If user can't interact, check response mode and handle accordingly
901
+ if (!canUserInteract) {
902
+ const responseMode = roomConfig.responseMode ?? "always";
903
+ const onMentionUnauthorized = roomConfig.onMentionUnauthorized ?? "ignore";
904
+
905
+ // Check if bot was @mentioned
906
+ const botUsername = account.userId; // This should be resolved to username
907
+ const wasMentioned = msg.mentions?.some((m: { _id: string }) => m._id === account.userId);
908
+
909
+ if (wasMentioned && onMentionUnauthorized === "reply") {
910
+ await sendMessageRocketChat(`room:${roomId}`, buildNotAuthorizedMessage(), {
911
+ accountId: account.accountId,
912
+ });
913
+ }
914
+
915
+ logger.debug?.(`[${account.accountId}] User ${senderUsername} not authorized in room ${roomName}`);
916
+ return;
917
+ }
918
+
919
+ // User can interact - check responseMode
920
+ const responseMode = roomConfig.responseMode ?? "always";
921
+ if (responseMode === "mention-only") {
922
+ const wasMentioned = msg.mentions?.some((m: { _id: string }) => m._id === account.userId);
923
+ if (!wasMentioned) {
924
+ logger.debug?.(`[${account.accountId}] Ignoring message from ${senderUsername} in ${roomName} (mention-only mode)`);
925
+ return;
926
+ }
927
+ }
928
+ }
929
+ }
930
+
596
931
  // === Owner Approval Command Handling ===
597
932
  // Check if this message is an approval command from an approver
598
933
  const ownerConfig = account.config.ownerApproval;
@@ -661,7 +996,9 @@ async function handleIncomingMessage(
661
996
  if (approval) {
662
997
  // Add to allowlist if approved
663
998
  if (command.action === "approve") {
664
- await addToAllowFrom(approval.targetId);
999
+ // Use different store for rooms vs users
1000
+ const storeId = approval.type === "room" ? "rocketchat-rooms" : "rocketchat";
1001
+ await addToAllowFrom(approval.targetId, storeId);
665
1002
  }
666
1003
 
667
1004
  // Notify the requester
@@ -46,8 +46,8 @@ function resolvePairingPath(): string {
46
46
  return path.join(resolveCredentialsDir(), `${CHANNEL_ID}-pairing.json`);
47
47
  }
48
48
 
49
- function resolveAllowFromPath(): string {
50
- return path.join(resolveCredentialsDir(), `${CHANNEL_ID}-allowFrom.json`);
49
+ function resolveAllowFromPath(storeId: string = CHANNEL_ID): string {
50
+ return path.join(resolveCredentialsDir(), `${storeId}-allowFrom.json`);
51
51
  }
52
52
 
53
53
  function generatePairingCode(): string {
@@ -82,9 +82,9 @@ async function writePairingStore(store: PairingStore): Promise<void> {
82
82
  await fs.writeFile(filePath, JSON.stringify(store, null, 2), { mode: 0o600 });
83
83
  }
84
84
 
85
- async function readAllowFromStore(): Promise<AllowFromStore> {
85
+ async function readAllowFromStore(storeId: string = CHANNEL_ID): Promise<AllowFromStore> {
86
86
  try {
87
- const data = await fs.readFile(resolveAllowFromPath(), "utf-8");
87
+ const data = await fs.readFile(resolveAllowFromPath(storeId), "utf-8");
88
88
  const parsed = JSON.parse(data);
89
89
  return {
90
90
  version: parsed.version ?? 1,
@@ -95,17 +95,18 @@ async function readAllowFromStore(): Promise<AllowFromStore> {
95
95
  }
96
96
  }
97
97
 
98
- async function writeAllowFromStore(store: AllowFromStore): Promise<void> {
98
+ async function writeAllowFromStore(store: AllowFromStore, storeId: string = CHANNEL_ID): Promise<void> {
99
99
  await ensureDir(resolveCredentialsDir());
100
- const filePath = resolveAllowFromPath();
100
+ const filePath = resolveAllowFromPath(storeId);
101
101
  await fs.writeFile(filePath, JSON.stringify(store, null, 2), { mode: 0o600 });
102
102
  }
103
103
 
104
104
  /**
105
105
  * Read the allowFrom list from the pairing store.
106
+ * @param storeId - Store identifier (default: "rocketchat", use "rocketchat-rooms" for room allowlist)
106
107
  */
107
- export async function readChannelAllowFromStore(): Promise<string[]> {
108
- const store = await readAllowFromStore();
108
+ export async function readChannelAllowFromStore(storeId: string = CHANNEL_ID): Promise<string[]> {
109
+ const store = await readAllowFromStore(storeId);
109
110
  return store.entries;
110
111
  }
111
112
 
@@ -189,12 +190,14 @@ export function buildPairingReply(params: {
189
190
 
190
191
  /**
191
192
  * Add an entry to the allowFrom store (called when pairing is approved).
193
+ * @param entry - The entry to add (user ID or room ID)
194
+ * @param storeId - Store identifier (default: "rocketchat", use "rocketchat-rooms" for room allowlist)
192
195
  */
193
- export async function addToAllowFrom(entry: string): Promise<void> {
194
- const store = await readAllowFromStore();
196
+ export async function addToAllowFrom(entry: string, storeId: string = CHANNEL_ID): Promise<void> {
197
+ const store = await readAllowFromStore(storeId);
195
198
  const normalized = entry.toLowerCase().trim();
196
199
  if (!store.entries.includes(normalized)) {
197
200
  store.entries.push(normalized);
198
- await writeAllowFromStore(store);
201
+ await writeAllowFromStore(store, storeId);
199
202
  }
200
203
  }
@@ -16,6 +16,40 @@ export type UserRoles = {
16
16
  const roleCache = new Map<string, { roles: UserRoles; fetchedAt: number }>();
17
17
  const CACHE_TTL_MS = 5 * 60 * 1000;
18
18
 
19
+ /**
20
+ * Fetch user info by username from Rocket.Chat.
21
+ */
22
+ export async function fetchUserByUsername(
23
+ client: RocketChatClient,
24
+ username: string
25
+ ): Promise<UserRoles | null> {
26
+ try {
27
+ const response = await fetch(`${client.baseUrl}/api/v1/users.info?username=${encodeURIComponent(username)}`, {
28
+ headers: {
29
+ "X-Auth-Token": client.authToken,
30
+ "X-User-Id": client.userId,
31
+ },
32
+ });
33
+
34
+ if (!response.ok) {
35
+ return null;
36
+ }
37
+
38
+ const data = await response.json();
39
+ const user = data.user;
40
+
41
+ if (!user) return null;
42
+
43
+ return {
44
+ userId: user._id,
45
+ username: user.username,
46
+ roles: Array.isArray(user.roles) ? user.roles : [],
47
+ };
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
19
53
  /**
20
54
  * Fetch user info including roles from Rocket.Chat.
21
55
  */
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Per-Room User Access Control
3
+ *
4
+ * Manages which users can interact with the bot in specific rooms.
5
+ * Separate from global approval — this is room-level granular control.
6
+ *
7
+ * Storage: ~/.openclaw/credentials/rocketchat-room-users.json
8
+ */
9
+
10
+ import * as fs from "node:fs/promises";
11
+ import * as path from "node:path";
12
+ import * as os from "node:os";
13
+
14
+ export type RoomUserEntry = {
15
+ userId: string;
16
+ username?: string;
17
+ approvedBy: string;
18
+ approvedAt: string;
19
+ };
20
+
21
+ export type RoomUsersData = {
22
+ approved: RoomUserEntry[];
23
+ };
24
+
25
+ type RoomUsersStore = {
26
+ version: number;
27
+ rooms: Record<string, RoomUsersData>;
28
+ };
29
+
30
+ // In-memory cache
31
+ let cache: RoomUsersStore | null = null;
32
+
33
+ function resolveStorePath(): string {
34
+ return path.join(os.homedir(), ".openclaw", "credentials", "rocketchat-room-users.json");
35
+ }
36
+
37
+ async function ensureDir(dirPath: string): Promise<void> {
38
+ await fs.mkdir(dirPath, { recursive: true, mode: 0o700 });
39
+ }
40
+
41
+ async function loadStore(): Promise<RoomUsersStore> {
42
+ if (cache) return cache;
43
+
44
+ try {
45
+ const data = await fs.readFile(resolveStorePath(), "utf-8");
46
+ const parsed = JSON.parse(data);
47
+ cache = {
48
+ version: parsed.version ?? 1,
49
+ rooms: parsed.rooms ?? {},
50
+ };
51
+ return cache;
52
+ } catch {
53
+ cache = { version: 1, rooms: {} };
54
+ return cache;
55
+ }
56
+ }
57
+
58
+ async function saveStore(store: RoomUsersStore): Promise<void> {
59
+ cache = store;
60
+ await ensureDir(path.dirname(resolveStorePath()));
61
+ await fs.writeFile(resolveStorePath(), JSON.stringify(store, null, 2), { mode: 0o600 });
62
+ }
63
+
64
+ /**
65
+ * Get all approved users for a room.
66
+ */
67
+ export async function getRoomApprovedUsers(roomId: string): Promise<RoomUserEntry[]> {
68
+ const store = await loadStore();
69
+ return store.rooms[roomId]?.approved ?? [];
70
+ }
71
+
72
+ /**
73
+ * Check if a user is approved for a specific room.
74
+ */
75
+ export async function isRoomUserApproved(
76
+ roomId: string,
77
+ userId: string,
78
+ username?: string
79
+ ): Promise<boolean> {
80
+ const approved = await getRoomApprovedUsers(roomId);
81
+ const normalizedUsername = username?.toLowerCase();
82
+
83
+ return approved.some(
84
+ (entry) =>
85
+ entry.userId === userId ||
86
+ (normalizedUsername && entry.username?.toLowerCase() === normalizedUsername)
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Add a user to a room's approved list.
92
+ */
93
+ export async function addRoomUser(params: {
94
+ roomId: string;
95
+ userId: string;
96
+ username?: string;
97
+ approvedBy: string;
98
+ }): Promise<{ added: boolean; existing: boolean }> {
99
+ const store = await loadStore();
100
+
101
+ if (!store.rooms[params.roomId]) {
102
+ store.rooms[params.roomId] = { approved: [] };
103
+ }
104
+
105
+ const room = store.rooms[params.roomId];
106
+ const normalizedUsername = params.username?.toLowerCase();
107
+
108
+ // Check if already approved
109
+ const existing = room.approved.find(
110
+ (entry) =>
111
+ entry.userId === params.userId ||
112
+ (normalizedUsername && entry.username?.toLowerCase() === normalizedUsername)
113
+ );
114
+
115
+ if (existing) {
116
+ return { added: false, existing: true };
117
+ }
118
+
119
+ room.approved.push({
120
+ userId: params.userId,
121
+ username: params.username,
122
+ approvedBy: params.approvedBy,
123
+ approvedAt: new Date().toISOString(),
124
+ });
125
+
126
+ await saveStore(store);
127
+ return { added: true, existing: false };
128
+ }
129
+
130
+ /**
131
+ * Remove a user from a room's approved list.
132
+ */
133
+ export async function removeRoomUser(params: {
134
+ roomId: string;
135
+ userId?: string;
136
+ username?: string;
137
+ }): Promise<{ removed: boolean; entry?: RoomUserEntry }> {
138
+ const store = await loadStore();
139
+ const room = store.rooms[params.roomId];
140
+
141
+ if (!room) {
142
+ return { removed: false };
143
+ }
144
+
145
+ const normalizedUsername = params.username?.toLowerCase();
146
+ const index = room.approved.findIndex(
147
+ (entry) =>
148
+ (params.userId && entry.userId === params.userId) ||
149
+ (normalizedUsername && entry.username?.toLowerCase() === normalizedUsername)
150
+ );
151
+
152
+ if (index === -1) {
153
+ return { removed: false };
154
+ }
155
+
156
+ const [removed] = room.approved.splice(index, 1);
157
+ await saveStore(store);
158
+ return { removed: true, entry: removed };
159
+ }
160
+
161
+ /**
162
+ * Format room users list for display.
163
+ */
164
+ export function formatRoomUsersList(users: RoomUserEntry[]): string {
165
+ if (users.length === 0) {
166
+ return "No users approved for this room.";
167
+ }
168
+
169
+ const lines = users.map((u) => {
170
+ const who = u.username ? `@${u.username}` : u.userId;
171
+ const when = new Date(u.approvedAt).toLocaleDateString();
172
+ return `• ${who} (by ${u.approvedBy}, ${when})`;
173
+ });
174
+
175
+ return `**Approved users (${users.length})**\n${lines.join("\n")}`;
176
+ }
177
+
178
+ /**
179
+ * Clear the cache (useful for testing).
180
+ */
181
+ export function clearRoomUsersCache(): void {
182
+ cache = null;
183
+ }
@@ -9,6 +9,7 @@ import { resolveRocketChatAccount } from "./accounts.js";
9
9
  import {
10
10
  createRocketChatClient,
11
11
  createRocketChatDirectMessage,
12
+ fetchRocketChatChannelByName,
12
13
  fetchRocketChatMe,
13
14
  fetchRocketChatUserByUsername,
14
15
  normalizeRocketChatBaseUrl,
@@ -203,9 +204,20 @@ export async function sendMessageRocketChat(
203
204
  let message = text?.trim() ?? "";
204
205
  const mediaUrl = opts.mediaUrl?.trim();
205
206
 
206
- // Resolve room ID for uploads (channels need special handling)
207
+ // Resolve actual room ID for uploads (channels need to be looked up)
207
208
  const isChannel = target.kind === "channel";
208
- const uploadRoomId = isChannel ? roomId : roomId;
209
+ let uploadRoomId = roomId;
210
+
211
+ if (isChannel && mediaUrl && isLocalPath(mediaUrl)) {
212
+ // For channel uploads, we need the actual room _id, not the #name
213
+ const channelInfo = await fetchRocketChatChannelByName(client, target.name);
214
+ if (channelInfo?._id) {
215
+ uploadRoomId = channelInfo._id;
216
+ logger?.debug?.(`Resolved channel "${target.name}" to room ID: ${uploadRoomId}`);
217
+ } else {
218
+ logger?.warn?.(`Could not resolve channel "${target.name}" to room ID, upload may fail`);
219
+ }
220
+ }
209
221
 
210
222
  // Handle local file uploads
211
223
  if (mediaUrl && isLocalPath(mediaUrl)) {
@@ -214,7 +226,7 @@ export async function sendMessageRocketChat(
214
226
  const fileName = path.basename(mediaUrl);
215
227
  const mimeType = getMimeFromExt(mediaUrl);
216
228
 
217
- logger?.debug?.(`Uploading file to Rocket.Chat: ${fileName} (${mimeType}, ${fileBuffer.length} bytes)`);
229
+ logger?.debug?.(`Uploading file to Rocket.Chat: ${fileName} (${mimeType}, ${fileBuffer.length} bytes) to room ${uploadRoomId}`);
218
230
 
219
231
  const upload = await uploadRocketChatFile(client, {
220
232
  roomId: uploadRoomId,