Files
Veskslop/poc/src/puppet.ts
T

186 lines
6.2 KiB
TypeScript

/*
* 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<string>(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<string>();
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);
}
}
}