548 lines
17 KiB
JavaScript
548 lines
17 KiB
JavaScript
const activeTabClasses = ["tab-btn-active"];
|
|
const inactiveTabClasses = ["tab-btn-inactive"];
|
|
|
|
const BOT_OPTIONS_PRESETS = {
|
|
default: {
|
|
response_mode: "free-will",
|
|
free_will_chance: 2,
|
|
memory_chance: 30,
|
|
mention_probability: 0,
|
|
gif_search_enabled: false,
|
|
image_gen_enabled: false,
|
|
nsfw_image_enabled: false,
|
|
spontaneous_posts_enabled: true,
|
|
},
|
|
lurker: {
|
|
response_mode: "mention-only",
|
|
free_will_chance: 0,
|
|
memory_chance: 18,
|
|
mention_probability: 0,
|
|
gif_search_enabled: false,
|
|
image_gen_enabled: false,
|
|
nsfw_image_enabled: false,
|
|
spontaneous_posts_enabled: false,
|
|
},
|
|
balanced: {
|
|
response_mode: "free-will",
|
|
free_will_chance: 6,
|
|
memory_chance: 45,
|
|
mention_probability: 8,
|
|
gif_search_enabled: true,
|
|
image_gen_enabled: false,
|
|
nsfw_image_enabled: false,
|
|
spontaneous_posts_enabled: true,
|
|
},
|
|
chaos: {
|
|
response_mode: "free-will",
|
|
free_will_chance: 22,
|
|
memory_chance: 65,
|
|
mention_probability: 35,
|
|
gif_search_enabled: true,
|
|
image_gen_enabled: true,
|
|
nsfw_image_enabled: true,
|
|
spontaneous_posts_enabled: true,
|
|
},
|
|
};
|
|
|
|
function switchTab(button, tabName) {
|
|
document.querySelectorAll(".tab-btn").forEach((tab) => {
|
|
tab.classList.remove(...activeTabClasses);
|
|
tab.classList.add(...inactiveTabClasses);
|
|
});
|
|
|
|
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
|
panel.classList.add("hidden");
|
|
});
|
|
|
|
button.classList.remove(...inactiveTabClasses);
|
|
button.classList.add(...activeTabClasses);
|
|
document.getElementById(`tab-${tabName}`)?.classList.remove("hidden");
|
|
}
|
|
|
|
function setActiveGuildById(guildId) {
|
|
document.querySelectorAll(".guild-list-item").forEach((item) => {
|
|
const isActive = item.dataset.guildId === guildId;
|
|
item.classList.toggle("guild-item-active", isActive);
|
|
item.classList.toggle("guild-item-inactive", !isActive);
|
|
});
|
|
}
|
|
|
|
function setActiveGuildFromPath() {
|
|
const match = window.location.pathname.match(/\/dashboard\/guild\/([^/]+)/);
|
|
if (!match) {
|
|
setActiveGuildById("");
|
|
return;
|
|
}
|
|
setActiveGuildById(match[1]);
|
|
}
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const item = event.target.closest(".guild-list-item");
|
|
if (!item) {
|
|
return;
|
|
}
|
|
setActiveGuildById(item.dataset.guildId);
|
|
});
|
|
|
|
document.addEventListener("htmx:afterSwap", (event) => {
|
|
if (event.target && event.target.id === "guild-main-content") {
|
|
setActiveGuildFromPath();
|
|
initBotOptionsUI(event.target);
|
|
}
|
|
});
|
|
|
|
setActiveGuildFromPath();
|
|
initBotOptionsUI(document);
|
|
|
|
function showNotification(message, type) {
|
|
const existing = document.querySelector(".notification");
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
|
|
const notification = document.createElement("div");
|
|
notification.className = `notification fixed bottom-5 right-5 z-[200] rounded-lg px-5 py-3 text-sm font-medium text-white shadow-lg ${type === "success" ? "bg-emerald-500" : "bg-rose-500"}`;
|
|
notification.textContent = message;
|
|
document.body.appendChild(notification);
|
|
setTimeout(() => {
|
|
notification.remove();
|
|
}, 3000);
|
|
}
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
const modal = document.querySelector(".modal-overlay");
|
|
if (modal) {
|
|
modal.remove();
|
|
}
|
|
}
|
|
});
|
|
|
|
function initBotOptionsUI(scope) {
|
|
const root = scope instanceof Element ? scope : document;
|
|
const forms = root.querySelectorAll("[data-bot-options-form]");
|
|
|
|
forms.forEach((form) => {
|
|
if (form.dataset.optionsInitialized === "1") {
|
|
return;
|
|
}
|
|
|
|
form.dataset.optionsInitialized = "1";
|
|
bindSyncedPercentInputs(form);
|
|
bindPresetButtons(form);
|
|
bindChannelManager(form);
|
|
bindDynamicState(form);
|
|
updateDynamicState(form);
|
|
});
|
|
}
|
|
|
|
function bindSyncedPercentInputs(form) {
|
|
const ranges = form.querySelectorAll("[data-options-range]");
|
|
|
|
ranges.forEach((range) => {
|
|
const key = range.dataset.optionsRange;
|
|
if (!key) {
|
|
return;
|
|
}
|
|
|
|
const numberInput = form.querySelector(`[data-options-number="${key}"]`);
|
|
const valueLabel = form.querySelector(`[data-options-value="${key}"]`);
|
|
|
|
const syncToValue = (rawValue, fromNumberInput) => {
|
|
const parsed = Number.parseInt(String(rawValue), 10);
|
|
const safe = Number.isFinite(parsed) ? Math.max(0, Math.min(100, parsed)) : 0;
|
|
|
|
range.value = String(safe);
|
|
if (numberInput) {
|
|
numberInput.value = String(safe);
|
|
}
|
|
if (valueLabel) {
|
|
valueLabel.textContent = `${safe}%`;
|
|
}
|
|
|
|
if (fromNumberInput && document.activeElement !== numberInput) {
|
|
numberInput.value = String(safe);
|
|
}
|
|
|
|
updateDynamicState(form);
|
|
};
|
|
|
|
range.addEventListener("input", () => syncToValue(range.value, false));
|
|
|
|
if (numberInput) {
|
|
numberInput.addEventListener("input", () => syncToValue(numberInput.value, true));
|
|
numberInput.addEventListener("blur", () => syncToValue(numberInput.value, true));
|
|
}
|
|
});
|
|
}
|
|
|
|
function bindPresetButtons(form) {
|
|
const buttons = form.parentElement?.querySelectorAll("[data-options-preset]") || [];
|
|
|
|
buttons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const presetKey = button.dataset.optionsPreset;
|
|
const preset = BOT_OPTIONS_PRESETS[presetKey] || null;
|
|
if (!preset) {
|
|
return;
|
|
}
|
|
|
|
setRadioValue(form, "response_mode", preset.response_mode);
|
|
setPercentValue(form, "free_will_chance", preset.free_will_chance);
|
|
setPercentValue(form, "memory_chance", preset.memory_chance);
|
|
setPercentValue(form, "mention_probability", preset.mention_probability);
|
|
|
|
setCheckboxValue(form, "gif_search_enabled", preset.gif_search_enabled);
|
|
setCheckboxValue(form, "image_gen_enabled", preset.image_gen_enabled);
|
|
setCheckboxValue(form, "nsfw_image_enabled", preset.nsfw_image_enabled);
|
|
setCheckboxValue(form, "spontaneous_posts_enabled", preset.spontaneous_posts_enabled);
|
|
|
|
updateDynamicState(form);
|
|
});
|
|
});
|
|
}
|
|
|
|
function bindDynamicState(form) {
|
|
form.addEventListener("change", () => {
|
|
updateDynamicState(form);
|
|
});
|
|
}
|
|
|
|
async function bindChannelManager(form) {
|
|
const manager = form.querySelector("[data-channel-manager]");
|
|
if (!manager || manager.dataset.channelManagerInitialized === "1") {
|
|
return;
|
|
}
|
|
|
|
manager.dataset.channelManagerInitialized = "1";
|
|
|
|
const guildId = manager.dataset.guildId;
|
|
const restrictedInput = form.querySelector("[data-restricted-channel-input]");
|
|
const spontaneousInput = form.querySelector("[data-spontaneous-channel-input]");
|
|
const loadingNode = manager.querySelector("[data-channel-loading]");
|
|
const contentNode = manager.querySelector("[data-channel-content]");
|
|
const searchInput = manager.querySelector("[data-channel-search]");
|
|
const restrictedList = manager.querySelector("[data-restricted-channel-list]");
|
|
const spontaneousList = manager.querySelector("[data-spontaneous-channel-list]");
|
|
|
|
if (
|
|
!guildId ||
|
|
!restrictedInput ||
|
|
!spontaneousInput ||
|
|
!loadingNode ||
|
|
!contentNode ||
|
|
!searchInput ||
|
|
!restrictedList ||
|
|
!spontaneousList
|
|
) {
|
|
return;
|
|
}
|
|
|
|
loadingNode.classList.remove("hidden");
|
|
contentNode.classList.add("hidden");
|
|
|
|
try {
|
|
const response = await fetch(`/api/guilds/${guildId}/channels`, {
|
|
credentials: "same-origin",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load channels (${response.status})`);
|
|
}
|
|
|
|
const channels = await response.json();
|
|
if (!Array.isArray(channels)) {
|
|
throw new Error("Invalid channel payload");
|
|
}
|
|
|
|
const selectedSpontaneous = parseChannelIdsFromText(spontaneousInput.value);
|
|
|
|
const render = () => {
|
|
const query = searchInput.value.trim().toLowerCase();
|
|
const filtered = channels.filter((channel) => {
|
|
const haystack = `${channel.name} ${channel.id}`.toLowerCase();
|
|
return !query || haystack.includes(query);
|
|
});
|
|
|
|
renderRestrictedChannelButtons({
|
|
listNode: restrictedList,
|
|
channels: filtered,
|
|
selectedChannelId: restrictedInput.value.trim(),
|
|
onSelect: (channel) => {
|
|
restrictedInput.value = restrictedInput.value === channel.id ? "" : channel.id;
|
|
render();
|
|
},
|
|
});
|
|
|
|
renderSpontaneousChannelButtons({
|
|
listNode: spontaneousList,
|
|
channels: filtered,
|
|
selectedIds: selectedSpontaneous,
|
|
onToggle: (channel) => {
|
|
if (selectedSpontaneous.has(channel.id)) {
|
|
selectedSpontaneous.delete(channel.id);
|
|
} else {
|
|
selectedSpontaneous.add(channel.id);
|
|
}
|
|
|
|
spontaneousInput.value = [...selectedSpontaneous].join("\n");
|
|
render();
|
|
},
|
|
});
|
|
};
|
|
|
|
searchInput.addEventListener("input", render);
|
|
render();
|
|
|
|
loadingNode.classList.add("hidden");
|
|
contentNode.classList.remove("hidden");
|
|
} catch {
|
|
loadingNode.textContent = "Failed to load channels.";
|
|
loadingNode.classList.remove("text-slate-400");
|
|
loadingNode.classList.add("text-rose-300");
|
|
}
|
|
}
|
|
|
|
function renderRestrictedChannelButtons({ listNode, channels, selectedChannelId, onSelect }) {
|
|
listNode.replaceChildren();
|
|
|
|
const clearButton = document.createElement("button");
|
|
clearButton.type = "button";
|
|
clearButton.textContent = "Allow all channels";
|
|
clearButton.className =
|
|
selectedChannelId.length === 0
|
|
? "rounded-lg border border-indigo-500 bg-indigo-500/20 px-2.5 py-1.5 text-xs font-medium text-indigo-200"
|
|
: "rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800";
|
|
clearButton.addEventListener("click", () => {
|
|
onSelect({ id: "" });
|
|
});
|
|
listNode.appendChild(clearButton);
|
|
|
|
channels.forEach((channel) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = `#${channel.name}`;
|
|
|
|
const isSelected = selectedChannelId === channel.id;
|
|
const isDisabled = !channel.writable;
|
|
button.disabled = isDisabled;
|
|
button.className = isSelected
|
|
? "rounded-lg border border-indigo-500 bg-indigo-500/20 px-2.5 py-1.5 text-xs font-medium text-indigo-200"
|
|
: isDisabled
|
|
? "rounded-lg border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-xs font-medium text-slate-500"
|
|
: "rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800";
|
|
|
|
button.title = isDisabled
|
|
? "Bot cannot send messages in this channel"
|
|
: `${channel.name} (${channel.id})`;
|
|
button.addEventListener("click", () => onSelect(channel));
|
|
listNode.appendChild(button);
|
|
});
|
|
|
|
if (channels.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "w-full text-xs text-slate-500";
|
|
empty.textContent = "No channels match your search.";
|
|
listNode.appendChild(empty);
|
|
}
|
|
}
|
|
|
|
function renderSpontaneousChannelButtons({ listNode, channels, selectedIds, onToggle }) {
|
|
listNode.replaceChildren();
|
|
|
|
channels.forEach((channel) => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = `#${channel.name}`;
|
|
|
|
const isSelected = selectedIds.has(channel.id);
|
|
const isDisabled = !channel.writable;
|
|
button.disabled = isDisabled;
|
|
button.className = isSelected
|
|
? "rounded-lg border border-emerald-500 bg-emerald-500/20 px-2.5 py-1.5 text-xs font-medium text-emerald-200"
|
|
: isDisabled
|
|
? "rounded-lg border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-xs font-medium text-slate-500"
|
|
: "rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800";
|
|
button.title = isDisabled
|
|
? "Bot cannot send messages in this channel"
|
|
: `${channel.name} (${channel.id})`;
|
|
|
|
button.addEventListener("click", () => {
|
|
onToggle(channel);
|
|
});
|
|
|
|
listNode.appendChild(button);
|
|
});
|
|
|
|
if (channels.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "w-full text-xs text-slate-500";
|
|
empty.textContent = "No channels match your search.";
|
|
listNode.appendChild(empty);
|
|
}
|
|
}
|
|
|
|
function parseChannelIdsFromText(raw) {
|
|
const set = new Set();
|
|
if (!raw || typeof raw !== "string") {
|
|
return set;
|
|
}
|
|
|
|
raw
|
|
.split(/[\s,]+/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
.forEach((channelId) => {
|
|
set.add(channelId);
|
|
});
|
|
|
|
return set;
|
|
}
|
|
|
|
function updateDynamicState(form) {
|
|
const state = readFormState(form);
|
|
const score = computeBehaviorScore(state);
|
|
const scoreBar = form.parentElement?.querySelector("[data-options-score-bar]") || null;
|
|
const scoreLabel = form.parentElement?.querySelector("[data-options-score-label]") || null;
|
|
|
|
if (scoreBar) {
|
|
scoreBar.style.width = `${score}%`;
|
|
}
|
|
if (scoreLabel) {
|
|
scoreLabel.textContent = `${score}% · ${getBehaviorTier(score)}`;
|
|
}
|
|
|
|
const responseModeInputs = form.querySelectorAll("[data-options-response-mode]");
|
|
responseModeInputs.forEach((input) => {
|
|
const wrapper = input.closest("label");
|
|
if (!wrapper) {
|
|
return;
|
|
}
|
|
|
|
const isActive = input.checked;
|
|
wrapper.classList.toggle("border-indigo-500", isActive);
|
|
wrapper.classList.toggle("bg-indigo-500/10", isActive);
|
|
wrapper.classList.toggle("border-slate-700", !isActive);
|
|
wrapper.classList.toggle("bg-slate-900", !isActive);
|
|
});
|
|
|
|
const freeWillRange = form.querySelector('[data-options-range="free_will_chance"]');
|
|
const freeWillNumber = form.querySelector('[data-options-number="free_will_chance"]');
|
|
const freeWillDisabled = state.response_mode !== "free-will";
|
|
if (freeWillRange) {
|
|
freeWillRange.disabled = freeWillDisabled;
|
|
}
|
|
if (freeWillNumber) {
|
|
freeWillNumber.disabled = freeWillDisabled;
|
|
}
|
|
|
|
const nsfwToggle = form.querySelector("[data-options-nsfw-toggle]");
|
|
const nsfwRow = form.querySelector("[data-options-nsfw-row]");
|
|
if (nsfwToggle && nsfwRow) {
|
|
nsfwToggle.disabled = !state.image_gen_enabled;
|
|
nsfwRow.classList.toggle("opacity-50", !state.image_gen_enabled);
|
|
nsfwRow.classList.toggle("pointer-events-none", !state.image_gen_enabled);
|
|
if (!state.image_gen_enabled) {
|
|
nsfwToggle.checked = false;
|
|
}
|
|
}
|
|
|
|
const intervalInputs = form.querySelectorAll("[data-options-interval]");
|
|
intervalInputs.forEach((input) => {
|
|
input.disabled = !state.spontaneous_posts_enabled;
|
|
});
|
|
}
|
|
|
|
function readFormState(form) {
|
|
return {
|
|
response_mode: getCheckedValue(form, "response_mode", "free-will"),
|
|
free_will_chance: getPercentValue(form, "free_will_chance"),
|
|
memory_chance: getPercentValue(form, "memory_chance"),
|
|
mention_probability: getPercentValue(form, "mention_probability"),
|
|
gif_search_enabled: getCheckboxValue(form, "gif_search_enabled"),
|
|
image_gen_enabled: getCheckboxValue(form, "image_gen_enabled"),
|
|
nsfw_image_enabled: getCheckboxValue(form, "nsfw_image_enabled"),
|
|
spontaneous_posts_enabled: getCheckboxValue(form, "spontaneous_posts_enabled"),
|
|
};
|
|
}
|
|
|
|
function computeBehaviorScore(state) {
|
|
const modeScore = state.response_mode === "free-will" ? 18 : 6;
|
|
const autonomyScore = Math.round(state.free_will_chance * 0.28);
|
|
const memoryScore = Math.round(state.memory_chance * 0.2);
|
|
const mentionScore = Math.round(state.mention_probability * 0.14);
|
|
const mediaScore =
|
|
(state.gif_search_enabled ? 8 : 0) +
|
|
(state.image_gen_enabled ? 10 : 0) +
|
|
(state.nsfw_image_enabled ? 8 : 0);
|
|
const spontaneityScore = state.spontaneous_posts_enabled ? 12 : 0;
|
|
|
|
return Math.max(0, Math.min(100, modeScore + autonomyScore + memoryScore + mentionScore + mediaScore + spontaneityScore));
|
|
}
|
|
|
|
function getBehaviorTier(score) {
|
|
if (score < 30) {
|
|
return "Conservative";
|
|
}
|
|
if (score < 60) {
|
|
return "Balanced";
|
|
}
|
|
if (score < 80) {
|
|
return "Aggressive";
|
|
}
|
|
return "Maximum Chaos";
|
|
}
|
|
|
|
function getPercentValue(form, key) {
|
|
const range = form.querySelector(`[data-options-range="${key}"]`);
|
|
const parsed = Number.parseInt(range?.value || "0", 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
return 0;
|
|
}
|
|
return Math.max(0, Math.min(100, parsed));
|
|
}
|
|
|
|
function setPercentValue(form, key, value) {
|
|
const safe = Math.max(0, Math.min(100, Number.parseInt(String(value), 10) || 0));
|
|
const range = form.querySelector(`[data-options-range="${key}"]`);
|
|
const numberInput = form.querySelector(`[data-options-number="${key}"]`);
|
|
const label = form.querySelector(`[data-options-value="${key}"]`);
|
|
|
|
if (range) {
|
|
range.value = String(safe);
|
|
}
|
|
if (numberInput) {
|
|
numberInput.value = String(safe);
|
|
}
|
|
if (label) {
|
|
label.textContent = `${safe}%`;
|
|
}
|
|
}
|
|
|
|
function getCheckboxValue(form, key) {
|
|
const input = form.querySelector(`[name="${key}"][type="checkbox"]`);
|
|
return Boolean(input?.checked);
|
|
}
|
|
|
|
function setCheckboxValue(form, key, value) {
|
|
const input = form.querySelector(`[name="${key}"][type="checkbox"]`);
|
|
if (input) {
|
|
input.checked = Boolean(value);
|
|
}
|
|
}
|
|
|
|
function getCheckedValue(form, name, fallback) {
|
|
const checked = form.querySelector(`[name="${name}"]:checked`);
|
|
return checked?.value || fallback;
|
|
}
|
|
|
|
function setRadioValue(form, name, value) {
|
|
const radio = form.querySelector(`[name="${name}"][value="${value}"]`);
|
|
if (radio) {
|
|
radio.checked = true;
|
|
}
|
|
}
|
|
|
|
window.switchTab = switchTab;
|
|
window.showNotification = showNotification;
|