@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 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 (Pairing)
357
+ ## DM Access Control
358
358
 
359
- The plugin supports OpenClaw's standard DM pairing flow for controlling who can message the bot.
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
- # DM access policy (default: "open")
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
- ### Pairing Flow
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
- When `dmPolicy: "pairing"`:
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 and notified: `"✅ You've been approved!"`
394
- 5. Future messages are processed normally
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 `pairing` or `allowlist` if you need per-user approval on top of that.
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
 
@@ -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.3.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 (server-level auth), 'pairing' requires approval, 'allowlist' requires pre-configured allowFrom, 'disabled' blocks all DMs."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudrise/openclaw-channel-rocketchat",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Rocket.Chat channel plugin for OpenClaw (Cloudrise)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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);