@abdarrahmanabdelnasir/relay-node 0.1.17 → 0.1.20

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/README.md CHANGED
@@ -62,6 +62,27 @@ discord.login(process.env.BOT_TOKEN);
62
62
  - Optional `execute(decision, ctx)` to override default reply behavior
63
63
  - `mentionRequired` (default true) to only process when mentioned or replying to the bot
64
64
 
65
+ ## Configuration System (NEW)
66
+
67
+ The SDK automatically fetches and caches bot configuration from your dashboard:
68
+
69
+ - **Channel filtering** - Only process messages from specific channels
70
+ - **Role permissions** - Restrict AI to certain roles (e.g., @Moderator, @Premium)
71
+ - **Rate limiting** - Local rate limits (per-user and per-server)
72
+ - **Command control** - Enable/disable specific command categories
73
+ - **Auto-updates** - Polls for config changes every 30 seconds (no bot restart needed)
74
+
75
+ Configuration is managed in the Commandless dashboard. The SDK enforces it locally (fast, no API calls for filtered messages).
76
+
77
+ To disable config filtering:
78
+ ```ts
79
+ useDiscordAdapter({
80
+ client,
81
+ relay,
82
+ disableConfigCache: true // Bypass all config checks
83
+ });
84
+ ```
85
+
65
86
  ## Security
66
87
 
67
88
  - Every request includes `x-commandless-key`.
@@ -35,6 +35,12 @@ const client = new Client({
35
35
 
36
36
  const relay = new RelayClient({ apiKey, baseUrl, hmacSecret });
37
37
 
38
+ // Set botId from env immediately (for config enforcement)
39
+ if (botId) {
40
+ relay.botId = parseInt(botId, 10);
41
+ console.log(`[commandless] Bot ID set from env: ${relay.botId}`);
42
+ }
43
+
38
44
  useDiscordAdapter({ client, relay, mentionRequired: true });
39
45
 
40
46
  client.once('ready', async () => {
@@ -46,11 +52,17 @@ client.once('ready', async () => {
46
52
  clientId: client.user.id,
47
53
  botId: parseInt(botId, 10)
48
54
  });
49
- if (registeredBotId) relay.botId = registeredBotId;
50
- console.log(`[commandless] Bot registered with ID: ${registeredBotId}`);
55
+ // Update botId if registration succeeded and returned a different ID
56
+ if (registeredBotId) {
57
+ relay.botId = registeredBotId;
58
+ console.log(`[commandless] Bot registered with ID: ${registeredBotId}`);
59
+ } else {
60
+ console.log(`[commandless] Registration returned null, using env BOT_ID: ${relay.botId}`);
61
+ }
51
62
  } catch (e) {
52
63
  console.error('[commandless] registerBot failed:', e?.message || e);
53
- process.exit(1);
64
+ // Don't exit - botId is already set from env, so config enforcement will still work
65
+ console.log(`[commandless] Continuing with botId from env: ${relay.botId}`);
54
66
  }
55
67
  setInterval(async () => { try { await relay.heartbeat(); } catch {} }, 30_000);
56
68
  });
@@ -17,5 +17,6 @@ export interface DiscordAdapterOptions {
17
17
  interaction?: Interaction;
18
18
  }) => Promise<void>;
19
19
  mentionRequired?: boolean;
20
+ disableConfigCache?: boolean;
20
21
  }
21
22
  export declare function useDiscordAdapter(opts: DiscordAdapterOptions): void;
