268 lines
8.3 KiB
TypeScript
268 lines
8.3 KiB
TypeScript
/*
|
|
* 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<string> {
|
|
const ids = new Set<string>();
|
|
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>): 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<TrustTier, number> = { 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<string, unknown> = {};
|
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
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
|
|
};
|
|
}
|