Add text features: trusted triggers, context cache, response policy, MCP config tools
This commit is contained in:
+2
-1
@@ -13,7 +13,8 @@
|
||||
"check": "tsc --noEmit",
|
||||
"control:mock": "tsx src/mock-core.ts",
|
||||
"test:trigger": "tsx src/test-trigger.ts",
|
||||
"cdp:dispatch": "tsx src/cdp-dispatch.ts"
|
||||
"cdp:dispatch": "tsx src/cdp-dispatch.ts",
|
||||
"test:cache": "tsx src/test-cache.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
* `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;
|
||||
@@ -18,6 +28,8 @@ export interface PuppetMessage {
|
||||
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;
|
||||
@@ -81,3 +93,81 @@ export function isTrusted(
|
||||
export function randomActionDelay(): number {
|
||||
return 500 + Math.floor(Math.random() * 2301);
|
||||
}
|
||||
|
||||
/* 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}]`);
|
||||
return `${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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +128,51 @@ function buildServer(): McpServer {
|
||||
async () => jsonText(requirePuppet().getLog())
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_context",
|
||||
{
|
||||
description:
|
||||
"Assemble recent conversation context for a channel. Fetches new messages incrementally by default.",
|
||||
inputSchema: {
|
||||
channelId: z.string().describe("Channel ID"),
|
||||
maxMessages: z.number().int().min(1).max(50).optional().describe("Message limit"),
|
||||
maxChars: z.number().int().min(200).max(20000).optional().describe("Character limit"),
|
||||
refresh: z.boolean().optional().describe("Fetch new messages first. Defaults to true.")
|
||||
}
|
||||
},
|
||||
async ({ channelId, maxMessages, maxChars, refresh }) =>
|
||||
jsonText(await requirePuppet().getContext(channelId, { maxMessages, maxChars, refresh }))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_puppet_config",
|
||||
{
|
||||
description: "Read the response policy and context settings."
|
||||
},
|
||||
async () => jsonText(requirePuppet().getConfig())
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"set_puppet_config",
|
||||
{
|
||||
description: "Update the response policy and context settings. Sensitive commands reply by DM.",
|
||||
inputSchema: {
|
||||
config: z.object({
|
||||
contextMessages: z.number().int().min(1).max(50).optional(),
|
||||
contextChars: z.number().int().min(200).max(20000).optional(),
|
||||
responsePolicy: z
|
||||
.object({
|
||||
defaultMode: z.enum(["channel", "dm"]).optional(),
|
||||
sensitiveCommands: z.array(z.string()).optional(),
|
||||
overrides: z.record(z.string(), z.enum(["channel", "dm"])).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
async ({ config }) => jsonText(requirePuppet().updateConfig(config))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"whitelist_list",
|
||||
{
|
||||
|
||||
@@ -131,6 +131,10 @@ export class CoreClient {
|
||||
return this.request("message.read", { channelId, limit });
|
||||
}
|
||||
|
||||
async fetchMessages(channelId: string, limit: number, options: { before?: string; after?: string } = {}) {
|
||||
return this.request("message.fetch", { channelId, limit, ...options });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.destroy();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Veskslop daemon — per-channel message cache in a tmp folder with a size limit.
|
||||
*
|
||||
* The cache backs conversation context. It keeps the newest messages per
|
||||
* channel and evicts the least recently written channel files when the folder
|
||||
* exceeds the size limit.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { compareSnowflakes, type CachedMessage } from "./communication.js";
|
||||
|
||||
export const CACHE_DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_MESSAGES_PER_CHANNEL = 200;
|
||||
|
||||
interface ChannelCacheFile {
|
||||
messages: CachedMessage[];
|
||||
}
|
||||
|
||||
export class MessageCache {
|
||||
private readonly dir: string;
|
||||
private readonly maxBytes: number;
|
||||
|
||||
constructor(
|
||||
dir = process.env.VESKSLOP_CACHE_DIR ?? join(tmpdir(), "veskslop-cache"),
|
||||
maxBytes = Number(process.env.VESKSLOP_CACHE_MAX_BYTES ?? CACHE_DEFAULT_MAX_BYTES)
|
||||
) {
|
||||
this.dir = dir;
|
||||
this.maxBytes = maxBytes;
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
get(channelId: string): CachedMessage[] {
|
||||
const file = this.file(channelId);
|
||||
if (!existsSync(file)) return [];
|
||||
|
||||
try {
|
||||
return (JSON.parse(readFileSync(file, "utf8")) as ChannelCacheFile).messages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
newestId(channelId: string): string | undefined {
|
||||
return this.get(channelId).at(-1)?.id;
|
||||
}
|
||||
|
||||
/** Merge messages by ID, keep the newest, write, then evict old files. */
|
||||
merge(channelId: string, incoming: readonly CachedMessage[]): CachedMessage[] {
|
||||
const byId = new Map<string, CachedMessage>();
|
||||
for (const message of [...this.get(channelId), ...incoming]) byId.set(message.id, message);
|
||||
|
||||
const merged = [...byId.values()]
|
||||
.sort((a, b) => compareSnowflakes(a.id, b.id))
|
||||
.slice(-MAX_MESSAGES_PER_CHANNEL);
|
||||
|
||||
this.write(channelId, merged);
|
||||
this.evict();
|
||||
return merged;
|
||||
}
|
||||
|
||||
private file(channelId: string): string {
|
||||
return join(this.dir, `${channelId}.json`);
|
||||
}
|
||||
|
||||
private write(channelId: string, messages: CachedMessage[]) {
|
||||
try {
|
||||
writeFileSync(this.file(channelId), JSON.stringify({ messages } satisfies ChannelCacheFile));
|
||||
} catch (error) {
|
||||
console.error("[cache] write failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private evict() {
|
||||
try {
|
||||
const files = readdirSync(this.dir)
|
||||
.filter(name => name.endsWith(".json"))
|
||||
.map(name => {
|
||||
const path = join(this.dir, name);
|
||||
const stats = statSync(path);
|
||||
return { path, size: stats.size, mtime: stats.mtimeMs };
|
||||
});
|
||||
|
||||
let total = files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (total <= this.maxBytes) return;
|
||||
|
||||
files.sort((a, b) => a.mtime - b.mtime);
|
||||
for (const file of files) {
|
||||
if (total <= this.maxBytes) break;
|
||||
rmSync(file.path, { force: true });
|
||||
total -= file.size;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[cache] eviction failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -36,6 +36,14 @@ const FAKE_MESSAGES = [
|
||||
}
|
||||
];
|
||||
|
||||
const FAKE_HISTORY = Array.from({ length: 8 }, (_, index) => ({
|
||||
id: String(400000000000000000n + BigInt(index)),
|
||||
author: { id: index % 2 ? "177117296140091393" : "999", username: index % 2 ? "zzib" : "mockuser" },
|
||||
content: `history message ${index}`,
|
||||
timestamp: new Date(Date.now() - (8 - index) * 60_000).toISOString(),
|
||||
attachments: index === 7 ? [{ filename: "clip.mp4", content_type: "video/mp4" }] : []
|
||||
}));
|
||||
|
||||
function handle(action: string | undefined, params: any) {
|
||||
switch (action) {
|
||||
case "ping":
|
||||
@@ -46,6 +54,20 @@ function handle(action: string | undefined, params: any) {
|
||||
return { channelId: params?.channelId, sent: true };
|
||||
case "message.read":
|
||||
return { cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES };
|
||||
case "message.fetch": {
|
||||
const after = typeof params?.after === "string" ? params.after : undefined;
|
||||
const before = typeof params?.before === "string" ? params.before : undefined;
|
||||
const limit = Number(params?.limit ?? 50);
|
||||
|
||||
let list = FAKE_HISTORY.filter(
|
||||
message =>
|
||||
(!after || BigInt(message.id) > BigInt(after)) &&
|
||||
(!before || BigInt(message.id) < BigInt(before))
|
||||
);
|
||||
list = list.slice(-limit);
|
||||
|
||||
return { channelId: params?.channelId, fetched: list.length, messages: list };
|
||||
}
|
||||
case "dm.send":
|
||||
return { userId: params?.userId, channelId: `dm-${params?.userId}`, sent: true };
|
||||
case "debug.emit": {
|
||||
@@ -84,7 +106,8 @@ const server = createServer(socket => {
|
||||
}
|
||||
|
||||
const result = handle(request.action, request.params);
|
||||
console.log(`[mock] ${new Date().toISOString()} ${request.action}`);
|
||||
const detail = request.action === "message.fetch" ? ` after=${request.params?.after ?? "-"}` : "";
|
||||
console.log(`[mock] ${new Date().toISOString()} ${request.action}${detail}`);
|
||||
socket.write(JSON.stringify({ id: request.id, ok: true, result }) + "\n");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
+203
-25
@@ -1,26 +1,29 @@
|
||||
/*
|
||||
* Veskslop daemon — puppet state: trust list, trigger log and Discord-facing
|
||||
* command handling. All of this used to live inside the Vesktop renderer; it
|
||||
* now lives here so Vesktop stays stock.
|
||||
* Veskslop daemon — puppet state: trust list, trigger log, response policy,
|
||||
* conversation context, and Discord-facing command handling.
|
||||
*
|
||||
* Trigger scope for now: direct messages from trusted users and direct @
|
||||
* mentions from trusted users. Mentions in guild channels and group DMs also
|
||||
* collect recent context for later LLM use.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
assembleContext,
|
||||
isTrusted,
|
||||
MessageLog,
|
||||
parseCommand,
|
||||
randomActionDelay,
|
||||
stripMentions,
|
||||
toCachedMessage,
|
||||
type AssembledContext,
|
||||
type CachedMessage,
|
||||
type ParsedCommand,
|
||||
type PuppetMessage
|
||||
} from "./communication.js";
|
||||
|
||||
/** The subset of the transport that the puppet logic needs. */
|
||||
export interface PuppetTransport {
|
||||
getSelfId(): Promise<string | undefined>;
|
||||
sendMessage(channelId: string, content: string): Promise<void>;
|
||||
}
|
||||
import { MessageCache } from "./message-cache.js";
|
||||
import { paced } from "./pacing.js";
|
||||
|
||||
const DM_CHANNEL = 1;
|
||||
const GROUP_DM_CHANNEL = 3;
|
||||
@@ -29,9 +32,58 @@ 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;
|
||||
responsePolicy: ResponsePolicy;
|
||||
}
|
||||
|
||||
export interface PuppetConfigPatch {
|
||||
contextMessages?: number;
|
||||
contextChars?: number;
|
||||
responsePolicy?: {
|
||||
defaultMode?: "channel" | "dm";
|
||||
sensitiveCommands?: string[];
|
||||
overrides?: Record<string, "channel" | "dm">;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PuppetConfig = {
|
||||
contextMessages: 15,
|
||||
contextChars: 4000,
|
||||
responsePolicy: {
|
||||
defaultMode: "channel",
|
||||
sensitiveCommands: ["whitelist", "config"],
|
||||
overrides: {}
|
||||
}
|
||||
};
|
||||
|
||||
interface PersistedState {
|
||||
ownerId?: string;
|
||||
whitelist?: string[];
|
||||
config?: Partial<PuppetConfig>;
|
||||
}
|
||||
|
||||
/** The transport methods that the puppet logic needs. */
|
||||
export interface PuppetTransport {
|
||||
getSelfId(): Promise<string | undefined>;
|
||||
sendMessage(channelId: string, content: string): Promise<void>;
|
||||
sendDm(userId: string, content: string): Promise<unknown>;
|
||||
fetchMessages(
|
||||
channelId: string,
|
||||
limit: number,
|
||||
options?: { before?: string; after?: string }
|
||||
): Promise<{ messages?: unknown[] }>;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export class Puppet {
|
||||
@@ -39,13 +91,28 @@ export class Puppet {
|
||||
|
||||
private ownerId = DEFAULT_OWNER_ID;
|
||||
private whitelistIds: string[] = [];
|
||||
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
|
||||
private selfId?: string;
|
||||
private readonly cache = new MessageCache();
|
||||
|
||||
constructor(private readonly discord: PuppetTransport) {
|
||||
constructor(private readonly core: PuppetTransport) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
|
||||
if (state.ownerId) this.ownerId = state.ownerId;
|
||||
if (Array.isArray(state.whitelist)) this.whitelistIds = state.whitelist;
|
||||
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),
|
||||
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
|
||||
}
|
||||
@@ -59,6 +126,27 @@ export class Puppet {
|
||||
return this.ownerId;
|
||||
}
|
||||
|
||||
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),
|
||||
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 };
|
||||
}
|
||||
@@ -85,12 +173,60 @@ export class Puppet {
|
||||
|
||||
private save() {
|
||||
try {
|
||||
writeFileSync(STATE_FILE, JSON.stringify({ ownerId: this.ownerId, whitelist: this.whitelistIds }, null, 4));
|
||||
writeFileSync(
|
||||
STATE_FILE,
|
||||
JSON.stringify(
|
||||
{ ownerId: this.ownerId, whitelist: this.whitelistIds, config: this.config },
|
||||
null,
|
||||
4
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[puppet] failed to save state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context */
|
||||
|
||||
private async refreshCache(channelId: string): Promise<CachedMessage[]> {
|
||||
const cached = this.cache.get(channelId);
|
||||
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 */
|
||||
|
||||
async handleEvent(event: any) {
|
||||
if (event?.type !== "MESSAGE_CREATE") return;
|
||||
|
||||
@@ -98,16 +234,18 @@ export class Puppet {
|
||||
if (!author || !channelId) return;
|
||||
if (author.bot) return;
|
||||
|
||||
this.selfId ??= await this.discord.getSelfId();
|
||||
this.selfId ??= await this.core.getSelfId();
|
||||
if (!this.selfId || author.id === this.selfId) return;
|
||||
|
||||
// Whitelist-only for now.
|
||||
if (!this.isTrusted(author.id)) return;
|
||||
|
||||
const isGroupDm = channelType === GROUP_DM_CHANNEL;
|
||||
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
|
||||
|
||||
const mentionIds = new Set<string>(mentions ?? []);
|
||||
if (!isDirectDm && !mentionIds.has(this.selfId)) return;
|
||||
|
||||
const trusted = this.isTrusted(author.id);
|
||||
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
|
||||
const command = parseCommand(text);
|
||||
|
||||
@@ -119,13 +257,23 @@ export class Puppet {
|
||||
authorName: author.username,
|
||||
content: content ?? "",
|
||||
timestamp: event.timestamp ?? new Date().toISOString(),
|
||||
trusted,
|
||||
trusted: true,
|
||||
command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined
|
||||
};
|
||||
|
||||
// Mentions need surrounding context for later LLM use. Direct messages do not.
|
||||
if (!isDirectDm) {
|
||||
try {
|
||||
const context = await this.collectContext(channelId, event.id);
|
||||
if (context.messages.length) entry.context = context.messages;
|
||||
} catch (error) {
|
||||
console.error("[puppet] context collection failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.log.add(entry);
|
||||
|
||||
if (command?.name === "whitelist" && trusted) {
|
||||
if (command?.name === "whitelist" && author.id === this.ownerId) {
|
||||
await this.handleWhitelistCommand(command, author.id, channelId);
|
||||
}
|
||||
}
|
||||
@@ -138,32 +286,35 @@ export class Puppet {
|
||||
}
|
||||
|
||||
private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) {
|
||||
if (authorId !== this.ownerId) return;
|
||||
|
||||
const [sub, ...rest] = command.args;
|
||||
|
||||
switch (sub) {
|
||||
case "list":
|
||||
await this.reply(
|
||||
return this.respond(
|
||||
authorId,
|
||||
channelId,
|
||||
"whitelist",
|
||||
`Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}`
|
||||
);
|
||||
return;
|
||||
|
||||
case "add": {
|
||||
const ids = this.extractUserIds(rest.join(" "));
|
||||
if (!ids.length) return this.reply(channelId, "Usage: whitelist add @user");
|
||||
if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user");
|
||||
|
||||
this.addToWhitelist(ids);
|
||||
return this.reply(channelId, `Added to whitelist:\n${ids.join("\n")}`);
|
||||
return this.respond(authorId, channelId, "whitelist", `Added to whitelist:\n${ids.join("\n")}`);
|
||||
}
|
||||
|
||||
case "remove": {
|
||||
const ids = this.extractUserIds(rest.join(" "));
|
||||
if (!ids.length) return this.reply(channelId, "Usage: whitelist remove @user");
|
||||
if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist remove @user");
|
||||
|
||||
const removed = ids.filter(id => id !== this.ownerId);
|
||||
this.removeFromWhitelist(removed);
|
||||
return this.reply(
|
||||
return this.respond(
|
||||
authorId,
|
||||
channelId,
|
||||
"whitelist",
|
||||
[
|
||||
removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.",
|
||||
ids.length !== removed.length ? "The owner cannot be removed." : ""
|
||||
@@ -174,17 +325,44 @@ export class Puppet {
|
||||
}
|
||||
|
||||
default:
|
||||
return this.reply(channelId, "Usage: whitelist add|remove|list");
|
||||
return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add|remove|list");
|
||||
}
|
||||
}
|
||||
|
||||
/* Replies */
|
||||
|
||||
private async reply(channelId: string, content: string) {
|
||||
// Autonomous replies use the same 500-2800 ms pacing as everything else.
|
||||
await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
|
||||
try {
|
||||
await this.discord.sendMessage(channelId, content);
|
||||
await this.core.sendMessage(channelId, content);
|
||||
} catch (error) {
|
||||
console.error("[puppet] reply failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (mode === "dm") {
|
||||
await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
|
||||
try {
|
||||
await this.core.sendDm(requesterId, content);
|
||||
} catch (error) {
|
||||
// Never fall back to a public channel for sensitive content.
|
||||
console.error("[puppet] sensitive reply by DM failed:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.reply(originChannelId, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Veskslop — unit tests for the message cache.
|
||||
*
|
||||
* Usage: pnpm test:cache
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { CachedMessage } from "./communication.js";
|
||||
import { MessageCache } from "./message-cache.js";
|
||||
|
||||
function message(id: number, content = `m${id}`): CachedMessage {
|
||||
return { id: String(id), authorId: "1", authorName: "a", content, timestamp: "" };
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "veskslop-cache-"));
|
||||
|
||||
try {
|
||||
const cache = new MessageCache(dir, 10 * 1024 * 1024);
|
||||
|
||||
// merge and dedupe by ID
|
||||
cache.merge("c1", [message(1), message(2)]);
|
||||
cache.merge("c1", [message(2), message(3)]);
|
||||
assert.deepEqual(
|
||||
cache.get("c1").map(m => m.id),
|
||||
["1", "2", "3"]
|
||||
);
|
||||
assert.equal(cache.newestId("c1"), "3");
|
||||
|
||||
// per-channel cap keeps the newest 200
|
||||
const merged = cache.merge(
|
||||
"c2",
|
||||
Array.from({ length: 250 }, (_, i) => message(1000 + i))
|
||||
);
|
||||
assert.equal(merged.length, 200);
|
||||
assert.equal(merged[0].id, "1050");
|
||||
|
||||
// size limit evicts the least recently written file
|
||||
const tiny = new MessageCache(dir, 500);
|
||||
tiny.merge("big", [message(1, "x".repeat(1000))]);
|
||||
tiny.merge("small", [message(2, "y")]);
|
||||
assert.equal(tiny.get("big").length, 0);
|
||||
assert.equal(tiny.get("small").length, 1);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("all cache tests passed");
|
||||
@@ -10,14 +10,23 @@ import {
|
||||
MessageLog,
|
||||
TRUSTED_LOG_LIMIT,
|
||||
UNTRUSTED_LOG_LIMIT,
|
||||
assembleContext,
|
||||
compareSnowflakes,
|
||||
extractMentionIds,
|
||||
formatContextLine,
|
||||
isTrusted,
|
||||
parseCommand,
|
||||
randomActionDelay,
|
||||
stripMentions,
|
||||
toCachedMessage,
|
||||
type CachedMessage,
|
||||
type PuppetMessage
|
||||
} from "./communication.js";
|
||||
|
||||
function cached(id: number, content = `m${id}`): CachedMessage {
|
||||
return { id: String(id), authorId: "1", authorName: "a", content, timestamp: "" };
|
||||
}
|
||||
|
||||
function entry(id: number, trusted: boolean): PuppetMessage {
|
||||
return {
|
||||
id: String(id),
|
||||
@@ -72,4 +81,60 @@ function entry(id: number, trusted: boolean): PuppetMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// context assembly
|
||||
{
|
||||
const messages = Array.from({ length: 20 }, (_, i) => cached(100 + i));
|
||||
const byCount = assembleContext(messages, { maxMessages: 15, maxChars: 1_000_000 });
|
||||
assert.equal(byCount.messages.length, 15);
|
||||
assert.equal(byCount.messages[0].id, "105");
|
||||
assert.equal(byCount.truncated, true);
|
||||
|
||||
const long = Array.from({ length: 10 }, (_, i) => cached(i, "y".repeat(50)));
|
||||
const byChars = assembleContext(long, { maxMessages: 15, maxChars: 120 });
|
||||
assert.equal(byChars.messages.length, 2);
|
||||
assert.ok(byChars.chars <= 120);
|
||||
assert.equal(byChars.truncated, true);
|
||||
|
||||
// the newest message is always kept, even when it exceeds the budget
|
||||
const single = assembleContext([cached(1, "z".repeat(5000))], { maxChars: 100 });
|
||||
assert.equal(single.messages.length, 1);
|
||||
|
||||
// the trigger message can be excluded
|
||||
const excluded = assembleContext([cached(1), cached(2), cached(3)], { excludeId: "3" });
|
||||
assert.deepEqual(
|
||||
excluded.messages.map(m => m.id),
|
||||
["1", "2"]
|
||||
);
|
||||
}
|
||||
|
||||
// context formatting and snowflake compare
|
||||
{
|
||||
const withFile: CachedMessage = { ...cached(1, "look"), attachments: ["clip.mp4"] };
|
||||
assert.equal(formatContextLine(withFile), "a: look [file: clip.mp4]");
|
||||
assert.equal(formatContextLine({ ...cached(2, ""), attachments: ["a.png"] }), "a: [file: a.png]");
|
||||
|
||||
assert.equal(compareSnowflakes("10", "9"), 1);
|
||||
assert.equal(compareSnowflakes("9", "10"), -1);
|
||||
assert.equal(compareSnowflakes("5", "5"), 0);
|
||||
}
|
||||
|
||||
// raw message conversion keeps file names only
|
||||
{
|
||||
const converted = toCachedMessage({
|
||||
id: 5,
|
||||
author: { id: 7, username: "u" },
|
||||
content: "see this",
|
||||
timestamp: "t",
|
||||
attachments: [{ filename: "a.png" }, { filename: "b.mp4" }]
|
||||
});
|
||||
assert.deepEqual(converted, {
|
||||
id: "5",
|
||||
authorId: "7",
|
||||
authorName: "u",
|
||||
content: "see this",
|
||||
timestamp: "t",
|
||||
attachments: ["a.png", "b.mp4"]
|
||||
});
|
||||
}
|
||||
|
||||
console.log("all communication tests passed");
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* Usage:
|
||||
* VESKSLOP_SOCKET=/tmp/vesktop-puppet-mock.sock pnpm test:trigger
|
||||
* TEST_TRIGGER_CONTENT="whitelist add @123" pnpm test:trigger
|
||||
* TEST_TRIGGER_CONTENT="whitelist list" pnpm test:trigger
|
||||
* TEST_TRIGGER_GUILD=100000000000000001 pnpm test:trigger # mention in a guild channel
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
@@ -13,16 +14,18 @@ import { coreSocketPath } from "./core-client.js";
|
||||
|
||||
const authorId = process.env.TEST_TRIGGER_AUTHOR ?? "177117296140091393";
|
||||
const content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list";
|
||||
const guildId = process.env.TEST_TRIGGER_GUILD;
|
||||
const isGuild = !!guildId;
|
||||
|
||||
const event = {
|
||||
type: "MESSAGE_CREATE",
|
||||
id: `30000000000000${Date.now() % 10000}`,
|
||||
channelId: "805255536223715370",
|
||||
guildId: null,
|
||||
channelType: 1,
|
||||
channelId: process.env.TEST_TRIGGER_CHANNEL ?? (isGuild ? "200000000000000010" : "805255536223715370"),
|
||||
guildId: guildId ?? null,
|
||||
channelType: isGuild ? 0 : 1,
|
||||
author: { id: authorId, username: "zzib", bot: false },
|
||||
content,
|
||||
mentions: [],
|
||||
content: isGuild ? `<@999> ${content}` : content,
|
||||
mentions: isGuild ? ["999"] : [],
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user