/* * 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; } 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; }; } 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; config?: Partial; } /** The transport methods that the puppet logic needs. */ export interface PuppetTransport { getSelfId(): Promise; sendMessage(channelId: string, content: string): Promise<{ messageId?: string } | void>; sendDm(userId: string, content: string): Promise; fetchMessages( channelId: string, limit: number, options?: { before?: string; after?: string } ): Promise<{ messages?: unknown[] }>; reactionAdd(channelId: string, messageId: string, emoji: string): Promise; voiceGet(userId?: string, guildId?: string): Promise<{ channelId?: string | null } | void>; voiceLeave(): Promise; } /** 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; } /** The audio actions a chat command can trigger. */ export interface PuppetAudio { speak(text: string, options?: { provider?: "local" | "api"; voice?: string }): Promise; 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>; endsAt: number; timer: ReturnType; } 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(); private readonly seen = new Set(); private readonly seenOrder: string[] = []; private readonly lastFetchAt = new Map(); private readonly pending = new Map(); private readonly polls = new Map(); /** Near-limit scrape warnings waiting for the same user to re-run the command. */ private readonly scrapeConfirm = new Map(); private voiceChannelId?: string | null; private ownerVoiceChannelId?: string | null; private voiceLeaveTimer?: ReturnType; private ownerId = DEFAULT_OWNER_ID; private config: PuppetConfig = structuredClone(DEFAULT_CONFIG); private selfId?: string; private queue: Promise = 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 { 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 & { type: string }) { this.actionLog.append(entry); } addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record { for (const id of userIds) { if (id && id !== this.ownerId) this.tiers.set(id, tier); } this.save(); return this.whitelist; } removeFromWhitelist(userIds: string[]): Record { 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 { 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 { 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 { 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 { 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(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(); 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 ", " 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 }; 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(); 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) }); } } }