@cloudrise/openclaw-channel-rocketchat 0.3.0 → 0.4.1
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 +36 -0
- package/README.md +69 -14
- package/openclaw.plugin.json +30 -3
- package/package.json +1 -1
- package/src/rocketchat/accounts.ts +33 -2
- package/src/rocketchat/approval-commands.ts +210 -0
- package/src/rocketchat/approval-queue.ts +256 -0
- package/src/rocketchat/monitor.ts +213 -2
- package/src/rocketchat/roles.ts +187 -0
- package/test/approval-commands.test.mjs +100 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.1] - 2026-02-15
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
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.
|
|
8
|
+
|
|
9
|
+
## [0.4.0] - 2026-02-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Owner Channel Approval**: New `dmPolicy: "owner-approval"` mode for in-channel approval flow
|
|
14
|
+
- No CLI needed — approve/deny via Rocket.Chat messages
|
|
15
|
+
- Configure `ownerApproval.notifyChannels` for where to receive requests
|
|
16
|
+
- Configure `ownerApproval.approvers` with usernames or Rocket.Chat roles (`role:admin`, `role:moderator`)
|
|
17
|
+
- Commands: `approve @user`, `deny @user`, `approve room:ID`, `pending`
|
|
18
|
+
- Requester gets notified when approved/denied
|
|
19
|
+
- **Rocket.Chat Role Integration**: Approvers can be defined by RC roles (admin, moderator, etc.)
|
|
20
|
+
|
|
21
|
+
### Example Config
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
channels:
|
|
25
|
+
rocketchat:
|
|
26
|
+
dmPolicy: "owner-approval"
|
|
27
|
+
ownerApproval:
|
|
28
|
+
enabled: true
|
|
29
|
+
notifyChannels:
|
|
30
|
+
- "@admin"
|
|
31
|
+
- "room:APPROVERS"
|
|
32
|
+
approvers:
|
|
33
|
+
- "@marshal"
|
|
34
|
+
- "role:admin"
|
|
35
|
+
notifyOnApprove: true
|
|
36
|
+
notifyOnDeny: true
|
|
37
|
+
```
|
|
38
|
+
|
|
3
39
|
## [0.3.0] - 2026-02-15
|
|
4
40
|
|
|
5
41
|
### Added
|
package/README.md
CHANGED
|
@@ -354,35 +354,89 @@ npm publish
|
|
|
354
354
|
|
|
355
355
|
(There is also a GitHub Actions workflow in `.github/workflows/publish.yml`.)
|
|
356
356
|
|
|
357
|
-
## DM Access Control
|
|
357
|
+
## DM Access Control
|
|
358
358
|
|
|
359
|
-
The plugin supports
|
|
359
|
+
The plugin supports multiple DM access control modes, including a unique **Owner Channel Approval** flow.
|
|
360
360
|
|
|
361
361
|
### DM Policies
|
|
362
362
|
|
|
363
363
|
```yaml
|
|
364
364
|
channels:
|
|
365
365
|
rocketchat:
|
|
366
|
-
#
|
|
367
|
-
dmPolicy: "pairing" # or "open" | "allowlist" | "disabled"
|
|
368
|
-
|
|
369
|
-
# Pre-approved users (used with "pairing" and "allowlist")
|
|
370
|
-
allowFrom:
|
|
371
|
-
- "@admin"
|
|
372
|
-
- "user123"
|
|
366
|
+
dmPolicy: "owner-approval" # or "open" | "pairing" | "allowlist" | "disabled"
|
|
373
367
|
```
|
|
374
368
|
|
|
375
369
|
| Policy | Behavior |
|
|
376
370
|
|--------|----------|
|
|
377
371
|
| `open` | **(Default)** All DMs allowed. Rocket.Chat server-level auth is the only gate. |
|
|
372
|
+
| `owner-approval` | **🆕** Unknown senders trigger approval request to owner channel. No CLI needed! |
|
|
378
373
|
| `pairing` | Unknown senders get a pairing code. Owner approves via CLI. |
|
|
379
374
|
| `allowlist` | Only users in `allowFrom` can DM. Others are silently blocked. |
|
|
380
375
|
| `disabled` | All DMs blocked. |
|
|
381
376
|
|
|
382
|
-
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
### Owner Channel Approval (Recommended)
|
|
380
|
+
|
|
381
|
+
Approve or deny users **directly in Rocket.Chat** — no CLI needed!
|
|
382
|
+
|
|
383
|
+
```yaml
|
|
384
|
+
channels:
|
|
385
|
+
rocketchat:
|
|
386
|
+
dmPolicy: "owner-approval"
|
|
387
|
+
ownerApproval:
|
|
388
|
+
enabled: true
|
|
389
|
+
|
|
390
|
+
# Where to send approval notifications
|
|
391
|
+
notifyChannels:
|
|
392
|
+
- "@admin" # DM to specific user
|
|
393
|
+
- "room:APPROVERS" # or a dedicated room
|
|
394
|
+
|
|
395
|
+
# Who can approve (supports Rocket.Chat roles!)
|
|
396
|
+
approvers:
|
|
397
|
+
- "@marshal" # specific username
|
|
398
|
+
- "role:admin" # anyone with RC admin role
|
|
399
|
+
- "role:moderator" # anyone with moderator role
|
|
400
|
+
|
|
401
|
+
# Notify requester when decision is made
|
|
402
|
+
notifyOnApprove: true
|
|
403
|
+
notifyOnDeny: true
|
|
404
|
+
|
|
405
|
+
# Optional timeout (seconds)
|
|
406
|
+
timeout: 3600
|
|
407
|
+
onTimeout: "pending" # or "deny" or "remind"
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**Flow:**
|
|
411
|
+
1. Unknown user sends a DM
|
|
412
|
+
2. Bot notifies owner channel: `"🔔 New DM request from @user123"`
|
|
413
|
+
3. Owner replies: `approve @user123` or `deny @user123`
|
|
414
|
+
4. Requester gets notified: `"✅ You've been approved!"`
|
|
415
|
+
5. Future messages are processed normally
|
|
416
|
+
|
|
417
|
+
**Commands (in owner channel or DM to bot):**
|
|
418
|
+
```
|
|
419
|
+
approve @user123 # approve a user
|
|
420
|
+
deny @user123 # deny a user
|
|
421
|
+
approve room:GENERAL # approve a room
|
|
422
|
+
pending # list pending requests
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
---
|
|
383
426
|
|
|
384
|
-
|
|
427
|
+
### CLI-Based Pairing
|
|
428
|
+
|
|
429
|
+
If you prefer CLI-based approval:
|
|
430
|
+
|
|
431
|
+
```yaml
|
|
432
|
+
channels:
|
|
433
|
+
rocketchat:
|
|
434
|
+
dmPolicy: "pairing"
|
|
435
|
+
allowFrom:
|
|
436
|
+
- "@admin" # pre-approved users
|
|
437
|
+
```
|
|
385
438
|
|
|
439
|
+
**Flow:**
|
|
386
440
|
1. Unknown user sends a DM
|
|
387
441
|
2. Bot replies with a pairing code: `"Pairing required. Code: ABC12345"`
|
|
388
442
|
3. Owner approves via CLI:
|
|
@@ -390,8 +444,9 @@ When `dmPolicy: "pairing"`:
|
|
|
390
444
|
openclaw pairing list rocketchat
|
|
391
445
|
openclaw pairing approve rocketchat ABC12345
|
|
392
446
|
```
|
|
393
|
-
4. User is added to allowlist
|
|
394
|
-
|
|
447
|
+
4. User is added to allowlist
|
|
448
|
+
|
|
449
|
+
---
|
|
395
450
|
|
|
396
451
|
### Why is the default "open"?
|
|
397
452
|
|
|
@@ -400,7 +455,7 @@ Unlike public platforms (Telegram, WhatsApp, Signal), Rocket.Chat is typically:
|
|
|
400
455
|
- Behind organizational access controls
|
|
401
456
|
- Already requires user accounts to message
|
|
402
457
|
|
|
403
|
-
So **server-level authentication acts as the primary gate**. Use `
|
|
458
|
+
So **server-level authentication acts as the primary gate**. Use `owner-approval` or `pairing` if you need per-user approval on top of that.
|
|
404
459
|
|
|
405
460
|
## Security
|
|
406
461
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-channel-rocketchat",
|
|
3
3
|
"name": "Rocket.Chat",
|
|
4
4
|
"description": "Rocket.Chat channel plugin for OpenClaw",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.0",
|
|
6
6
|
"channels": ["rocketchat"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
|
@@ -15,9 +15,36 @@
|
|
|
15
15
|
"authTokenFile": { "type": "string" },
|
|
16
16
|
"dmPolicy": {
|
|
17
17
|
"type": "string",
|
|
18
|
-
"enum": ["open", "pairing", "allowlist", "disabled"],
|
|
18
|
+
"enum": ["open", "pairing", "allowlist", "disabled", "owner-approval"],
|
|
19
19
|
"default": "open",
|
|
20
|
-
"description": "DM access policy. 'open' allows all
|
|
20
|
+
"description": "DM access policy. 'open' allows all, 'pairing' requires CLI approval, 'owner-approval' requires owner channel approval, 'allowlist' requires pre-configured allowFrom, 'disabled' blocks all DMs."
|
|
21
|
+
},
|
|
22
|
+
"ownerApproval": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"enabled": { "type": "boolean", "default": false },
|
|
26
|
+
"notifyChannels": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": { "type": "string" },
|
|
29
|
+
"description": "Where to send approval notifications (e.g., '@admin', 'room:APPROVERS')"
|
|
30
|
+
},
|
|
31
|
+
"approvers": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": { "type": "string" },
|
|
34
|
+
"description": "Who can approve (e.g., '@marshal', 'role:admin', 'role:moderator')"
|
|
35
|
+
},
|
|
36
|
+
"timeout": { "type": "number", "description": "Timeout in seconds (0 = no timeout)" },
|
|
37
|
+
"onTimeout": { "type": "string", "enum": ["pending", "deny", "remind"], "default": "pending" },
|
|
38
|
+
"notifyOnDeny": { "type": "boolean", "default": false },
|
|
39
|
+
"notifyOnApprove": { "type": "boolean", "default": true },
|
|
40
|
+
"requireApproval": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"dm": { "type": "boolean", "default": true },
|
|
44
|
+
"roomInvite": { "type": "boolean", "default": true }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
21
48
|
},
|
|
22
49
|
"allowFrom": {
|
|
23
50
|
"type": "array",
|
package/package.json
CHANGED
|
@@ -12,6 +12,34 @@ type RocketChatRoomConfig = {
|
|
|
12
12
|
replyMode?: RocketChatReplyMode;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
export type OwnerApprovalConfig = {
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
|
|
18
|
+
/** Where to send approval notifications (e.g., "@admin", "room:APPROVERS") */
|
|
19
|
+
notifyChannels?: string[];
|
|
20
|
+
|
|
21
|
+
/** Who can approve (e.g., "@marshal", "role:admin", "role:moderator") */
|
|
22
|
+
approvers?: string[];
|
|
23
|
+
|
|
24
|
+
/** Timeout in seconds (0 = no timeout) */
|
|
25
|
+
timeout?: number;
|
|
26
|
+
|
|
27
|
+
/** What to do on timeout: "pending" (keep waiting), "deny", "remind" */
|
|
28
|
+
onTimeout?: "pending" | "deny" | "remind";
|
|
29
|
+
|
|
30
|
+
/** Notify requester when denied */
|
|
31
|
+
notifyOnDeny?: boolean;
|
|
32
|
+
|
|
33
|
+
/** Notify requester when approved */
|
|
34
|
+
notifyOnApprove?: boolean;
|
|
35
|
+
|
|
36
|
+
/** What requires approval */
|
|
37
|
+
requireApproval?: {
|
|
38
|
+
dm?: boolean;
|
|
39
|
+
roomInvite?: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
15
43
|
export type RocketChatAccountConfig = {
|
|
16
44
|
enabled?: boolean;
|
|
17
45
|
name?: string;
|
|
@@ -19,12 +47,15 @@ export type RocketChatAccountConfig = {
|
|
|
19
47
|
userId?: string;
|
|
20
48
|
authToken?: string;
|
|
21
49
|
authTokenFile?: string;
|
|
22
|
-
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
50
|
+
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled" | "owner-approval";
|
|
23
51
|
allowFrom?: string[];
|
|
24
|
-
groupPolicy?: "allowlist" | "open" | "disabled";
|
|
52
|
+
groupPolicy?: "allowlist" | "open" | "disabled" | "owner-approval";
|
|
25
53
|
groupAllowFrom?: string[];
|
|
26
54
|
rooms?: Record<string, RocketChatRoomConfig>;
|
|
27
55
|
|
|
56
|
+
/** Owner approval configuration */
|
|
57
|
+
ownerApproval?: OwnerApprovalConfig;
|
|
58
|
+
|
|
28
59
|
/** Reply mode selection (thread | channel | auto). Default: thread (legacy behavior). */
|
|
29
60
|
replyMode?: RocketChatReplyMode;
|
|
30
61
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval Command Parser
|
|
3
|
+
*
|
|
4
|
+
* Parses commands like:
|
|
5
|
+
* - approve @user123
|
|
6
|
+
* - deny @user123
|
|
7
|
+
* - approve room:GENERAL
|
|
8
|
+
* - list pending
|
|
9
|
+
* - pending
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ApprovalCommand =
|
|
13
|
+
| { action: "approve"; targets: string[] }
|
|
14
|
+
| { action: "deny"; targets: string[] }
|
|
15
|
+
| { action: "list" }
|
|
16
|
+
| null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse an approval command from a message.
|
|
20
|
+
* Returns null if the message is not a command.
|
|
21
|
+
*/
|
|
22
|
+
export function parseApprovalCommand(text: string): ApprovalCommand {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
const lower = trimmed.toLowerCase();
|
|
25
|
+
|
|
26
|
+
// List commands
|
|
27
|
+
if (lower === "list pending" || lower === "pending" || lower === "list") {
|
|
28
|
+
return { action: "list" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Approve command
|
|
32
|
+
const approveMatch = trimmed.match(/^approve\s+(.+)$/i);
|
|
33
|
+
if (approveMatch) {
|
|
34
|
+
const targets = parseTargets(approveMatch[1]);
|
|
35
|
+
if (targets.length > 0) {
|
|
36
|
+
return { action: "approve", targets };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Deny command
|
|
41
|
+
const denyMatch = trimmed.match(/^deny\s+(.+)$/i);
|
|
42
|
+
if (denyMatch) {
|
|
43
|
+
const targets = parseTargets(denyMatch[1]);
|
|
44
|
+
if (targets.length > 0) {
|
|
45
|
+
return { action: "deny", targets };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Short forms: yes/no with target
|
|
50
|
+
const yesMatch = trimmed.match(/^(yes|ok|y)\s+(.+)$/i);
|
|
51
|
+
if (yesMatch) {
|
|
52
|
+
const targets = parseTargets(yesMatch[2]);
|
|
53
|
+
if (targets.length > 0) {
|
|
54
|
+
return { action: "approve", targets };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const noMatch = trimmed.match(/^(no|n|reject)\s+(.+)$/i);
|
|
59
|
+
if (noMatch) {
|
|
60
|
+
const targets = parseTargets(noMatch[2]);
|
|
61
|
+
if (targets.length > 0) {
|
|
62
|
+
return { action: "deny", targets };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse target identifiers from a string.
|
|
71
|
+
* Supports: @username, room:NAME, user:ID, plain IDs
|
|
72
|
+
* Supports multiple targets separated by spaces or commas.
|
|
73
|
+
*/
|
|
74
|
+
function parseTargets(input: string): string[] {
|
|
75
|
+
// Split by comma or whitespace
|
|
76
|
+
const parts = input.split(/[\s,]+/).filter(Boolean);
|
|
77
|
+
|
|
78
|
+
const targets: string[] = [];
|
|
79
|
+
for (const part of parts) {
|
|
80
|
+
const trimmed = part.trim();
|
|
81
|
+
if (!trimmed) continue;
|
|
82
|
+
|
|
83
|
+
// Already prefixed
|
|
84
|
+
if (trimmed.startsWith("@") || trimmed.startsWith("room:") || trimmed.startsWith("user:")) {
|
|
85
|
+
targets.push(trimmed);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Plain identifier - could be username or room
|
|
90
|
+
targets.push(trimmed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return targets;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Format a pending approval for display.
|
|
98
|
+
*/
|
|
99
|
+
export function formatApprovalRequest(approval: {
|
|
100
|
+
id: string;
|
|
101
|
+
type: "dm" | "room";
|
|
102
|
+
targetId: string;
|
|
103
|
+
targetName?: string;
|
|
104
|
+
targetUsername?: string;
|
|
105
|
+
requesterName?: string;
|
|
106
|
+
requesterUsername?: string;
|
|
107
|
+
createdAt: number;
|
|
108
|
+
}): string {
|
|
109
|
+
const age = formatAge(Date.now() - approval.createdAt);
|
|
110
|
+
|
|
111
|
+
if (approval.type === "dm") {
|
|
112
|
+
const who = approval.targetUsername
|
|
113
|
+
? `@${approval.targetUsername}`
|
|
114
|
+
: approval.targetName ?? approval.targetId;
|
|
115
|
+
return `• **DM** from ${who} (${age} ago) — \`approve ${approval.targetUsername ?? approval.targetId}\``;
|
|
116
|
+
} else {
|
|
117
|
+
const room = approval.targetName ?? approval.targetId;
|
|
118
|
+
const who = approval.requesterUsername
|
|
119
|
+
? `@${approval.requesterUsername}`
|
|
120
|
+
: approval.requesterName ?? "someone";
|
|
121
|
+
return `• **Room** #${room} (invited by ${who}, ${age} ago) — \`approve room:${approval.targetId}\``;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Format age in human-readable form.
|
|
127
|
+
*/
|
|
128
|
+
function formatAge(ms: number): string {
|
|
129
|
+
const seconds = Math.floor(ms / 1000);
|
|
130
|
+
if (seconds < 60) return `${seconds}s`;
|
|
131
|
+
const minutes = Math.floor(seconds / 60);
|
|
132
|
+
if (minutes < 60) return `${minutes}m`;
|
|
133
|
+
const hours = Math.floor(minutes / 60);
|
|
134
|
+
if (hours < 24) return `${hours}h`;
|
|
135
|
+
const days = Math.floor(hours / 24);
|
|
136
|
+
return `${days}d`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build the approval request notification message.
|
|
141
|
+
*/
|
|
142
|
+
export function buildApprovalRequestMessage(params: {
|
|
143
|
+
type: "dm" | "room";
|
|
144
|
+
targetId: string;
|
|
145
|
+
targetName?: string;
|
|
146
|
+
targetUsername?: string;
|
|
147
|
+
requesterName?: string;
|
|
148
|
+
requesterUsername?: string;
|
|
149
|
+
}): string {
|
|
150
|
+
if (params.type === "dm") {
|
|
151
|
+
const who = params.targetUsername
|
|
152
|
+
? `@${params.targetUsername}`
|
|
153
|
+
: params.targetName ?? params.targetId;
|
|
154
|
+
return [
|
|
155
|
+
`🔔 **New DM request**`,
|
|
156
|
+
``,
|
|
157
|
+
`User: ${who}`,
|
|
158
|
+
``,
|
|
159
|
+
`Reply:`,
|
|
160
|
+
`• \`approve ${params.targetUsername ?? params.targetId}\` — allow this user`,
|
|
161
|
+
`• \`deny ${params.targetUsername ?? params.targetId}\` — block this user`,
|
|
162
|
+
`• \`pending\` — list all pending requests`,
|
|
163
|
+
].join("\n");
|
|
164
|
+
} else {
|
|
165
|
+
const room = params.targetName ?? params.targetId;
|
|
166
|
+
const who = params.requesterUsername
|
|
167
|
+
? `@${params.requesterUsername}`
|
|
168
|
+
: params.requesterName ?? "Someone";
|
|
169
|
+
return [
|
|
170
|
+
`🔔 **Bot invited to new room**`,
|
|
171
|
+
``,
|
|
172
|
+
`Room: #${room}`,
|
|
173
|
+
`Invited by: ${who}`,
|
|
174
|
+
``,
|
|
175
|
+
`Reply:`,
|
|
176
|
+
`• \`approve room:${params.targetId}\` — allow this room`,
|
|
177
|
+
`• \`deny room:${params.targetId}\` — leave this room`,
|
|
178
|
+
`• \`pending\` — list all pending requests`,
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build the "waiting for approval" message for requesters.
|
|
185
|
+
*/
|
|
186
|
+
export function buildWaitingMessage(): string {
|
|
187
|
+
return `⏳ Your request is pending approval. The owner has been notified.`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the approval confirmation message.
|
|
192
|
+
*/
|
|
193
|
+
export function buildApprovedMessage(target: string, type: "dm" | "room"): string {
|
|
194
|
+
if (type === "dm") {
|
|
195
|
+
return `✅ You've been approved! You can now send messages.`;
|
|
196
|
+
} else {
|
|
197
|
+
return `✅ This room has been approved. I'm ready to help!`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build the denial message.
|
|
203
|
+
*/
|
|
204
|
+
export function buildDeniedMessage(target: string, type: "dm" | "room"): string {
|
|
205
|
+
if (type === "dm") {
|
|
206
|
+
return `❌ Your request was not approved.`;
|
|
207
|
+
} else {
|
|
208
|
+
return `❌ This room was not approved. Goodbye!`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval Queue for Owner Approval Flow
|
|
3
|
+
*
|
|
4
|
+
* Manages pending approval requests with memory + file persistence.
|
|
5
|
+
* Survives gateway restarts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as crypto from "node:crypto";
|
|
12
|
+
|
|
13
|
+
export type ApprovalType = "dm" | "room";
|
|
14
|
+
|
|
15
|
+
export type PendingApproval = {
|
|
16
|
+
id: string;
|
|
17
|
+
type: ApprovalType;
|
|
18
|
+
|
|
19
|
+
// For DMs: the user requesting access
|
|
20
|
+
// For rooms: the room the bot was invited to
|
|
21
|
+
targetId: string;
|
|
22
|
+
targetName?: string;
|
|
23
|
+
targetUsername?: string;
|
|
24
|
+
|
|
25
|
+
// Who triggered the approval (user who DM'd or invited bot)
|
|
26
|
+
requesterId: string;
|
|
27
|
+
requesterName?: string;
|
|
28
|
+
requesterUsername?: string;
|
|
29
|
+
|
|
30
|
+
// Where to reply to the requester
|
|
31
|
+
replyRoomId: string;
|
|
32
|
+
|
|
33
|
+
// Timestamps
|
|
34
|
+
createdAt: number;
|
|
35
|
+
lastNotifiedAt: number;
|
|
36
|
+
expiresAt?: number;
|
|
37
|
+
|
|
38
|
+
// State
|
|
39
|
+
status: "pending" | "approved" | "denied" | "expired";
|
|
40
|
+
decidedBy?: string;
|
|
41
|
+
decidedAt?: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ApprovalStore = {
|
|
45
|
+
version: number;
|
|
46
|
+
pending: PendingApproval[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// In-memory cache
|
|
50
|
+
let cache: ApprovalStore | null = null;
|
|
51
|
+
|
|
52
|
+
function resolveStorePath(): string {
|
|
53
|
+
return path.join(os.homedir(), ".openclaw", "credentials", "rocketchat-approval-queue.json");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ensureDir(dirPath: string): Promise<void> {
|
|
57
|
+
await fs.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadStore(): Promise<ApprovalStore> {
|
|
61
|
+
if (cache) return cache;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const data = await fs.readFile(resolveStorePath(), "utf-8");
|
|
65
|
+
const parsed = JSON.parse(data);
|
|
66
|
+
cache = {
|
|
67
|
+
version: parsed.version ?? 1,
|
|
68
|
+
pending: Array.isArray(parsed.pending) ? parsed.pending : [],
|
|
69
|
+
};
|
|
70
|
+
return cache;
|
|
71
|
+
} catch {
|
|
72
|
+
cache = { version: 1, pending: [] };
|
|
73
|
+
return cache;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function saveStore(store: ApprovalStore): Promise<void> {
|
|
78
|
+
cache = store;
|
|
79
|
+
await ensureDir(path.dirname(resolveStorePath()));
|
|
80
|
+
await fs.writeFile(resolveStorePath(), JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a new pending approval request.
|
|
85
|
+
*/
|
|
86
|
+
export async function createApproval(params: {
|
|
87
|
+
type: ApprovalType;
|
|
88
|
+
targetId: string;
|
|
89
|
+
targetName?: string;
|
|
90
|
+
targetUsername?: string;
|
|
91
|
+
requesterId: string;
|
|
92
|
+
requesterName?: string;
|
|
93
|
+
requesterUsername?: string;
|
|
94
|
+
replyRoomId: string;
|
|
95
|
+
timeoutMs?: number;
|
|
96
|
+
}): Promise<PendingApproval> {
|
|
97
|
+
const store = await loadStore();
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
|
|
100
|
+
// Check for existing pending approval for same target
|
|
101
|
+
const existing = store.pending.find(
|
|
102
|
+
(p) => p.targetId === params.targetId && p.type === params.type && p.status === "pending"
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (existing) {
|
|
106
|
+
// Update last notified time
|
|
107
|
+
existing.lastNotifiedAt = now;
|
|
108
|
+
await saveStore(store);
|
|
109
|
+
return existing;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const approval: PendingApproval = {
|
|
113
|
+
id: crypto.randomUUID().slice(0, 8),
|
|
114
|
+
type: params.type,
|
|
115
|
+
targetId: params.targetId,
|
|
116
|
+
targetName: params.targetName,
|
|
117
|
+
targetUsername: params.targetUsername,
|
|
118
|
+
requesterId: params.requesterId,
|
|
119
|
+
requesterName: params.requesterName,
|
|
120
|
+
requesterUsername: params.requesterUsername,
|
|
121
|
+
replyRoomId: params.replyRoomId,
|
|
122
|
+
createdAt: now,
|
|
123
|
+
lastNotifiedAt: now,
|
|
124
|
+
expiresAt: params.timeoutMs ? now + params.timeoutMs : undefined,
|
|
125
|
+
status: "pending",
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
store.pending.push(approval);
|
|
129
|
+
await saveStore(store);
|
|
130
|
+
return approval;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get all pending approvals.
|
|
135
|
+
*/
|
|
136
|
+
export async function listPendingApprovals(): Promise<PendingApproval[]> {
|
|
137
|
+
const store = await loadStore();
|
|
138
|
+
return store.pending.filter((p) => p.status === "pending");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Find a pending approval by target (user ID, username, or room ID).
|
|
143
|
+
*/
|
|
144
|
+
export async function findPendingApproval(
|
|
145
|
+
target: string,
|
|
146
|
+
type?: ApprovalType
|
|
147
|
+
): Promise<PendingApproval | null> {
|
|
148
|
+
const store = await loadStore();
|
|
149
|
+
const normalized = target.toLowerCase().replace(/^@/, "");
|
|
150
|
+
|
|
151
|
+
return store.pending.find((p) => {
|
|
152
|
+
if (p.status !== "pending") return false;
|
|
153
|
+
if (type && p.type !== type) return false;
|
|
154
|
+
|
|
155
|
+
// Match by ID
|
|
156
|
+
if (p.targetId === target) return true;
|
|
157
|
+
if (p.id === target) return true;
|
|
158
|
+
|
|
159
|
+
// Match by username (case-insensitive)
|
|
160
|
+
if (p.targetUsername?.toLowerCase() === normalized) return true;
|
|
161
|
+
|
|
162
|
+
// Match by room name for room approvals
|
|
163
|
+
if (p.type === "room" && p.targetName?.toLowerCase() === normalized) return true;
|
|
164
|
+
|
|
165
|
+
return false;
|
|
166
|
+
}) ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Approve a pending request.
|
|
171
|
+
*/
|
|
172
|
+
export async function approveRequest(
|
|
173
|
+
target: string,
|
|
174
|
+
decidedBy: string,
|
|
175
|
+
type?: ApprovalType
|
|
176
|
+
): Promise<PendingApproval | null> {
|
|
177
|
+
const store = await loadStore();
|
|
178
|
+
const approval = await findPendingApproval(target, type);
|
|
179
|
+
|
|
180
|
+
if (!approval) return null;
|
|
181
|
+
|
|
182
|
+
approval.status = "approved";
|
|
183
|
+
approval.decidedBy = decidedBy;
|
|
184
|
+
approval.decidedAt = Date.now();
|
|
185
|
+
|
|
186
|
+
await saveStore(store);
|
|
187
|
+
return approval;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Deny a pending request.
|
|
192
|
+
*/
|
|
193
|
+
export async function denyRequest(
|
|
194
|
+
target: string,
|
|
195
|
+
decidedBy: string,
|
|
196
|
+
type?: ApprovalType
|
|
197
|
+
): Promise<PendingApproval | null> {
|
|
198
|
+
const store = await loadStore();
|
|
199
|
+
const approval = await findPendingApproval(target, type);
|
|
200
|
+
|
|
201
|
+
if (!approval) return null;
|
|
202
|
+
|
|
203
|
+
approval.status = "denied";
|
|
204
|
+
approval.decidedBy = decidedBy;
|
|
205
|
+
approval.decidedAt = Date.now();
|
|
206
|
+
|
|
207
|
+
await saveStore(store);
|
|
208
|
+
return approval;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if a user/room is already approved (in allowlist).
|
|
213
|
+
*/
|
|
214
|
+
export async function isApproved(targetId: string): Promise<boolean> {
|
|
215
|
+
const store = await loadStore();
|
|
216
|
+
return store.pending.some(
|
|
217
|
+
(p) => p.targetId === targetId && p.status === "approved"
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Get expired approvals and mark them.
|
|
223
|
+
*/
|
|
224
|
+
export async function processExpiredApprovals(): Promise<PendingApproval[]> {
|
|
225
|
+
const store = await loadStore();
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const expired: PendingApproval[] = [];
|
|
228
|
+
|
|
229
|
+
for (const approval of store.pending) {
|
|
230
|
+
if (approval.status === "pending" && approval.expiresAt && approval.expiresAt < now) {
|
|
231
|
+
approval.status = "expired";
|
|
232
|
+
expired.push(approval);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (expired.length > 0) {
|
|
237
|
+
await saveStore(store);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return expired;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Clean up old completed/expired approvals (keep last 100).
|
|
245
|
+
*/
|
|
246
|
+
export async function cleanupOldApprovals(): Promise<void> {
|
|
247
|
+
const store = await loadStore();
|
|
248
|
+
const pending = store.pending.filter((p) => p.status === "pending");
|
|
249
|
+
const completed = store.pending
|
|
250
|
+
.filter((p) => p.status !== "pending")
|
|
251
|
+
.sort((a, b) => (b.decidedAt ?? b.createdAt) - (a.decidedAt ?? a.createdAt))
|
|
252
|
+
.slice(0, 100);
|
|
253
|
+
|
|
254
|
+
store.pending = [...pending, ...completed];
|
|
255
|
+
await saveStore(store);
|
|
256
|
+
}
|
|
@@ -19,8 +19,34 @@ import {
|
|
|
19
19
|
readChannelAllowFromStore,
|
|
20
20
|
upsertChannelPairingRequest,
|
|
21
21
|
buildPairingReply,
|
|
22
|
+
addToAllowFrom,
|
|
22
23
|
} from "./pairing.js";
|
|
23
24
|
|
|
25
|
+
import {
|
|
26
|
+
createApproval,
|
|
27
|
+
listPendingApprovals,
|
|
28
|
+
findPendingApproval,
|
|
29
|
+
approveRequest,
|
|
30
|
+
denyRequest,
|
|
31
|
+
type PendingApproval,
|
|
32
|
+
} from "./approval-queue.js";
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
parseApprovalCommand,
|
|
36
|
+
buildApprovalRequestMessage,
|
|
37
|
+
buildWaitingMessage,
|
|
38
|
+
buildApprovedMessage,
|
|
39
|
+
buildDeniedMessage,
|
|
40
|
+
formatApprovalRequest,
|
|
41
|
+
} from "./approval-commands.js";
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
isApprover,
|
|
45
|
+
isNotifyChannel,
|
|
46
|
+
getDmNotifyTargets,
|
|
47
|
+
fetchUserRoles,
|
|
48
|
+
} from "./roles.js";
|
|
49
|
+
|
|
24
50
|
import { getRocketChatRuntime } from "../runtime.js";
|
|
25
51
|
import { resolveRocketChatAccount, type ResolvedRocketChatAccount } from "./accounts.js";
|
|
26
52
|
import {
|
|
@@ -460,7 +486,6 @@ async function handleIncomingMessage(
|
|
|
460
486
|
if (dmPolicy === "pairing") {
|
|
461
487
|
try {
|
|
462
488
|
const { code, created } = await upsertChannelPairingRequest({
|
|
463
|
-
channel: "rocketchat",
|
|
464
489
|
id: senderId,
|
|
465
490
|
meta: {
|
|
466
491
|
name: senderName ?? undefined,
|
|
@@ -471,7 +496,6 @@ async function handleIncomingMessage(
|
|
|
471
496
|
if (created) {
|
|
472
497
|
logger.info?.(`[${account.accountId}] Pairing request created for ${senderUsername ?? senderId}, code: ${code}`);
|
|
473
498
|
const reply = buildPairingReply({
|
|
474
|
-
channel: "rocketchat",
|
|
475
499
|
idLine: `Rocket.Chat user: ${senderUsername ? `@${senderUsername}` : senderId}`,
|
|
476
500
|
code,
|
|
477
501
|
});
|
|
@@ -486,6 +510,193 @@ async function handleIncomingMessage(
|
|
|
486
510
|
}
|
|
487
511
|
return;
|
|
488
512
|
}
|
|
513
|
+
|
|
514
|
+
// If "owner-approval" mode, use owner channel approval flow
|
|
515
|
+
if (dmPolicy === "owner-approval") {
|
|
516
|
+
const ownerConfig = account.config.ownerApproval;
|
|
517
|
+
if (!ownerConfig?.enabled) {
|
|
518
|
+
logger.debug?.(`[${account.accountId}] DM from ${senderUsername ?? senderId} blocked (dmPolicy=owner-approval but ownerApproval not enabled)`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Approvers are always allowed through — they need DM access
|
|
523
|
+
// to send approval commands (approve/deny/list) and to chat.
|
|
524
|
+
const approvers = ownerConfig.approvers ?? [];
|
|
525
|
+
if (approvers.length > 0) {
|
|
526
|
+
const restClient = createRocketChatClient({
|
|
527
|
+
baseUrl: account.baseUrl!,
|
|
528
|
+
userId: account.userId!,
|
|
529
|
+
authToken: account.authToken!,
|
|
530
|
+
});
|
|
531
|
+
if (await isApprover(restClient, senderId, senderUsername, approvers)) {
|
|
532
|
+
logger.debug?.(`[${account.accountId}] DM from ${senderUsername ?? senderId} allowed (sender is approver)`);
|
|
533
|
+
// Fall through to normal message processing (skip access control)
|
|
534
|
+
} else {
|
|
535
|
+
// Not an approver — run the approval request flow
|
|
536
|
+
try {
|
|
537
|
+
const existing = await findPendingApproval(senderId, "dm");
|
|
538
|
+
if (existing?.status === "approved") {
|
|
539
|
+
await addToAllowFrom(senderId);
|
|
540
|
+
// Fall through to normal message processing
|
|
541
|
+
} else {
|
|
542
|
+
const approval = await createApproval({
|
|
543
|
+
type: "dm",
|
|
544
|
+
targetId: senderId,
|
|
545
|
+
targetName: senderName,
|
|
546
|
+
targetUsername: senderUsername,
|
|
547
|
+
requesterId: senderId,
|
|
548
|
+
requesterName: senderName,
|
|
549
|
+
requesterUsername: senderUsername,
|
|
550
|
+
replyRoomId: msg.rid,
|
|
551
|
+
timeoutMs: ownerConfig.timeout ? ownerConfig.timeout * 1000 : undefined,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
if (approval.createdAt === approval.lastNotifiedAt) {
|
|
555
|
+
const notifyChannels = ownerConfig.notifyChannels ?? [];
|
|
556
|
+
const notifyMessage = buildApprovalRequestMessage({
|
|
557
|
+
type: "dm",
|
|
558
|
+
targetId: senderId,
|
|
559
|
+
targetName: senderName,
|
|
560
|
+
targetUsername: senderUsername,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
for (const channel of notifyChannels) {
|
|
564
|
+
try {
|
|
565
|
+
if (channel.startsWith("@")) {
|
|
566
|
+
await sendMessageRocketChat(`user:${channel.slice(1)}`, notifyMessage, { accountId: account.accountId });
|
|
567
|
+
} else if (channel.startsWith("room:")) {
|
|
568
|
+
await sendMessageRocketChat(channel, notifyMessage, { accountId: account.accountId });
|
|
569
|
+
} else if (channel.startsWith("#")) {
|
|
570
|
+
await sendMessageRocketChat(`room:${channel.slice(1)}`, notifyMessage, { accountId: account.accountId });
|
|
571
|
+
} else {
|
|
572
|
+
await sendMessageRocketChat(`room:${channel}`, notifyMessage, { accountId: account.accountId });
|
|
573
|
+
}
|
|
574
|
+
} catch (err) {
|
|
575
|
+
logger.error?.(`[${account.accountId}] Failed to send approval notification to ${channel}: ${String(err)}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
await sendMessageRocketChat(`room:${msg.rid}`, buildWaitingMessage(), { accountId: account.accountId });
|
|
580
|
+
logger.info?.(`[${account.accountId}] Approval request created for ${senderUsername ?? senderId}`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return; // Don't process the message yet
|
|
584
|
+
}
|
|
585
|
+
} catch (err) {
|
|
586
|
+
logger.error?.(`[${account.accountId}] Failed to handle owner-approval flow: ${String(err)}`);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// === Owner Approval Command Handling ===
|
|
597
|
+
// Check if this message is an approval command from an approver
|
|
598
|
+
const ownerConfig = account.config.ownerApproval;
|
|
599
|
+
if (ownerConfig?.enabled) {
|
|
600
|
+
const approvers = ownerConfig.approvers ?? [];
|
|
601
|
+
const notifyChannels = ownerConfig.notifyChannels ?? [];
|
|
602
|
+
|
|
603
|
+
// Check if sender is an approver
|
|
604
|
+
const restClient = createRocketChatClient({
|
|
605
|
+
baseUrl: account.baseUrl!,
|
|
606
|
+
userId: account.userId!,
|
|
607
|
+
authToken: account.authToken!,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const senderIsApprover = await isApprover(
|
|
611
|
+
restClient,
|
|
612
|
+
msg.u._id,
|
|
613
|
+
msg.u.username,
|
|
614
|
+
approvers
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
// Check if this room is a notify channel
|
|
618
|
+
const roomName = room?.name ?? room?.fname;
|
|
619
|
+
const roomIsNotifyChannel = isNotifyChannel(msg.rid, roomName, notifyChannels);
|
|
620
|
+
|
|
621
|
+
// Also check if this is a DM with the bot (approvers can send commands via DM)
|
|
622
|
+
const isDmWithBot = !isGroup;
|
|
623
|
+
|
|
624
|
+
if (senderIsApprover && (roomIsNotifyChannel || isDmWithBot)) {
|
|
625
|
+
const command = parseApprovalCommand(msg.msg);
|
|
626
|
+
|
|
627
|
+
if (command) {
|
|
628
|
+
logger.debug?.(`[${account.accountId}] Approval command from ${msg.u.username}: ${JSON.stringify(command)}`);
|
|
629
|
+
|
|
630
|
+
if (command.action === "list") {
|
|
631
|
+
// List pending approvals
|
|
632
|
+
const pending = await listPendingApprovals();
|
|
633
|
+
if (pending.length === 0) {
|
|
634
|
+
await sendMessageRocketChat(`room:${msg.rid}`, "✅ No pending approvals.", {
|
|
635
|
+
accountId: account.accountId,
|
|
636
|
+
});
|
|
637
|
+
} else {
|
|
638
|
+
const lines = pending.map(formatApprovalRequest);
|
|
639
|
+
await sendMessageRocketChat(`room:${msg.rid}`, `📋 **Pending approvals (${pending.length})**\n\n${lines.join("\n")}`, {
|
|
640
|
+
accountId: account.accountId,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return; // Don't process as regular message
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (command.action === "approve" || command.action === "deny") {
|
|
647
|
+
const results: string[] = [];
|
|
648
|
+
|
|
649
|
+
for (const target of command.targets) {
|
|
650
|
+
// Determine type from target format
|
|
651
|
+
const type = target.startsWith("room:") ? "room" : "dm";
|
|
652
|
+
const cleanTarget = target.replace(/^room:/, "").replace(/^@/, "");
|
|
653
|
+
|
|
654
|
+
let approval: PendingApproval | null;
|
|
655
|
+
if (command.action === "approve") {
|
|
656
|
+
approval = await approveRequest(cleanTarget, msg.u._id, type === "room" ? "room" : "dm");
|
|
657
|
+
} else {
|
|
658
|
+
approval = await denyRequest(cleanTarget, msg.u._id, type === "room" ? "room" : "dm");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (approval) {
|
|
662
|
+
// Add to allowlist if approved
|
|
663
|
+
if (command.action === "approve") {
|
|
664
|
+
await addToAllowFrom(approval.targetId);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Notify the requester
|
|
668
|
+
const shouldNotify = command.action === "approve"
|
|
669
|
+
? ownerConfig.notifyOnApprove !== false
|
|
670
|
+
: ownerConfig.notifyOnDeny === true;
|
|
671
|
+
|
|
672
|
+
if (shouldNotify) {
|
|
673
|
+
try {
|
|
674
|
+
const message = command.action === "approve"
|
|
675
|
+
? buildApprovedMessage(approval.targetId, approval.type)
|
|
676
|
+
: buildDeniedMessage(approval.targetId, approval.type);
|
|
677
|
+
|
|
678
|
+
await sendMessageRocketChat(`room:${approval.replyRoomId}`, message, {
|
|
679
|
+
accountId: account.accountId,
|
|
680
|
+
});
|
|
681
|
+
} catch (err) {
|
|
682
|
+
logger.error?.(`[${account.accountId}] Failed to notify requester: ${String(err)}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const targetLabel = approval.targetUsername
|
|
687
|
+
? `@${approval.targetUsername}`
|
|
688
|
+
: approval.targetId;
|
|
689
|
+
results.push(`${command.action === "approve" ? "✅" : "❌"} ${targetLabel}`);
|
|
690
|
+
} else {
|
|
691
|
+
results.push(`⚠️ No pending request for: ${target}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
await sendMessageRocketChat(`room:${msg.rid}`, results.join("\n"), {
|
|
696
|
+
accountId: account.accountId,
|
|
697
|
+
});
|
|
698
|
+
return; // Don't process as regular message
|
|
699
|
+
}
|
|
489
700
|
}
|
|
490
701
|
}
|
|
491
702
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rocket.Chat Role Checker
|
|
3
|
+
*
|
|
4
|
+
* Fetches and checks user roles via the Rocket.Chat API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RocketChatClient } from "./client.js";
|
|
8
|
+
|
|
9
|
+
export type UserRoles = {
|
|
10
|
+
userId: string;
|
|
11
|
+
username?: string;
|
|
12
|
+
roles: string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Cache user roles for 5 minutes
|
|
16
|
+
const roleCache = new Map<string, { roles: UserRoles; fetchedAt: number }>();
|
|
17
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch user info including roles from Rocket.Chat.
|
|
21
|
+
*/
|
|
22
|
+
export async function fetchUserRoles(
|
|
23
|
+
client: RocketChatClient,
|
|
24
|
+
userId: string
|
|
25
|
+
): Promise<UserRoles | null> {
|
|
26
|
+
// Check cache
|
|
27
|
+
const cached = roleCache.get(userId);
|
|
28
|
+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
29
|
+
return cached.roles;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${client.baseUrl}/api/v1/users.info?userId=${encodeURIComponent(userId)}`, {
|
|
34
|
+
headers: {
|
|
35
|
+
"X-Auth-Token": client.authToken,
|
|
36
|
+
"X-User-Id": client.userId,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
const user = data.user;
|
|
46
|
+
|
|
47
|
+
if (!user) return null;
|
|
48
|
+
|
|
49
|
+
const roles: UserRoles = {
|
|
50
|
+
userId: user._id,
|
|
51
|
+
username: user.username,
|
|
52
|
+
roles: Array.isArray(user.roles) ? user.roles : [],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Cache the result
|
|
56
|
+
roleCache.set(userId, { roles, fetchedAt: Date.now() });
|
|
57
|
+
|
|
58
|
+
return roles;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a user matches any of the approver patterns.
|
|
66
|
+
*
|
|
67
|
+
* Patterns:
|
|
68
|
+
* - @username — matches specific username
|
|
69
|
+
* - role:admin — matches anyone with "admin" role
|
|
70
|
+
* - role:moderator — matches anyone with "moderator" role
|
|
71
|
+
* - userId — matches specific user ID
|
|
72
|
+
*/
|
|
73
|
+
export async function isApprover(
|
|
74
|
+
client: RocketChatClient,
|
|
75
|
+
userId: string,
|
|
76
|
+
username: string | undefined,
|
|
77
|
+
approverPatterns: string[]
|
|
78
|
+
): Promise<boolean> {
|
|
79
|
+
if (approverPatterns.length === 0) return false;
|
|
80
|
+
|
|
81
|
+
const normalizedUsername = username?.toLowerCase();
|
|
82
|
+
|
|
83
|
+
// Check each pattern
|
|
84
|
+
for (const pattern of approverPatterns) {
|
|
85
|
+
const trimmed = pattern.trim();
|
|
86
|
+
if (!trimmed) continue;
|
|
87
|
+
|
|
88
|
+
// @username match
|
|
89
|
+
if (trimmed.startsWith("@")) {
|
|
90
|
+
const targetUsername = trimmed.slice(1).toLowerCase();
|
|
91
|
+
if (normalizedUsername === targetUsername) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// role:xxx match
|
|
98
|
+
if (trimmed.startsWith("role:")) {
|
|
99
|
+
const targetRole = trimmed.slice(5).toLowerCase();
|
|
100
|
+
const userRoles = await fetchUserRoles(client, userId);
|
|
101
|
+
if (userRoles?.roles.some(r => r.toLowerCase() === targetRole)) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Direct user ID match
|
|
108
|
+
if (trimmed === userId) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Username without @ prefix
|
|
113
|
+
if (normalizedUsername === trimmed.toLowerCase()) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if a room/channel is in the notify channels list.
|
|
123
|
+
*/
|
|
124
|
+
export function isNotifyChannel(
|
|
125
|
+
roomId: string,
|
|
126
|
+
roomName: string | undefined,
|
|
127
|
+
notifyChannels: string[]
|
|
128
|
+
): boolean {
|
|
129
|
+
if (notifyChannels.length === 0) return false;
|
|
130
|
+
|
|
131
|
+
const normalizedName = roomName?.toLowerCase();
|
|
132
|
+
|
|
133
|
+
for (const channel of notifyChannels) {
|
|
134
|
+
const trimmed = channel.trim();
|
|
135
|
+
if (!trimmed) continue;
|
|
136
|
+
|
|
137
|
+
// @username (DM) — we check this separately
|
|
138
|
+
if (trimmed.startsWith("@")) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// room:ID or room:NAME
|
|
143
|
+
if (trimmed.startsWith("room:")) {
|
|
144
|
+
const target = trimmed.slice(5);
|
|
145
|
+
if (target === roomId) return true;
|
|
146
|
+
if (normalizedName && target.toLowerCase() === normalizedName) return true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// #channel
|
|
151
|
+
if (trimmed.startsWith("#")) {
|
|
152
|
+
const target = trimmed.slice(1).toLowerCase();
|
|
153
|
+
if (normalizedName === target) return true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Plain room ID or name
|
|
158
|
+
if (trimmed === roomId) return true;
|
|
159
|
+
if (normalizedName && trimmed.toLowerCase() === normalizedName) return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get DM targets from notify channels.
|
|
167
|
+
* Returns usernames (without @) that should receive DM notifications.
|
|
168
|
+
*/
|
|
169
|
+
export function getDmNotifyTargets(notifyChannels: string[]): string[] {
|
|
170
|
+
const targets: string[] = [];
|
|
171
|
+
|
|
172
|
+
for (const channel of notifyChannels) {
|
|
173
|
+
const trimmed = channel.trim();
|
|
174
|
+
if (trimmed.startsWith("@")) {
|
|
175
|
+
targets.push(trimmed.slice(1));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return targets;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Clear the role cache (useful for testing or when roles change).
|
|
184
|
+
*/
|
|
185
|
+
export function clearRoleCache(): void {
|
|
186
|
+
roleCache.clear();
|
|
187
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test cases for approval command parsing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert";
|
|
6
|
+
|
|
7
|
+
// Simple test runner
|
|
8
|
+
const tests = [];
|
|
9
|
+
function test(name, fn) {
|
|
10
|
+
tests.push({ name, fn });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Import the parseApprovalCommand function
|
|
14
|
+
// Since this is a TypeScript project, we'll test the logic manually
|
|
15
|
+
// In a real setup, you'd use ts-node or compile first
|
|
16
|
+
|
|
17
|
+
// Test cases for parseApprovalCommand logic
|
|
18
|
+
test("parse 'approve @user123' → approve action with target", () => {
|
|
19
|
+
const text = "approve @user123";
|
|
20
|
+
// Expected: { action: "approve", targets: ["@user123"] }
|
|
21
|
+
assert.ok(text.match(/^approve\s+(.+)$/i));
|
|
22
|
+
const match = text.match(/^approve\s+(.+)$/i);
|
|
23
|
+
assert.strictEqual(match[1], "@user123");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("parse 'deny user456' → deny action with target", () => {
|
|
27
|
+
const text = "deny user456";
|
|
28
|
+
assert.ok(text.match(/^deny\s+(.+)$/i));
|
|
29
|
+
const match = text.match(/^deny\s+(.+)$/i);
|
|
30
|
+
assert.strictEqual(match[1], "user456");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("parse 'approve room:GENERAL' → approve with room target", () => {
|
|
34
|
+
const text = "approve room:GENERAL";
|
|
35
|
+
const match = text.match(/^approve\s+(.+)$/i);
|
|
36
|
+
assert.strictEqual(match[1], "room:GENERAL");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("parse 'pending' → list action", () => {
|
|
40
|
+
const text = "pending";
|
|
41
|
+
const lower = text.toLowerCase();
|
|
42
|
+
assert.ok(lower === "pending" || lower === "list pending" || lower === "list");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("parse 'list pending' → list action", () => {
|
|
46
|
+
const text = "list pending";
|
|
47
|
+
const lower = text.toLowerCase();
|
|
48
|
+
assert.ok(lower === "pending" || lower === "list pending" || lower === "list");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("parse 'approve @user1 @user2 @user3' → multiple targets", () => {
|
|
52
|
+
const text = "approve @user1 @user2 @user3";
|
|
53
|
+
const match = text.match(/^approve\s+(.+)$/i);
|
|
54
|
+
const targets = match[1].split(/[\s,]+/).filter(Boolean);
|
|
55
|
+
assert.deepStrictEqual(targets, ["@user1", "@user2", "@user3"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("parse 'yes @user123' → approve (shorthand)", () => {
|
|
59
|
+
const text = "yes @user123";
|
|
60
|
+
const yesMatch = text.match(/^(yes|ok|y)\s+(.+)$/i);
|
|
61
|
+
assert.ok(yesMatch);
|
|
62
|
+
assert.strictEqual(yesMatch[2], "@user123");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("parse 'no @user123' → deny (shorthand)", () => {
|
|
66
|
+
const text = "no @user123";
|
|
67
|
+
const noMatch = text.match(/^(no|n|reject)\s+(.+)$/i);
|
|
68
|
+
assert.ok(noMatch);
|
|
69
|
+
assert.strictEqual(noMatch[2], "@user123");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("regular message 'hello' → not a command", () => {
|
|
73
|
+
const text = "hello";
|
|
74
|
+
assert.ok(!text.match(/^approve\s+(.+)$/i));
|
|
75
|
+
assert.ok(!text.match(/^deny\s+(.+)$/i));
|
|
76
|
+
assert.ok(!text.match(/^(yes|ok|y)\s+(.+)$/i));
|
|
77
|
+
assert.ok(!text.match(/^(no|n|reject)\s+(.+)$/i));
|
|
78
|
+
const lower = text.toLowerCase();
|
|
79
|
+
assert.ok(lower !== "pending" && lower !== "list pending" && lower !== "list");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Run tests
|
|
83
|
+
console.log("Running approval command tests...\n");
|
|
84
|
+
let passed = 0;
|
|
85
|
+
let failed = 0;
|
|
86
|
+
|
|
87
|
+
for (const { name, fn } of tests) {
|
|
88
|
+
try {
|
|
89
|
+
fn();
|
|
90
|
+
console.log(`✅ ${name}`);
|
|
91
|
+
passed++;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.log(`❌ ${name}`);
|
|
94
|
+
console.log(` ${err.message}`);
|
|
95
|
+
failed++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
100
|
+
process.exit(failed > 0 ? 1 : 0);
|