Files
Veskslop-t/poc/public/app.js
T

2298 lines
72 KiB
JavaScript

"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const SENDABLE_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
const TEXT_TYPES = new Set([0, 5]);
const VOICE_TYPES = new Set([2, 13]);
const state = {
user: null,
guilds: [],
guildId: null,
channelId: null,
channel: null,
messages: [],
dms: [],
voiceOverview: null,
voiceSelf: null,
health: null,
eventCount: 0,
lastEventSample: null,
eventRate: 0,
events: [],
visibleGuilds: null,
visibleGuildsSaved: null,
timeZoneOverride: localStorage.getItem("veskslop-timezone") ?? "",
speech: { provider: null, language: null, voice: null },
scope: "guild",
speaking: new Set(),
people: new Map(),
favourites: { users: [], channels: [] },
favouriteUsers: [],
speakingRenderAt: 0,
daemonDown: false,
composerChannelId: null,
audioRecording: false,
clientAudio: {
on: false,
ws: null,
ctx: null,
nextTime: 0,
sinkId: localStorage.getItem("veskslop-browser-output") ?? ""
},
takeover: localStorage.getItem("veskslop-takeover") === "1",
consoleOpen: false,
consoleTab: "events",
voiceReloadAt: 0
};
/* Drafts survive re-renders and reloads. Nothing else is persisted client-side. */
const draftKey = channelId => `veskslop-draft:${channelId}`;
const TTS_DRAFT_KEY = "veskslop-tts-draft";
function isGuildVisible(guildId) {
return !state.visibleGuilds || state.visibleGuilds.has(guildId);
}
function visibleGuildsList() {
return state.guilds.filter(guild => isGuildVisible(guild.id));
}
/** All guilds visible is stored as null. */
function normalizedVisibility(visibility) {
if (!visibility) return null;
if (state.guilds.length > 0 && visibility.size === state.guilds.length) return null;
return visibility;
}
function sameVisibility(a, b) {
if (!a || !b) return !a && !b;
if (a.size !== b.size) return false;
for (const id of a) if (!b.has(id)) return false;
return true;
}
function updateGuildsSaveButton() {
const button = $("#guilds-save");
if (button) button.disabled = sameVisibility(state.visibleGuilds, state.visibleGuildsSaved);
}
async function loadPreferences() {
try {
const prefs = await VK.api.get("/api/preferences");
const visibility = Array.isArray(prefs.visibleGuilds) ? new Set(prefs.visibleGuilds) : null;
state.visibleGuilds = visibility ? new Set(visibility) : null;
state.visibleGuildsSaved = visibility ? new Set(visibility) : null;
} catch {
state.visibleGuilds = null;
state.visibleGuildsSaved = null;
}
updateGuildsSaveButton();
}
/** Persist the current checkbox selection to the daemon state. */
async function saveGuildVisibility() {
const visibility = normalizedVisibility(state.visibleGuilds);
const visibleGuilds = visibility ? [...visibility] : null;
try {
const prefs = await VK.api.post("/api/preferences", { visibleGuilds });
state.visibleGuildsSaved = Array.isArray(prefs.visibleGuilds) ? new Set(prefs.visibleGuilds) : null;
state.visibleGuilds = state.visibleGuildsSaved ? new Set(state.visibleGuildsSaved) : null;
renderVisibleGuilds();
renderGuildPicker();
renderGuildSwitcher();
toast("Guild visibility saved", "ok");
} catch (error) {
toast(error.message, "error");
}
}
/* Helpers */
function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value === undefined || value === null) continue;
if (key === "class") node.className = value;
else if (key === "text") node.textContent = value;
else if (key === "html") node.innerHTML = value;
else if (key.startsWith("on")) node.addEventListener(key.slice(2).toLowerCase(), value);
else if (key === "disabled") node.disabled = !!value;
else node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child === undefined || child === null) continue;
node.append(child.nodeType ? child : document.createTextNode(String(child)));
}
return node;
}
function icon(name, size = 13) {
const span = el("span", { class: "vk-icon" });
span.innerHTML = VK.icon(name, size);
return span;
}
function hydrateIcons(root = document) {
for (const span of $$("[data-icon]", root)) span.innerHTML = VK.icon(span.dataset.icon);
}
function toast(message, kind = "") {
const node = el("div", { class: `vk-toast ${kind}`, text: message });
$("#toasts").append(node);
setTimeout(() => node.remove(), 5000);
}
function monogram(name) {
const clean = String(name ?? "?").replace(/[^a-zA-Z0-9]/g, "");
return (clean[0] ?? "?").toUpperCase();
}
const CDN_BASE = "https://cdn.discordapp.com";
function cdnHashUrl(route, id, hash, size = 64) {
if (!hash) return null;
const ext = String(hash).startsWith("a_") ? "gif" : "png";
return `${CDN_BASE}/${route}/${id}/${hash}.${ext}?size=${size}`;
}
function defaultAvatarUrl(id) {
try {
return `${CDN_BASE}/embed/avatars/${Number(BigInt(String(id)) >> 22n) % 6}.png`;
} catch {
return null;
}
}
function userAvatarUrl(user, size = 64) {
if (!user?.id) return null;
return cdnHashUrl("avatars", user.id, user.avatar, size) ?? defaultAvatarUrl(user.id);
}
function guildIconUrl(guild, size = 64) {
return cdnHashUrl("icons", guild.id, guild.icon, size);
}
function dmIconUrl(dm, size = 64) {
if (dm?.icon) return cdnHashUrl("channel-icons", dm.id, dm.icon, size);
const recipient = dm?.recipients?.[0];
return recipient ? userAvatarUrl(recipient, size) : null;
}
function avatar(entity, { agent = false, size = "", url } = {}) {
const name = typeof entity === "string" ? entity : entity?.username ?? entity?.name ?? "?";
const resolved = url ?? (typeof entity === "object" && entity ? userAvatarUrl(entity) : null);
const node = el("span", { class: `vk-avatar ${agent ? "agent" : ""} ${size}`.trim() });
if (resolved) {
const img = el("img", { src: resolved, alt: "", loading: "lazy" });
img.addEventListener("error", () => {
img.remove();
node.textContent = monogram(name);
});
node.append(img);
} else {
node.textContent = monogram(name);
}
return node;
}
function dmAvatar(dm, opts = {}) {
return avatar(dmLabel(dm), { ...opts, url: dmIconUrl(dm) });
}
function guildMarkContent(mark, guild) {
const url = guildIconUrl(guild);
if (!url) {
mark.textContent = monogram(guild.name);
return;
}
const img = el("img", { src: url, alt: "", loading: "lazy" });
img.addEventListener("error", () => {
img.remove();
mark.textContent = monogram(guild.name);
});
mark.append(img);
}
function browserTimeZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
}
function formatDate(timestamp, options) {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
...options,
timeZone: state.timeZoneOverride || undefined
}).format(date);
}
function formatTime(timestamp) {
return formatDate(timestamp, { hour: "2-digit", minute: "2-digit" });
}
function formatClock(timestamp) {
return formatDate(timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatDateTime(timestamp) {
return formatDate(timestamp, { dateStyle: "medium", timeStyle: "medium" });
}
function updateTimeZoneStatus() {
const status = $("#timezone-status");
if (!status) return;
status.textContent = state.timeZoneOverride
? `override · ${state.timeZoneOverride}`
: `auto · ${browserTimeZone()}`;
}
function renderTimeZones() {
renderMessages();
renderEvents();
if (state.consoleTab === "transcript") void refreshTranscript();
if (!$("#settings-overlay").hidden) void refreshLogs();
}
const CURATED_ZONES = [
{ name: "UTC", abbr: "UTC", zone: "UTC" },
{ name: "UK", abbr: "GMT/BST", zone: "Europe/London" },
{ name: "Central Europe", abbr: "CET/CEST", zone: "Europe/Berlin" },
{ name: "Eastern Europe", abbr: "EET/EEST", zone: "Europe/Bucharest" },
{ name: "US East Coast", abbr: "ET", zone: "America/New_York" },
{ name: "Ohio", abbr: "ET", zone: "America/New_York" },
{ name: "US Central", abbr: "CT", zone: "America/Chicago" },
{ name: "US Mountain", abbr: "MT", zone: "America/Denver" },
{ name: "US West Coast", abbr: "PT", zone: "America/Los_Angeles" },
{ name: "Japan", abbr: "JST", zone: "Asia/Tokyo" },
{ name: "India", abbr: "IST", zone: "Asia/Kolkata" },
{ name: "Australia", abbr: "AET", zone: "Australia/Sydney" }
];
function initTimeZone() {
const select = $("#timezone-select");
const zones = typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : [];
const curated = new Set(CURATED_ZONES.map(entry => entry.zone));
select.replaceChildren(el("option", { value: "", text: `Auto (browser: ${browserTimeZone()})` }));
select.append(
el(
"optgroup",
{ label: "Common" },
...CURATED_ZONES.map(entry =>
el("option", { value: entry.zone, text: `${entry.name} (${entry.abbr})` })
)
)
);
select.append(
el(
"optgroup",
{ label: "All timezones" },
...zones.filter(zone => !curated.has(zone)).map(zone => el("option", { value: zone, text: zone }))
)
);
if (state.timeZoneOverride && !zones.includes(state.timeZoneOverride) && !curated.has(state.timeZoneOverride)) {
select.append(el("option", { value: state.timeZoneOverride, text: state.timeZoneOverride }));
}
select.value = state.timeZoneOverride;
updateTimeZoneStatus();
select.addEventListener("change", () => {
state.timeZoneOverride = select.value;
if (state.timeZoneOverride) localStorage.setItem("veskslop-timezone", state.timeZoneOverride);
else localStorage.removeItem("veskslop-timezone");
updateTimeZoneStatus();
renderTimeZones();
});
}
function formatUptime(seconds) {
if (!Number.isFinite(seconds)) return "up —";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days) return `up ${days}d ${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
if (hours) return `up ${hours}h ${String(minutes).padStart(2, "0")}m`;
return `up ${minutes}m`;
}
function currentGuild() {
return state.guilds.find(guild => guild.id === state.guildId) ?? null;
}
function findGuildChannel(channelId) {
for (const guild of state.guilds) {
const channel = guild.channels.find(item => item.id === channelId);
if (channel) return channel;
}
return null;
}
function dmById(channelId) {
return state.dms.find(dm => dm.id === channelId) ?? null;
}
function dmLabel(dm) {
if (dm.name) return dm.name;
const names = (dm.recipients ?? []).map(recipient => recipient.username);
if (!names.length) return "DM";
if (names.length <= 2) return names.join(", ");
return `${names[0]} + ${names.length - 1}`;
}
function channelLabel() {
if (!state.channel) return "No channel selected";
return state.channel.name ?? channelLabelFromDm();
}
function channelLabelFromDm() {
const dm = state.channelId ? dmById(state.channelId) : null;
return dm ? dmLabel(dm) : "Direct message";
}
/* Data loading */
async function loadState() {
let data;
try {
data = await VK.api.get("/api/state");
} catch {
renderIdentity();
renderGuildPicker();
renderGuildSwitcher();
return false;
}
state.user = data.user ?? null;
state.guilds = data.guilds ?? [];
const visible = visibleGuildsList();
if (!state.guildId || !visible.some(guild => guild.id === state.guildId)) {
state.guildId = visible[0]?.id ?? null;
}
renderIdentity();
renderGuildPicker();
renderGuildSwitcher();
renderVisibleGuilds();
renderRail();
const guild = currentGuild();
if (!state.channelId && guild) {
const first = guild.channels.find(channel => SENDABLE_TYPES.has(channel.type));
if (first) await selectChannel(first.id, guild.id);
} else if (state.channelId) {
renderComposer();
}
return true;
}
async function loadDms() {
try {
const result = await VK.api.get("/api/dms");
state.dms = result.channels ?? [];
} catch {
state.dms = [];
}
renderDirect();
renderRightDms();
renderRail();
}
async function loadVoice() {
try {
state.voiceSelf = await VK.api.get("/api/voice");
} catch {
state.voiceSelf = null;
}
if (state.guildId) {
try {
const overview = await VK.api.get(`/api/voice/overview?guildId=${encodeURIComponent(state.guildId)}`);
state.voiceOverview = overview.channels ?? [];
} catch {
state.voiceOverview = [];
}
} else {
state.voiceOverview = [];
}
trackPeople((state.voiceOverview ?? []).flatMap(entry => entry.members ?? []));
renderVoiceRail();
renderPeople();
renderRightVoice();
renderCallStrip();
}
async function loadMessages({ refresh = false } = {}) {
if (!state.channelId) {
state.messages = [];
renderMessages();
return;
}
const channelId = state.channelId;
try {
// refresh fetches the latest 50 from Discord and fills the client cache.
// Attachments stay CDN links; nothing is downloaded or cached locally.
const result = await VK.api.get(
`/api/messages?channelId=${encodeURIComponent(channelId)}&limit=50${refresh ? "&refresh=1" : ""}`
);
if (channelId !== state.channelId) return;
state.messages = result.messages ?? [];
} catch (error) {
if (channelId !== state.channelId) return;
state.messages = [];
toast(error.message, "error");
}
renderMessages();
}
let refreshing = false;
/** Reload everything from the daemon without touching any draft input. */
async function refreshAll() {
if (refreshing) return;
refreshing = true;
try {
await loadPreferences();
// The bridge can connect before the renderer answers state.get.
let loaded = false;
for (let attempt = 0; attempt < 15 && !loaded; attempt++) {
loaded = await loadState();
if (!loaded) await new Promise(resolve => setTimeout(resolve, 2000));
}
if (!loaded) return;
await loadDms();
await refreshFavourites();
await loadVoice();
if (state.channelId) await loadMessages({ refresh: true });
} catch (error) {
toast(error.message, "error");
} finally {
refreshing = false;
}
}
async function selectGuild(guildId) {
state.guildId = guildId;
state.channelId = null;
state.channel = null;
state.scope = "guild";
$("#scope-guild").classList.add("active");
$("#scope-dm").classList.remove("active");
$("#guild-menu").hidden = true;
renderGuildPicker();
renderGuildSwitcher();
renderRail();
renderRightVoice();
const guild = currentGuild();
const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type));
if (first) await selectChannel(first.id, guildId);
await loadVoice();
}
async function selectChannel(channelId, guildId) {
if (guildId && guildId !== state.guildId) state.guildId = guildId;
state.channelId = channelId;
state.channel = findGuildChannel(channelId);
if (!state.channel) {
const dm = dmById(channelId);
if (dm) state.channel = { id: dm.id, name: dmLabel(dm), type: dm.type, dm };
}
renderGuildPicker();
renderRail();
renderCenterHeader();
renderRightDms();
renderComposer();
await loadMessages({ refresh: true });
await loadVoice();
}
async function sendMessage() {
if (!state.takeover) {
toast("Composer is read-only — turn Takeover on to send", "error");
return;
}
const input = $("#composer-input");
const content = input?.value.trim() ?? "";
if (!content || !state.channelId) return;
try {
await VK.api.post("/api/messages", { channelId: state.channelId, content });
localStorage.removeItem(draftKey(state.channelId));
input.value = "";
autoGrow(input);
toast("Message sent", "ok");
await loadMessages();
} catch (error) {
toast(error.message, "error");
}
}
function autoGrow(input) {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}
/* Header and rail rendering */
function renderIdentity() {
const footer = $("#agent-identity");
const name = state.user?.username ?? "not logged in";
footer.replaceChildren(
state.user ? avatar(state.user, { agent: true }) : avatar(name, { agent: true }),
el(
"span",
{ class: "vk-right-name" },
el("span", { class: "vk-agent-name", text: name }),
el("span", { class: "vk-agent-sub", text: "agent · headless" })
)
);
}
function renderGuildPicker() {
const guild = currentGuild();
$("#guild-picker-label").textContent =
state.scope === "dm" ? "Direct messages" : guild?.name ?? "Direct messages";
}
function renderGuildSwitcher() {
const root = $("#guild-switcher");
root.replaceChildren();
for (const guild of visibleGuildsList()) {
const button = el("button", {
class: `vk-guild-mark ${guild.id === state.guildId ? "active" : ""}`,
type: "button",
title: guild.name,
onclick: () => void selectGuild(guild.id)
});
guildMarkContent(button, guild);
root.append(button);
}
}
function setGuildVisible(guildId, visible) {
if (!state.visibleGuilds) state.visibleGuilds = new Set(state.guilds.map(guild => guild.id));
if (visible) state.visibleGuilds.add(guildId);
else state.visibleGuilds.delete(guildId);
if (state.guildId && !isGuildVisible(state.guildId)) {
const next = visibleGuildsList()[0];
if (next) void selectGuild(next.id);
}
renderGuildPicker();
renderGuildSwitcher();
renderVisibleGuilds();
}
function renderVisibleGuilds() {
const root = $("#guild-visibility");
if (!root) return;
root.replaceChildren();
if (!state.guilds.length) {
root.append(el("div", { class: "vk-empty", text: "No guilds cached." }));
return;
}
for (const guild of state.guilds) {
const checkbox = el("input", { type: "checkbox" });
checkbox.checked = isGuildVisible(guild.id);
checkbox.addEventListener("change", () => setGuildVisible(guild.id, checkbox.checked));
const mark = el("span", { class: "vk-guild-icon-sm" });
guildMarkContent(mark, guild);
root.append(el("label", { class: "vk-visibility-row" }, checkbox, mark, el("span", { text: guild.name })));
}
updateGuildsSaveButton();
}
function voiceMarks(member) {
const marks = el("span", { class: "vk-marks" });
const speaking = state.speaking.has(member.id);
// Fixed order: audio → video → share.
if (speaking) {
const levels = el("span", { class: "vk-levels", title: "speaking" });
for (let index = 0; index < 4; index++) levels.append(el("span"));
marks.append(levels);
}
if (member.suppress || member.mute || member.selfMute) {
const title = member.suppress ? "muted by server" : member.mute ? "server-muted" : "self-muted";
marks.append(el("span", { class: "neutral", title, html: VK.icon("micSlash") }));
}
if (member.deaf || member.selfDeaf) {
marks.append(el("span", { class: "neutral", title: "deafened", html: VK.icon("speakerSlash") }));
}
if (member.video) {
marks.append(el("span", { class: "accent", title: "camera on", html: VK.icon("camera") }));
}
if (member.stream) {
marks.append(el("span", { class: "accent", title: "screen sharing", html: VK.icon("monitor") }));
}
return marks;
}
function memberName(member) {
return member.nick ?? member.username ?? member.id;
}
function memberNameNode(member, className = "vk-name") {
return el("span", {
class: `${className} ${state.speaking.has(member.id) ? "speaking" : ""}`.trim(),
text: memberName(member),
style: member.id === state.user?.id ? "color: var(--color-accent-300)" : ""
});
}
function trackPeople(members) {
for (const member of members ?? []) {
if (!member?.id || member.id === state.user?.id) continue;
state.people.set(member.id, {
id: member.id,
username: member.username ?? member.id,
nick: member.nick ?? member.username ?? member.id,
avatar: member.avatar ?? null,
at: Date.now()
});
}
}
function renderRail() {
const guildScope = state.scope === "guild";
$("#rail-guild").hidden = !guildScope;
$("#rail-dm").hidden = guildScope;
if (guildScope) {
renderTextChannels();
renderVoiceRail();
renderPeople();
} else {
renderDmRail();
}
}
function renderTextChannels() {
const root = $("#text-channels");
root.replaceChildren();
const guild = currentGuild();
if (!guild) return;
const channels = guild.channels.filter(channel => TEXT_TYPES.has(channel.type));
if (!channels.length) {
root.append(el("div", { class: "vk-empty", text: "No text channels cached." }));
return;
}
for (const channel of channels) {
root.append(
el(
"button",
{
class: `vk-rail-row ${channel.id === state.channelId ? "active" : ""}`,
type: "button",
onclick: () => void selectChannel(channel.id)
},
el("span", { class: "vk-icon vk-dim", html: VK.icon("hash") }),
el("span", { class: "vk-rail-name", text: channel.name })
)
);
}
}
function renderVoiceRail() {
const root = $("#voice-channels");
if (!root) return;
root.replaceChildren();
const guild = currentGuild();
if (!guild) return;
const voiceChannels = guild.channels.filter(channel => VOICE_TYPES.has(channel.type));
if (!voiceChannels.length) {
root.append(el("div", { class: "vk-empty", text: "No voice channels cached." }));
return;
}
const occupied = new Map((state.voiceOverview ?? []).map(entry => [entry.channelId, entry]));
for (const channel of voiceChannels) {
const call = occupied.get(channel.id);
const active = state.voiceSelf?.channelId === channel.id;
if (call?.members?.length) {
trackPeople(call.members);
const block = el("div", { class: "vk-rail-voice" });
block.append(
el(
"button",
{
class: "vk-rail-voice-head",
type: "button",
title: active ? "Leave this call" : "Join this voice channel",
onclick: () => void joinVoice(channel.id)
},
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
el("span", { class: "vk-rail-name", style: "font-weight:500", text: channel.name }),
el(
"span",
{ class: "vk-rail-trail" },
el("span", {
style: `display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:5px;background:var(--color-${active ? "accent" : "neutral-500"})`
}),
`${call.members.length}`
)
)
);
const members = el("div", { class: "vk-rail-voice-members" });
for (const member of call.members) {
members.append(
el("span", { class: "vk-voice-member" }, memberNameNode(member, "vk-name"), voiceMarks(member))
);
}
block.append(members);
root.append(block);
} else {
root.append(
el(
"button",
{
class: `vk-rail-row ${active ? "active" : ""}`,
type: "button",
title: "Join this voice channel",
onclick: () => joinVoice(channel.id)
},
el("span", { class: "vk-icon vk-dim", html: VK.icon("volume") }),
el("span", { class: "vk-rail-name", text: channel.name }),
el("span", { class: "vk-rail-trail", text: "0" })
)
);
}
}
}
function renderPeople() {
const root = $("#people-list");
if (!root) return;
root.replaceChildren();
const people = [...state.people.values()].sort((a, b) => b.at - a.at).slice(0, 20);
if (!people.length) {
root.append(el("div", { class: "vk-empty", text: "No voice people seen yet." }));
return;
}
for (const person of people) {
root.append(
el(
"button",
{
class: "vk-rail-row",
type: "button",
title: person.username,
onclick: () => openPersonDm(person.id)
},
avatar(person, { size: "sm" }),
el("span", {
class: `vk-rail-name ${state.speaking.has(person.id) ? "speaking" : ""}`.trim(),
text: person.nick ?? person.username
})
)
);
}
}
function openPersonDm(userId) {
const dm = state.dms.find(
entry => entry.type === 1 && (entry.recipients ?? []).some(recipient => recipient.id === userId)
);
if (!dm) {
toast("No cached DM with that user", "error");
return;
}
void openDm(dm);
}
function dmStarred(dm) {
if (dm.type === 3) return state.favourites.channels.includes(dm.id);
const recipient = dm.recipients?.[0]?.id;
return !!recipient && state.favourites.users.includes(recipient);
}
function starButton(active, onToggle) {
const button = el("button", {
class: `vk-star ${active ? "on" : ""}`,
type: "button",
title: active ? "Remove from favourites" : "Add to favourites",
onclick: event => {
event.stopPropagation();
onToggle();
}
});
button.innerHTML = VK.icon("star", 12);
return button;
}
function unreadBadge(dm) {
const mentions = dm.mentions ?? 0;
const unread = dm.unread ?? 0;
if (!mentions && !unread) return null;
const kind = !state.takeover ? "muted" : mentions ? "mention" : "unread";
return el("span", {
class: `vk-dm-badge ${kind}`,
text: mentions ? String(mentions) : unread > 99 ? "99+" : String(unread)
});
}
function formatDmTime(iso) {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
if (date.toDateString() === now.toDateString()) return formatTime(iso);
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) return "yesterday";
return formatDate(iso, { month: "short", day: "numeric" });
}
function dmRow(dm) {
const active = dm.id === state.channelId;
const row = el("button", {
class: `vk-rail-row ${active ? "active" : ""}`,
type: "button",
onclick: () => void openDm(dm)
});
const badge = unreadBadge(dm);
if (badge) row.append(badge);
row.append(dmAvatar(dm, { size: "sm" }));
row.append(el("span", { class: "vk-rail-name", text: dmLabel(dm) }));
const time = formatDmTime(dm.lastMessageAt);
if (time) row.append(el("span", { class: "vk-dm-time", text: time }));
row.append(starButton(dmStarred(dm), () => void toggleFavouriteDm(dm)));
return row;
}
function renderDmRail() {
const root = $("#dm-rail-list");
if (!root) return;
root.replaceChildren();
if (!state.dms.length) {
root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
return;
}
for (const dm of state.dms) root.append(dmRow(dm));
}
async function openDm(dm) {
await applyScope("dm");
await selectChannel(dm.id);
}
async function applyScope(scope) {
state.scope = scope;
$("#scope-guild").classList.toggle("active", scope === "guild");
$("#scope-dm").classList.toggle("active", scope === "dm");
renderGuildPicker();
if (scope === "dm") {
if (!state.channel?.dm) {
const first = state.dms[0];
if (first) await selectChannel(first.id);
}
} else if (state.channel?.dm) {
const guild = currentGuild();
const first = guild?.channels.find(channel => SENDABLE_TYPES.has(channel.type));
if (first) await selectChannel(first.id);
}
renderRail();
}
async function saveFavourites(patch) {
try {
const prefs = await VK.api.post("/api/preferences", {
favourites: { ...state.favourites, ...patch }
});
state.favourites = prefs.favourites ?? { users: [], channels: [] };
await refreshFavourites();
renderDmRail();
renderRightDms();
} catch (error) {
toast(error.message, "error");
}
}
function toggleFavouriteDm(dm) {
if (dm.type === 3) {
const channels = state.favourites.channels.includes(dm.id)
? state.favourites.channels.filter(id => id !== dm.id)
: [...state.favourites.channels, dm.id];
void saveFavourites({ channels });
return;
}
const userId = dm.recipients?.[0]?.id;
if (!userId) {
toast("No cached recipient to favourite", "error");
return;
}
const users = state.favourites.users.includes(userId)
? state.favourites.users.filter(id => id !== userId)
: [...state.favourites.users, userId];
void saveFavourites({ users });
}
function removeFavouriteUser(userId) {
void saveFavourites({ users: state.favourites.users.filter(id => id !== userId) });
}
/* Center rendering */
function renderCenterHeader() {
const title = channelLabel();
const channel = state.channel;
const guild = currentGuild();
$("#channel-title").textContent = title;
$("#channel-icon").innerHTML = VK.icon(
channel && VOICE_TYPES.has(channel.type) ? "volume" : channel?.dm ? "speaker" : "hash"
);
let context = "";
if (channel?.dm) {
const count = channel.dm.recipients?.length ?? 0;
context = channel.dm.type === 3 ? `group · ${count} recipients` : "direct message";
} else if (guild) {
context = `${guild.name} · ${guild.channels.length} channels cached`;
}
$("#channel-context").textContent = context;
}
function renderMessages() {
const root = $("#message-list");
root.replaceChildren();
if (!state.channelId) {
root.append(el("div", { class: "vk-empty", text: "Pick a channel on the left." }));
return;
}
if (!state.messages.length) {
root.append(
el("div", {
class: "vk-empty",
text: "No cached messages. The client only holds channels it has loaded."
})
);
return;
}
for (const message of state.messages) {
const author = message.author?.username ?? "unknown";
const isAgent = message.author?.id === state.user?.id;
const body = el(
"div",
{ class: "vk-message-body-wrap" },
el(
"span",
{ class: "vk-message-head" },
el("span", { class: "vk-message-author", text: author }),
isAgent ? el("span", { class: "tag tag-outline vk-tag-agent", text: "agent" }) : null,
el("span", { class: "vk-mono vk-dim", text: formatTime(message.timestamp) })
),
el("span", { class: "vk-message-body", text: message.content })
);
for (const attachment of message.attachments ?? []) {
if (!attachment) continue;
const filename = attachment.filename ?? "attachment";
const isImage =
String(attachment.contentType ?? "").startsWith("image/") ||
/\.(png|jpe?g|gif|webp|avif)$/i.test(filename);
if (isImage && attachment.url) {
const image = el("img", {
class: "vk-message-image",
src: attachment.url,
alt: filename,
loading: "lazy"
});
image.addEventListener("error", () => image.remove());
body.append(image);
} else if (attachment.url) {
body.append(
el("a", {
class: "vk-attachment",
href: attachment.url,
target: "_blank",
rel: "noreferrer",
text: filename
})
);
} else if (filename) {
body.append(el("span", { class: "vk-attachment", text: filename }));
}
}
root.append(el("div", { class: "vk-message" }, avatar(message.author ?? author, { agent: isAgent, size: "lg" }), body));
}
const scroll = $("#message-scroll");
scroll.scrollTop = scroll.scrollHeight;
}
function ttsToggleButton() {
return el("button", {
id: "tts-toggle",
class: "btn btn-secondary vk-icon-btn vk-tts-toggle",
type: "button",
title: "Text to speech",
onclick: toggleTts,
html: VK.icon("wave")
});
}
function composerPlaceholder() {
const target = channelLabel() === "No channel selected" ? "" : channelLabel();
return `Message ${target} as ${state.user?.username ?? "the account"}`;
}
function renderComposer() {
const wrap = $("#composer");
const existing = $("#composer-input");
if (state.takeover) {
wrap.classList.add("unlocked");
// Reuse the live input so typing and caret survive re-renders.
if (existing && state.composerChannelId === state.channelId) {
existing.placeholder = composerPlaceholder();
return;
}
const draft = state.channelId ? localStorage.getItem(draftKey(state.channelId)) ?? "" : "";
const input = el("textarea", {
id: "composer-input",
class: "vk-composer-input",
rows: "1",
placeholder: composerPlaceholder()
});
input.value = draft;
input.addEventListener("input", () => {
autoGrow(input);
if (state.channelId) localStorage.setItem(draftKey(state.channelId), input.value);
});
input.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void sendMessage();
}
});
state.composerChannelId = state.channelId;
wrap.replaceChildren(
ttsToggleButton(),
input,
el("button", {
id: "send",
class: "btn btn-primary vk-send",
type: "button",
text: "Send",
onclick: () => void sendMessage()
})
);
requestAnimationFrame(() => autoGrow(input));
return;
}
state.composerChannelId = null;
wrap.classList.remove("unlocked");
wrap.replaceChildren(
el("span", { class: "tag tag-neutral vk-readonly-tag", text: "read-only" }),
icon("lock"),
el("span", {
class: "vk-composer-copy",
text: `Composer locked — takeover is off. Flip Takeover to send as ${state.user?.username ?? "the account"}.`
}),
ttsToggleButton(),
el("button", { class: "btn btn-secondary vk-send", type: "button", text: "Send", disabled: true })
);
}
function toggleTts() {
const panel = $("#tts-panel");
panel.hidden = !panel.hidden;
$("#tts-target").textContent = `as ${state.user?.username ?? "account"} → ${channelLabel()}`;
}
/* Right column */
function renderRightVoice() {
const root = $("#right-voice");
if (!root) return;
root.replaceChildren();
const inCall = state.favouriteUsers.filter(user => user.channelId);
$("#voice-count").textContent = inCall.length ? `${inCall.length} in call` : "";
if (!inCall.length) {
root.append(el("div", { class: "vk-empty", text: "No favourited users in a call." }));
return;
}
const groups = new Map();
for (const user of inCall) {
const list = groups.get(user.channelId) ?? [];
list.push(user);
groups.set(user.channelId, list);
}
for (const [channelId, users] of groups) {
const card = el(
"div",
{ class: "vk-right-group" },
el(
"div",
{ class: "vk-right-group-head" },
el("span", { class: "vk-icon", style: "color: var(--color-accent)", html: VK.icon("volume") }),
el("span", { class: "vk-name", text: users[0].channelName ?? channelId })
)
);
for (const user of users) {
card.append(
el(
"div",
{ class: "vk-right-row" },
avatar({ id: user.id, username: user.username, avatar: user.avatar }, { size: "sm" }),
el(
"span",
{ class: "vk-right-name" },
el("span", {
class: `vk-name ${state.speaking.has(user.id) ? "speaking" : ""}`.trim(),
text: user.username
}),
el("span", { class: "vk-sub", text: user.status })
),
el(
"span",
{ class: "vk-right-trail" },
el("button", {
class: "btn btn-primary vk-small-btn",
type: "button",
text: "Join",
onclick: () => joinVoice(channelId)
})
)
)
);
}
root.append(card);
}
}
function renderRightDms() {
const root = $("#right-dms");
if (!root) return;
root.replaceChildren();
$("#dm-count").textContent = state.dms.length ? `${state.dms.length} threads` : "";
if (!state.dms.length) {
root.append(el("div", { class: "vk-empty", text: "No cached DMs." }));
return;
}
for (const dm of state.dms.slice(0, 8)) root.append(dmRow(dm));
}
function renderCallStrip() {
const strip = $("#call-strip");
const call = state.voiceSelf;
if (!call?.channelId || call.channelId === state.channelId) {
strip.hidden = true;
strip.replaceChildren();
return;
}
strip.replaceChildren(
el(
"span",
{ class: "vk-call-title" },
el("span", { class: "vk-call-live" }),
el("span", { class: "vk-call-name", text: call.channelName ?? call.channelId }),
el("span", { class: "vk-mono vk-dim", text: `${call.members?.length ?? 0} in call` })
),
el(
"div",
{ class: "vk-call-pills" },
...(call.members ?? []).map(member =>
el(
"span",
{ class: "vk-strip-pill" },
avatar(member, { size: "sm" }),
memberNameNode(member, "vk-strip-name"),
voiceMarks(member)
)
)
),
el(
"div",
{ class: "vk-call-actions" },
el("button", {
class: "btn btn-secondary vk-small-btn",
type: "button",
text: "Return to call",
onclick: () => void selectChannel(call.channelId)
}),
el("button", {
class: "btn btn-secondary vk-small-btn",
type: "button",
text: "Leave",
onclick: () => voiceAction("/api/voice/leave")
})
)
);
strip.hidden = false;
}
async function joinVoice(channelId) {
if (state.voiceSelf?.channelId === channelId) {
await voiceAction("/api/voice/leave");
return;
}
await voiceAction("/api/voice/join", { channelId });
}
async function voiceAction(path, body) {
try {
await VK.api.post(path, body ?? {});
toast("Voice action accepted", "ok");
await loadVoice();
} catch (error) {
toast(error.message, "error");
}
}
/* Console */
function eventLine(entry) {
return el(
"div",
{ class: "vk-event-line" },
el("span", { class: "vk-event-time", text: formatClock(entry.at) }),
el("span", { class: "vk-event-type", text: entry.event.type ?? "event" }),
el("span", { text: JSON.stringify(entry.event) })
);
}
function renderEvents() {
const log = $("#event-log");
if (!log) return;
log.replaceChildren(...state.events.slice(0, 200).map(eventLine));
}
function addEvent(event) {
state.events.unshift({ at: Date.now(), event });
if (state.events.length > 300) state.events.pop();
state.eventCount++;
const log = $("#event-log");
if (log) {
log.prepend(eventLine(state.events[0]));
while (log.childElementCount > 200) log.lastElementChild.remove();
}
const ticker = $("#ticker-line");
if (ticker) ticker.textContent = `${event.type ?? "event"} ${JSON.stringify(event).slice(0, 180)}`;
syncConsoleCount();
}
function syncConsoleCount() {
const count = $("#console-count");
if (count) count.textContent = `${state.eventCount} events`;
}
function setConsoleOpen(open) {
state.consoleOpen = open;
$("#console-drawer").hidden = !open;
$("#console-caret").innerHTML = VK.icon(open ? "caretDown" : "caretUp");
}
function setConsoleTab(tab) {
state.consoleTab = tab;
for (const button of $$(".vk-console-tab")) button.classList.toggle("active", button.dataset.consoleTab === tab);
for (const panel of $$(".vk-console-panel")) panel.hidden = panel.id !== `console-panel-${tab}`;
if (!state.consoleOpen) setConsoleOpen(true);
if (tab === "transcript") void refreshTranscript();
}
/* Settings */
function openSettings() {
$("#settings-overlay").hidden = false;
void refreshCoreStatus();
void refreshAuth();
void refreshLogs();
void refreshAudio();
}
function closeSettings() {
$("#settings-overlay").hidden = true;
}
function statusRow(label, value) {
return el(
"div",
{ class: "vk-status-row" },
el("span", { class: "vk-status-label", text: label }),
el("span", { class: "vk-status-value" }, value)
);
}
function badge(text, kind = "") {
return el("span", { class: `tag ${kind === "ok" ? "tag-accent" : "tag-neutral"}`, text });
}
function renderCoreStatus(status) {
const card = $("#core-status");
const process = status.process ?? {};
const rows = [
statusRow("bridge", badge(status.bridgeConnected ? "connected" : "disconnected", status.bridgeConnected ? "ok" : "")),
statusRow("process", badge(process.running ? "running" : "stopped", process.running ? "ok" : "")),
statusRow("pid", el("code", { text: process.pid ?? "—" })),
statusRow("managed", el("span", { text: process.managed ? "yes" : process.running ? "external" : "no" })),
statusRow("started", el("span", { text: process.startedAt ? formatDateTime(process.startedAt) : "—" })),
statusRow("command", el("code", { text: process.command ?? "—" }))
];
if (process.lastExit) {
const exit = process.lastExit;
const text = exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? "?"}`;
rows.push(statusRow("last exit", badge(text, exit.code === 0 ? "ok" : "")));
}
card.replaceChildren(...rows);
if (process.output?.length) {
card.append(el("pre", { class: "vk-core-output", text: process.output.join("\n") }));
}
}
async function refreshCoreStatus() {
try {
renderCoreStatus(await VK.api.get("/api/core/status"));
} catch (error) {
toast(error.message, "error");
}
}
async function coreAction(path, label) {
try {
renderCoreStatus(await VK.api.post(path));
toast(`${label} requested`, "ok");
void refreshHealth();
} catch (error) {
toast(error.message, "error");
}
}
function renderAuth(status) {
const card = $("#auth-status");
const session = status.session;
const rows = [
statusRow("token file", el("code", { text: status.tokenFile })),
statusRow("token", badge(status.hasToken ? "present" : "missing", status.hasToken ? "ok" : "")),
statusRow("session", el("span", { text: session?.state ?? "none" }))
];
if (session?.user) rows.push(statusRow("user", el("span", { text: `${session.user.tag} — ${session.user.id}` })));
if (session?.captcha) rows.push(statusRow("captcha sitekey", el("code", { text: session.captcha.sitekey ?? "—" })));
if (session?.error) rows.push(statusRow("error", el("span", { text: session.error })));
card.replaceChildren(...rows);
const qr = $("#auth-qr");
if (status.qrDataUrl) {
qr.replaceChildren(el("img", { src: status.qrDataUrl, alt: "Discord login QR" }));
qr.hidden = false;
} else {
qr.replaceChildren();
qr.hidden = true;
}
$("#auth-captcha").hidden = session?.state !== "captcha";
}
async function refreshAuth() {
try {
renderAuth(await VK.api.get("/api/auth"));
} catch (error) {
toast(error.message, "error");
}
}
async function authAction(path, label) {
try {
renderAuth(await VK.api.post(path));
if (label) toast(label, "ok");
} catch (error) {
toast(error.message, "error");
}
}
async function refreshLogs() {
const root = $("#action-log");
try {
const entries = await VK.api.get("/api/logs?limit=100");
root.replaceChildren();
if (!entries.length) {
root.append(el("div", { class: "vk-empty", text: "Action log is empty." }));
return;
}
for (const entry of [...entries].reverse()) {
root.append(
el(
"div",
{ class: "vk-log-line" },
el("span", { class: "vk-log-time", text: entry.timestamp ? formatClock(entry.timestamp) : "" }),
el("span", { class: "vk-log-type", text: entry.type ?? "?" }),
el("span", { text: JSON.stringify(entry).slice(0, 220) })
)
);
}
} catch (error) {
root.replaceChildren(el("div", { class: "vk-empty", text: error.message }));
}
}
/* Audio */
function fillDeviceSelect(select, devices, defaultLabel) {
const current = select.value;
select.replaceChildren();
if (!devices.length) {
select.append(el("option", { value: "", text: "No devices reported", disabled: true }));
return;
}
select.append(el("option", { value: "", text: defaultLabel }));
for (const device of devices) {
select.append(el("option", { value: device.id, text: device.label }));
}
if ([...select.options].some(option => option.value === current)) select.value = current;
}
async function refreshAudio() {
try {
const status = await VK.api.get("/api/audio");
state.audioRecording = !!status.recording;
$("#audio-status").textContent = status.available
? `ok · ${status.backend ?? "pulse"} · duck ${status.duckPercent}%${status.recording ? " · recording" : ""}`
: (status.error ?? "unavailable");
const hintRow = $("#audio-hint-row");
hintRow.hidden = !status.hint;
$("#audio-hint").textContent = status.hint ?? "";
const tts = status.tts ?? {};
$("#audio-tts").textContent = `local ${tts.local ? "yes" : "no"} · api ${
tts.api ? "yes" : "no"
} · player ${status.player ? "yes" : "no"} · queue ${status.queue ?? 0}`;
$("#audio-capture").textContent = status.recording ? "Stop capture" : "Start capture";
} catch (error) {
$("#audio-status").textContent = error.message;
}
await refreshBrowserAudio();
await refreshSpeech();
}
/* Speech settings (server-side preferences). */
async function refreshSpeech() {
try {
const status = await VK.api.get("/api/audio");
const tts = status.tts ?? {};
const stt = status.stt ?? {};
$("#speech-status").textContent = `${tts.engine ?? "no local engine"}${tts.api ? " + api" : ""}`;
$("#speech-stt").textContent = stt.local
? `local ready${stt.engine ? ` (${stt.engine})` : ""}`
: stt.api
? "API configured"
: "not configured";
} catch {
// the daemon status is optional here
}
let groups = [];
try {
groups = await VK.api.get("/api/audio/tts/voices");
} catch {
// voices need a local engine
}
try {
const prefs = await VK.api.get("/api/preferences");
state.speech = prefs.tts ?? { provider: null, language: null, voice: null };
const languageSelect = $("#speech-language");
languageSelect.replaceChildren(el("option", { value: "", text: "Auto" }));
for (const group of groups) {
languageSelect.append(el("option", { value: group.id, text: `${group.name} (${group.id})` }));
}
languageSelect.value = state.speech.language ?? "";
fillVoiceOptions(groups, state.speech.language, state.speech.voice);
$("#speech-provider").value = state.speech.provider ?? "";
$("#tts-provider").value = state.speech.provider ?? "";
} catch {
// preferences are optional
}
}
function fillVoiceOptions(groups, language, selected) {
const select = $("#speech-voice");
const voices = groups.find(group => group.id === language)?.voices ?? [];
select.replaceChildren(el("option", { value: "", text: "Default" }));
for (const voice of voices) {
const label = `${voice.name}${voice.gender ? ` · ${voice.gender}` : ""}`;
select.append(el("option", { value: voice.id, text: label }));
}
select.value = selected && voices.some(voice => voice.id === selected) ? selected : "";
}
async function saveSpeech() {
const provider = $("#speech-provider").value || null;
const language = $("#speech-language").value || null;
const voice = $("#speech-voice").value || null;
try {
const prefs = await VK.api.post("/api/preferences", { tts: { provider, language, voice } });
state.speech = prefs.tts ?? { provider: null, language: null, voice: null };
$("#tts-provider").value = state.speech.provider ?? "";
await refreshSpeech();
toast("Speech settings saved", "ok");
} catch (error) {
toast(error.message, "error");
}
}
/* Browser audio (puppeting) — separate from the bot's own audio devices. */
async function refreshBrowserAudio() {
const hint = $("#browser-hint");
const output = $("#browser-output");
if (!navigator.mediaDevices?.enumerateDevices) {
hint.textContent = "Browser audio needs HTTPS or localhost.";
output.disabled = true;
return;
}
hint.textContent = "The microphone will be used for talk-through later.";
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const outputs = devices
.filter(device => device.kind === "audiooutput")
.map(device => ({ id: device.deviceId, label: device.label || "Output device" }));
fillDeviceSelect(output, outputs, "Browser default");
output.value = state.clientAudio.sinkId;
if (!outputs.length) {
hint.textContent = "No devices listed. Press Enable browser audio and allow the microphone prompt.";
}
} catch (error) {
hint.textContent = error.message;
}
}
async function grantBrowserAudio() {
if (!navigator.mediaDevices?.getUserMedia) {
toast("Browser audio needs HTTPS or localhost", "error");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
for (const track of stream.getTracks()) track.stop();
toast("Browser audio enabled", "ok");
await refreshBrowserAudio();
} catch (error) {
toast(`Could not enable browser audio: ${error.message}`, "error");
}
}
function applyBrowserSink() {
const context = state.clientAudio.ctx;
if (!context || typeof context.setSinkId !== "function") return;
context.setSinkId(state.clientAudio.sinkId || "").catch(error => {
console.warn("[client-audio] could not set the output device:", error);
});
}
function playPcm(arrayBuffer) {
if (!state.clientAudio.ctx) {
state.clientAudio.ctx = new AudioContext({ sampleRate: 48000 });
applyBrowserSink();
}
const context = state.clientAudio.ctx;
const samples = new Int16Array(arrayBuffer);
const buffer = context.createBuffer(1, samples.length, 48000);
const data = buffer.getChannelData(0);
for (let index = 0; index < samples.length; index++) data[index] = samples[index] / 32768;
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(context.destination);
const now = context.currentTime;
if (state.clientAudio.nextTime < now + 0.05) state.clientAudio.nextTime = now + 0.05;
source.start(state.clientAudio.nextTime);
state.clientAudio.nextTime += buffer.duration;
}
function updateClientAudioButton() {
const button = $("#client-audio");
if (!button) return;
button.classList.toggle("active", state.clientAudio.on);
button.textContent = state.clientAudio.on ? "Client audio on" : "Client audio";
const status = $("#browser-audio-status");
if (status) status.textContent = state.clientAudio.on ? "on" : "off";
}
function startClientAudio() {
const socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/audio`);
socket.binaryType = "arraybuffer";
state.clientAudio.ws = socket;
socket.onopen = () => socket.send(JSON.stringify({ type: "listen", on: true }));
socket.onmessage = event => {
if (typeof event.data === "string") {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "error") {
toast(message.error, "error");
stopClientAudio();
} else if (message.type === "status" && message.listening) {
state.clientAudio.on = true;
updateClientAudioButton();
} else if (message.type === "status" && !message.listening) {
stopClientAudio();
}
return;
}
if (state.clientAudio.on) playPcm(event.data);
};
socket.onclose = () => {
state.clientAudio.ws = null;
state.clientAudio.on = false;
updateClientAudioButton();
};
}
function stopClientAudio() {
const socket = state.clientAudio.ws;
state.clientAudio.ws = null;
state.clientAudio.on = false;
if (socket) {
try {
socket.send(JSON.stringify({ type: "listen", on: false }));
} catch {
// already closing
}
try {
socket.close();
} catch {
// already closed
}
}
updateClientAudioButton();
}
function toggleClientAudio() {
if (state.clientAudio.on || state.clientAudio.ws) stopClientAudio();
else startClientAudio();
}
function syncTtsControls() {
const speak = $("#tts-speak");
if (speak) speak.disabled = !state.takeover;
}
async function speakText() {
if (!state.takeover) {
toast("Composer is read-only — turn Takeover on to speak", "error");
return;
}
const text = $("#tts-input").value.trim();
if (!text) return;
try {
const provider = $("#tts-provider").value;
await VK.api.post("/api/audio/speak", { text, provider });
$("#tts-status").textContent = "queued";
toast("Queued for speech", "ok");
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
}
/* Transcript */
function transcriptLine(segment) {
const time = formatClock(segment.startedAt);
const speaker = segment.speakerName ?? segment.speakerId ?? "call";
const text = segment.text ?? `(audio ${(segment.durationMs / 1000).toFixed(1)}s, no STT)`;
return el(
"div",
{ class: "vk-transcript-line", "data-segment": segment.id },
el("span", { class: "vk-transcript-time", text: time }),
el("span", { class: "vk-transcript-speaker", text: speaker }),
el("span", { class: "vk-transcript-text", text }),
segment.wav ? el("span", { class: "vk-transcript-wav", text: segment.wav.split("/").pop() }) : null
);
}
async function refreshTranscript() {
try {
const result = await VK.api.get("/api/transcript?limit=200");
const list = $("#transcript-list");
list.replaceChildren();
const segments = result.segments ?? [];
if (!segments.length) {
list.append(
el("div", {
class: "vk-empty",
text: "No captured segments yet. Start capture in Settings → Client → Bot audio."
})
);
} else {
for (const segment of segments) list.append(transcriptLine(segment));
list.scrollTop = list.scrollHeight;
}
const active = [...(result.sessions ?? [])].reverse().find(session => !session.endedAt);
$("#transcript-status").textContent = active
? `session ${active.id.slice(-8)} · ${segments.length} segments`
: `${segments.length} segments`;
} catch (error) {
toast(error.message, "error");
}
}
/* Health */
async function refreshHealth() {
try {
const health = await VK.api.get("/api/health");
state.health = health;
const now = Date.now();
if (state.lastEventSample) {
const seconds = (now - state.lastEventSample.at) / 1000;
const delta = (health.events ?? 0) - state.lastEventSample.events;
if (seconds > 0) state.eventRate = delta / seconds;
}
state.lastEventSample = { at: now, events: health.events ?? 0 };
const dot = $("#health-dot");
dot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
$("#health-bridge").textContent = health.coreConnected ? "bridge ok" : "bridge down";
$("#health-uptime").textContent = formatUptime(health.uptimeSeconds);
$("#health-events").textContent = `${state.eventRate.toFixed(1)} ev/s`;
const mcpDot = $("#mcp-dot");
mcpDot.className = `vk-dot ${health.coreConnected ? "online" : "offline"}`;
$("#mcp-state").textContent = health.coreConnected ? "mcp connected" : "mcp offline";
const process = health.process ?? {};
const startButton = $("#core-start-banner");
state.daemonDown = false;
document.body.classList.toggle("vk-offline", !health.coreConnected);
if (health.coreConnected) {
startButton.hidden = true;
} else if (process.running) {
startButton.hidden = false;
startButton.disabled = true;
startButton.textContent = "Starting…";
} else {
startButton.hidden = false;
startButton.disabled = false;
startButton.textContent = "Start server";
}
// A missed CORE_STATUS, or a renderer that was not ready yet: keep trying.
if (health.coreConnected && !state.user) void refreshAll();
} catch {
state.daemonDown = true;
document.body.classList.add("vk-offline");
$("#health-dot").className = "vk-dot offline";
$("#health-bridge").textContent = "daemon down";
const startButton = $("#core-start-banner");
if (startButton) {
startButton.hidden = false;
startButton.disabled = false;
startButton.textContent = "Refresh";
}
}
}
/* Live events */
function applyHello(event) {
if (typeof event.coreConnected !== "boolean") return;
$("#health-dot").className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
$("#health-bridge").textContent = event.coreConnected ? "bridge ok" : "bridge down";
const mcpDot = $("#mcp-dot");
if (mcpDot) mcpDot.className = `vk-dot ${event.coreConnected ? "online" : "offline"}`;
// The page may have loaded before the bridge was up.
if (event.coreConnected && !state.user) void refreshAll();
}
function renderRightFavourites() {
const root = $("#right-favourites");
if (!root) return;
root.replaceChildren();
const users = state.favouriteUsers;
const groups = state.dms.filter(dm => dm.type === 3 && state.favourites.channels.includes(dm.id));
if (!users.length && !groups.length) {
root.append(el("div", { class: "vk-empty", text: "No favourites yet. Star a DM." }));
return;
}
for (const user of users) {
const status = user.channelId ? `in ${user.channelName ?? "a call"}` : user.status;
root.append(
el(
"div",
{ class: "vk-right-row" },
avatar({ id: user.id, username: user.username, avatar: user.avatar }, { size: "sm" }),
el(
"span",
{ class: "vk-right-name" },
el("span", {
class: `vk-name ${state.speaking.has(user.id) ? "speaking" : ""}`.trim(),
text: user.username
}),
el("span", { class: "vk-sub", text: status })
),
el("span", { class: "vk-right-trail" }, starButton(true, () => removeFavouriteUser(user.id)))
)
);
}
for (const dm of groups) root.append(dmRow(dm));
}
async function refreshFavourites() {
try {
const result = await VK.api.get("/api/favourites");
state.favourites = {
users: (result.users ?? []).map(user => user.id),
channels: result.channels ?? []
};
state.favouriteUsers = result.users ?? [];
} catch {
// the core may be disconnected
}
renderRightFavourites();
renderRightVoice();
}
function handleLiveEvent(event) {
if (event.type === "MESSAGE_CREATE" && event.channelId === state.channelId) {
void loadMessages();
}
if (event.type === "TRANSCRIPT_SEGMENT") {
const list = $("#transcript-list");
if (list) {
list.append(transcriptLine(event.segment));
list.scrollTop = list.scrollHeight;
$("#transcript-status").textContent = `live · ${list.childElementCount} segments`;
}
return;
}
if (event.type === "TRANSCRIPT_TEXT") {
const line = document.querySelector(`[data-segment="${event.id}"] .vk-transcript-text`);
if (line) line.textContent = event.text;
return;
}
if (event.type === "TRANSCRIPT_ERROR") {
toast(event.error, "error");
return;
}
if (event.type === "VOICE_SPEAKING") {
if (event.speaking) state.speaking.add(event.userId);
else state.speaking.delete(event.userId);
const now = Date.now();
if (now - state.speakingRenderAt > 500) {
state.speakingRenderAt = now;
renderVoiceRail();
renderPeople();
renderRightVoice();
renderRightFavourites();
renderCallStrip();
}
return;
}
if (event.type === "VOICE_STATE_UPDATE") {
const now = Date.now();
if (now - state.voiceReloadAt > 1000) {
state.voiceReloadAt = now;
void loadVoice();
}
}
if (event.type === "CORE_STATUS") {
void refreshHealth();
if (event.coreConnected) void refreshAll();
if (!$("#settings-overlay").hidden) void refreshCoreStatus();
}
if (event.type === "AUTH_QR" && !$("#settings-overlay").hidden) {
void refreshAuth();
}
}
/* Wiring */
function init() {
hydrateIcons();
initTimeZone();
const takeover = $("#takeover");
takeover.checked = state.takeover;
$("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
takeover.addEventListener("change", () => {
state.takeover = takeover.checked;
localStorage.setItem("veskslop-takeover", state.takeover ? "1" : "0");
$("#takeover-label").textContent = state.takeover ? "Takeover on" : "Takeover";
renderComposer();
syncTtsControls();
});
$("#guild-picker").addEventListener("click", () => {
const menu = $("#guild-menu");
menu.hidden = !menu.hidden;
if (menu.hidden) return;
menu.replaceChildren(
...visibleGuildsList().map(guild =>
el("button", {
class: `vk-menu-item ${guild.id === state.guildId ? "active" : ""}`,
type: "button",
text: guild.name,
onclick: () => void selectGuild(guild.id)
})
)
);
});
$("#settings-open").addEventListener("click", openSettings);
$("#settings-close").addEventListener("click", closeSettings);
$("#settings-overlay").addEventListener("click", event => {
if (event.target === $("#settings-overlay")) closeSettings();
});
$("#core-start").addEventListener("click", () => void coreAction("/api/core/start", "Start"));
$("#core-stop").addEventListener("click", () => void coreAction("/api/core/stop", "Stop"));
$("#core-restart").addEventListener("click", () => void coreAction("/api/core/restart", "Restart"));
$("#core-start-banner").addEventListener("click", async event => {
if (state.daemonDown) {
location.reload();
return;
}
const button = event.currentTarget;
button.disabled = true;
button.textContent = "Starting…";
try {
await VK.api.post("/api/core/start");
toast("Starting the client…", "ok");
} catch (error) {
toast(error.message, "error");
}
setTimeout(() => void refreshHealth(), 1000);
setTimeout(() => void refreshHealth(), 5000);
});
$("#auth-start").addEventListener("click", () => void authAction("/api/auth/qr", "QR login started"));
$("#auth-cancel").addEventListener("click", () => void authAction("/api/auth/qr/cancel", "Login cancelled"));
$("#auth-restart").addEventListener("click", async () => {
try {
renderCoreStatus(await VK.api.post("/api/core/restart", { forceToken: true }));
toast("Restarting with the token file", "ok");
} catch (error) {
toast(error.message, "error");
}
});
$("#auth-captcha").addEventListener("submit", async event => {
event.preventDefault();
const key = $("#auth-captcha-key").value.trim();
if (!key) return;
try {
await VK.api.post("/api/auth/qr/captcha", { key });
$("#auth-captcha-key").value = "";
toast("Captcha submitted", "ok");
void refreshAuth();
} catch (error) {
toast(error.message, "error");
}
});
$("#logs-refresh").addEventListener("click", () => void refreshLogs());
$("#console-toggle").addEventListener("click", () => setConsoleOpen(!state.consoleOpen));
for (const tab of $$(".vk-console-tab")) {
tab.addEventListener("click", () => setConsoleTab(tab.dataset.consoleTab));
}
$("#raw-events").addEventListener("click", () => setConsoleTab("events"));
$("#transcript-refresh").addEventListener("click", () => void refreshTranscript());
$("#channel-refresh").addEventListener("click", () => void loadMessages({ refresh: true }));
$("#guilds-show-all").addEventListener("click", () => {
state.visibleGuilds = null;
renderVisibleGuilds();
renderGuildSwitcher();
renderGuildPicker();
});
$("#guilds-hide-all").addEventListener("click", () => {
state.visibleGuilds = new Set();
renderVisibleGuilds();
renderGuildSwitcher();
renderGuildPicker();
});
$("#guilds-save").addEventListener("click", () => void saveGuildVisibility());
const ttsInput = $("#tts-input");
ttsInput.value = localStorage.getItem(TTS_DRAFT_KEY) ?? "";
ttsInput.addEventListener("input", () => localStorage.setItem(TTS_DRAFT_KEY, ttsInput.value));
const ttsProvider = $("#tts-provider");
ttsProvider.addEventListener("change", () => {
$("#speech-provider").value = ttsProvider.value;
void saveSpeech();
});
$("#speech-provider").addEventListener("change", () => void saveSpeech());
$("#speech-language").addEventListener("change", () => {
$("#speech-voice").value = "";
void saveSpeech();
});
$("#speech-voice").addEventListener("change", () => void saveSpeech());
$("#scope-guild").addEventListener("click", () => void applyScope("guild"));
$("#scope-dm").addEventListener("click", () => void applyScope("dm"));
$("#tts-close").addEventListener("click", () => {
$("#tts-panel").hidden = true;
});
$("#tts-speak").addEventListener("click", () => void speakText());
$("#tts-stop").addEventListener("click", async () => {
try {
await VK.api.post("/api/audio/stop");
$("#tts-status").textContent = "stopped";
} catch (error) {
toast(error.message, "error");
}
});
$("#audio-refresh").addEventListener("click", () => void refreshAudio());
$("#audio-connect").addEventListener("click", async () => {
try {
const result = await VK.api.post("/api/audio/connect");
toast(result.message ?? (result.connected ? "Devices connected" : "Could not connect"), result.connected ? "ok" : "error");
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
});
$("#client-audio").addEventListener("click", toggleClientAudio);
$("#browser-grant").addEventListener("click", () => void grantBrowserAudio());
$("#browser-output").addEventListener("change", event => {
state.clientAudio.sinkId = event.currentTarget.value;
localStorage.setItem("veskslop-browser-output", state.clientAudio.sinkId);
applyBrowserSink();
});
$("#audio-capture").addEventListener("click", async () => {
try {
const start = !state.audioRecording;
await VK.api.post("/api/audio/capture", { start });
toast(start ? "Capture started" : "Capture stopped", "ok");
void refreshAudio();
} catch (error) {
toast(error.message, "error");
}
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
if (!$("#settings-overlay").hidden) closeSettings();
else if (state.consoleOpen) setConsoleOpen(false);
});
VK.bus.on("event", event => {
if (event.type === "hello") {
applyHello(event);
return;
}
addEvent(event);
handleLiveEvent(event);
});
VK.connectEvents();
setConsoleTab("events");
setConsoleOpen(false);
syncConsoleCount();
renderRightFavourites();
void refreshAll();
void refreshHealth();
syncTtsControls();
setInterval(refreshHealth, 5000);
setInterval(() => {
if (!$("#tts-panel").hidden) void refreshAudio();
}, 3000);
setInterval(() => {
if (!$("#settings-overlay").hidden) {
if (!$("#auth-qr").hidden || $("#auth-captcha").hidden === false) void refreshAuth();
}
}, 3000);
}
init();