@@ -1,11 +1,54 @@
1
+ import { ConfigCache } from "../configCache.js";
1
2
  export function useDiscordAdapter(opts) {
2
3
  const { client, relay } = opts;
4
+ const configCache = opts.disableConfigCache ? null : new ConfigCache(relay.baseUrl || '', relay.apiKey);
5
+ // Fetch config on client ready and start polling
6
+ if (configCache) {
7
+ client.once("ready", async () => {
8
+ try {
9
+ // Wait for bot ID to be set (either from registration or env)
10
+ await new Promise(resolve => setTimeout(resolve, 1000));
11
+ if (relay.botId) {
12
+ console.log(`[commandless] Fetching config for bot ${relay.botId}...`);
13
+ await configCache.fetch(relay.botId);
14
+ // Start polling for updates every 30 seconds
15
+ configCache.startPolling(relay.botId, 30000);
16
+ console.log('[commandless] Config polling started (30s interval)');
17
+ // Cleanup rate limits every 5 minutes
18
+ setInterval(() => configCache.cleanupRateLimits(), 5 * 60 * 1000);
19
+ }
20
+ else {
21
+ console.warn('[commandless] No botId available, config filtering disabled');
22
+ }
23
+ }
24
+ catch (error) {
25
+ console.error('[commandless] Failed to initialize config cache:', error);
26
+ }
27
+ });
28
+ }
3
29
  client.on("messageCreate", async (message) => {
4
30
  if (message.author.bot)
5
31
  return;
6
- const mentionRequired = opts.mentionRequired !== false;
32
+ // Get member roles if in guild
33
+ const memberRoles = message.member?.roles.cache.map(r => r.id) || [];
34
+ // Config-based filtering (if enabled)
35
+ if (configCache && relay.botId) {
36
+ const filterResult = configCache.shouldProcessMessage({
37
+ channelId: message.channelId,
38
+ authorId: message.author.id,
39
+ guildId: message.guildId || undefined,
40
+ memberRoles,
41
+ });
42
+ if (!filterResult.allowed) {
43
+ // Silently ignore (filtered by config)
44
+ console.log(`[commandless] Message filtered: ${filterResult.reason}`);
45
+ return;
46
+ }
47
+ }
48
+ // Check mention requirement (from config or options)
49
+ const config = configCache?.getConfig();
50
+ const mentionRequired = config?.mentionRequired !== false && opts.mentionRequired !== false;
7
51
  const mentioned = !!client.user?.id && (message.mentions?.users?.has?.(client.user.id) ?? false);
8
- // Typing indicator will be driven by a short loop only when addressed
9
52
  // Detect reply-to-bot accurately
10
53
  let isReplyToBot = false;
11
54
  try {
@@ -27,6 +70,7 @@ export function useDiscordAdapter(opts) {
27
70
  content: message.content,
28
71
  timestamp: message.createdTimestamp,
29
72
  botClientId: client.user?.id,
73
+ botId: relay.botId || undefined, // Include botId for backend config enforcement
30
74
  isReplyToBot,
31
75
  referencedMessageId: message.reference?.messageId,
32
76
  referencedMessageAuthorId: message.reference ? client.user?.id : undefined,
@@ -78,6 +122,7 @@ export function useDiscordAdapter(opts) {
78
122
  return out;
79
123
  })(),
80
124
  timestamp: Date.now(),
125
+ botId: relay.botId || undefined, // Include botId for backend config enforcement
81
126
  };
82
127
  try {
83
128
  const dec = await relay.sendEvent(evt);
@@ -0,0 +1,82 @@
1
+ export interface BotConfig {
2
+ version: number;
3
+ enabled: boolean;
4
+ channelMode: 'all' | 'whitelist' | 'blacklist';
5
+ enabledChannels: string[];
6
+ disabledChannels: string[];
7
+ permissionMode: 'all' | 'whitelist' | 'blacklist' | 'premium_only';
8
+ enabledRoles: string[];
9
+ disabledRoles: string[];
10
+ enabledUsers: string[];
11
+ disabledUsers: string[];
12
+ premiumRoleIds: string[];
13
+ enabledCommandCategories: string[];
14
+ disabledCommands: string[];
15
+ commandMode: 'all' | 'category_based' | 'whitelist' | 'blacklist';
16
+ mentionRequired: boolean;
17
+ customPrefix: string | null;
18
+ triggerMode: 'mention' | 'prefix' | 'always';
19
+ freeRateLimit: number;
20
+ premiumRateLimit: number;
21
+ serverRateLimit: number;
22
+ confidenceThreshold: number;
23
+ requireConfirmation: boolean;
24
+ dangerousCommands: string[];
25
+ responseStyle: 'friendly' | 'professional' | 'minimal';
26
+ }
27
+ interface MessageContext {
28
+ channelId: string;
29
+ authorId: string;
30
+ guildId?: string;
31
+ memberRoles?: string[];
32
+ }
33
+ export declare class ConfigCache {
34
+ private config;
35
+ private rateLimits;
36
+ private serverRateLimits;
37
+ private baseUrl;
38
+ private apiKey;
39
+ private botId;
40
+ private pollInterval;
41
+ constructor(baseUrl: string, apiKey: string);
42
+ /**
43
+ * Fetch configuration from backend
44
+ */
45
+ fetch(botId: string): Promise<BotConfig | null>;
46
+ /**
47
+ * Start polling for config updates every 30 seconds
48
+ */
49
+ startPolling(botId: string, intervalMs?: number): void;
50
+ /**
51
+ * Stop polling
52
+ */
53
+ stopPolling(): void;
54
+ /**
55
+ * Get current config (may be null if not fetched yet)
56
+ */
57
+ getConfig(): BotConfig | null;
58
+ /**
59
+ * Check if a message should be processed based on config
60
+ */
61
+ shouldProcessMessage(ctx: MessageContext): {
62
+ allowed: boolean;
63
+ reason?: string;
64
+ };
65
+ /**
66
+ * Check if channel is allowed
67
+ */
68
+ private checkChannel;
69
+ /**
70
+ * Check if user has permission
71
+ */
72
+ private checkPermissions;
73
+ /**
74
+ * Check rate limits (local check, server has final authority)
75
+ */
76
+ private checkRateLimit;
77
+ /**
78
+ * Clean up old rate limit entries (call periodically)
79
+ */
80
+ cleanupRateLimits(): void;
81
+ }
82
+ export {};
@@ -0,0 +1,230 @@
1
+ export class ConfigCache {
2
+ constructor(baseUrl, apiKey) {
3
+ this.config = null;
4
+ this.rateLimits = new Map();
5
+ this.serverRateLimits = new Map();
6
+ this.botId = null;
7
+ this.pollInterval = null;
8
+ this.baseUrl = baseUrl;
9
+ this.apiKey = apiKey;
10
+ }
11
+ /**
12
+ * Fetch configuration from backend
13
+ */
14
+ async fetch(botId) {
15
+ try {
16
+ this.botId = botId;
17
+ const currentVersion = this.config?.version || 0;
18
+ const url = `${this.baseUrl}/v1/relay/config?botId=${botId}&version=${currentVersion}`;
19
+ const response = await fetch(url, {
20
+ headers: {
21
+ 'x-api-key': this.apiKey,
22
+ },
23
+ });
24
+ if (!response.ok) {
25
+ console.error(`[commandless] Failed to fetch config: ${response.status}`);
26
+ return null;
27
+ }
28
+ const data = await response.json();
29
+ // If up to date, keep existing config
30
+ if (data.upToDate && this.config) {
31
+ return this.config;
32
+ }
33
+ // Update config
34
+ this.config = data;
35
+ console.log(`[commandless] Config loaded (v${this.config.version})`);
36
+ return this.config;
37
+ }
38
+ catch (error) {
39
+ console.error('[commandless] Error fetching config:', error);
40
+ return null;
41
+ }
42
+ }
43
+ /**
44
+ * Start polling for config updates every 30 seconds
45
+ */
46
+ startPolling(botId, intervalMs = 30000) {
47
+ if (this.pollInterval) {
48
+ clearInterval(this.pollInterval);
49
+ }
50
+ this.pollInterval = setInterval(async () => {
51
+ try {
52
+ await this.fetch(botId);
53
+ }
54
+ catch (error) {
55
+ console.error('[commandless] Config poll error:', error);
56
+ }
57
+ }, intervalMs);
58
+ }
59
+ /**
60
+ * Stop polling
61
+ */
62
+ stopPolling() {
63
+ if (this.pollInterval) {
64
+ clearInterval(this.pollInterval);
65
+ this.pollInterval = null;
66
+ }
67
+ }
68
+ /**
69
+ * Get current config (may be null if not fetched yet)
70
+ */
71
+ getConfig() {
72
+ return this.config;
73
+ }
74
+ /**
75
+ * Check if a message should be processed based on config
76
+ */
77
+ shouldProcessMessage(ctx) {
78
+ // If no config loaded yet, allow (fail open for initial startup)
79
+ if (!this.config) {
80
+ return { allowed: true };
81
+ }
82
+ // Check if bot is enabled
83
+ if (!this.config.enabled) {
84
+ return { allowed: false, reason: 'Bot disabled' };
85
+ }
86
+ // Check channel permissions
87
+ const channelCheck = this.checkChannel(ctx.channelId);
88
+ if (!channelCheck.allowed) {
89
+ return channelCheck;
90
+ }
91
+ // Check user/role permissions
92
+ const permissionCheck = this.checkPermissions(ctx.authorId, ctx.memberRoles);
93
+ if (!permissionCheck.allowed) {
94
+ return permissionCheck;
95
+ }
96
+ // Check rate limits
97
+ const rateLimitCheck = this.checkRateLimit(ctx.authorId, ctx.guildId, ctx.memberRoles);
98
+ if (!rateLimitCheck.allowed) {
99
+ return rateLimitCheck;
100
+ }
101
+ return { allowed: true };
102
+ }
103
+ /**
104
+ * Check if channel is allowed
105
+ */
106
+ checkChannel(channelId) {
107
+ if (!this.config)
108
+ return { allowed: true };
109
+ switch (this.config.channelMode) {
110
+ case 'whitelist':
111
+ if (!this.config.enabledChannels.includes(channelId)) {
112
+ return { allowed: false, reason: 'Channel not whitelisted' };
113
+ }
114
+ break;
115
+ case 'blacklist':
116
+ if (this.config.disabledChannels.includes(channelId)) {
117
+ return { allowed: false, reason: 'Channel blacklisted' };
118
+ }
119
+ break;
120
+ case 'all':
121
+ default:
122
+ break;
123
+ }
124
+ return { allowed: true };
125
+ }
126
+ /**
127
+ * Check if user has permission
128
+ */
129
+ checkPermissions(userId, memberRoles) {
130
+ if (!this.config)
131
+ return { allowed: true };
132
+ const roles = memberRoles || [];
133
+ // Check if user is explicitly disabled
134
+ if (this.config.disabledUsers.includes(userId)) {
135
+ return { allowed: false, reason: 'User blacklisted' };
136
+ }
137
+ // Check permission mode
138
+ switch (this.config.permissionMode) {
139
+ case 'premium_only': {
140
+ const isPremium = roles.some(roleId => this.config.premiumRoleIds.includes(roleId));
141
+ if (!isPremium) {
142
+ return { allowed: false, reason: 'Premium only' };
143
+ }
144
+ break;
145
+ }
146
+ case 'whitelist': {
147
+ const hasEnabledRole = roles.some(roleId => this.config.enabledRoles.includes(roleId));
148
+ const isEnabledUser = this.config.enabledUsers.includes(userId);
149
+ if (!hasEnabledRole && !isEnabledUser) {
150
+ return { allowed: false, reason: 'No required role' };
151
+ }
152
+ break;
153
+ }
154
+ case 'blacklist': {
155
+ const hasDisabledRole = roles.some(roleId => this.config.disabledRoles.includes(roleId));
156
+ if (hasDisabledRole) {
157
+ return { allowed: false, reason: 'Role blacklisted' };
158
+ }
159
+ break;
160
+ }
161
+ case 'all':
162
+ default:
163
+ break;
164
+ }
165
+ return { allowed: true };
166
+ }
167
+ /**
168
+ * Check rate limits (local check, server has final authority)
169
+ */
170
+ checkRateLimit(userId, guildId, memberRoles) {
171
+ if (!this.config)
172
+ return { allowed: true };
173
+ const now = Date.now();
174
+ const roles = memberRoles || [];
175
+ const isPremium = roles.some(roleId => this.config.premiumRoleIds.includes(roleId));
176
+ // User rate limit
177
+ const userLimit = isPremium ? this.config.premiumRateLimit : this.config.freeRateLimit;
178
+ const userKey = `user:${userId}`;
179
+ const userEntry = this.rateLimits.get(userKey);
180
+ if (!userEntry || now > userEntry.resetAt) {
181
+ // Start new window
182
+ this.rateLimits.set(userKey, {
183
+ count: 1,
184
+ resetAt: now + 60 * 60 * 1000, // 1 hour
185
+ });
186
+ }
187
+ else {
188
+ // Check limit
189
+ if (userEntry.count >= userLimit) {
190
+ return { allowed: false, reason: `Rate limit (${userLimit}/hr)` };
191
+ }
192
+ userEntry.count++;
193
+ }
194
+ // Server rate limit (if in guild)
195
+ if (guildId) {
196
+ const serverLimit = this.config.serverRateLimit;
197
+ const serverKey = `server:${guildId}`;
198
+ const serverEntry = this.serverRateLimits.get(serverKey);
199
+ if (!serverEntry || now > serverEntry.resetAt) {
200
+ this.serverRateLimits.set(serverKey, {
201
+ count: 1,
202
+ resetAt: now + 60 * 60 * 1000,
203
+ });
204
+ }
205
+ else {
206
+ if (serverEntry.count >= serverLimit) {
207
+ return { allowed: false, reason: `Server rate limit (${serverLimit}/hr)` };
208
+ }
209
+ serverEntry.count++;
210
+ }
211
+ }
212
+ return { allowed: true };
213
+ }
214
+ /**
215
+ * Clean up old rate limit entries (call periodically)
216
+ */
217
+ cleanupRateLimits() {
218
+ const now = Date.now();
219
+ for (const [key, entry] of this.rateLimits.entries()) {
220
+ if (now > entry.resetAt) {
221
+ this.rateLimits.delete(key);
222
+ }
223
+ }
224
+ for (const [key, entry] of this.serverRateLimits.entries()) {
225
+ if (now > entry.resetAt) {
226
+ this.serverRateLimits.delete(key);
227
+ }
228
+ }
229
+ }
230
+ }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./signing.js";
3
3
  export * from "./http.js";
4
4
  export * from "./relayClient.js";
5
5
  export * from "./adapters/discord.js";
6
+ export * from "./configCache.js";
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export * from "./signing.js";
3
3
  export * from "./http.js";
4
4
  export * from "./relayClient.js";
5
5
  export * from "./adapters/discord.js";
6
+ export * from "./configCache.js";
@@ -1,13 +1,13 @@
1
1
  import { Decision, RelayClientOptions, RelayEvent } from "./types.js";
2
2
  export declare class RelayClient {
3
- private readonly apiKey;
4
- private readonly baseUrl;
3
+ readonly apiKey: string;
4
+ readonly baseUrl: string;
5
5
  private readonly hmacSecret?;
6
6
  private readonly timeoutMs;
7
7
  private readonly maxRetries;
8
8
  private readonly queue;
9
9
  private sending;
10
- private botId?;
10
+ botId?: string;
11
11
  constructor(opts: RelayClientOptions);
12
12
  registerBot(info: {
13
13
  platform: 'discord';
package/dist/types.d.ts CHANGED
@@ -8,6 +8,7 @@ export type RelayEvent = {
8
8
  content: string;
9
9
  timestamp: number;
10
10
  botClientId?: Snowflake;
11
+ botId?: number | string;
11
12
  isReplyToBot?: boolean;
12
13
  referencedMessageId?: string;
13
14
  referencedMessageAuthorId?: Snowflake;
@@ -22,6 +23,7 @@ export type RelayEvent = {
22
23
  options?: Record<string, unknown>;
23
24
  timestamp: number;
24
25
  botClientId?: Snowflake;
26
+ botId?: number | string;
25
27
  };
26
28
  export interface Decision {
27
29
  id: string;
@@ -17,5 +17,6 @@ export interface DiscordAdapterOptions {
17
17
  interaction?: Interaction;
18
18
  }) => Promise<void>;
19
19
  mentionRequired?: boolean;
20
+ disableConfigCache?: boolean;
20
21
  }
21
22
  export declare function useDiscordAdapter(opts: DiscordAdapterOptions): void;
@@ -1,14 +1,57 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.useDiscordAdapter = useDiscordAdapter;
4
+ const configCache_js_1 = require("../configCache.js");
4
5
  function useDiscordAdapter(opts) {
5
6
  const { client, relay } = opts;
7
+ const configCache = opts.disableConfigCache ? null : new configCache_js_1.ConfigCache(relay.baseUrl || '', relay.apiKey);
8
+ // Fetch config on client ready and start polling
9
+ if (configCache) {
10
+ client.once("ready", async () => {
11
+ try {
12
+ // Wait for bot ID to be set (either from registration or env)
13
+ await new Promise(resolve => setTimeout(resolve, 1000));
14
+ if (relay.botId) {
15
+ console.log(`[commandless] Fetching config for bot ${relay.botId}...`);
16
+ await configCache.fetch(relay.botId);
17
+ // Start polling for updates every 30 seconds
18
+ configCache.startPolling(relay.botId, 30000);
19
+ console.log('[commandless] Config polling started (30s interval)');
20
+ // Cleanup rate limits every 5 minutes
21
+ setInterval(() => configCache.cleanupRateLimits(), 5 * 60 * 1000);
22
+ }
23
+ else {
24
+ console.warn('[commandless] No botId available, config filtering disabled');
25
+ }
26
+ }
27
+ catch (error) {
28
+ console.error('[commandless] Failed to initialize config cache:', error);
29
+ }
30
+ });
31
+ }
6
32
  client.on("messageCreate", async (message) => {
7
33
  if (message.author.bot)
8
34
  return;
9
- const mentionRequired = opts.mentionRequired !== false;
35
+ // Get member roles if in guild
36
+ const memberRoles = message.member?.roles.cache.map(r => r.id) || [];
37
+ // Config-based filtering (if enabled)
38
+ if (configCache && relay.botId) {
39
+ const filterResult = configCache.shouldProcessMessage({
40
+ channelId: message.channelId,
41
+ authorId: message.author.id,
42
+ guildId: message.guildId || undefined,
43
+ memberRoles,
44
+ });
45
+ if (!filterResult.allowed) {
46
+ // Silently ignore (filtered by config)
47
+ console.log(`[commandless] Message filtered: ${filterResult.reason}`);
48
+ return;
49
+ }
50
+ }
51
+ // Check mention requirement (from config or options)
52
+ const config = configCache?.getConfig();
53
+ const mentionRequired = config?.mentionRequired !== false && opts.mentionRequired !== false;
10
54
  const mentioned = !!client.user?.id && (message.mentions?.users?.has?.(client.user.id) ?? false);
11
- // Typing indicator will be driven by a short loop only when addressed
12
55
  // Detect reply-to-bot accurately
13
56
  let isReplyToBot = false;
14
57
  try {
@@ -30,6 +73,7 @@ function useDiscordAdapter(opts) {
30
73
  content: message.content,
31
74
  timestamp: message.createdTimestamp,
32
75
  botClientId: client.user?.id,
76
+ botId: relay.botId || undefined, // Include botId for backend config enforcement
33
77
  isReplyToBot,
34
78
  referencedMessageId: message.reference?.messageId,
35
79
  referencedMessageAuthorId: message.reference ? client.user?.id : undefined,
@@ -81,6 +125,7 @@ function useDiscordAdapter(opts) {
81
125
  return out;
82
126
  })(),
83
127
  timestamp: Date.now(),
128
+ botId: relay.botId || undefined, // Include botId for backend config enforcement
84
129
  };
85
130
  try {
86
131
  const dec = await relay.sendEvent(evt);
@@ -0,0 +1,82 @@
1
+ export interface BotConfig {
2
+ version: number;
3
+ enabled: boolean;
4
+ channelMode: 'all' | 'whitelist' | 'blacklist';
5
+ enabledChannels: string[];
6
+ disabledChannels: string[];
7
+ permissionMode: 'all' | 'whitelist' | 'blacklist' | 'premium_only';
8
+ enabledRoles: string[];
9
+ disabledRoles: string[];
10
+ enabledUsers: string[];
11
+ disabledUsers: string[];
12
+ premiumRoleIds: string[];
13
+ enabledCommandCategories: string[];
14
+ disabledCommands: string[];
15
+ commandMode: 'all' | 'category_based' | 'whitelist' | 'blacklist';
16
+ mentionRequired: boolean;
17
+ customPrefix: string | null;
18
+ triggerMode: 'mention' | 'prefix' | 'always';
19
+ freeRateLimit: number;
20
+ premiumRateLimit: number;
21
+ serverRateLimit: number;
22
+ confidenceThreshold: number;
23
+ requireConfirmation: boolean;
24
+ dangerousCommands: string[];
25
+ responseStyle: 'friendly' | 'professional' | 'minimal';
26
+ }
27
+ interface MessageContext {
28
+ channelId: string;
29
+ authorId: string;
30
+ guildId?: string;
31
+ memberRoles?: string[];
32
+ }
33
+ export declare class ConfigCache {
34
+ private config;
35
+ private rateLimits;
36
+ private serverRateLimits;
37
+ private baseUrl;
38
+ private apiKey;
39
+ private botId;
40
+ private pollInterval;
41
+ constructor(baseUrl: string, apiKey: string);
42
+ /**
43
+ * Fetch configuration from backend
44
+ */
45
+ fetch(botId: string): Promise<BotConfig | null>;
46
+ /**
47
+ * Start polling for config updates every 30 seconds
48
+ */
49
+ startPolling(botId: string, intervalMs?: number): void;
50
+ /**
51
+ * Stop polling
52
+ */
53
+ stopPolling(): void;
54
+ /**
55
+ * Get current config (may be null if not fetched yet)
56
+ */
57
+ getConfig(): BotConfig | null;
58
+ /**
59
+ * Check if a message should be processed based on config
60
+ */
61
+ shouldProcessMessage(ctx: MessageContext): {
62
+ allowed: boolean;
63
+ reason?: string;
64
+ };
65
+ /**
66
+ * Check if channel is allowed
67
+ */
68
+ private checkChannel;
69
+ /**
70
+ * Check if user has permission
71
+ */
72
+ private checkPermissions;
73
+ /**
74
+ * Check rate limits (local check, server has final authority)
75
+ */
76
+ private checkRateLimit;
77
+ /**
78
+ * Clean up old rate limit entries (call periodically)
79
+ */
80
+ cleanupRateLimits(): void;
81
+ }
82
+ export {};
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConfigCache = void 0;
4
+ class ConfigCache {
5
+ constructor(baseUrl, apiKey) {
6
+ this.config = null;
7
+ this.rateLimits = new Map();
8
+ this.serverRateLimits = new Map();
9
+ this.botId = null;
10
+ this.pollInterval = null;
11
+ this.baseUrl = baseUrl;
12
+ this.apiKey = apiKey;
13
+ }
14
+ /**
15
+ * Fetch configuration from backend
16
+ */
17
+ async fetch(botId) {
18
+ try {
19
+ this.botId = botId;
20
+ const currentVersion = this.config?.version || 0;
21
+ const url = `${this.baseUrl}/v1/relay/config?botId=${botId}&version=${currentVersion}`;
22
+ const response = await fetch(url, {
23
+ headers: {
24
+ 'x-api-key': this.apiKey,
25
+ },
26
+ });
27
+ if (!response.ok) {
28
+ console.error(`[commandless] Failed to fetch config: ${response.status}`);
29
+ return null;
30
+ }
31
+ const data = await response.json();
32
+ // If up to date, keep existing config
33
+ if (data.upToDate && this.config) {
34
+ return this.config;
35
+ }
36
+ // Update config
37
+ this.config = data;
38
+ console.log(`[commandless] Config loaded (v${this.config.version})`);
39
+ return this.config;
40
+ }
41
+ catch (error) {
42
+ console.error('[commandless] Error fetching config:', error);
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * Start polling for config updates every 30 seconds
48
+ */
49
+ startPolling(botId, intervalMs = 30000) {
50
+ if (this.pollInterval) {
51
+ clearInterval(this.pollInterval);
52
+ }
53
+ this.pollInterval = setInterval(async () => {
54
+ try {
55
+ await this.fetch(botId);
56
+ }
57
+ catch (error) {
58
+ console.error('[commandless] Config poll error:', error);
59
+ }
60
+ }, intervalMs);
61
+ }
62
+ /**
63
+ * Stop polling
64
+ */
65
+ stopPolling() {
66
+ if (this.pollInterval) {
67
+ clearInterval(this.pollInterval);
68
+ this.pollInterval = null;
69
+ }
70
+ }
71
+ /**
72
+ * Get current config (may be null if not fetched yet)
73
+ */
74
+ getConfig() {
75
+ return this.config;
76
+ }
77
+ /**
78
+ * Check if a message should be processed based on config
79
+ */
80
+ shouldProcessMessage(ctx) {
81
+ // If no config loaded yet, allow (fail open for initial startup)
82
+ if (!this.config) {
83
+ return { allowed: true };
84
+ }
85
+ // Check if bot is enabled
86
+ if (!this.config.enabled) {
87
+ return { allowed: false, reason: 'Bot disabled' };
88
+ }
89
+ // Check channel permissions
90
+ const channelCheck = this.checkChannel(ctx.channelId);
91
+ if (!channelCheck.allowed) {
92
+ return channelCheck;
93
+ }
94
+ // Check user/role permissions
95
+ const permissionCheck = this.checkPermissions(ctx.authorId, ctx.memberRoles);
96
+ if (!permissionCheck.allowed) {
97
+ return permissionCheck;
98
+ }
99
+ // Check rate limits
100
+ const rateLimitCheck = this.checkRateLimit(ctx.authorId, ctx.guildId, ctx.memberRoles);
101
+ if (!rateLimitCheck.allowed) {
102
+ return rateLimitCheck;
103
+ }
104
+ return { allowed: true };
105
+ }
106
+ /**
107
+ * Check if channel is allowed
108
+ */
109
+ checkChannel(channelId) {
110
+ if (!this.config)
111
+ return { allowed: true };
112
+ switch (this.config.channelMode) {
113
+ case 'whitelist':
114
+ if (!this.config.enabledChannels.includes(channelId)) {
115
+ return { allowed: false, reason: 'Channel not whitelisted' };
116
+ }
117
+ break;
118
+ case 'blacklist':
119
+ if (this.config.disabledChannels.includes(channelId)) {
120
+ return { allowed: false, reason: 'Channel blacklisted' };
121
+ }
122
+ break;
123
+ case 'all':
124
+ default:
125
+ break;
126
+ }
127
+ return { allowed: true };
128
+ }
129
+ /**
130
+ * Check if user has permission
131
+ */
132
+ checkPermissions(userId, memberRoles) {
133
+ if (!this.config)
134
+ return { allowed: true };
135
+ const roles = memberRoles || [];
136
+ // Check if user is explicitly disabled
137
+ if (this.config.disabledUsers.includes(userId)) {
138
+ return { allowed: false, reason: 'User blacklisted' };
139
+ }
140
+ // Check permission mode
141
+ switch (this.config.permissionMode) {
142
+ case 'premium_only': {
143
+ const isPremium = roles.some(roleId => this.config.premiumRoleIds.includes(roleId));
144
+ if (!isPremium) {
145
+ return { allowed: false, reason: 'Premium only' };
146
+ }
147
+ break;
148
+ }
149
+ case 'whitelist': {
150
+ const hasEnabledRole = roles.some(roleId => this.config.enabledRoles.includes(roleId));
151
+ const isEnabledUser = this.config.enabledUsers.includes(userId);
152
+ if (!hasEnabledRole && !isEnabledUser) {
153
+ return { allowed: false, reason: 'No required role' };
154
+ }
155
+ break;
156
+ }
157
+ case 'blacklist': {
158
+ const hasDisabledRole = roles.some(roleId => this.config.disabledRoles.includes(roleId));
159
+ if (hasDisabledRole) {
160
+ return { allowed: false, reason: 'Role blacklisted' };
161
+ }
162
+ break;
163
+ }
164
+ case 'all':
165
+ default:
166
+ break;
167
+ }
168
+ return { allowed: true };
169
+ }
170
+ /**
171
+ * Check rate limits (local check, server has final authority)
172
+ */
173
+ checkRateLimit(userId, guildId, memberRoles) {
174
+ if (!this.config)
175
+ return { allowed: true };
176
+ const now = Date.now();
177
+ const roles = memberRoles || [];
178
+ const isPremium = roles.some(roleId => this.config.premiumRoleIds.includes(roleId));
179
+ // User rate limit
180
+ const userLimit = isPremium ? this.config.premiumRateLimit : this.config.freeRateLimit;
181
+ const userKey = `user:${userId}`;
182
+ const userEntry = this.rateLimits.get(userKey);
183
+ if (!userEntry || now > userEntry.resetAt) {
184
+ // Start new window
185
+ this.rateLimits.set(userKey, {
186
+ count: 1,
187
+ resetAt: now + 60 * 60 * 1000, // 1 hour
188
+ });
189
+ }
190
+ else {
191
+ // Check limit
192
+ if (userEntry.count >= userLimit) {
193
+ return { allowed: false, reason: `Rate limit (${userLimit}/hr)` };
194
+ }
195
+ userEntry.count++;
196
+ }
197
+ // Server rate limit (if in guild)
198
+ if (guildId) {
199
+ const serverLimit = this.config.serverRateLimit;
200
+ const serverKey = `server:${guildId}`;
201
+ const serverEntry = this.serverRateLimits.get(serverKey);
202
+ if (!serverEntry || now > serverEntry.resetAt) {
203
+ this.serverRateLimits.set(serverKey, {
204
+ count: 1,
205
+ resetAt: now + 60 * 60 * 1000,
206
+ });
207
+ }
208
+ else {
209
+ if (serverEntry.count >= serverLimit) {
210
+ return { allowed: false, reason: `Server rate limit (${serverLimit}/hr)` };
211
+ }
212
+ serverEntry.count++;
213
+ }
214
+ }
215
+ return { allowed: true };
216
+ }
217
+ /**
218
+ * Clean up old rate limit entries (call periodically)
219
+ */
220
+ cleanupRateLimits() {
221
+ const now = Date.now();
222
+ for (const [key, entry] of this.rateLimits.entries()) {
223
+ if (now > entry.resetAt) {
224
+ this.rateLimits.delete(key);
225
+ }
226
+ }
227
+ for (const [key, entry] of this.serverRateLimits.entries()) {
228
+ if (now > entry.resetAt) {
229
+ this.serverRateLimits.delete(key);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ exports.ConfigCache = ConfigCache;
@@ -19,3 +19,4 @@ __exportStar(require("./signing.js"), exports);
19
19
  __exportStar(require("./http.js"), exports);
20
20
  __exportStar(require("./relayClient.js"), exports);
21
21
  __exportStar(require("./adapters/discord.js"), exports);
22
+ __exportStar(require("./configCache.js"), exports);
@@ -3,3 +3,4 @@ export * from "./signing.js";
3
3
  export * from "./http.js";
4
4
  export * from "./relayClient.js";
5
5
  export * from "./adapters/discord.js";
6
+ export * from "./configCache.js";
@@ -1,13 +1,13 @@
1
1
  import { Decision, RelayClientOptions, RelayEvent } from "./types.js";
2
2
  export declare class RelayClient {
3
- private readonly apiKey;
4
- private readonly baseUrl;
3
+ readonly apiKey: string;
4
+ readonly baseUrl: string;
5
5
  private readonly hmacSecret?;
6
6
  private readonly timeoutMs;
7
7
  private readonly maxRetries;
8
8
  private readonly queue;
9
9
  private sending;
10
- private botId?;
10
+ botId?: string;
11
11
  constructor(opts: RelayClientOptions);
12
12
  registerBot(info: {
13
13
  platform: 'discord';
@@ -8,6 +8,7 @@ export type RelayEvent = {
8
8
  content: string;
9
9
  timestamp: number;
10
10
  botClientId?: Snowflake;
11
+ botId?: number | string;
11
12
  isReplyToBot?: boolean;
12
13
  referencedMessageId?: string;
13
14
  referencedMessageAuthorId?: Snowflake;
@@ -22,6 +23,7 @@ export type RelayEvent = {
22
23
  options?: Record<string, unknown>;
23
24
  timestamp: number;
24
25
  botClientId?: Snowflake;
26
+ botId?: number | string;
25
27
  };
26
28
  export interface Decision {
27
29
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abdarrahmanabdelnasir/relay-node",
3
- "version": "0.1.17",
3
+ "version": "0.1.20",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "dist/index.js",