diff --git a/README.md b/README.md index 14fdb5ac..c3e53aea 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,17 @@ All configuration lives in `config.json` and can be updated at runtime via the ` **Escalation thresholds** are objects with: `warns` (count), `withinDays` (window), `action` ("timeout" or "ban"), `duration` (for timeout, e.g. "1h"). +### Starboard (`starboard`) + +| Key | Type | Description | +|-----|------|-------------| +| `enabled` | boolean | Enable the starboard feature | +| `channelId` | string | Channel ID where starred messages are reposted | +| `threshold` | number | Reaction count required to star a message (default: 3) | +| `emoji` | string | Emoji to watch for stars (default: `⭐`) | +| `selfStarAllowed` | boolean | Allow users to star their own messages | +| `ignoredChannels` | string[] | Channel IDs excluded from starboard tracking | + ### Permissions (`permissions`) | Key | Type | Description | @@ -208,6 +219,7 @@ All configuration lives in `config.json` and can be updated at runtime via the ` | `enabled` | boolean | Enable permission checks | | `adminRoleId` | string | Role ID for admin commands | | `moderatorRoleId` | string | Role ID for moderator commands | +| `modRoles` | string[] | Additional role IDs or names that count as moderators (legacy/`modExempt` checks) | | `botOwners` | string[] | Discord user IDs that bypass all permission checks | | `allowedCommands` | object | Per-command permission levels (`everyone`, `moderator`, `admin`) | diff --git a/config.json b/config.json index 03efd92a..d3a9e111 100644 --- a/config.json +++ b/config.json @@ -84,6 +84,14 @@ "maxContextMemories": 5, "autoExtract": true }, + "starboard": { + "enabled": false, + "channelId": "", + "threshold": 3, + "emoji": "⭐", + "selfStarAllowed": false, + "ignoredChannels": [] + }, "logging": { "level": "info", "fileOutput": true, diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index fdb511ca..69425325 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -4,9 +4,18 @@ * can be read or written via the API. */ -export const SAFE_CONFIG_KEYS = new Set(['ai', 'welcome', 'spam', 'moderation', 'triage']); +export const SAFE_CONFIG_KEYS = new Set([ + 'ai', + 'welcome', + 'spam', + 'moderation', + 'triage', + 'starboard', + 'permissions', + 'memory', +]); -export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging', 'memory', 'permissions']; +export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging']; /** * Dot-notation paths to config values that contain secrets (e.g. API keys). diff --git a/tests/api/utils/configAllowlist.test.js b/tests/api/utils/configAllowlist.test.js index 51fc2be1..183333f1 100644 --- a/tests/api/utils/configAllowlist.test.js +++ b/tests/api/utils/configAllowlist.test.js @@ -17,6 +17,9 @@ describe('configAllowlist', () => { expect(SAFE_CONFIG_KEYS.has('spam')).toBe(true); expect(SAFE_CONFIG_KEYS.has('moderation')).toBe(true); expect(SAFE_CONFIG_KEYS.has('triage')).toBe(true); + expect(SAFE_CONFIG_KEYS.has('starboard')).toBe(true); + expect(SAFE_CONFIG_KEYS.has('permissions')).toBe(true); + expect(SAFE_CONFIG_KEYS.has('memory')).toBe(true); }); }); @@ -31,6 +34,7 @@ describe('configAllowlist', () => { expect(READABLE_CONFIG_KEYS).toContain('logging'); expect(READABLE_CONFIG_KEYS).toContain('memory'); expect(READABLE_CONFIG_KEYS).toContain('permissions'); + expect(READABLE_CONFIG_KEYS).toContain('starboard'); }); }); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index ce511e37..db363700 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -28,17 +28,27 @@ type GuildConfig = DeepPartial; const inputClasses = "w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; +/** Parse a number input value, enforcing optional min/max constraints. Returns undefined if invalid. */ +function parseNumberInput(raw: string, min?: number, max?: number): number | undefined { + if (raw === "") return undefined; + const num = Number(raw); + if (!Number.isFinite(num)) return undefined; + if (min !== undefined && num < min) return min; + if (max !== undefined && num > max) return max; + return num; +} + /** * Type guard that checks whether a value is a guild configuration object returned by the API. * * @returns `true` if the value is an object containing at least one known top-level section - * (`ai`, `welcome`, `spam`, `moderation`, `triage`) and each present section is a plain object + * (`ai`, `welcome`, `spam`, `moderation`, `triage`, `starboard`, `permissions`, `memory`) and each present section is a plain object * (not an array or null). Returns `false` otherwise. */ function isGuildConfig(data: unknown): data is GuildConfig { if (typeof data !== "object" || data === null || Array.isArray(data)) return false; const obj = data as Record; - const knownSections = ["ai", "welcome", "spam", "moderation", "triage"] as const; + const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory"] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; for (const key of knownSections) { @@ -231,7 +241,7 @@ export function ConfigEditor() { // Abort all other in-flight requests before redirecting saveAbortController.abort(); window.location.href = "/login"; - return; + throw new Error('Unauthorized'); } if (!res.ok) { @@ -316,42 +326,42 @@ export function ConfigEditor() { const updateSystemPrompt = useCallback((value: string) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, ai: { ...prev.ai, systemPrompt: value } }; + return { ...prev, ai: { ...prev.ai, systemPrompt: value } } as GuildConfig; }); }, []); const updateAiEnabled = useCallback((enabled: boolean) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, ai: { ...prev.ai, enabled } }; + return { ...prev, ai: { ...prev.ai, enabled } } as GuildConfig; }); }, []); const updateWelcomeEnabled = useCallback((enabled: boolean) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, welcome: { ...prev.welcome, enabled } }; + return { ...prev, welcome: { ...prev.welcome, enabled } } as GuildConfig; }); }, []); const updateWelcomeMessage = useCallback((message: string) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, welcome: { ...prev.welcome, message } }; + return { ...prev, welcome: { ...prev.welcome, message } } as GuildConfig; }); }, []); const updateModerationEnabled = useCallback((enabled: boolean) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, moderation: { ...prev.moderation, enabled } }; + return { ...prev, moderation: { ...prev.moderation, enabled } } as GuildConfig; }); }, []); const updateModerationField = useCallback((field: string, value: unknown) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, moderation: { ...prev.moderation, [field]: value } }; + return { ...prev, moderation: { ...prev.moderation, [field]: value } } as GuildConfig; }); }, []); @@ -364,7 +374,7 @@ export function ConfigEditor() { ...prev.moderation, dmNotifications: { ...prev.moderation?.dmNotifications, [action]: value }, }, - }; + } as GuildConfig; }); }, []); @@ -377,21 +387,68 @@ export function ConfigEditor() { ...prev.moderation, escalation: { ...prev.moderation?.escalation, enabled }, }, - }; + } as GuildConfig; }); }, []); const updateTriageEnabled = useCallback((enabled: boolean) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, triage: { ...prev.triage, enabled } }; + return { ...prev, triage: { ...prev.triage, enabled } } as GuildConfig; }); }, []); const updateTriageField = useCallback((field: string, value: unknown) => { setDraftConfig((prev) => { if (!prev) return prev; - return { ...prev, triage: { ...prev.triage, [field]: value } }; + return { ...prev, triage: { ...prev.triage, [field]: value } } as GuildConfig; + }); + }, []); + + const updateStarboardField = useCallback((field: string, value: unknown) => { + setDraftConfig((prev) => { + if (!prev) return prev; + return { ...prev, starboard: { ...prev.starboard, [field]: value } } as GuildConfig; + }); + }, []); + + const updateRateLimitField = useCallback((field: string, value: unknown) => { + setDraftConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + moderation: { + ...prev.moderation, + rateLimit: { ...prev.moderation?.rateLimit, [field]: value }, + }, + } as GuildConfig; + }); + }, []); + + const updateLinkFilterField = useCallback((field: string, value: unknown) => { + setDraftConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + moderation: { + ...prev.moderation, + linkFilter: { ...prev.moderation?.linkFilter, [field]: value }, + }, + } as GuildConfig; + }); + }, []); + + const updatePermissionsField = useCallback((field: string, value: unknown) => { + setDraftConfig((prev) => { + if (!prev) return prev; + return { ...prev, permissions: { ...prev.permissions, [field]: value } } as GuildConfig; + }); + }, []); + + const updateMemoryField = useCallback((field: string, value: unknown) => { + setDraftConfig((prev) => { + if (!prev) return prev; + return { ...prev, memory: { ...prev.memory, [field]: value } } as GuildConfig; }); }, []); @@ -617,6 +674,124 @@ export function ConfigEditor() { label="Escalation" /> + + {/* Rate Limiting sub-section */} +
+ Rate Limiting +
+ Enabled + updateRateLimitField("enabled", v)} + disabled={saving} + label="Rate Limiting" + /> +
+
+ + +
+
+ + + +
+
+ + {/* Link Filtering sub-section */} +
+ Link Filtering +
+ Enabled + updateLinkFilterField("enabled", v)} + disabled={saving} + label="Link Filtering" + /> +
+ +
)} @@ -672,11 +847,8 @@ export function ConfigEditor() { min={0} value={draftConfig.triage?.classifyBudget ?? 0} onChange={(e) => { - const raw = e.target.value; - if (raw === "") return; - const num = Number(raw); - if (!Number.isFinite(num)) return; - updateTriageField("classifyBudget", num); + const num = parseNumberInput(e.target.value, 0); + if (num !== undefined) updateTriageField("classifyBudget", num); }} disabled={saving} className={inputClasses} @@ -690,11 +862,8 @@ export function ConfigEditor() { min={0} value={draftConfig.triage?.respondBudget ?? 0} onChange={(e) => { - const raw = e.target.value; - if (raw === "") return; - const num = Number(raw); - if (!Number.isFinite(num)) return; - updateTriageField("respondBudget", num); + const num = parseNumberInput(e.target.value, 0); + if (num !== undefined) updateTriageField("respondBudget", num); }} disabled={saving} className={inputClasses} @@ -709,11 +878,8 @@ export function ConfigEditor() { min={1} value={draftConfig.triage?.defaultInterval ?? 3000} onChange={(e) => { - const raw = e.target.value; - if (raw === "") return; - const num = Number(raw); - if (!Number.isFinite(num)) return; - updateTriageField("defaultInterval", num); + const num = parseNumberInput(e.target.value, 1); + if (num !== undefined) updateTriageField("defaultInterval", num); }} disabled={saving} className={inputClasses} @@ -726,11 +892,8 @@ export function ConfigEditor() { min={1} value={draftConfig.triage?.timeout ?? 30000} onChange={(e) => { - const raw = e.target.value; - if (raw === "") return; - const num = Number(raw); - if (!Number.isFinite(num)) return; - updateTriageField("timeout", num); + const num = parseNumberInput(e.target.value, 1); + if (num !== undefined) updateTriageField("timeout", num); }} disabled={saving} className={inputClasses} @@ -745,11 +908,8 @@ export function ConfigEditor() { min={1} value={draftConfig.triage?.contextMessages ?? 10} onChange={(e) => { - const raw = e.target.value; - if (raw === "") return; - const num = Number(raw); - if (!Number.isFinite(num)) return; - updateTriageField("contextMessages", num); + const num = parseNumberInput(e.target.value, 1); + if (num !== undefined) updateTriageField("contextMessages", num); }} disabled={saving} className={inputClasses} @@ -762,11 +922,8 @@ export function ConfigEditor() { min={1} value={draftConfig.triage?.maxBufferSize ?? 30} onChange={(e) => { - const raw = e.target.value; - if (raw === "") return; - const num = Number(raw); - if (!Number.isFinite(num)) return; - updateTriageField("maxBufferSize", num); + const num = parseNumberInput(e.target.value, 1); + if (num !== undefined) updateTriageField("maxBufferSize", num); }} disabled={saving} className={inputClasses} @@ -800,6 +957,15 @@ export function ConfigEditor() { label="Debug Footer" /> +
+ Status Reactions + updateTriageField("statusReactions", v)} + disabled={saving} + label="Status Reactions" + /> +