Rework core integration: validated capability layer, docs, and session state
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user