@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 +21 -0
- package/bin/commandless-discord.js +15 -3
- package/dist/adapters/discord.d.ts +1 -0
- package/dist/adapters/discord.js +47 -2
- package/dist/configCache.d.ts +82 -0
- package/dist/configCache.js +230 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/relayClient.d.ts +3 -3
- package/dist/types.d.ts +2 -0
- package/dist-cjs/adapters/discord.d.ts +1 -0
- package/dist-cjs/adapters/discord.js +47 -2
- package/dist-cjs/configCache.d.ts +82 -0
- package/dist-cjs/configCache.js +234 -0
- package/dist-cjs/index.cjs +1 -0
- package/dist-cjs/index.d.ts +1 -0
- package/dist-cjs/relayClient.d.ts +3 -3
- package/dist-cjs/types.d.ts +2 -0
- package/package.json +1 -1
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/adapters/discord.js
CHANGED
|
@@ -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
|
-
|
|
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
package/dist/index.js
CHANGED
package/dist/relayClient.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Decision, RelayClientOptions, RelayEvent } from "./types.js";
|
|
2
2
|
export declare class RelayClient {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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;
|
package/dist-cjs/index.cjs
CHANGED
package/dist-cjs/index.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Decision, RelayClientOptions, RelayEvent } from "./types.js";
|
|
2
2
|
export declare class RelayClient {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
10
|
+
botId?: string;
|
|
11
11
|
constructor(opts: RelayClientOptions);
|
|
12
12
|
registerBot(info: {
|
|
13
13
|
platform: 'discord';
|
package/dist-cjs/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;
|