/* * Veskslop communication backend — pure logic (no Discord imports). * * Keeps a small ring buffer of trigger-qualifying messages: the last 100 from * trusted users and the last 30 from untrusted ones. Also parses the * `whitelist` commands and normalizes @mentions. */ export interface CachedMessage { id: string; authorId: string; authorName: string; content: string; timestamp: string; /** Attachment file names only. Bodies stay on Discord. */ attachments?: string[]; } export interface PuppetMessage { id: string; channelId: string; guildId?: string; authorId: string; authorName: string; /** Raw message content as received. */ content: string; timestamp: string; trusted: boolean; /** Parsed command text when the message is one. */ command?: string; /** Prior messages for context. Present on mention triggers. */ context?: CachedMessage[]; } export const TRUSTED_LOG_LIMIT = 100; export const UNTRUSTED_LOG_LIMIT = 30; export class MessageLog { trusted: PuppetMessage[] = []; untrusted: PuppetMessage[] = []; add(message: PuppetMessage) { const bucket = message.trusted ? this.trusted : this.untrusted; const limit = message.trusted ? TRUSTED_LOG_LIMIT : UNTRUSTED_LOG_LIMIT; bucket.push(message); if (bucket.length > limit) bucket.splice(0, bucket.length - limit); } snapshot() { return { trusted: [...this.trusted], untrusted: [...this.untrusted] }; } find(id: string): PuppetMessage | undefined { return this.trusted.find(message => message.id === id) ?? this.untrusted.find(message => message.id === id); } } const USER_MENTION = /<@!?(\d+)>/g; export function extractMentionIds(content: string, mentions?: Array<{ id: string }>): Set { const ids = new Set(); for (const mention of mentions ?? []) ids.add(mention.id); for (const match of content.matchAll(USER_MENTION)) ids.add(match[1]); return ids; } export function stripMentions(content: string, ids: ReadonlySet): string { return content .replace(USER_MENTION, (full, id) => (ids.has(id) ? "" : full)) .replace(/\s+/g, " ") .trim(); } export interface ParsedCommand { name: string; args: string[]; } export function parseCommand(text: string): ParsedCommand | undefined { const trimmed = text.trim(); if (!trimmed) return undefined; const [name, ...args] = trimmed.split(/\s+/); return { name: name.toLowerCase(), args }; } export function isTrusted( userId: string, ownerId: string | undefined, whitelist: readonly string[] | undefined ): boolean { return userId === ownerId || !!whitelist?.includes(userId); } /* Context assembly */ export interface ContextOptions { maxMessages?: number; maxChars?: number; /** Skip this message ID. Use it to keep the trigger out of its own context. */ excludeId?: string; } export interface AssembledContext { messages: CachedMessage[]; chars: number; truncated: boolean; } export function formatContextLine(message: CachedMessage): string { const parts: string[] = []; if (message.content) parts.push(message.content); for (const name of message.attachments ?? []) parts.push(`[file: ${name}]`); let stamp = ""; if (message.timestamp) { const date = new Date(message.timestamp); if (!Number.isNaN(date.getTime())) { const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); stamp = `[${hours}:${minutes}] `; } } return `${stamp}${message.authorName}: ${parts.join(" ")}`.trim(); } /** Assemble the newest messages inside a message count limit and a character limit. */ export function assembleContext(messages: readonly CachedMessage[], options: ContextOptions = {}): AssembledContext { const maxMessages = options.maxMessages ?? 15; const maxChars = options.maxChars ?? 4000; const selected: CachedMessage[] = []; let chars = 0; let truncated = false; for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (options.excludeId && message.id === options.excludeId) continue; if (selected.length >= maxMessages) { truncated = true; break; } const line = formatContextLine(message); if (selected.length > 0 && chars + line.length > maxChars) { truncated = true; break; } selected.push(message); chars += line.length; } selected.reverse(); return { messages: selected, chars, truncated }; } /** Compare two snowflake IDs. Falls back to string compare for invalid input. */ export function compareSnowflakes(a: string, b: string): number { try { const left = BigInt(a); const right = BigInt(b); return left < right ? -1 : left > right ? 1 : 0; } catch { return a < b ? -1 : a > b ? 1 : 0; } } /* Trust tiers */ export type TrustTier = "user" | "trusted" | "admin"; const TIER_RANK: Record = { user: 0, trusted: 1, admin: 2 }; export function hasTier(tier: TrustTier | undefined, required: TrustTier): boolean { return !!tier && TIER_RANK[tier] >= TIER_RANK[required]; } export function parseTier(value: string): TrustTier | undefined { return value === "user" || value === "trusted" || value === "admin" ? value : undefined; } /* Polls */ export const POLL_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣"]; export interface ParsedPoll { question: string; options: string[]; } /** Parse `question | option 1 | option 2 [| option 3 [| option 4]]`. */ export function parsePoll(text: string): ParsedPoll | undefined { // A quoted question lets the first option follow without a separator: // "What country is best" Sweden // Denmark // Madagaskar const quoted = text.match(/^\s*["'“”](.+?)["'“”]\s*([\s\S]*)$/); let question: string; let optionParts: string[]; if (quoted) { question = quoted[1].trim(); optionParts = splitPollParts(quoted[2]); } else { const parts = splitPollParts(text); question = parts[0]?.trim() ?? ""; optionParts = parts.slice(1); } if (!question || optionParts.length < 2 || optionParts.length > 4) return undefined; if (optionParts.some(option => option.length > 80)) return undefined; return { question: question.slice(0, 200), options: optionParts.map(option => option.slice(0, 80)) }; } /** * Split poll parts on `|` or on `//` with whitespace on both sides. The * whitespace guard keeps URLs such as `https://example.com` in one piece. */ function splitPollParts(text: string): string[] { return text .split(/\s+\/\/\s+|\s*\|\s*/) .map(part => part.trim()) .filter(Boolean); } /* Secret redaction for persistent logs. Keep it minimal. */ const SECRET_PATTERNS = [ /[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{5,7}\.[A-Za-z0-9_-]{25,}/g, /mfa\.[A-Za-z0-9_-]{20,}/g ]; export function redactSecrets(text: string): string { let output = text; for (const pattern of SECRET_PATTERNS) output = output.replace(pattern, "[redacted]"); return output; } export function redactValue(value: unknown, depth = 0): unknown { if (typeof value === "string") return redactSecrets(value); if (depth > 4 || value === null || typeof value !== "object") return value; if (Array.isArray(value)) return value.map(item => redactValue(item, depth + 1)); const result: Record = {}; for (const [key, item] of Object.entries(value as Record)) { result[key] = redactValue(item, depth + 1); } return result; } /** Convert a core `message.fetch` or `message.read` message into a cached message. */ export function toCachedMessage(raw: any): CachedMessage { return { id: String(raw.id), authorId: String(raw.author?.id ?? ""), authorName: String(raw.author?.username ?? raw.author?.id ?? "unknown"), content: String(raw.content ?? ""), timestamp: String(raw.timestamp ?? ""), attachments: Array.isArray(raw.attachments) ? raw.attachments.map((attachment: any) => String(attachment?.filename ?? attachment)).filter(Boolean) : undefined }; }