1158 lines
41 KiB
TypeScript
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)
|
|
});
|
|
}
|
|
}
|
|
}
|