/* * Veskslop daemon — puppet state: trust list, trigger log and Discord-facing * command handling. All of this used to live inside the Vesktop renderer; it * now lives here so Vesktop stays stock. */ import { readFileSync, writeFileSync } from "node:fs"; import { isTrusted, MessageLog, parseCommand, randomActionDelay, stripMentions, type ParsedCommand, type PuppetMessage } from "./communication.js"; import type { Discord } from "./discord.js"; const DM_CHANNEL = 1; const GROUP_DM_CHANNEL = 3; const DEFAULT_OWNER_ID = "177117296140091393"; const STATE_FILE = process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname; interface PersistedState { ownerId?: string; whitelist?: string[]; } export class Puppet { readonly log = new MessageLog(); private ownerId = DEFAULT_OWNER_ID; private whitelistIds: string[] = []; private selfId?: string; constructor(private readonly discord: Discord) { try { const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState; if (state.ownerId) this.ownerId = state.ownerId; if (Array.isArray(state.whitelist)) this.whitelistIds = state.whitelist; } catch { // first run: defaults apply } } get whitelist(): string[] { return [...this.whitelistIds]; } get owner() { return this.ownerId; } getLog() { return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist }; } isTrusted(userId: string): boolean { return isTrusted(userId, this.ownerId, this.whitelistIds); } addToWhitelist(userIds: string[]): string[] { const next = new Set(this.whitelistIds); for (const id of userIds) if (id && id !== this.ownerId) next.add(id); this.whitelistIds = [...next]; this.save(); return this.whitelist; } removeFromWhitelist(userIds: string[]): string[] { const remove = new Set(userIds); this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id)); this.save(); return this.whitelist; } private save() { try { writeFileSync(STATE_FILE, JSON.stringify({ ownerId: this.ownerId, whitelist: this.whitelistIds }, null, 4)); } catch (error) { console.error("[puppet] failed to save state:", error); } } async handleEvent(event: any) { if (event?.type !== "MESSAGE_CREATE") return; const { author, channelId, guildId, channelType, content, mentions } = event; if (!author || !channelId) return; if (author.bot) return; this.selfId ??= await this.discord.getSelfId(); if (!this.selfId || author.id === this.selfId) return; const isGroupDm = channelType === GROUP_DM_CHANNEL; const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null)); const mentionIds = new Set(mentions ?? []); if (!isDirectDm && !mentionIds.has(this.selfId)) return; const trusted = this.isTrusted(author.id); const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId])); const command = parseCommand(text); const entry: PuppetMessage = { id: event.id, channelId, guildId: guildId ?? undefined, authorId: author.id, authorName: author.username, content: content ?? "", timestamp: event.timestamp ?? new Date().toISOString(), trusted, command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined }; this.log.add(entry); if (command?.name === "whitelist" && trusted) { await this.handleWhitelistCommand(command, author.id, channelId); } } private extractUserIds(text: string): string[] { const ids = new Set(); for (const match of text.matchAll(/<@!?(\d+)>/g)) ids.add(match[1]); for (const part of text.split(/\s+/)) if (/^\d{17,20}$/.test(part)) ids.add(part); return [...ids]; } private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) { if (authorId !== this.ownerId) return; const [sub, ...rest] = command.args; switch (sub) { case "list": await this.reply( channelId, `Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}` ); return; case "add": { const ids = this.extractUserIds(rest.join(" ")); if (!ids.length) return this.reply(channelId, "Usage: whitelist add @user"); this.addToWhitelist(ids); return this.reply(channelId, `Added to whitelist:\n${ids.join("\n")}`); } case "remove": { const ids = this.extractUserIds(rest.join(" ")); if (!ids.length) return this.reply(channelId, "Usage: whitelist remove @user"); const removed = ids.filter(id => id !== this.ownerId); this.removeFromWhitelist(removed); return this.reply( channelId, [ removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.", ids.length !== removed.length ? "The owner cannot be removed." : "" ] .filter(Boolean) .join("\n") ); } default: return this.reply(channelId, "Usage: whitelist add|remove|list"); } } private async reply(channelId: string, content: string) { // Autonomous replies use the same 500-2800 ms pacing as everything else. await new Promise(resolve => setTimeout(resolve, randomActionDelay())); try { await this.discord.sendMessage(channelId, content); } catch (error) { console.error("[puppet] reply failed:", error); } } }