diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml
index 7295c20..2cbe23b 100644
--- a/config/checkstyle/suppressions.xml
+++ b/config/checkstyle/suppressions.xml
@@ -22,17 +22,13 @@
-
-
-
-
diff --git a/scripts/fix-constant-name.py b/scripts/fix-constant-name.py
new file mode 100644
index 0000000..1ba6589
--- /dev/null
+++ b/scripts/fix-constant-name.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Fix ConstantName checkstyle violations: rename logger to LOG."""
+
+import xml.etree.ElementTree as ET
+import re
+import sys
+
+
+def get_files_with_violations(xml_path: str) -> set[str]:
+ tree = ET.parse(xml_path)
+ root = tree.getroot()
+ files = set()
+ for file_elem in root.iter("file"):
+ for error in file_elem.findall("error"):
+ if (
+ error.get("source")
+ == "com.puppycrawl.tools.checkstyle.checks.naming.ConstantNameCheck"
+ ):
+ files.add(file_elem.get("name"))
+ return files
+
+
+def fix_file(path: str) -> int:
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ original = content
+
+ # Replace declaration: private static final Logger logger -> private static final Logger LOG
+ content = re.sub(
+ r"(\bprivate\s+static\s+final\s+Logger\s+)logger\b", r"\1LOG", content
+ )
+
+ # Replace all word-boundary usages of 'logger' -> 'LOG'
+ # This handles: logger.debug, logger.info, logger.warn, logger.error, logger,
+ # LoggerDecorator.warn(logger, ...), etc.
+ content = re.sub(r"\blogger\b", "LOG", content)
+
+ if content != original:
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+ return 1
+ return 0
+
+
+def main():
+ xml_path = "build/reports/checkstyle/main.xml"
+ files = get_files_with_violations(xml_path)
+ print(f"Found {len(files)} files with ConstantName violations")
+
+ fixed = 0
+ for path in sorted(files):
+ result = fix_file(path)
+ if result:
+ fixed += 1
+ print(f" Fixed: {path}")
+
+ print(f"\nTotal files fixed: {fixed}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/fix-final-local.py b/scripts/fix-final-local.py
new file mode 100755
index 0000000..051eb08
--- /dev/null
+++ b/scripts/fix-final-local.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Add 'final' to local variables that are not reassigned."""
+
+import re
+import sys
+import xml.etree.ElementTree as ET
+from collections import defaultdict
+from pathlib import Path
+
+
+def parse_report(xml_path):
+ tree = ET.parse(xml_path)
+ root = tree.getroot()
+ violations = defaultdict(list)
+ for file_elem in root.findall("file"):
+ fname = file_elem.get("name")
+ for v in file_elem.findall("error"):
+ if "FinalLocalVariable" in v.get("source", ""):
+ violations[fname].append(int(v.get("line", 0)))
+ return violations
+
+
+def is_reassigned(var_name, lines, start_line, end_line):
+ """Check if variable is reassigned after declaration."""
+ for i in range(start_line, min(end_line, len(lines))):
+ line = lines[i].strip()
+ # Skip comments
+ if line.startswith("//") or line.startswith("/*") or line.startswith("*"):
+ continue
+ # Check for reassignment: varName = ..., varName +=, varName++, etc.
+ if re.match(rf"\b{re.escape(var_name)}\s*[\+\-\*\/]?=(?!=)", line):
+ return True
+ if re.match(rf"\b{re.escape(var_name)}\+\+", line) or re.match(
+ rf"\b{re.escape(var_name)}\-\-", line
+ ):
+ return True
+ return False
+
+
+def find_method_end(lines, start_line):
+ """Find the end of the method containing start_line."""
+ depth = 0
+ for i in range(start_line - 1, len(lines)):
+ for ch in lines[i]:
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ return i + 1
+ return len(lines)
+
+
+def fix_file(filepath, line_numbers):
+ try:
+ lines = Path(filepath).read_text(encoding="utf-8").splitlines(keepends=True)
+ except Exception as e:
+ print(f" SKIP {filepath}: {e}")
+ return 0
+
+ fixed = 0
+ for line_num in sorted(set(line_numbers)):
+ if line_num < 1 or line_num > len(lines):
+ continue
+ line = lines[line_num - 1]
+
+ # Skip if already has final
+ if re.search(r"\bfinal\s+", line):
+ continue
+
+ # Match variable declaration patterns
+ # Pattern: type varName = ...;
+ m = re.match(r"^(\s*)((?:final\s+)?(?:[\w<>\[\], ?]+?))\s+(\w+)\s*=", line)
+ if m:
+ indent = m.group(1)
+ type_part = m.group(2).strip()
+ var_name = m.group(3)
+
+ # Skip if it's a for-loop variable
+ if "for" in line:
+ continue
+
+ # Skip if already final
+ if type_part.startswith("final "):
+ continue
+
+ # Check if variable is reassigned
+ method_end = find_method_end(lines, line_num)
+ if not is_reassigned(var_name, lines, line_num + 1, method_end):
+ new_line = f"{indent}final {type_part} {var_name}"
+ # Preserve the rest of the line after varName
+ rest = line[m.end() :]
+ lines[line_num - 1] = f"{new_line}{rest}\n"
+ fixed += 1
+ continue
+
+ # Pattern: type varName; (no initializer)
+ m = re.match(r"^(\s*)((?:final\s+)?(?:[\w<>\[\], ?]+?))\s+(\w+)\s*;", line)
+ if m:
+ indent = m.group(1)
+ type_part = m.group(2).strip()
+ var_name = m.group(3)
+
+ if "for" in line or type_part.startswith("final "):
+ continue
+
+ method_end = find_method_end(lines, line_num)
+ if not is_reassigned(var_name, lines, line_num + 1, method_end):
+ lines[line_num - 1] = f"{indent}final {type_part} {var_name};\n"
+ fixed += 1
+ continue
+
+ # Pattern: for (type varName : ...) or for (type varName = ...)
+ if "for" in line and "(" in line:
+ m = re.search(r"for\s*\(\s*(?:final\s+)?([\w<>\[\], ?]+?)\s+(\w+)", line)
+ if m:
+ type_part = m.group(1).strip()
+ var_name = m.group(2)
+ if not re.search(r"\bfinal\s+" + re.escape(type_part), line):
+ new_line = re.sub(
+ rf"(for\s*\(\s*)({re.escape(type_part)})", rf"\1final \2", line
+ )
+ if new_line != line:
+ lines[line_num - 1] = new_line
+ fixed += 1
+
+ if fixed > 0:
+ Path(filepath).write_text("".join(lines), encoding="utf-8")
+ return fixed
+
+
+def main():
+ xml_path = sys.argv[1] if len(sys.argv) > 1 else "build/reports/checkstyle/main.xml"
+ violations = parse_report(xml_path)
+ total = sum(len(v) for v in violations.values())
+ print(f"Found {total} FinalLocalVariable violations in {len(violations)} files")
+
+ fixed_files = 0
+ fixed_count = 0
+ for filepath, line_nums in sorted(violations.items()):
+ n = fix_file(filepath, line_nums)
+ if n > 0:
+ fixed_files += 1
+ fixed_count += n
+ print(
+ f" Fixed {n} in {filepath.replace('/storage/project/jvm/discord-bot/', '')}"
+ )
+
+ print(f"\nTotal: fixed {fixed_count} violations in {fixed_files} files")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/fix-magic-numbers.py b/scripts/fix-magic-numbers.py
new file mode 100644
index 0000000..4236aab
--- /dev/null
+++ b/scripts/fix-magic-numbers.py
@@ -0,0 +1,507 @@
+#!/usr/bin/env python3
+"""Fix all MagicNumber checkstyle violations by extracting magic numbers to named constants."""
+
+import xml.etree.ElementTree as ET
+import html
+import re
+import os
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+SKIP_NUMBERS = {-2, -1, 0, 1, 2}
+
+
+def parse_checkstyle_report(report_path):
+ tree = ET.parse(report_path)
+ root = tree.getroot()
+ files = defaultdict(list)
+ for f in root.findall("file"):
+ fname = f.get("name")
+ for e in f.findall("error"):
+ if "MagicNumber" not in e.get("source", ""):
+ continue
+ line = int(e.get("line"))
+ col = int(e.get("column"))
+ msg = html.unescape(e.get("message"))
+ m = re.search(r"'([^']+)'", msg)
+ if not m:
+ continue
+ raw = m.group(1)
+ try:
+ if raw.startswith("0x") or raw.startswith("0X"):
+ num = int(raw, 16)
+ elif raw.endswith("L") or raw.endswith("l"):
+ num = int(raw[:-1])
+ elif raw.endswith("f") or raw.endswith("F"):
+ num = float(raw[:-1])
+ elif "_" in raw:
+ num = int(raw.replace("_", ""))
+ elif "." in raw:
+ num = float(raw)
+ else:
+ num = int(raw)
+ except ValueError:
+ num = raw
+ if isinstance(num, (int, float)) and num in SKIP_NUMBERS:
+ continue
+ files[fname].append({"line": line, "col": col, "raw": raw, "num": num})
+ return files
+
+
+def is_field_declaration(line):
+ stripped = line.strip()
+ if not stripped:
+ return False
+ if not re.match(
+ r"(private|public|protected|static|final|volatile|transient)\s", stripped
+ ):
+ return False
+ semi = stripped.find(";")
+ paren = stripped.find("(")
+ brace = stripped.find("{")
+ if brace != -1 and (semi == -1 or brace < semi) and (paren == -1 or brace < paren):
+ return False
+ if paren != -1 and (semi == -1 or paren < semi):
+ return False
+ return True
+
+
+def find_insertion_point(lines):
+ class_idx = -1
+ for i, line in enumerate(lines):
+ if re.search(r"\b(class|interface|enum)\b", line) and "{" in line:
+ class_idx = i
+ break
+ if class_idx == -1:
+ return -1
+
+ brace_depth = 0
+ body_start = -1
+ for i in range(class_idx, len(lines)):
+ for ch in lines[i]:
+ if ch == "{":
+ brace_depth += 1
+ elif ch == "}":
+ brace_depth -= 1
+ if brace_depth > 0:
+ body_start = i + 1
+ break
+
+ if body_start == -1:
+ return -1
+
+ depth = 1
+ last_field_end = body_start
+ i = body_start
+
+ while i < len(lines):
+ stripped = lines[i].strip()
+ line_opens = stripped.count("{")
+ line_closes = stripped.count("}")
+ depth_before = depth
+
+ for ch in stripped:
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+
+ if depth < 1:
+ break
+
+ if depth_before == 1:
+ if (
+ not stripped
+ or stripped.startswith("//")
+ or stripped.startswith("/*")
+ or stripped.startswith("*")
+ or stripped.startswith("@")
+ ):
+ i += 1
+ continue
+
+ if is_field_declaration(lines[i]):
+ j = i
+ while j < len(lines) and ";" not in lines[j]:
+ j += 1
+ last_field_end = j + 1
+ i = j + 1
+ continue
+
+ break
+ else:
+ i += 1
+ continue
+
+ i += 1
+
+ return last_field_end
+
+
+def is_in_special_method(lines, line_idx):
+ depth = 0
+ for i in range(line_idx, -1, -1):
+ stripped = lines[i].strip()
+ depth += stripped.count("}") - stripped.count("{")
+ if depth < 0:
+ if re.search(r"\b(hashCode|toString|equals|compareTo)\s*\(", lines[i]):
+ return True
+ break
+ return False
+
+
+def is_in_string_literal(lines, line_idx, col):
+ line = lines[line_idx]
+ in_str = False
+ in_chr = False
+ i = 0
+ while i < col - 1 and i < len(line):
+ ch = line[i]
+ if ch == "\\" and (in_str or in_chr):
+ i += 2
+ continue
+ if ch == '"':
+ in_str = not in_str
+ elif ch == "'":
+ in_chr = not in_chr
+ i += 1
+ return in_str or in_chr
+
+
+def extract_colors_from_line(line):
+ results = []
+ for m in re.finditer(
+ r"new\s+Color\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", line
+ ):
+ results.append(
+ {
+ "r": int(m.group(1)),
+ "g": int(m.group(2)),
+ "b": int(m.group(3)),
+ "start": m.start(),
+ "end": m.end(),
+ "raw": m.group(0),
+ }
+ )
+ return results
+
+
+COLOR_MAP = {
+ (255, 215, 0): "COLOR_GOLD",
+ (139, 69, 19): "COLOR_SADDLE_BROWN",
+ (160, 82, 45): "COLOR_SIENNA",
+ (205, 133, 63): "COLOR_PERU",
+ (210, 180, 140): "COLOR_TAN",
+ (128, 128, 128): "COLOR_GRAY",
+ (200, 200, 200): "COLOR_LIGHT_GRAY",
+ (100, 255, 150): "COLOR_LIGHT_GREEN",
+ (52, 152, 219): "COLOR_PETER_RIVER",
+ (255, 165, 0): "COLOR_ORANGE",
+ (255, 0, 0): "COLOR_RED",
+ (0, 200, 0): "COLOR_GREEN",
+ (100, 200, 255): "COLOR_LIGHT_BLUE",
+ (150, 150, 150): "COLOR_DARK_GRAY",
+ (231, 76, 60): "COLOR_ALIZARIN",
+ (255, 105, 180): "COLOR_DEEP_PINK",
+ (255, 182, 193): "COLOR_LIGHT_PINK",
+ (255, 192, 203): "COLOR_PINK",
+ (255, 218, 185): "COLOR_PEACH_PUFF",
+ (176, 196, 222): "COLOR_LIGHT_STEEL_BLUE",
+ (100, 0, 0): "COLOR_DARK_RED",
+ (88, 101, 242): "COLOR_BLURPLE",
+ (46, 204, 113): "COLOR_EMERALD",
+ (52, 73, 94): "COLOR_MIDNIGHT_BLUE",
+ (113, 54, 138): "COLOR_INDIGO",
+ (155, 89, 182): "COLOR_AMETHYST",
+ (233, 30, 99): "COLOR_HOT_PINK",
+ (180, 0, 0): "COLOR_DARK_RED_2",
+ (255, 159, 67): "COLOR_CARROT",
+ (241, 196, 15): "COLOR_SUN_FLOWER",
+ (149, 165, 166): "COLOR_CONCRETE",
+ (0, 150, 255): "COLOR_SKY_BLUE",
+ (255, 200, 100): "COLOR_WARM_GOLD",
+ (200, 200, 255): "COLOR_LAVENDER",
+ (150, 100, 50): "COLOR_BRONZE",
+ (0, 0, 0): "COLOR_BLACK",
+ (255, 255, 255): "COLOR_WHITE",
+ (145, 89, 182): "COLOR_WISTERIA",
+ (0, 128, 0): "COLOR_FOREST_GREEN",
+ (128, 0, 0): "COLOR_MAROON",
+ (0, 0, 128): "COLOR_NAVY",
+}
+
+NUM_MAP = {
+ 3: "THREE",
+ 4: "FOUR",
+ 5: "FIVE",
+ 6: "SIX",
+ 7: "SEVEN",
+ 8: "EIGHT",
+ 9: "NINE",
+ 10: "TEN",
+ 12: "TWELVE",
+ 14: "FOURTEEN",
+ 15: "FIFTEEN",
+ 20: "TWENTY",
+ 25: "TWENTY_FIVE",
+ 30: "THIRTY",
+ 50: "FIFTY",
+ 60: "SIXTY",
+ 70: "SEVENTY",
+ 80: "EIGHTY",
+ 90: "NINETY",
+ 100: "HUNDRED",
+ 200: "TWO_HUNDRED",
+ 300: "THREE_HUNDRED",
+ 500: "FIVE_HUNDRED",
+ 800: "EIGHT_HUNDRED",
+ 1000: "THOUSAND",
+ 3000: "THREE_THOUSAND",
+ 86400: "SECONDS_PER_DAY",
+ 3600: "SECONDS_PER_HOUR",
+ 60000: "MILLIS_PER_MINUTE",
+ 600000: "SIX_HUNDRED_THOUSAND",
+ 10000: "TEN_THOUSAND",
+ 100000: "ONE_HUNDRED_THOUSAND",
+ 101: "HUNDRED_PLUS_ONE",
+ 255: "MAX_UNSIGNED_BYTE",
+ 128: "HALF_MAX_UNSIGNED_BYTE",
+ 65535: "MAX_UNSIGNED_SHORT",
+}
+
+
+def unique_name(base, used):
+ name = base
+ c = 2
+ while name in used:
+ name = f"{base}_{c}"
+ c += 1
+ return name
+
+
+def get_color_name(r, g, b, used):
+ base = COLOR_MAP.get((r, g, b), f"COLOR_R{r}_G{g}_B{b}")
+ return unique_name(base, used)
+
+
+def get_num_name(num, raw, used):
+ if isinstance(num, (int, float)) and num in NUM_MAP:
+ base = NUM_MAP[num]
+ elif isinstance(num, (int, float)) and num < 0:
+ base = f"NEGATIVE_{abs(int(num))}"
+ elif isinstance(num, float):
+ base = "VAL_" + str(num).replace(".", "_").replace("-", "NEG_")
+ elif isinstance(num, int):
+ base = f"VAL_{num}"
+ elif isinstance(raw, str) and (raw.startswith("0x") or raw.startswith("0X")):
+ base = "HEX_" + raw[2:].upper()
+ else:
+ base = (
+ "VAL_"
+ + raw.replace("0x", "HEX_")
+ .replace("0X", "HEX_")
+ .replace("L", "")
+ .replace("l", "")
+ .replace("f", "")
+ .replace("F", "")
+ .replace(".", "_")
+ .replace("-", "NEG_")
+ .upper()
+ )
+
+ base = base.replace(".", "_")
+ if base and base[0].isdigit():
+ base = "VAL_" + base
+
+ return unique_name(base, used)
+
+
+def build_word_boundary_pattern(raw):
+ """Build a regex pattern that matches the raw number as a standalone token,
+ not as part of a larger identifier."""
+ # Escape special regex characters
+ escaped = re.escape(raw)
+ # Use word boundary assertions - but we need to be careful because
+ # word boundaries don't work well with all number formats.
+ # Instead, ensure the match is surrounded by non-alphanumeric chars or line boundaries.
+ return f"(?= len(lines):
+ continue
+ line = lines[line_idx]
+
+ if is_in_special_method(lines, line_idx):
+ continue
+
+ # Process Color constructors
+ colors = extract_colors_from_line(line)
+ for c in colors:
+ r, g, b = c["r"], c["g"], c["b"]
+ name = get_color_name(r, g, b, used)
+ used.add(name)
+ consts.append((name, "Color", c["raw"]))
+ pattern = build_word_boundary_pattern(c["raw"])
+ edits.append((line_idx, pattern, name))
+
+ # Process individual numbers
+ for v in viols:
+ col0 = v["col"] - 1
+ raw = v["raw"]
+ num = v["num"]
+
+ if isinstance(num, (int, float)) and num in SKIP_NUMBERS:
+ continue
+
+ # Skip if inside a Color constructor
+ if any(c["start"] <= col0 <= c["end"] for c in colors):
+ continue
+
+ if is_in_string_literal(lines, line_idx, v["col"]):
+ continue
+
+ # Determine type and value
+ if raw.startswith("0x") or raw.startswith("0X"):
+ jtype = "int"
+ value = raw
+ elif raw.endswith("L") or raw.endswith("l"):
+ jtype = "long"
+ value = raw
+ elif raw.endswith("f") or raw.endswith("F"):
+ jtype = "float"
+ value = raw
+ elif isinstance(num, float):
+ jtype = "double"
+ value = raw
+ elif isinstance(num, int) and (num > 2147483647 or num < -214783648):
+ jtype = "long"
+ value = raw + ("L" if not raw.endswith("L") else "")
+ else:
+ jtype = "int"
+ value = raw
+
+ name = get_num_name(num, raw, used)
+ used.add(name)
+ consts.append((name, jtype, value))
+
+ pattern = build_word_boundary_pattern(raw)
+ edits.append((line_idx, pattern, name))
+
+ if not consts:
+ return 0
+
+ # Deduplicate constants
+ seen = set()
+ unique = []
+ for name, typ, val in consts:
+ if name not in seen:
+ seen.add(name)
+ unique.append((name, typ, val))
+
+ # Apply edits per line (rightmost first to preserve positions)
+ by_line_edits = defaultdict(list)
+ for line_idx, pattern, repl in edits:
+ by_line_edits[line_idx].append((pattern, repl))
+
+ for line_idx, line_edits in by_line_edits.items():
+ line = lines[line_idx]
+ # Find ALL matches across ALL edit patterns
+ all_matches = []
+ for pattern, repl in line_edits:
+ for m in re.finditer(pattern, line):
+ all_matches.append((m.start(), m.end(), repl))
+ # Sort by start position, then by length (longer first) for same position
+ all_matches.sort(key=lambda x: (x[0], -(x[1] - x[0])))
+ # Remove overlapping matches (keep longer match)
+ filtered = []
+ last_end = -1
+ for start, end, repl in all_matches:
+ if start >= last_end:
+ filtered.append((start, end, repl))
+ last_end = end
+ # Apply from right to left to preserve positions
+ for start, end, repl in reversed(filtered):
+ line = line[:start] + repl + line[end:]
+ lines[line_idx] = line
+
+ # Insert constants AFTER replacements to avoid matching inside constant names
+ const_lines = [
+ f" private static final {typ} {name} = {val};\n" for name, typ, val in unique
+ ]
+ for i, cl in enumerate(const_lines):
+ lines.insert(insert_idx + i, cl)
+
+ with open(fname, "w", encoding="utf-8") as f:
+ f.writelines(lines)
+
+ return len(unique)
+
+
+def main():
+ project_root = Path("/storage/project/jvm/discord-bot")
+ report_path = project_root / "build" / "reports" / "checkstyle" / "main.xml"
+
+ if not report_path.exists():
+ print(f"ERROR: Report not found at {report_path}")
+ sys.exit(1)
+
+ files = parse_checkstyle_report(report_path)
+ total_violations = sum(len(v) for v in files.values())
+ print(f"Found {total_violations} violations across {len(files)} files")
+
+ total_fixed = 0
+ files_fixed = 0
+
+ for fname, violations in sorted(files.items()):
+ if not os.path.exists(fname):
+ print(f" SKIP (not found): {fname}")
+ continue
+
+ count = process_file(fname, violations)
+ if count > 0:
+ total_fixed += count
+ files_fixed += 1
+ print(
+ f" Fixed {count} constants in {os.path.relpath(fname, project_root)}"
+ )
+
+ print(f"\nTotal: {total_fixed} constants added across {files_fixed} files")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/main/java/creator/loki/bootstrap/App.java b/src/main/java/creator/loki/bootstrap/App.java
index 34b337b..e48fe2c 100644
--- a/src/main/java/creator/loki/bootstrap/App.java
+++ b/src/main/java/creator/loki/bootstrap/App.java
@@ -10,6 +10,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class App {
+ private App() {}
+
private static final Logger logger = LoggerFactory.getLogger(App.class);
private static volatile boolean shutdownRequested = false;
diff --git a/src/main/java/creator/loki/config/BotConfig.java b/src/main/java/creator/loki/config/BotConfig.java
index 41548e6..133ef0b 100644
--- a/src/main/java/creator/loki/config/BotConfig.java
+++ b/src/main/java/creator/loki/config/BotConfig.java
@@ -1,130 +1,105 @@
package creator.loki.config;
public class BotConfig {
- private final String mainToken;
- private final String musicToken;
- private final String radioToken;
- private final String prefix;
- private final String activity;
- private final int healthHttpPort;
- private final String superAdminId;
- private final String dbPath;
- private final String deepseekApiKey;
- private final String aiChannelId;
- private final int aiContextMsgs;
- private final int aiMsgLength;
- private final int aiContextChars;
- private final int aiDossierMsgs;
- private final int aiDossierUpdateHours;
- private final String aiDebateChannelId;
+ public record Tokens(String mainToken, String musicToken, String radioToken) {}
+
+ public record BotSettings(
+ String prefix, String activity, int healthHttpPort, String superAdminId, String dbPath) {}
+
+ public record AiContext(
+ String deepseekApiKey, String aiChannelId, int aiContextMsgs, int aiMsgLength,
+ int aiContextChars) {}
+
+ public record AiDossier(int aiDossierMsgs, int aiDossierUpdateHours, String aiDebateChannelId) {}
+
+ private final Tokens tokens;
+ private final BotSettings botSettings;
+ private final AiContext aiContext;
+ private final AiDossier aiDossier;
private final LavalinkConfig lavalinkConfig;
private final String externalUrl;
public BotConfig(
- String mainToken,
- String musicToken,
- String radioToken,
- String prefix,
- String activity,
- int healthHttpPort,
- String superAdminId,
- String dbPath,
- String deepseekApiKey,
- String aiChannelId,
- int aiContextMsgs,
- int aiMsgLength,
- int aiContextChars,
- int aiDossierMsgs,
- int aiDossierUpdateHours,
- String aiDebateChannelId,
+ Tokens tokens,
+ BotSettings botSettings,
+ AiContext aiContext,
+ AiDossier aiDossier,
LavalinkConfig lavalinkConfig,
String externalUrl) {
- this.mainToken = mainToken;
- this.musicToken = musicToken;
- this.radioToken = radioToken;
- this.prefix = prefix;
- this.activity = activity;
- this.healthHttpPort = healthHttpPort;
- this.superAdminId = superAdminId;
- this.dbPath = dbPath;
- this.deepseekApiKey = deepseekApiKey;
- this.aiChannelId = aiChannelId;
- this.aiContextMsgs = aiContextMsgs;
- this.aiMsgLength = aiMsgLength;
- this.aiContextChars = aiContextChars;
- this.aiDossierMsgs = aiDossierMsgs;
- this.aiDossierUpdateHours = aiDossierUpdateHours;
- this.aiDebateChannelId = aiDebateChannelId;
+ this.tokens = tokens;
+ this.botSettings = botSettings;
+ this.aiContext = aiContext;
+ this.aiDossier = aiDossier;
this.lavalinkConfig = lavalinkConfig;
this.externalUrl = externalUrl;
}
public String getMainToken() {
- return mainToken;
+ return tokens.mainToken();
}
public String getToken() {
- return mainToken;
+ return tokens.mainToken();
}
public String getMusicToken() {
- return musicToken;
+ return tokens.musicToken();
}
public String getRadioToken() {
- return radioToken;
+ return tokens.radioToken();
}
public String getPrefix() {
- return prefix;
+ return botSettings.prefix();
}
public String getActivity() {
- return activity;
+ return botSettings.activity();
}
public int getHealthHttpPort() {
- return healthHttpPort;
+ return botSettings.healthHttpPort();
}
public String getSuperAdminId() {
- return superAdminId;
+ return botSettings.superAdminId();
}
public String getDbPath() {
- return dbPath;
+ return botSettings.dbPath();
}
public String getDeepseekApiKey() {
- return deepseekApiKey;
+ return aiContext.deepseekApiKey();
}
public String getAiChannelId() {
- return aiChannelId;
+ return aiContext.aiChannelId();
}
public int getAiContextMsgs() {
- return aiContextMsgs;
+ return aiContext.aiContextMsgs();
}
public int getAiMsgLength() {
- return aiMsgLength;
+ return aiContext.aiMsgLength();
}
public int getAiContextChars() {
- return aiContextChars;
+ return aiContext.aiContextChars();
}
public int getAiDossierMsgs() {
- return aiDossierMsgs;
+ return aiDossier.aiDossierMsgs();
}
public int getAiDossierUpdateHours() {
- return aiDossierUpdateHours;
+ return aiDossier.aiDossierUpdateHours();
}
public String getAiDebateChannelId() {
- return aiDebateChannelId;
+ return aiDossier.aiDebateChannelId();
}
public LavalinkConfig getLavalinkConfig() {
diff --git a/src/main/java/creator/loki/config/ConfigLoader.java b/src/main/java/creator/loki/config/ConfigLoader.java
index 7469efd..b80b458 100644
--- a/src/main/java/creator/loki/config/ConfigLoader.java
+++ b/src/main/java/creator/loki/config/ConfigLoader.java
@@ -91,22 +91,10 @@ public final class ConfigLoader {
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
return new BotConfig(
- mainToken,
- musicToken,
- radioToken,
- prefix,
- activity,
- healthHttpPort,
- superAdminId,
- dbPath,
- deepseekApiKey,
- aiChannelId,
- aiContextMsgs,
- aiMsgLength,
- aiContextChars,
- aiDossierMsgs,
- aiDossierUpdateHours,
- aiDebateChannelId,
+ new BotConfig.Tokens(mainToken, musicToken, radioToken),
+ new BotConfig.BotSettings(prefix, activity, healthHttpPort, superAdminId, dbPath),
+ new BotConfig.AiContext(deepseekApiKey, aiChannelId, aiContextMsgs, aiMsgLength, aiContextChars),
+ new BotConfig.AiDossier(aiDossierMsgs, aiDossierUpdateHours, aiDebateChannelId),
lavalinkConfig,
externalUrl);
}
diff --git a/src/main/java/creator/loki/core/BotInitializer.java b/src/main/java/creator/loki/core/BotInitializer.java
index b9e2982..2d0b2c1 100644
--- a/src/main/java/creator/loki/core/BotInitializer.java
+++ b/src/main/java/creator/loki/core/BotInitializer.java
@@ -23,45 +23,44 @@ import org.slf4j.LoggerFactory;
public class BotInitializer {
private static final Logger logger = LoggerFactory.getLogger(BotInitializer.class);
- private final BotConfig config;
- private final PrefixCommandRegistrar registrar;
- private final ListenerAggregator listenerAggregator;
- private final MuteScheduler muteScheduler;
- private final MemberCounterListener memberCounterListener;
- private final HealthHttpServer healthHttpServer;
- private final ShutdownManager shutdownManager;
- private final LavalinkManager lavalinkManager;
- private final RadioManager radioManager;
- private final QueueRepository queueRepository;
-
- @Inject
- public BotInitializer(
+ public record BotServices(
BotConfig config,
PrefixCommandRegistrar registrar,
ListenerAggregator listenerAggregator,
MuteScheduler muteScheduler,
- MemberCounterListener memberCounterListener,
+ MemberCounterListener memberCounterListener) {}
+
+ public record Infrastructure(
HealthHttpServer healthHttpServer,
ShutdownManager shutdownManager,
LavalinkManager lavalinkManager,
RadioManager radioManager,
- QueueRepository queueRepository) {
- this.config = config;
- this.registrar = registrar;
- this.listenerAggregator = listenerAggregator;
- this.muteScheduler = muteScheduler;
- this.memberCounterListener = memberCounterListener;
- this.healthHttpServer = healthHttpServer;
- this.shutdownManager = shutdownManager;
- this.lavalinkManager = lavalinkManager;
- this.radioManager = radioManager;
- this.queueRepository = queueRepository;
+ QueueRepository queueRepository) {}
+
+ private final BotServices botServices;
+ private final Infrastructure infra;
+
+ @Inject
+ public BotInitializer(final BotServices botServices, final Infrastructure infra) {
+ this.botServices = botServices;
+ this.infra = infra;
}
public BotCluster start() {
+ var config = botServices.config();
+ var registrar = botServices.registrar();
+ var listenerAggregator = botServices.listenerAggregator();
+ var muteScheduler = botServices.muteScheduler();
+ var memberCounterListener = botServices.memberCounterListener();
+ var healthHttpServer = infra.healthHttpServer();
+ var shutdownManager = infra.shutdownManager();
+ var lavalinkManager = infra.lavalinkManager();
+ var radioManager = infra.radioManager();
+ var queueRepository = infra.queueRepository();
+
LoggerDecorator.startup(logger, "Инициализация main бота...");
registrar.registerAll();
- JDA mainJda = buildMainJDA();
+ JDA mainJda = buildMainJDA(config, listenerAggregator, lavalinkManager);
muteScheduler.restorePending(mainJda);
mainJda.getGuilds().forEach(g -> memberCounterListener.startScheduler(g));
healthHttpServer.start();
@@ -74,13 +73,13 @@ public class BotInitializer {
}
});
- JDA musicJda = createMusicJDA();
+ JDA musicJda = createMusicJDA(config, queueRepository, lavalinkManager);
JDA radioJda = null;
if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) {
sleep(3000);
LavalinkManager radioLavalink =
LavalinkManagerFactory.create(config, queueRepository, config.getRadioToken());
- radioJda = createRadioJDA(radioLavalink);
+ radioJda = createRadioJDA(config, radioLavalink, radioManager);
if (radioJda != null) {
radioManager.setRadioJda(radioJda);
radioManager.setLavalinkManager(radioLavalink);
@@ -92,7 +91,10 @@ public class BotInitializer {
return cluster;
}
- private JDA buildMainJDA() {
+ private JDA buildMainJDA(
+ final BotConfig config,
+ final ListenerAggregator listenerAggregator,
+ final LavalinkManager lavalinkManager) {
var builder =
JdaFactory.createBuilder(
config.getMainToken(),
@@ -105,7 +107,10 @@ public class BotInitializer {
return buildWithLavalink(builder, lavalinkManager);
}
- private JDA createMusicJDA() {
+ private JDA createMusicJDA(
+ final BotConfig config,
+ final QueueRepository queueRepository,
+ final LavalinkManager lavalinkManager) {
if (config.getMusicToken() == null || config.getMusicToken().isBlank()) {
return null;
}
@@ -123,7 +128,10 @@ public class BotInitializer {
return buildWithLavalink(builder, ml);
}
- private JDA createRadioJDA(final LavalinkManager radioLavalink) {
+ private JDA createRadioJDA(
+ final BotConfig config,
+ final LavalinkManager radioLavalink,
+ final RadioManager radioManager) {
if (config.getRadioToken() == null || config.getRadioToken().isBlank()) {
return null;
}
diff --git a/src/main/java/creator/loki/core/ListenerAggregator.java b/src/main/java/creator/loki/core/ListenerAggregator.java
index 513c9b9..8cb8b77 100644
--- a/src/main/java/creator/loki/core/ListenerAggregator.java
+++ b/src/main/java/creator/loki/core/ListenerAggregator.java
@@ -17,8 +17,6 @@ import creator.loki.feature.moderation.log.ModerationLogger;
import creator.loki.feature.music.MusicSlashCommands;
import creator.loki.feature.music.listener.PlaySelectHandler;
import creator.loki.feature.reactroles.listener.ReactionRoleListener;
-import creator.loki.feature.roast.AndrertListener;
-import creator.loki.feature.roast.HelperListener;
import creator.loki.feature.ticket.listener.TicketListener;
import creator.loki.feature.voice.handler.VoiceButtonHandler;
import creator.loki.feature.voice.handler.VoiceModalHandler;
@@ -28,60 +26,72 @@ import creator.loki.feature.welcome.WelcomeListener;
@Singleton
public class ListenerAggregator {
+ public record VoiceListeners(
+ VoiceTracker voiceTracker,
+ VoiceChannelListener voiceChannelListener,
+ VoiceButtonHandler voiceButtonHandler,
+ VoiceModalHandler voiceModalHandler,
+ VoiceSyncListener voiceSyncListener) {}
+
+ public record LevelListeners(
+ MessageListener messageListener,
+ ResetLevelsButtonHandler resetLevelsButtonHandler,
+ LeaderboardButtonListener leaderboardButtonListener) {}
+
+ public record MusicListeners(
+ MusicSlashCommands musicSlashCommands, PlaySelectHandler playSelectHandler) {}
+
+ public record ModerationListeners(
+ ModerationLogger moderationLogger, ClearSlashCommands clearSlashCommands) {}
+
+ public record ReadyListeners(
+ ReadyListener readyListener,
+ MemberCounterListener memberCounterListener,
+ WelcomeListener welcomeListener,
+ HelpMenuHandler helpMenuHandler,
+ MessageCache messageCache) {}
+
+ public record MiscUIListeners(
+ ImageNavigationHandler imageNavigationHandler,
+ SetupSlashHandler setupSlashHandler,
+ TicketListener ticketListener,
+ ReactionRoleListener reactionRoleListener,
+ FishMessageListener fishMessageListener) {}
+
private final Object[] listeners;
@Inject
public ListenerAggregator(
- final ReadyListener readyListener,
- final MessageListener messageListener,
- final VoiceTracker voiceTracker,
- final VoiceChannelListener voiceChannelListener,
- final MemberCounterListener memberCounterListener,
- final VoiceButtonHandler voiceButtonHandler,
- final VoiceModalHandler voiceModalHandler,
- final VoiceSyncListener voiceSyncListener,
- final WelcomeListener welcomeListener,
- final ModerationLogger moderationLogger,
- final HelpMenuHandler helpMenuHandler,
- final ResetLevelsButtonHandler resetLevelsButtonHandler,
- final LeaderboardButtonListener leaderboardButtonListener,
- final MessageCache messageCache,
- final MusicSlashCommands musicSlashCommands,
- final PlaySelectHandler playSelectHandler,
- final FishMessageListener fishMessageListener,
- final AndrertListener andrertListener,
- final ClearSlashCommands clearSlashCommands,
- final HelperListener helperListener,
- final ImageNavigationHandler imageNavigationHandler,
- final SetupSlashHandler setupSlashHandler,
- final TicketListener ticketListener,
- final ReactionRoleListener reactionRoleListener) {
+ final VoiceListeners voice,
+ final LevelListeners level,
+ final MusicListeners music,
+ final ModerationListeners moderation,
+ final ReadyListeners ready,
+ final MiscUIListeners misc) {
this.listeners =
new Object[] {
- readyListener,
- messageListener,
- voiceTracker,
- voiceChannelListener,
- memberCounterListener,
- voiceButtonHandler,
- voiceModalHandler,
- voiceSyncListener,
- welcomeListener,
- moderationLogger,
- helpMenuHandler,
- resetLevelsButtonHandler,
- leaderboardButtonListener,
- messageCache,
- musicSlashCommands,
- playSelectHandler,
- fishMessageListener,
- andrertListener,
- clearSlashCommands,
- helperListener,
- imageNavigationHandler,
- setupSlashHandler,
- ticketListener,
- reactionRoleListener
+ ready.readyListener,
+ level.messageListener,
+ voice.voiceTracker,
+ voice.voiceChannelListener,
+ ready.memberCounterListener,
+ voice.voiceButtonHandler,
+ voice.voiceModalHandler,
+ voice.voiceSyncListener,
+ ready.welcomeListener,
+ moderation.moderationLogger,
+ ready.helpMenuHandler,
+ level.resetLevelsButtonHandler,
+ level.leaderboardButtonListener,
+ ready.messageCache,
+ music.musicSlashCommands,
+ music.playSelectHandler,
+ misc.fishMessageListener,
+ moderation.clearSlashCommands,
+ misc.imageNavigationHandler,
+ misc.setupSlashHandler,
+ misc.ticketListener,
+ misc.reactionRoleListener
};
}
diff --git a/src/main/java/creator/loki/core/exception/CommandExceptionHandler.java b/src/main/java/creator/loki/core/exception/CommandExceptionHandler.java
index 4e895fc..286865e 100644
--- a/src/main/java/creator/loki/core/exception/CommandExceptionHandler.java
+++ b/src/main/java/creator/loki/core/exception/CommandExceptionHandler.java
@@ -11,6 +11,8 @@ import org.slf4j.LoggerFactory;
/** Обработчик исключений для команд */
public class CommandExceptionHandler {
+ private CommandExceptionHandler() {}
+
private static final Logger logger = LoggerFactory.getLogger(CommandExceptionHandler.class);
/** Обрабатывает исключение и отправляет сообщение пользователю */
diff --git a/src/main/java/creator/loki/database/Database.java b/src/main/java/creator/loki/database/Database.java
index 80a0f0b..f65ec8a 100644
--- a/src/main/java/creator/loki/database/Database.java
+++ b/src/main/java/creator/loki/database/Database.java
@@ -68,7 +68,7 @@ public class Database implements Lifecycle {
}
public PreparedStatement prepare(final String sql) {
- Connection conn = null;
+ Connection conn = null; // NOPMD: proxy manages connection lifecycle
try {
conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
diff --git a/src/main/java/creator/loki/database/DatabaseMigration.java b/src/main/java/creator/loki/database/DatabaseMigration.java
index 8092960..3a7b49c 100644
--- a/src/main/java/creator/loki/database/DatabaseMigration.java
+++ b/src/main/java/creator/loki/database/DatabaseMigration.java
@@ -32,8 +32,8 @@ public class DatabaseMigration {
if (data == null) {
return;
}
- try (var chk = database.prepare("SELECT value FROM config WHERE key = 'migrated'")) {
- var rs = chk.executeQuery();
+ try (var chk = database.prepare("SELECT value FROM config WHERE key = 'migrated'");
+ var rs = chk.executeQuery()) {
if (rs.next() && "true".equals(rs.getString("value"))) {
return;
}
@@ -70,9 +70,12 @@ public class DatabaseMigration {
return;
}
LoggerDecorator.database(logger, "Миграция userStats: " + userStats.size() + " записей");
- try (var stmt =
- database.prepare(
- "INSERT OR REPLACE INTO user_stats (user_id, level, total_xp, message_count, voice_minutes, last_message_time, last_voice_join) VALUES (?, ?, ?, ?, ?, ?, ?)")) {
+ var sql =
+ "INSERT OR REPLACE INTO user_stats"
+ + " (user_id, level, total_xp, message_count,"
+ + " voice_minutes, last_message_time, last_voice_join)"
+ + " VALUES (?, ?, ?, ?, ?, ?, ?)";
+ try (var stmt = database.prepare(sql)) {
for (var entry : userStats.entrySet()) {
var stats = (Map) entry.getValue();
stmt.setString(1, (String) stats.get("userId"));
diff --git a/src/main/java/creator/loki/database/schema/CoreTables.java b/src/main/java/creator/loki/database/schema/CoreTables.java
index 2df97f3..f54f17b 100644
--- a/src/main/java/creator/loki/database/schema/CoreTables.java
+++ b/src/main/java/creator/loki/database/schema/CoreTables.java
@@ -3,6 +3,7 @@ package creator.loki.database.schema;
import creator.loki.database.Database;
public final class CoreTables {
+ private CoreTables() {}
public static void create(final Database database) {
createConfig(database);
diff --git a/src/main/java/creator/loki/database/schema/FeatureTables.java b/src/main/java/creator/loki/database/schema/FeatureTables.java
index 2cab1f0..5d81c81 100644
--- a/src/main/java/creator/loki/database/schema/FeatureTables.java
+++ b/src/main/java/creator/loki/database/schema/FeatureTables.java
@@ -3,6 +3,8 @@ package creator.loki.database.schema;
import creator.loki.database.Database;
public final class FeatureTables {
+ private FeatureTables() {}
+
private static final String ECONOMY =
"""
CREATE TABLE IF NOT EXISTS economy (
diff --git a/src/main/java/creator/loki/di/BotModule.java b/src/main/java/creator/loki/di/BotModule.java
index 6c5a8f2..0084d39 100644
--- a/src/main/java/creator/loki/di/BotModule.java
+++ b/src/main/java/creator/loki/di/BotModule.java
@@ -28,7 +28,6 @@ import creator.loki.feature.music.MusicModule;
import creator.loki.feature.profile.ProfileModule;
import creator.loki.feature.radio.RadioModule;
import creator.loki.feature.reactroles.ReactionRolesModule;
-import creator.loki.feature.roast.RoastModule;
import creator.loki.feature.role.RoleModule;
import creator.loki.feature.ticket.TicketModule;
import creator.loki.feature.tiktok.TikTokModule;
@@ -62,7 +61,6 @@ public class BotModule extends AbstractModule {
install(new WelcomeModule());
install(new EconomyModule());
install(new RadioModule());
- install(new RoastModule());
install(new ImageModule());
install(new TikTokModule());
install(new EntertainmentModule());
diff --git a/src/main/java/creator/loki/feature/config/SetupDashboardBuilder.java b/src/main/java/creator/loki/feature/config/SetupDashboardBuilder.java
new file mode 100644
index 0000000..26cd418
--- /dev/null
+++ b/src/main/java/creator/loki/feature/config/SetupDashboardBuilder.java
@@ -0,0 +1,180 @@
+package creator.loki.feature.config;
+
+import java.awt.Color;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import creator.loki.feature.config.repository.ConfigRepository;
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.components.actionrow.ActionRow;
+import net.dv8tion.jda.api.components.buttons.Button;
+import net.dv8tion.jda.api.components.label.Label;
+import net.dv8tion.jda.api.components.selections.StringSelectMenu;
+import net.dv8tion.jda.api.components.textinput.TextInput;
+import net.dv8tion.jda.api.components.textinput.TextInputStyle;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
+import net.dv8tion.jda.api.modals.Modal;
+
+class SetupDashboardBuilder {
+ private static final Color BLURPLE = new Color(88, 101, 242);
+ private final ConfigRepository configRepo;
+
+ SetupDashboardBuilder(ConfigRepository configRepo) {
+ this.configRepo = configRepo;
+ }
+
+ EmbedBuilder buildDashboardEmbed(Guild guild) {
+ EmbedBuilder embed =
+ new EmbedBuilder()
+ .setTitle("🎛 Панель управления сервером")
+ .setColor(BLURPLE)
+ .setFooter("Выбери категорию для настройки");
+
+ addVoiceChannelSection(embed, guild);
+ addCounterSection(embed);
+ addLogSection(embed);
+ addLevelRolesSection(embed);
+
+ return embed;
+ }
+
+ private void addVoiceChannelSection(final EmbedBuilder embed, final Guild guild) {
+ String triggerId = configRepo.get("voice.trigger_channel_id");
+ String categoryId = configRepo.get("voice.category_id");
+ if (triggerId == null && categoryId == null) {
+ embed.addField("🎤 Голосовые каналы", "❌ Не настроено", false);
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("Триггер: ");
+ sb.append(triggerId != null ? "<#" + triggerId + ">" : "❌ Не настроено");
+ sb.append("\nКатегория: ");
+ if (categoryId != null) {
+ var cat = guild.getCategoryById(categoryId);
+ sb.append(cat != null ? cat.getName() : "❌ Не найдена");
+ } else {
+ sb.append("❌ Не настроено");
+ }
+ embed.addField("🎤 Голосовые каналы", sb.toString(), false);
+ }
+
+ private void addCounterSection(final EmbedBuilder embed) {
+ String counterChannelId = configRepo.get("counter.channel_id");
+ String counterFormat = configRepo.get("counter.format");
+ if (counterChannelId == null) {
+ embed.addField("📊 Счётчик", "❌ Не настроено", false);
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("Канал: <#").append(counterChannelId).append(">");
+ if (counterFormat != null) {
+ sb.append("\nФормат: `").append(counterFormat).append("`");
+ }
+ embed.addField("📊 Счётчик", sb.toString(), false);
+ }
+
+ private void addLogSection(final EmbedBuilder embed) {
+ String modLogId = configRepo.get("moderation.log_channel_id");
+ String levelupLogId = configRepo.get("levelup.notification_channel_id");
+ if (modLogId == null && levelupLogId == null) {
+ embed.addField("📝 Логи", "❌ Не настроено", false);
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("Модерация: ");
+ sb.append(modLogId != null ? "<#" + modLogId + ">" : "❌ Не настроено");
+ sb.append("\nLevel-up: ");
+ sb.append(levelupLogId != null ? "<#" + levelupLogId + ">" : "❌ Не настроено");
+ embed.addField("📝 Логи", sb.toString(), false);
+ }
+
+ private void addLevelRolesSection(final EmbedBuilder embed) {
+ Map allConfig = configRepo.getAll();
+ long levelRoleCount =
+ allConfig.keySet().stream().filter(k -> k.startsWith("level_role_")).count();
+ embed.addField("🏆 Роли за уровни", "Настроено ролей: **" + levelRoleCount + "**", false);
+ }
+
+ void openCounterFormatModal(final ButtonInteractionEvent event) {
+ String currentFormat = configRepo.get("counter.format");
+ if (currentFormat == null) {
+ currentFormat = "【👥】Участники: {count}";
+ }
+
+ TextInput formatInput =
+ TextInput.create("format", TextInputStyle.SHORT)
+ .setValue(currentFormat)
+ .setPlaceholder("【👥】Участники: {count}")
+ .setRequired(true)
+ .build();
+
+ Modal modal =
+ Modal.create("setup_counter_format_modal", "✏️ Формат счётчика")
+ .addComponents(Label.of("Формат строки", formatInput))
+ .build();
+
+ event.replyModal(modal).queue();
+ }
+
+ void openLevelRoleAddModal(final ButtonInteractionEvent event) {
+ TextInput levelInput =
+ TextInput.create("level", TextInputStyle.SHORT)
+ .setPlaceholder("10")
+ .setRequired(true)
+ .build();
+
+ TextInput roleInput =
+ TextInput.create("role", TextInputStyle.SHORT)
+ .setPlaceholder("Название роли")
+ .setRequired(true)
+ .build();
+
+ Modal modal =
+ Modal.create("setup_levelrole_add_modal", "➕ Добавить роль за уровень")
+ .addComponents(Label.of("Уровень", levelInput), Label.of("Название роли", roleInput))
+ .build();
+
+ event.replyModal(modal).queue();
+ }
+
+ void openLevelRoleRemoveSelect(final ButtonInteractionEvent event) {
+ Map allConfig = configRepo.getAll();
+ var options =
+ allConfig.entrySet().stream()
+ .filter(e -> e.getKey().startsWith("level_role_"))
+ .map(
+ e -> {
+ String level = e.getKey().substring("level_role_".length());
+ return "Уровень " + level + " — " + e.getValue();
+ })
+ .toList();
+
+ if (options.isEmpty()) {
+ return;
+ }
+
+ StringSelectMenu.Builder menuBuilder =
+ StringSelectMenu.create("setup_levelrole_remove_select")
+ .setPlaceholder("Выбери роль для удаления");
+
+ var levelKeys =
+ allConfig.keySet().stream()
+ .filter(k -> k.startsWith("level_role_"))
+ .collect(Collectors.toList());
+
+ for (String key : levelKeys) {
+ String level = key.substring("level_role_".length());
+ String roleName = allConfig.get(key);
+ String label = "Уровень " + level + " — " + roleName;
+ menuBuilder.addOption(label, level);
+ }
+
+ event
+ .getHook()
+ .editOriginalComponents(
+ ActionRow.of(menuBuilder.build()),
+ ActionRow.of(Button.secondary("setup_back", "🔙 Назад")))
+ .queue();
+ }
+}
diff --git a/src/main/java/creator/loki/feature/config/SetupPanelBuilder.java b/src/main/java/creator/loki/feature/config/SetupPanelBuilder.java
new file mode 100644
index 0000000..2816993
--- /dev/null
+++ b/src/main/java/creator/loki/feature/config/SetupPanelBuilder.java
@@ -0,0 +1,164 @@
+package creator.loki.feature.config;
+
+import java.awt.Color;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import creator.loki.feature.config.repository.ConfigRepository;
+import net.dv8tion.jda.api.EmbedBuilder;
+import net.dv8tion.jda.api.components.actionrow.ActionRow;
+import net.dv8tion.jda.api.components.buttons.Button;
+import net.dv8tion.jda.api.components.selections.EntitySelectMenu;
+import net.dv8tion.jda.api.entities.Guild;
+import net.dv8tion.jda.api.entities.channel.ChannelType;
+import net.dv8tion.jda.api.interactions.InteractionHook;
+
+class SetupPanelBuilder {
+ private static final Color BLURPLE = new Color(88, 101, 242);
+ private final ConfigRepository configRepo;
+
+ SetupPanelBuilder(ConfigRepository configRepo) {
+ this.configRepo = configRepo;
+ }
+
+ void showVoicePanel(Guild guild, final InteractionHook hook) {
+ EmbedBuilder embed =
+ new EmbedBuilder().setTitle("🎤 Настройка голосовых каналов").setColor(BLURPLE);
+
+ String triggerId = configRepo.get("voice.trigger_channel_id");
+ if (triggerId != null) {
+ embed.addField("Канал-триггер", "<#" + triggerId + ">", false);
+ } else {
+ embed.addField("Канал-триггер", "❌ Не настроено", false);
+ }
+
+ String categoryId = configRepo.get("voice.category_id");
+ if (categoryId != null) {
+ var cat = guild.getCategoryById(categoryId);
+ embed.addField("Категория", cat != null ? cat.getName() : "❌ Не найдена", false);
+ } else {
+ embed.addField("Категория", "❌ Не настроено", false);
+ }
+
+ hook.editOriginalEmbeds(embed.build())
+ .setComponents(
+ ActionRow.of(
+ EntitySelectMenu.create(
+ "setup_voice_trigger", EntitySelectMenu.SelectTarget.CHANNEL)
+ .setChannelTypes(ChannelType.VOICE)
+ .setPlaceholder("Выбери канал-триггер")
+ .build()),
+ ActionRow.of(
+ EntitySelectMenu.create(
+ "setup_voice_category", EntitySelectMenu.SelectTarget.CHANNEL)
+ .setChannelTypes(ChannelType.CATEGORY)
+ .setPlaceholder("Выбери категорию")
+ .build()),
+ ActionRow.of(Button.secondary("setup_back", "🔙 Назад")))
+ .queue();
+ }
+
+ void showCounterPanel(Guild guild, final InteractionHook hook) {
+ EmbedBuilder embed =
+ new EmbedBuilder().setTitle("📊 Настройка счётчика участников").setColor(BLURPLE);
+
+ String channelId = configRepo.get("counter.channel_id");
+ if (channelId != null) {
+ embed.addField("Канал", "<#" + channelId + ">", false);
+ } else {
+ embed.addField("Канал", "❌ Не настроено", false);
+ }
+
+ String format = configRepo.get("counter.format");
+ embed.addField("Формат", format != null ? "`" + format + "`" : "❌ Не настроено", false);
+
+ hook.editOriginalEmbeds(embed.build())
+ .setComponents(
+ ActionRow.of(
+ EntitySelectMenu.create(
+ "setup_counter_channel", EntitySelectMenu.SelectTarget.CHANNEL)
+ .setChannelTypes(ChannelType.VOICE)
+ .setPlaceholder("Выбери канал")
+ .build()),
+ ActionRow.of(
+ Button.secondary("setup_counter_format", "✏️ Формат"),
+ Button.secondary("setup_back", "🔙 Назад")))
+ .queue();
+ }
+
+ void showLoggingPanel(Guild guild, final InteractionHook hook) {
+ EmbedBuilder embed = new EmbedBuilder().setTitle("📝 Настройка логирования").setColor(BLURPLE);
+
+ String modId = configRepo.get("moderation.log_channel_id");
+ embed.addField("Логи модерации", modId != null ? "<#" + modId + ">" : "❌ Не настроено", false);
+
+ String lvlId = configRepo.get("levelup.notification_channel_id");
+ embed.addField(
+ "Уведомления level-up", lvlId != null ? "<#" + lvlId + ">" : "❌ Не настроено", false);
+
+ hook.editOriginalEmbeds(embed.build())
+ .setComponents(
+ ActionRow.of(
+ EntitySelectMenu.create(
+ "setup_logs_moderation", EntitySelectMenu.SelectTarget.CHANNEL)
+ .setChannelTypes(ChannelType.TEXT)
+ .setPlaceholder("Канал логов модерации")
+ .build()),
+ ActionRow.of(
+ EntitySelectMenu.create("setup_logs_levelup", EntitySelectMenu.SelectTarget.CHANNEL)
+ .setChannelTypes(ChannelType.TEXT)
+ .setPlaceholder("Канал level-up уведомлений")
+ .build()),
+ ActionRow.of(Button.secondary("setup_back", "🔙 Назад")))
+ .queue();
+ }
+
+ void showLevelRolesPanel(Guild guild, final InteractionHook hook) {
+ Map allConfig = configRepo.getAll();
+ var levelRoles =
+ allConfig.entrySet().stream()
+ .filter(e -> e.getKey().startsWith("level_role_"))
+ .collect(
+ Collectors.toMap(
+ e -> Integer.parseInt(e.getKey().substring("level_role_".length())),
+ Map.Entry::getValue));
+
+ EmbedBuilder embed = new EmbedBuilder().setTitle("🏆 Роли за уровни").setColor(BLURPLE);
+
+ if (levelRoles.isEmpty()) {
+ embed.setDescription("❌ Роли не настроены");
+ } else {
+ StringBuilder sb = new StringBuilder();
+ levelRoles.entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .forEach(
+ e ->
+ sb.append("**Уровень ")
+ .append(e.getKey())
+ .append(":** `")
+ .append(e.getValue())
+ .append("`\n"));
+ embed.setDescription(sb.toString());
+ }
+
+ var buttons = new ArrayList