Add text features: trusted triggers, context cache, response policy, MCP config tools

This commit is contained in:
Fisto
2026-09-11 15:31:23 +02:00
parent 23a1a8e593
commit 82f78bb2b4
20 changed files with 841 additions and 88 deletions
+2 -1
View File
@@ -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",
+90
View File
@@ -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
};
}
+45
View File
@@ -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",
{
+4
View File
@@ -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();
}
+99
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+51
View File
@@ -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");
+65
View File
@@ -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");
+9 -6
View File
@@ -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()
};