Enhance AutoMod configuration by introducing event-specific settings and thresholds. Refactor Automod schemas to include event configurations, thresholds, and limits for ignored roles and channels. Update database interactions to support new structure and improve handling of AutoMod rules in cogs, ensuring dynamic thresholds for various events. Update dashboard components to accommodate new configurations and improve user experience in managing AutoMod settings.
This commit is contained in:
@@ -14,17 +14,20 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Zap,
|
||||
Type,
|
||||
Link as LinkIcon,
|
||||
MessageSquare,
|
||||
UserMinus,
|
||||
ShieldAlert,
|
||||
Smile,
|
||||
EyeOff,
|
||||
Gavel,
|
||||
RefreshCcw,
|
||||
Save
|
||||
Save,
|
||||
Info,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -32,63 +35,162 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AutomodConfig } from "@/types/api";
|
||||
import {
|
||||
AutomodConfig,
|
||||
AutomodEventConfig,
|
||||
DiscordChannel,
|
||||
DiscordRole,
|
||||
} from "@/types/api";
|
||||
|
||||
const PUNISHMENT_OPTIONS = [
|
||||
{ value: "delete", label: "Delete Message" },
|
||||
{ value: "warn", label: "Warn User" },
|
||||
{ value: "mute", label: "Mute User" },
|
||||
{ value: "kick", label: "Kick User" },
|
||||
{ value: "ban", label: "Ban User" },
|
||||
{ value: "Mute", label: "Mute" },
|
||||
{ value: "Kick", label: "Kick" },
|
||||
{ value: "Ban", label: "Ban" },
|
||||
];
|
||||
|
||||
const RULES = [
|
||||
{ id: 'anti_spam', name: 'Anti Spam', desc: 'Detects and removes repetitive messages or rapid firing.', icon: Zap },
|
||||
{ id: 'anti_caps', name: 'Anti Caps', desc: 'Prevents excessive use of uppercase letters.', icon: Type },
|
||||
{ id: 'anti_links', name: 'Anti Links', desc: 'Blocks unauthorized external links in channels.', icon: LinkIcon },
|
||||
{ id: 'anti_invites', name: 'Anti Invites', desc: 'Automatically removes Discord server invite links.', icon: MessageSquare },
|
||||
{ id: 'anti_mentions', name: 'Anti Mass Mention', desc: 'Protects against mentioned spam (@everyone, @here).', icon: UserMinus },
|
||||
{ id: "anti_spam", name: "Anti Spam", desc: "Repetitive messages or rapid firing.", icon: Zap },
|
||||
{ id: "anti_caps", name: "Anti Caps", desc: "Excessive uppercase letters.", icon: Type },
|
||||
{ id: "anti_link", name: "Anti Links", desc: "Unauthorized external links.", icon: LinkIcon },
|
||||
{ id: "anti_invites", name: "Anti Invites", desc: "Discord invite links.", icon: MessageSquare },
|
||||
{ id: "anti_mass_mention", name: "Anti Mass Mention", desc: "Too many user mentions.", icon: UserMinus },
|
||||
{ id: "anti_emoji_spam", name: "Anti Emoji Spam", desc: "Too many emojis in one message.", icon: Smile },
|
||||
{ id: "anti_nsfw_link", name: "Anti NSFW Links", desc: "Discord AutoMod keyword filter (blocks message).", icon: EyeOff },
|
||||
];
|
||||
|
||||
function buildInitialEvents(config: AutomodConfig): Record<string, AutomodEventConfig> {
|
||||
const events: Record<string, AutomodEventConfig> = { ...(config.events || {}) };
|
||||
for (const rule of RULES) {
|
||||
if (!events[rule.id]) {
|
||||
const legacy = config.punishments?.[rule.id];
|
||||
events[rule.id] = {
|
||||
enabled: legacy !== undefined,
|
||||
punishment:
|
||||
rule.id === "anti_nsfw_link"
|
||||
? "block_message"
|
||||
: legacy === "mute" || legacy === "Mute"
|
||||
? "Mute"
|
||||
: legacy === "kick" || legacy === "Kick"
|
||||
? "Kick"
|
||||
: legacy === "ban" || legacy === "Ban"
|
||||
? "Ban"
|
||||
: "Mute",
|
||||
readonly_punishment: rule.id === "anti_nsfw_link",
|
||||
};
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
interface AutomodFormProps {
|
||||
initialConfig: AutomodConfig;
|
||||
guildId: string;
|
||||
channels: DiscordChannel[];
|
||||
roles: DiscordRole[];
|
||||
}
|
||||
|
||||
export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
||||
const [config, setConfig] = useState<AutomodConfig>(initialConfig);
|
||||
export function AutomodForm({ initialConfig, guildId, channels, roles }: AutomodFormProps) {
|
||||
const [enabled, setEnabled] = useState(initialConfig.enabled);
|
||||
const [events, setEvents] = useState(() => buildInitialEvents(initialConfig));
|
||||
const [thresholds, setThresholds] = useState<Record<string, Record<string, number>>>(
|
||||
() => ({ ...(initialConfig.thresholds || {}) })
|
||||
);
|
||||
const [ignoredRoles, setIgnoredRoles] = useState<number[]>(initialConfig.ignored_roles || []);
|
||||
const [ignoredChannels, setIgnoredChannels] = useState<number[]>(
|
||||
initialConfig.ignored_channels || []
|
||||
);
|
||||
const [loggingChannel, setLoggingChannel] = useState<string>(
|
||||
initialConfig.logging_channel ? String(initialConfig.logging_channel) : ""
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleToggle = (key: string) => {
|
||||
setConfig({
|
||||
...config,
|
||||
enabled: key === 'master' ? !config.enabled : config.enabled,
|
||||
});
|
||||
const maxIgnored = initialConfig.limits?.max_ignored_roles ?? 10;
|
||||
const thresholdMeta = initialConfig.threshold_meta || {};
|
||||
|
||||
const textChannels = useMemo(
|
||||
() =>
|
||||
channels.filter(
|
||||
(c) => c.type === "0" || c.type === "text" || !c.type
|
||||
),
|
||||
[channels]
|
||||
);
|
||||
|
||||
const channelOptions = useMemo(
|
||||
() => [
|
||||
{ value: "", label: "No log channel" },
|
||||
...textChannels.map((c) => ({ value: String(c.id), label: `#${c.name}` })),
|
||||
],
|
||||
[textChannels]
|
||||
);
|
||||
|
||||
const toggleIgnore = (
|
||||
list: number[],
|
||||
setList: (v: number[]) => void,
|
||||
id: number,
|
||||
cap: number
|
||||
) => {
|
||||
if (list.includes(id)) {
|
||||
setList(list.filter((x) => x !== id));
|
||||
return;
|
||||
}
|
||||
if (list.length >= cap) {
|
||||
toast.error(`Maximum ${cap} entries allowed`);
|
||||
return;
|
||||
}
|
||||
setList([...list, id]);
|
||||
};
|
||||
|
||||
const handlePunishmentChange = (ruleId: string, value: string) => {
|
||||
const newPunishments = { ...config.punishments };
|
||||
newPunishments[ruleId] = value;
|
||||
setConfig({ ...config, punishments: newPunishments });
|
||||
const setEventEnabled = (ruleId: string, on: boolean) => {
|
||||
setEvents((prev) => ({
|
||||
...prev,
|
||||
[ruleId]: {
|
||||
...prev[ruleId],
|
||||
enabled: on,
|
||||
punishment:
|
||||
ruleId === "anti_nsfw_link"
|
||||
? "block_message"
|
||||
: prev[ruleId]?.punishment || "Mute",
|
||||
readonly_punishment: ruleId === "anti_nsfw_link",
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const setPunishment = (ruleId: string, value: string) => {
|
||||
setEvents((prev) => ({
|
||||
...prev,
|
||||
[ruleId]: { ...prev[ruleId], enabled: true, punishment: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const setThresholdValue = (ruleId: string, key: string, value: number) => {
|
||||
setThresholds((prev) => ({
|
||||
...prev,
|
||||
[ruleId]: { ...(prev[ruleId] || {}), [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
enabled,
|
||||
events,
|
||||
thresholds,
|
||||
ignored_roles: ignoredRoles,
|
||||
ignored_channels: ignoredChannels,
|
||||
logging_channel: loggingChannel ? Number(loggingChannel) : 0,
|
||||
clear_logging: !loggingChannel,
|
||||
};
|
||||
|
||||
const promise = api.updateAutomod(guildId, {
|
||||
enabled: config.enabled,
|
||||
punishments: config.punishments,
|
||||
});
|
||||
|
||||
const promise = api.updateAutomod(guildId, payload);
|
||||
toast.promise(promise, {
|
||||
loading: 'Saving configuration...',
|
||||
success: 'Configuration saved successfully!',
|
||||
error: (err) => err.message || 'Failed to update settings',
|
||||
loading: "Saving configuration...",
|
||||
success: "Configuration saved successfully!",
|
||||
error: (err) => err.message || "Failed to update settings",
|
||||
});
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -100,78 +202,127 @@ export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">Master Control</h3>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={() => handleToggle('master')}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest">
|
||||
Master Control
|
||||
</h3>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pb-4 border-b border-slate-800">
|
||||
<label className="text-xs font-bold uppercase text-slate-500 tracking-wider flex items-center gap-2">
|
||||
<Hash className="h-3.5 w-3.5" /> Logging Channel
|
||||
</label>
|
||||
<Select
|
||||
value={loggingChannel}
|
||||
onValueChange={setLoggingChannel}
|
||||
options={channelOptions}
|
||||
placeholder="Select channel"
|
||||
className="max-w-md"
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{RULES.map((rule) => {
|
||||
const isEnabled = config.enabled && config.punishments?.[rule.id] !== undefined;
|
||||
const cfg = events[rule.id] || { enabled: false, punishment: "Mute" };
|
||||
const isOn = enabled && cfg.enabled;
|
||||
const meta = thresholdMeta[rule.id] || [];
|
||||
const isNsfw = rule.id === "anti_nsfw_link";
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={rule.id}
|
||||
className={cn(
|
||||
"p-6 rounded-2xl border transition-all duration-300",
|
||||
config.enabled ? "bg-slate-900/40 border-slate-800" : "bg-slate-900/10 border-slate-900 opacity-40 grayscale"
|
||||
enabled
|
||||
? "bg-slate-900/40 border-slate-800"
|
||||
: "bg-slate-900/10 border-slate-900 opacity-40 grayscale"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"h-12 w-12 rounded-xl flex items-center justify-center transition-colors",
|
||||
isEnabled ? "bg-primary/20 text-primary" : "bg-slate-800 text-slate-500"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-12 w-12 rounded-xl flex items-center justify-center transition-colors",
|
||||
isOn ? "bg-primary/20 text-primary" : "bg-slate-800 text-slate-500"
|
||||
)}
|
||||
>
|
||||
<rule.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white">{rule.name}</h3>
|
||||
<p className="text-xs text-slate-500 max-w-xs">{rule.desc}</p>
|
||||
<p className="text-xs text-slate-500 max-w-sm">{rule.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{isEnabled && (
|
||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-2 duration-300">
|
||||
<Gavel className="h-4 w-4 text-primary" />
|
||||
<Select
|
||||
value={config.punishments[rule.id] || "delete"}
|
||||
onValueChange={(val) => handlePunishmentChange(rule.id, val)}
|
||||
options={PUNISHMENT_OPTIONS}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-8 w-[1px] bg-slate-800 hidden sm:block" />
|
||||
<Switch
|
||||
disabled={!config.enabled}
|
||||
checked={config.punishments?.[rule.id] !== undefined}
|
||||
onCheckedChange={() => {
|
||||
const newPunishments = { ...config.punishments };
|
||||
if (newPunishments[rule.id]) {
|
||||
delete newPunishments[rule.id];
|
||||
} else {
|
||||
newPunishments[rule.id] = "delete";
|
||||
}
|
||||
setConfig({...config, punishments: newPunishments});
|
||||
}}
|
||||
{isOn && !isNsfw && (
|
||||
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-2 duration-300">
|
||||
<Gavel className="h-4 w-4 text-primary" />
|
||||
<Select
|
||||
value={cfg.punishment || "Mute"}
|
||||
onValueChange={(val) => setPunishment(rule.id, val)}
|
||||
options={PUNISHMENT_OPTIONS}
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isOn && isNsfw && (
|
||||
<span className="text-xs text-slate-400 font-mono">block_message</span>
|
||||
)}
|
||||
<div className="h-8 w-[1px] bg-slate-800 hidden sm:block" />
|
||||
<Switch
|
||||
disabled={!enabled}
|
||||
checked={!!cfg.enabled}
|
||||
onCheckedChange={(on) => setEventEnabled(rule.id, on)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOn && meta.length > 0 && (
|
||||
<div className="mt-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-4 border-t border-slate-800/80">
|
||||
{meta.map((field) => {
|
||||
const val =
|
||||
thresholds[rule.id]?.[field.key] ??
|
||||
field.min;
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-slate-400">{field.label}</span>
|
||||
<span className="text-primary font-mono">{val}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={val}
|
||||
onChange={(e) =>
|
||||
setThresholdValue(rule.id, field.key, Number(e.target.value))
|
||||
}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full h-14 text-base font-bold gap-2"
|
||||
>
|
||||
{saving ? <RefreshCcw className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
|
||||
{saving ? (
|
||||
<RefreshCcw className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-5 w-5" />
|
||||
)}
|
||||
Save Moderation Rules
|
||||
</Button>
|
||||
</div>
|
||||
@@ -179,28 +330,87 @@ export function AutomodForm({ initialConfig, guildId }: AutomodFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-4">Logging Level</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-slate-900/50 rounded-xl border border-white/5 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">Log Channel</span>
|
||||
<span className="text-xs font-mono text-primary">#{config.logging_channel || 'None'}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 italic text-center">Mod logs are automatically sent to the configured channel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-2">
|
||||
Ignored Roles
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-500 mb-3">
|
||||
Up to {maxIgnored} roles bypass Automod filters.
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 pr-1">
|
||||
{roles
|
||||
.filter((r) => r.name !== "@everyone")
|
||||
.map((role) => {
|
||||
const id = Number(role.id);
|
||||
const checked = ignoredRoles.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() =>
|
||||
toggleIgnore(ignoredRoles, setIgnoredRoles, id, maxIgnored)
|
||||
}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-lg text-sm border transition-colors",
|
||||
checked
|
||||
? "border-primary/40 bg-primary/10 text-white"
|
||||
: "border-transparent bg-slate-900/40 text-slate-400 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
{role.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-primary/10 to-transparent border border-primary/20 rounded-3xl p-6 relative overflow-hidden group">
|
||||
<div className="absolute -right-4 -top-4 opacity-[0.03] group-hover:scale-110 transition-transform">
|
||||
<ShieldAlert className="h-32 w-32 text-white" />
|
||||
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
||||
<h3 className="text-sm font-black uppercase text-slate-500 tracking-widest mb-2">
|
||||
Ignored Channels
|
||||
</h3>
|
||||
<p className="text-[11px] text-slate-500 mb-3">
|
||||
Up to {maxIgnored} channels are skipped by filters.
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 pr-1">
|
||||
{textChannels.map((ch) => {
|
||||
const id = Number(ch.id);
|
||||
const checked = ignoredChannels.includes(id);
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() =>
|
||||
toggleIgnore(ignoredChannels, setIgnoredChannels, id, maxIgnored)
|
||||
}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded-lg text-sm border transition-colors",
|
||||
checked
|
||||
? "border-primary/40 bg-primary/10 text-white"
|
||||
: "border-transparent bg-slate-900/40 text-slate-400 hover:border-slate-700"
|
||||
)}
|
||||
>
|
||||
#{ch.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#141B2D] border border-slate-800 rounded-3xl p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-primary shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white mb-1">How it works</h3>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
Enable rules, pick Mute / Kick / Ban, and tune thresholds. NSFW uses Discord's
|
||||
native AutoMod and always blocks the message. Prefix commands (
|
||||
<code className="text-primary">automod</code>) stay in sync with these settings.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white mb-2">Automod AI</h3>
|
||||
<p className="text-xs text-slate-400 leading-relaxed mb-4">Our neural network analyzes message context to prevent false positives.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
<span className="text-[10px] font-black uppercase text-primary">V2 Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user