Files
Veskslop-t/poc/src/puppet.ts
T

1158 lines
41 KiB
TypeScript

/*
* Veskslop daemon — puppet state: trust tiers, trigger log, response policy,
* conversation context, continuation windows, and Discord-facing commands.
*
* Trigger scope: direct messages and direct mentions from trusted users.
* Trust tiers: user, trusted, admin. The owner is an admin. Whitelist
* management requires the admin tier.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { ActionLog } from "./action-log.js";
import {
assembleContext,
DEFAULT_WAKEWORD,
hasTier,
MessageLog,
normalizeWakeword,
parseCommand,
parsePoll,
parseTier,
POLL_EMOJIS,
stripMentions,
toCachedMessage,
type AssembledContext,
type CachedMessage,
type ParsedCommand,
type PuppetMessage,
type TrustTier
} from "./communication.js";
import { MessageCache } from "./message-cache.js";
import { paced, pacedSend } from "./pacing.js";
const DM_CHANNEL = 1;
const GROUP_DM_CHANNEL = 3;
const POLL_ENABLED = false;
const DEFAULT_OWNER_ID = "177117296140091393";
const STATE_FILE =
process.env.VESKSLOP_STATE_FILE ?? new URL("../.veskslop-state.json", import.meta.url).pathname;
export interface ResponsePolicy {
defaultMode: "channel" | "dm";
sensitiveCommands: string[];
overrides: Record<string, "channel" | "dm">;
}
export interface PuppetConfig {
contextMessages: number;
contextChars: number;
fetchCooldownSeconds: number;
continuationSeconds: number;
wakeword: string;
pollSeconds: number;
voiceLeaveDelaySeconds: number;
responsePolicy: ResponsePolicy;
}
export interface PuppetConfigPatch {
contextMessages?: number;
contextChars?: number;
fetchCooldownSeconds?: number;
continuationSeconds?: number;
/** Command prefix: a message must start with it plus a space. */
wakeword?: string;
pollSeconds?: number;
voiceLeaveDelaySeconds?: number;
responsePolicy?: {
defaultMode?: "channel" | "dm";
sensitiveCommands?: string[];
overrides?: Record<string, "channel" | "dm">;
};
}
const DEFAULT_CONFIG: PuppetConfig = {
contextMessages: 15,
contextChars: 4000,
fetchCooldownSeconds: 5,
continuationSeconds: 10,
wakeword: DEFAULT_WAKEWORD,
pollSeconds: 120,
voiceLeaveDelaySeconds: clamp(Number(process.env.VESKSLOP_VOICE_LEAVE_DELAY ?? 30), 0, 600),
responsePolicy: {
defaultMode: "channel",
sensitiveCommands: ["whitelist", "config"],
overrides: {}
}
};
interface PersistedState {
ownerId?: string;
whitelist?: string[] | Record<string, TrustTier>;
config?: Partial<PuppetConfig>;
}
/** The transport methods that the puppet logic needs. */
export interface PuppetTransport {
getSelfId(): Promise<string | undefined>;
sendMessage(channelId: string, content: string): Promise<{ messageId?: string } | void>;
sendDm(userId: string, content: string): Promise<unknown>;
fetchMessages(
channelId: string,
limit: number,
options?: { before?: string; after?: string }
): Promise<{ messages?: unknown[] }>;
reactionAdd(channelId: string, messageId: string, emoji: string): Promise<unknown>;
voiceGet(userId?: string, guildId?: string): Promise<{ channelId?: string | null } | void>;
voiceLeave(): Promise<unknown>;
}
/** A trusted free-form message, for the reply-only agent. */
export interface PuppetAgentRequest {
id: string;
authorId: string;
authorName: string;
channelId: string;
guildId?: string;
content: string;
context: Array<{ author: string; content: string; timestamp: string }>;
}
/** The reply-only LLM harness. It never receives commands and cannot act. */
export interface PuppetAgent {
respond(request: PuppetAgentRequest): Promise<string | undefined>;
}
/** The audio actions a chat command can trigger. */
export interface PuppetAudio {
speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise<unknown>;
stop(): void;
}
/** The scrape state a chat command reads. */
export interface PuppetScrape {
id: string;
channelId: string;
state: string;
}
/** The media actions a chat command can trigger. */
export interface PuppetMedia {
startScrape(input: {
channelId: string;
guildId: string | null;
mode: "links" | "download";
confirm?: boolean;
}): Promise<{ id: string }>;
stopScrape(id: string): void;
listScrapes(): PuppetScrape[];
}
interface PollState {
channelId: string;
authorId: string;
question: string;
options: string[];
/** One set of option indexes per user. Multiple reactions are allowed. */
votes: Map<string, Set<number>>;
endsAt: number;
timer: ReturnType<typeof setTimeout>;
}
function whitelistHelp(wakeword: string): string {
return [
`${wakeword} whitelist — manage who can use the puppet.`,
"Usage:",
` ${wakeword} whitelist Show this help`,
` ${wakeword} whitelist list Show the owner and the trusted users`,
` ${wakeword} whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)`,
` ${wakeword} whitelist remove @user Remove a user`
].join("\n");
}
function scrapeHelp(wakeword: string): string {
return [
`${wakeword} scrape media — archive this channel's media.`,
"Usage:",
` ${wakeword} scrape media Index links (default)`,
` ${wakeword} scrape media download Also download attachment bytes (admin)`,
` ${wakeword} scrape media status Show this channel's latest scrape`,
` ${wakeword} scrape media stop Stop it (admin)`
].join("\n");
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export class Puppet {
readonly log = new MessageLog();
private readonly actionLog = new ActionLog(
process.env.VESKSLOP_ACTION_LOG ?? join(dirname(STATE_FILE), "actions.jsonl")
);
private readonly cache = new MessageCache();
private readonly tiers = new Map<string, TrustTier>();
private readonly seen = new Set<string>();
private readonly seenOrder: string[] = [];
private readonly lastFetchAt = new Map<string, number>();
private readonly pending = new Map<string, { entryId: string; expiresAt: number }>();
private readonly polls = new Map<string, PollState>();
/** Near-limit scrape warnings waiting for the same user to re-run the command. */
private readonly scrapeConfirm = new Map<string, number>();
private voiceChannelId?: string | null;
private ownerVoiceChannelId?: string | null;
private voiceLeaveTimer?: ReturnType<typeof setTimeout>;
private ownerId = DEFAULT_OWNER_ID;
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
private selfId?: string;
private queue: Promise<void> = Promise.resolve();
/** The logged-in account's user ID, once the core has been asked for it. */
get self(): string | undefined {
return this.selfId;
}
constructor(
private readonly core: PuppetTransport,
private readonly audio?: PuppetAudio,
private readonly media?: PuppetMedia,
private readonly agent?: PuppetAgent
) {
try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
if (state.ownerId) this.ownerId = state.ownerId;
// Accept the old array shape and the new tier map.
if (Array.isArray(state.whitelist)) {
for (const id of state.whitelist) this.tiers.set(id, "user");
} else if (state.whitelist) {
for (const [id, tier] of Object.entries(state.whitelist)) {
const parsed = parseTier(tier);
if (parsed) this.tiers.set(id, parsed);
}
}
if (state.config) {
this.config = {
contextMessages: clamp(state.config.contextMessages ?? DEFAULT_CONFIG.contextMessages, 1, 50), contextChars: clamp(state.config.contextChars ?? DEFAULT_CONFIG.contextChars, 200, 20000),
fetchCooldownSeconds: clamp(
state.config.fetchCooldownSeconds ?? DEFAULT_CONFIG.fetchCooldownSeconds,
0,
300
),
continuationSeconds: clamp(
state.config.continuationSeconds ?? DEFAULT_CONFIG.continuationSeconds,
0,
300
),
wakeword: normalizeWakeword(state.config.wakeword ?? DEFAULT_CONFIG.wakeword),
pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
state.config.voiceLeaveDelaySeconds ?? DEFAULT_CONFIG.voiceLeaveDelaySeconds,
0,
600
),
responsePolicy: {
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
sensitiveCommands:
state.config.responsePolicy?.sensitiveCommands ??
DEFAULT_CONFIG.responsePolicy.sensitiveCommands,
overrides: state.config.responsePolicy?.overrides ?? {}
}
};
}
} catch {
// first run: defaults apply
}
}
get whitelist(): Record<string, TrustTier> {
return Object.fromEntries(this.tiers);
}
get owner() {
return this.ownerId;
}
tierOf(userId: string): TrustTier | undefined {
return userId === this.ownerId ? "admin" : this.tiers.get(userId);
}
isTrusted(userId: string): boolean {
return this.tierOf(userId) !== undefined;
}
getConfig(): PuppetConfig {
return structuredClone(this.config);
}
updateConfig(patch: PuppetConfigPatch): PuppetConfig {
const policy = patch.responsePolicy ?? {};
this.config = {
contextMessages: clamp(patch.contextMessages ?? this.config.contextMessages, 1, 50),
contextChars: clamp(patch.contextChars ?? this.config.contextChars, 200, 20000),
fetchCooldownSeconds: clamp(
patch.fetchCooldownSeconds ?? this.config.fetchCooldownSeconds,
0,
300
),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
wakeword: normalizeWakeword(patch.wakeword ?? this.config.wakeword),
pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600),
voiceLeaveDelaySeconds: clamp(
patch.voiceLeaveDelaySeconds ?? this.config.voiceLeaveDelaySeconds,
0,
600
),
responsePolicy: {
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
overrides: { ...this.config.responsePolicy.overrides, ...(policy.overrides ?? {}) }
}
};
this.save();
return this.getConfig();
}
getLog() {
return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist };
}
readActionLog(limit = 100) {
return this.actionLog.read(limit);
}
/** Write a web UI action to the persistent log. */
appendAction(entry: Record<string, unknown> & { type: string }) {
this.actionLog.append(entry);
}
addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record<string, TrustTier> {
for (const id of userIds) {
if (id && id !== this.ownerId) this.tiers.set(id, tier);
}
this.save();
return this.whitelist;
}
removeFromWhitelist(userIds: string[]): Record<string, TrustTier> {
for (const id of userIds) this.tiers.delete(id);
this.save();
return this.whitelist;
}
private save() {
try {
writeFileSync(
STATE_FILE,
JSON.stringify(
{
ownerId: this.ownerId,
whitelist: this.whitelist,
config: this.config
},
null,
4
)
);
} catch (error) {
console.error("[puppet] failed to save state:", error);
this.actionLog.append({ type: "error", scope: "state-save", message: String(error) });
}
}
/* Context */
private async refreshCache(channelId: string): Promise<CachedMessage[]> {
const cached = this.cache.get(channelId);
const lastFetch = this.lastFetchAt.get(channelId) ?? 0;
if (Date.now() - lastFetch < this.config.fetchCooldownSeconds * 1000) return cached;
this.lastFetchAt.set(channelId, Date.now());
const newest = cached.at(-1)?.id;
const raw = await paced(() =>
newest
? this.core.fetchMessages(channelId, 100, { after: newest })
: this.core.fetchMessages(channelId, Math.min(100, this.config.contextMessages + 5))
);
return this.cache.merge(channelId, (raw?.messages ?? []).map(toCachedMessage));
}
async getContext(
channelId: string,
options: { maxMessages?: number; maxChars?: number; refresh?: boolean } = {}
): Promise<AssembledContext & { channelId: string }> {
const merged =
options.refresh === false ? this.cache.get(channelId) : await this.refreshCache(channelId);
const assembled = assembleContext(merged, {
maxMessages: options.maxMessages ?? this.config.contextMessages,
maxChars: options.maxChars ?? this.config.contextChars
});
return { channelId, ...assembled };
}
private async collectContext(channelId: string, excludeId?: string): Promise<AssembledContext> {
const merged = await this.refreshCache(channelId);
return assembleContext(merged, {
maxMessages: this.config.contextMessages,
maxChars: this.config.contextChars,
excludeId
});
}
/* Triggers */
/** Process events one at a time. Order matters for continuation windows. */
enqueueEvent(event: any): Promise<void> {
this.queue = this.queue
.then(() => this.handleEvent(event))
.catch(error => {
console.error("[puppet] event failed:", error);
this.actionLog.append({ type: "error", scope: "event", message: String(error) });
});
return this.queue;
}
private remember(id: string) {
this.seen.add(id);
this.seenOrder.push(id);
if (this.seenOrder.length > 1000) {
const oldest = this.seenOrder.shift()!;
this.seen.delete(oldest);
}
}
async handleEvent(event: any) {
if (event?.type === "MESSAGE_REACTION_ADD" || event?.type === "MESSAGE_REACTION_REMOVE") {
await this.handleReactionEvent(event);
return;
}
if (event?.type === "MESSAGE_UPDATE") {
this.handleMessageUpdate(event);
return;
}
if (event?.type === "MESSAGE_DELETE") {
this.handleMessageDelete(event);
return;
}
if (event?.type === "MESSAGE_DELETE_BULK") {
for (const id of event.ids ?? []) {
this.handleMessageDelete({ id, channelId: event.channelId, guildId: event.guildId });
}
return;
}
if (event?.type === "VOICE_STATE_UPDATE") {
await this.handleVoiceStateUpdate(event);
return;
}
if (event?.type !== "MESSAGE_CREATE") return;
const { author, channelId, guildId, channelType, content, mentions } = event;
if (!author || !channelId) return;
if (author.bot) return;
// Drop replayed events after a reconnect.
if (event.id) {
if (this.seen.has(event.id)) return;
this.remember(event.id);
}
this.selfId ??= await this.core.getSelfId();
if (!this.selfId || author.id === this.selfId) return;
const tier = this.tierOf(author.id);
if (!tier) return;
const isGroupDm = channelType === GROUP_DM_CHANNEL;
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
const mentioned = new Set<string>(mentions ?? []).has(this.selfId);
// A reply to a message the puppet sent counts as a trigger too.
const replyTo = event.replyTo as
| { id?: string; authorId?: string | null; authorName?: string | null; content?: string }
| null
| undefined;
const repliesToPuppet = !!replyTo?.authorId && replyTo.authorId === this.selfId;
const isTrigger = isDirectDm || mentioned || repliesToPuppet;
const key = `${channelId}:${author.id}`;
// A rushed follow-up message joins the previous request as context.
if (!isTrigger) {
const pending = this.pending.get(key);
if (pending && Date.now() < pending.expiresAt) {
const entry = this.log.find(pending.entryId);
if (entry) {
const followUp: CachedMessage = {
id: event.id,
authorId: author.id,
authorName: author.username,
content: content ?? "",
timestamp: event.timestamp ?? new Date().toISOString()
};
entry.context = [...(entry.context ?? []), followUp];
pending.expiresAt = Date.now() + this.config.continuationSeconds * 1000;
this.actionLog.append({
type: "continuation",
messageId: event.id,
entryId: entry.id,
channelId,
authorId: author.id
});
}
}
return;
}
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text, this.config.wakeword);
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: true,
command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined
};
if (!isDirectDm) {
try {
const context = await this.collectContext(channelId, event.id);
if (context.messages.length) entry.context = context.messages;
} catch (error) {
this.actionLog.append({ type: "error", scope: "context", channelId, message: String(error) });
}
}
// Keep the referenced message in the request context.
if (replyTo?.id && replyTo.authorId) {
const referenced: CachedMessage = {
id: replyTo.id,
authorId: replyTo.authorId,
authorName: replyTo.authorName ?? replyTo.authorId,
content: replyTo.content ?? "",
timestamp: ""
};
if (!entry.context?.some(message => message.id === referenced.id)) {
entry.context = [...(entry.context ?? []), referenced];
}
}
this.log.add(entry);
if (this.config.continuationSeconds > 0) {
this.pending.set(key, {
entryId: entry.id,
expiresAt: Date.now() + this.config.continuationSeconds * 1000
});
}
this.actionLog.append({
type: "trigger",
messageId: entry.id,
channelId,
guildId: guildId ?? null,
authorId: author.id,
tier,
command: entry.command,
replyTo: replyTo?.id ?? null,
contextCount: entry.context?.length ?? 0
});
if (command?.name === "whitelist") {
await this.handleWhitelistCommand(command, author.id, tier, channelId);
} else if (command?.name === "speak") {
await this.handleSpeakCommand(command, author.id, channelId);
} else if (command?.name === "scrape") {
await this.handleScrapeCommand(command, author.id, channelId, guildId, tier);
} else if (POLL_ENABLED && command?.name === "poll") {
const body = text.trim().slice(this.config.wakeword.length).trim();
await this.handlePollCommand(author.id, channelId, body);
} else {
// No command: the message belongs to the reply-only agent.
void this.handleFreeform(entry, text);
}
}
/**
* Send a trusted free-form message to the agent and reply with its answer.
* Runs detached: the agent can take minutes and must not block other events.
*/
private async handleFreeform(entry: PuppetMessage, text: string) {
if (!this.agent) return;
try {
const reply = await this.agent.respond({
id: entry.id,
authorId: entry.authorId,
authorName: entry.authorName,
channelId: entry.channelId,
...(entry.guildId ? { guildId: entry.guildId } : {}),
content: text,
context: (entry.context ?? []).map(message => ({
author: message.authorName,
content: message.content,
timestamp: message.timestamp
}))
});
if (!reply) {
this.actionLog.append({ type: "agent", messageId: entry.id, result: "empty" });
return;
}
await this.respond(entry.authorId, entry.channelId, "chat", reply);
} catch (error) {
this.actionLog.append({ type: "agent", messageId: entry.id, result: "error", error: String(error) });
}
}
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,
tier: TrustTier,
channelId: string
) {
const [sub, ...rest] = command.args;
// No arguments or "help" shows the help. No permission needed for help.
if (!sub || sub === "help") {
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
}
if (!hasTier(tier, "admin")) {
return this.respond(authorId, channelId, "whitelist", "You do not have permission for this command.");
}
switch (sub) {
case "list": {
const lines = [this.ownerId, ...this.tiers.keys()].map(id => `${id} — ${this.tierOf(id)}`);
this.actionLog.append({ type: "command", command: "whitelist list", requesterId: authorId, result: "ok" });
return this.respond(authorId, channelId, "whitelist", `Trusted users:\n${lines.join("\n")}`);
}
case "add": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) {
return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user [user|trusted|admin]");
}
const last = rest.at(-1)?.toLowerCase();
const requestedTier = (last && parseTier(last)) || "user";
this.addToWhitelist(ids, requestedTier);
this.actionLog.append({
type: "command",
command: `whitelist add ${ids.join(",")} ${requestedTier}`,
requesterId: authorId,
result: "ok"
});
return this.respond(
authorId,
channelId,
"whitelist",
`Added:\n${ids.map(id => `${id} — ${requestedTier}`).join("\n")}`
);
}
case "remove": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) {
return this.respond(authorId, channelId, "whitelist", "Usage: whitelist remove @user");
}
const removed = ids.filter(id => id !== this.ownerId);
this.removeFromWhitelist(removed);
this.actionLog.append({
type: "command",
command: `whitelist remove ${removed.join(",")}`,
requesterId: authorId,
result: "ok"
});
return this.respond(
authorId,
channelId,
"whitelist",
[
removed.length ? `Removed:\n${removed.join("\n")}` : "Nothing removed.",
ids.length !== removed.length ? "The owner cannot be removed." : ""
]
.filter(Boolean)
.join("\n")
);
}
default:
return this.respond(authorId, channelId, "whitelist", whitelistHelp(this.config.wakeword));
}
}
/* Text to speech */
private async handleSpeakCommand(command: ParsedCommand, authorId: string, channelId: string) {
if (!this.audio) {
return this.respond(authorId, channelId, "speak", "Voice audio is not available.");
}
const text = command.args.join(" ").trim();
if (!text || text === "help") {
return this.respond(
authorId,
channelId,
"speak",
["speak — text to speech.", "Usage:", " speak <text>", " speak stop"].join("\n")
);
}
if (text === "stop") {
this.audio.stop();
this.actionLog.append({ type: "command", command: "speak stop", requesterId: authorId, result: "ok" });
return this.respond(authorId, channelId, "speak", "Stopped.");
}
try {
await this.audio.speak(text, {});
this.actionLog.append({
type: "command",
command: "speak",
requesterId: authorId,
result: "queued",
chars: text.length
});
return this.respond(authorId, channelId, "speak", "Speaking.");
} catch (error) {
this.actionLog.append({
type: "command",
command: "speak",
requesterId: authorId,
result: "error",
error: String(error)
});
return this.respond(authorId, channelId, "speak", `Could not speak: ${(error as Error).message}`);
}
}
private async handleScrapeCommand(
command: ParsedCommand,
authorId: string,
channelId: string,
guildId: string | undefined,
tier: TrustTier
) {
const [kind, sub] = command.args;
const knownSub = sub === undefined || sub === "download" || sub === "status" || sub === "stop";
if (kind !== "media" || sub === "help" || !knownSub) {
return this.respond(authorId, channelId, "scrape", scrapeHelp(this.config.wakeword));
}
if (!this.media) {
return this.respond(authorId, channelId, "scrape", "The media archive is not available.");
}
if (sub === "status") {
const scrape = this.media.listScrapes().find(entry => entry.channelId === channelId);
return this.respond(
authorId,
channelId,
"scrape",
scrape
? `Media scrape ${scrape.id}: ${scrape.state}.`
: "No media scrape for this channel yet."
);
}
if (sub === "stop") {
if (!hasTier(tier, "admin")) {
return this.respond(authorId, channelId, "scrape", "You do not have permission for this command.");
}
const scrape = this.media
.listScrapes()
.find(entry => entry.channelId === channelId && ["queued", "running", "paused"].includes(entry.state));
if (!scrape) {
return this.respond(authorId, channelId, "scrape", "No active media scrape for this channel.");
}
this.media.stopScrape(scrape.id);
this.actionLog.append({ type: "media", action: "stop", channelId, scrapeId: scrape.id });
return this.respond(authorId, channelId, "scrape", `Stopping media scrape ${scrape.id}.`);
}
const mode = sub === "download" ? "download" : "links";
if (mode === "download" && !hasTier(tier, "admin")) {
return this.respond(authorId, channelId, "scrape", "Download mode needs the admin tier.");
}
const key = `${channelId}:${authorId}`;
const confirmUntil = this.scrapeConfirm.get(key);
const confirm = confirmUntil !== undefined && Date.now() < confirmUntil;
if (confirm) this.scrapeConfirm.delete(key);
try {
const scrape = await this.media.startScrape({ channelId, guildId: guildId ?? null, mode, confirm });
this.actionLog.append({ type: "media", action: "scrape", channelId, mode, scrapeId: scrape.id });
return this.respond(authorId, channelId, "scrape", `Media scrape started (${mode}): ${scrape.id}.`);
} catch (error) {
if ((error as { code?: string }).code === "MEDIA_LIMIT" && !confirm) {
this.scrapeConfirm.set(key, Date.now() + 120_000);
return this.respond(
authorId,
channelId,
"scrape",
`${(error as Error).message}. Run the command again within 2 minutes to accept.`
);
}
this.actionLog.append({ type: "error", scope: "media", channelId, message: String(error) });
return this.respond(
authorId,
channelId,
"scrape",
`Media scrape failed: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/* Polls */
private async handlePollCommand(authorId: string, channelId: string, text: string) {
const body = text.replace(/^poll\s*/i, "").trim();
if (!body || /^help$/i.test(body)) {
return this.respond(authorId, channelId, "poll", this.pollHelp());
}
const poll = parsePoll(body);
if (!poll) {
return this.respond(authorId, channelId, "poll", this.pollHelp());
}
const lines = [
`📊 ${poll.question}`,
...poll.options.map((option, index) => `${POLL_EMOJIS[index]} ${option}`),
"",
"React to vote!"
];
const sent = await this.core.sendMessage(channelId, lines.join("\n"));
const messageId = (sent as { messageId?: string } | void)?.messageId;
if (!messageId) {
this.actionLog.append({ type: "error", scope: "poll", channelId, message: "no message ID for the poll" });
return;
}
const state: PollState = {
channelId,
authorId,
question: poll.question,
options: poll.options,
votes: new Map(),
endsAt: Date.now() + this.config.pollSeconds * 1000,
timer: undefined as unknown as ReturnType<typeof setTimeout>
};
state.timer = setTimeout(() => void this.finishPoll(messageId), this.config.pollSeconds * 1000);
this.polls.set(messageId, state);
for (let index = 0; index < poll.options.length; index++) {
try {
await this.core.reactionAdd(channelId, messageId, POLL_EMOJIS[index]);
} catch (error) {
this.actionLog.append({ type: "error", scope: "poll-reaction", message: String(error) });
}
}
this.actionLog.append({
type: "poll",
messageId,
channelId,
question: poll.question,
options: poll.options.length,
endsAt: new Date(state.endsAt).toISOString()
});
}
private pollHelp(): string {
return [
"poll — start a reaction poll.",
"Usage:",
' poll "Question" Option 1 // Option 2 [// Option 3 [// Option 4]]',
" poll Question | Option 1 | Option 2 (pipe works too)",
"Examples:",
' poll "What country is best" Sweden // Denmark // Madagaskar',
" poll Lunch? | Pizza | Sushi",
`Users vote with the number reactions. The poll closes after ${this.config.pollSeconds}s.`
].join("\n");
}
private handleMessageUpdate(event: any) {
const cached = this.cache.edit(event.channelId, event.id, event.content ?? "");
this.actionLog.append({
type: "message-edit",
messageId: event.id,
channelId: event.channelId,
guildId: event.guildId ?? null,
authorId: event.author?.id ?? null,
contentLength: String(event.content ?? "").length,
cached
});
}
private handleMessageDelete(event: any) {
const cached = this.cache.remove(event.channelId, event.id);
this.actionLog.append({
type: "message-delete",
messageId: event.id,
channelId: event.channelId,
guildId: event.guildId ?? null,
cached
});
}
/* Voice auto-leave: the bot follows the owner (zzib) for now. */
private async handleVoiceStateUpdate(event: any) {
const userId = event?.userId;
if (!userId) return;
this.selfId ??= await this.core.getSelfId();
if (!this.selfId) return;
if (userId === this.selfId) {
const previous = this.voiceChannelId;
this.voiceChannelId = event.channelId ?? null;
if (!this.voiceChannelId) {
this.cancelAutoLeave("the bot left the call");
} else if (previous !== this.voiceChannelId) {
await this.checkOwnerPresence();
}
return;
}
if (userId !== this.ownerId) return;
this.ownerVoiceChannelId = event.channelId ?? null;
if (this.ownerVoiceChannelId && this.ownerVoiceChannelId === this.voiceChannelId) {
this.cancelAutoLeave("the owner is in the call");
} else if (this.voiceChannelId) {
this.scheduleAutoLeave();
}
}
private async checkOwnerPresence() {
try {
const owner = (await this.core.voiceGet(this.ownerId)) as { channelId?: string | null } | void;
this.ownerVoiceChannelId = owner?.channelId ?? null;
} catch (error) {
this.actionLog.append({ type: "error", scope: "voice-presence", message: String(error) });
return;
}
if (this.ownerVoiceChannelId && this.ownerVoiceChannelId === this.voiceChannelId) {
this.cancelAutoLeave("the owner is in the call");
} else if (this.voiceChannelId) {
this.scheduleAutoLeave();
}
}
private scheduleAutoLeave() {
if (this.voiceLeaveTimer) return;
const delaySeconds = this.config.voiceLeaveDelaySeconds;
if (delaySeconds <= 0) {
void this.leaveVoice("the owner left");
return;
}
this.actionLog.append({
type: "voice",
action: "auto-leave-scheduled",
channelId: this.voiceChannelId ?? null,
delaySeconds
});
this.voiceLeaveTimer = setTimeout(() => {
this.voiceLeaveTimer = undefined;
void this.leaveIfOwnerAway();
}, delaySeconds * 1000);
}
private cancelAutoLeave(reason: string) {
if (!this.voiceLeaveTimer) return;
clearTimeout(this.voiceLeaveTimer);
this.voiceLeaveTimer = undefined;
this.actionLog.append({ type: "voice", action: "auto-leave-cancelled", reason });
}
private async leaveIfOwnerAway() {
if (!this.voiceChannelId) return;
if (!this.ownerVoiceChannelId) {
try {
const owner = (await this.core.voiceGet(this.ownerId)) as { channelId?: string | null } | void;
this.ownerVoiceChannelId = owner?.channelId ?? null;
} catch (error) {
this.actionLog.append({ type: "error", scope: "voice-presence", message: String(error) });
}
}
if (this.ownerVoiceChannelId === this.voiceChannelId) {
this.cancelAutoLeave("the owner rejoined");
return;
}
await this.leaveVoice("the owner is not in the call");
}
private async leaveVoice(reason: string) {
try {
await this.core.voiceLeave();
this.actionLog.append({ type: "voice", action: "auto-leave", reason, channelId: this.voiceChannelId ?? null });
} catch (error) {
this.actionLog.append({ type: "voice", action: "auto-leave-failed", reason, error: String(error) });
}
this.voiceChannelId = null;
}
private async handleReactionEvent(event: any) {
this.selfId ??= await this.core.getSelfId();
if (!this.selfId || event.userId === this.selfId) return;
// Trusted reactions are notifications; poll votes do not need a tier.
if (this.isTrusted(event.userId)) {
this.actionLog.append({
type: "reaction",
action: event.type === "MESSAGE_REACTION_ADD" ? "add" : "remove",
channelId: event.channelId,
guildId: event.guildId ?? null,
messageId: event.messageId,
userId: event.userId,
emoji: event.emoji?.name ?? event.emoji?.id ?? null
});
}
const poll = this.polls.get(event.messageId);
if (!poll) return;
const emoji = event.emoji?.id ?? event.emoji?.name;
const index = POLL_EMOJIS.indexOf(emoji);
if (index < 0 || index >= poll.options.length) return;
const votes = poll.votes.get(event.userId) ?? new Set<number>();
if (event.type === "MESSAGE_REACTION_ADD") votes.add(index);
else votes.delete(index);
if (votes.size) poll.votes.set(event.userId, votes);
else poll.votes.delete(event.userId);
}
private async finishPoll(messageId: string) {
const poll = this.polls.get(messageId);
if (!poll) return;
this.polls.delete(messageId);
clearTimeout(poll.timer);
const counts = poll.options.map(() => 0);
for (const votes of poll.votes.values()) {
for (const index of votes) counts[index]++;
}
const lines = [
`📊 Results: ${poll.question}`,
...poll.options.map((option, index) => `${POLL_EMOJIS[index]} ${option} — ${counts[index]}`)
];
await this.respond(poll.authorId, poll.channelId, "poll", lines.join("\n"));
this.actionLog.append({ type: "poll-result", messageId, channelId: poll.channelId, counts });
}
/* Replies */
private async respond(
requesterId: string,
originChannelId: string,
commandName: string | undefined,
content: string
) {
const policy = this.config.responsePolicy;
const override = commandName ? policy.overrides[commandName] : undefined;
const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName);
const mode = override ?? (sensitive ? "dm" : policy.defaultMode);
try {
await pacedSend(async () => {
if (mode === "dm") await this.core.sendDm(requesterId, content);
else await this.core.sendMessage(originChannelId, content);
});
this.actionLog.append({
type: "reply",
command: commandName,
mode,
target: mode === "dm" ? requesterId : originChannelId,
result: "ok"
});
} catch (error) {
// Never fall back to a public channel for sensitive content.
this.actionLog.append({
type: "reply",
command: commandName,
mode,
target: mode === "dm" ? requesterId : originChannelId,
result: "error",
error: String(error)
});
}
}
}