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

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
};
}