186 lines
6.2 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|