@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 +42 -2
- package/README.md +140 -0
- package/package.json +1 -1
- package/src/rocketchat/accounts.ts +14 -0
- package/src/rocketchat/approval-commands.ts +106 -0
- package/src/rocketchat/client.ts +27 -0
- package/src/rocketchat/monitor.ts +338 -1
- package/src/rocketchat/pairing.ts +14 -11
- package/src/rocketchat/roles.ts +34 -0
- package/src/rocketchat/room-users.ts +183 -0
- package/src/rocketchat/send.ts +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
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
|
|
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
|
@@ -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
|
+
}
|
package/src/rocketchat/client.ts
CHANGED
|
@@ -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
|
-
|
|
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(), `${
|
|
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
|
}
|
package/src/rocketchat/roles.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/rocketchat/send.ts
CHANGED
|
@@ -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
|
|
207
|
+
// Resolve actual room ID for uploads (channels need to be looked up)
|
|
207
208
|
const isChannel = target.kind === "channel";
|
|
208
|
-
|
|
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,
|