Add tiers, continuation, fetch cooldown, persistent action log, voice read, batch MCP

This commit is contained in:
Fisto
2026-09-11 15:53:06 +02:00
parent 82f78bb2b4
commit 8e8c15e8bc
17 changed files with 669 additions and 139 deletions
+56
View File
@@ -0,0 +1,56 @@
/*
* Veskslop daemon — persistent action log.
*
* Writes one JSON line per event: triggers, commands, replies, and errors.
* The file rotates at a size limit. Secret-looking strings are redacted.
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
import { dirname } from "node:path";
import { redactValue } from "./communication.js";
export const ACTION_LOG_MAX_BYTES = 5 * 1024 * 1024;
export class ActionLog {
constructor(
private readonly file = process.env.VESKSLOP_ACTION_LOG ?? ".veskslop-actions.jsonl",
private readonly maxBytes = Number(process.env.VESKSLOP_ACTION_LOG_MAX_BYTES ?? ACTION_LOG_MAX_BYTES)
) {
try {
mkdirSync(dirname(this.file), { recursive: true });
} catch {
// the current directory works
}
}
append(entry: Record<string, unknown> & { type: string }) {
const line = JSON.stringify(redactValue({ timestamp: new Date().toISOString(), ...entry }));
try {
if (existsSync(this.file) && statSync(this.file).size + line.length + 1 > this.maxBytes) {
renameSync(this.file, `${this.file}.1`);
}
appendFileSync(this.file, line + "\n");
} catch (error) {
console.error("[action-log] write failed:", error);
}
}
read(limit = 100): unknown[] {
try {
if (!existsSync(this.file)) return [];
const lines = readFileSync(this.file, "utf8").split("\n").filter(Boolean);
return lines.slice(-limit).map(line => {
try {
return JSON.parse(line);
} catch {
return { type: "unparsed", line };
}
});
} catch (error) {
console.error("[action-log] read failed:", error);
return [];
}
}
}
+56 -1
View File
@@ -50,6 +50,10 @@ export class MessageLog {
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;
@@ -113,7 +117,18 @@ 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();
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. */
@@ -158,6 +173,46 @@ export function compareSnowflakes(a: string, b: string): number {
}
}
/* 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;
}
/* 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 {
+86 -5
View File
@@ -34,7 +34,7 @@ async function connectLoop() {
puppet = new Puppet(client);
client.onEvent(event => {
puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error));
void puppet!.enqueueEvent(event);
});
coreConnected = true;
@@ -62,6 +62,23 @@ function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
}
/** Read-only tools that the batch tool can run. Mutating tools stay out. */
const READ_ONLY_TOOLS: Record<string, (args: any) => Promise<unknown> | unknown> = {
get_state: () => requireCore().getState(),
get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId),
get_context: args =>
requirePuppet().getContext(args?.channelId, {
maxMessages: args?.maxMessages,
maxChars: args?.maxChars,
refresh: args?.refresh
}),
get_message_log: () => requirePuppet().getLog(),
get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100),
get_puppet_config: () => requirePuppet().getConfig(),
whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }),
read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20))
};
function buildServer(): McpServer {
const server = new McpServer({ name: "veskslop-control", version: "0.0.0" });
@@ -160,6 +177,8 @@ function buildServer(): McpServer {
config: z.object({
contextMessages: z.number().int().min(1).max(50).optional(),
contextChars: z.number().int().min(200).max(20000).optional(),
fetchCooldownSeconds: z.number().int().min(0).max(300).optional(),
continuationSeconds: z.number().int().min(0).max(300).optional(),
responsePolicy: z
.object({
defaultMode: z.enum(["channel", "dm"]).optional(),
@@ -173,10 +192,70 @@ function buildServer(): McpServer {
async ({ config }) => jsonText(requirePuppet().updateConfig(config))
);
server.registerTool(
"get_voice_state",
{
description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.",
inputSchema: {
userId: z.string().optional().describe("User ID. Defaults to the logged-in user."),
guildId: z.string().optional().describe("Guild ID. Omit for a DM call.")
}
},
async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId))
);
server.registerTool(
"get_action_log",
{
description: "Read the persistent action log: triggers, commands, replies, and errors.",
inputSchema: {
limit: z.number().int().min(1).max(500).default(100).describe("How many entries")
}
},
async ({ limit }) => jsonText(requirePuppet().readActionLog(limit))
);
server.registerTool(
"run_commands",
{
description: "Run several read-only commands in one call, in order.",
inputSchema: {
commands: z
.array(
z.object({
tool: z.string().describe("Read-only tool name"),
arguments: z.record(z.string(), z.any()).optional()
})
)
.min(1)
.max(10)
}
},
async ({ commands }) => {
const results: Array<Record<string, unknown>> = [];
for (const item of commands) {
const handler = READ_ONLY_TOOLS[item.tool];
if (!handler) {
results.push({ tool: item.tool, ok: false, error: "unknown or non-read-only tool" });
continue;
}
try {
results.push({ tool: item.tool, ok: true, result: await handler((item.arguments ?? {}) as any) });
} catch (error) {
results.push({ tool: item.tool, ok: false, error: String((error as Error).message) });
}
}
return jsonText({ results });
}
);
server.registerTool(
"whitelist_list",
{
description: "List the puppet owner and trusted (whitelisted) user IDs."
description: "List the puppet owner and trusted users with their tiers."
},
async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist })
);
@@ -184,12 +263,14 @@ function buildServer(): McpServer {
server.registerTool(
"whitelist_add",
{
description: "Add users to the puppet whitelist.",
description: "Add users to the puppet whitelist with an optional tier. Defaults to 'user'.",
inputSchema: {
userIds: z.array(z.string()).min(1).describe("User IDs to trust")
userIds: z.array(z.string()).min(1).describe("User IDs to trust"),
tier: z.enum(["user", "trusted", "admin"]).optional().describe("Trust tier")
}
},
async ({ userIds }) => jsonText({ whitelist: requirePuppet().addToWhitelist(userIds) })
async ({ userIds, tier }) =>
jsonText({ whitelist: requirePuppet().addToWhitelist(userIds, tier ?? "user") })
);
server.registerTool(
+7
View File
@@ -135,6 +135,13 @@ export class CoreClient {
return this.request("message.fetch", { channelId, limit, ...options });
}
async voiceGet(userId?: string, guildId?: string) {
return this.request("voice.get", {
...(userId ? { userId } : {}),
...(guildId ? { guildId } : {})
});
}
close() {
this.socket.destroy();
}
+10
View File
@@ -50,6 +50,16 @@ function handle(action: string | undefined, params: any) {
return "pong";
case "state.get":
return { user: { id: "999", username: "mockuser" }, guilds: FAKE_GUILDS };
case "voice.get":
return {
guildId: params?.guildId ?? "100000000000000001",
channelId: "200000000000000002",
channelName: "voice",
members: [
{ id: params?.userId ?? "999", username: "mockuser", mute: false, deaf: false },
{ id: "177117296140091393", username: "zzib", mute: false, deaf: false }
]
};
case "message.send":
return { channelId: params?.channelId, sent: true };
case "message.read":
+232 -65
View File
@@ -1,26 +1,30 @@
/*
* Veskslop daemon — puppet state: trust list, trigger log, response policy,
* conversation context, and Discord-facing command handling.
* Veskslop daemon — puppet state: trust tiers, trigger log, response policy,
* conversation context, continuation windows, and Discord-facing commands.
*
* 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.
* Trigger scope: direct messages and direct mentions from trusted users.
* Trust tiers: user, trusted, admin. The owner is an admin. Whitelist
* management requires the admin tier.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { ActionLog } from "./action-log.js";
import {
assembleContext,
isTrusted,
hasTier,
MessageLog,
parseCommand,
parseTier,
randomActionDelay,
stripMentions,
toCachedMessage,
type AssembledContext,
type CachedMessage,
type ParsedCommand,
type PuppetMessage
type PuppetMessage,
type TrustTier
} from "./communication.js";
import { MessageCache } from "./message-cache.js";
import { paced } from "./pacing.js";
@@ -41,12 +45,16 @@ export interface ResponsePolicy {
export interface PuppetConfig {
contextMessages: number;
contextChars: number;
fetchCooldownSeconds: number;
continuationSeconds: number;
responsePolicy: ResponsePolicy;
}
export interface PuppetConfigPatch {
contextMessages?: number;
contextChars?: number;
fetchCooldownSeconds?: number;
continuationSeconds?: number;
responsePolicy?: {
defaultMode?: "channel" | "dm";
sensitiveCommands?: string[];
@@ -57,6 +65,8 @@ export interface PuppetConfigPatch {
const DEFAULT_CONFIG: PuppetConfig = {
contextMessages: 15,
contextChars: 4000,
fetchCooldownSeconds: 5,
continuationSeconds: 10,
responsePolicy: {
defaultMode: "channel",
sensitiveCommands: ["whitelist", "config"],
@@ -66,7 +76,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
interface PersistedState {
ownerId?: string;
whitelist?: string[];
whitelist?: string[] | Record<string, TrustTier>;
config?: Partial<PuppetConfig>;
}
@@ -89,21 +99,50 @@ function clamp(value: number, min: number, max: number): number {
export class Puppet {
readonly log = new MessageLog();
private readonly actionLog = new ActionLog(
process.env.VESKSLOP_ACTION_LOG ?? join(dirname(STATE_FILE), "actions.jsonl")
);
private readonly cache = new MessageCache();
private readonly tiers = new Map<string, TrustTier>();
private readonly seen = new Set<string>();
private readonly seenOrder: string[] = [];
private readonly lastFetchAt = new Map<string, number>();
private readonly pending = new Map<string, { entryId: string; expiresAt: number }>();
private ownerId = DEFAULT_OWNER_ID;
private whitelistIds: string[] = [];
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
private selfId?: string;
private readonly cache = new MessageCache();
private queue: Promise<void> = Promise.resolve();
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;
// Accept the old array shape and the new tier map.
if (Array.isArray(state.whitelist)) {
for (const id of state.whitelist) this.tiers.set(id, "user");
} else if (state.whitelist) {
for (const [id, tier] of Object.entries(state.whitelist)) {
const parsed = parseTier(tier);
if (parsed) this.tiers.set(id, parsed);
}
}
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),
fetchCooldownSeconds: clamp(
state.config.fetchCooldownSeconds ?? DEFAULT_CONFIG.fetchCooldownSeconds,
0,
300
),
continuationSeconds: clamp(
state.config.continuationSeconds ?? DEFAULT_CONFIG.continuationSeconds,
0,
300
),
responsePolicy: {
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
sensitiveCommands:
@@ -118,14 +157,22 @@ export class Puppet {
}
}
get whitelist(): string[] {
return [...this.whitelistIds];
get whitelist(): Record<string, TrustTier> {
return Object.fromEntries(this.tiers);
}
get owner() {
return this.ownerId;
}
tierOf(userId: string): TrustTier | undefined {
return userId === this.ownerId ? "admin" : this.tiers.get(userId);
}
isTrusted(userId: string): boolean {
return this.tierOf(userId) !== undefined;
}
getConfig(): PuppetConfig {
return structuredClone(this.config);
}
@@ -136,6 +183,12 @@ export class Puppet {
this.config = {
contextMessages: clamp(patch.contextMessages ?? this.config.contextMessages, 1, 50),
contextChars: clamp(patch.contextChars ?? this.config.contextChars, 200, 20000),
fetchCooldownSeconds: clamp(
patch.fetchCooldownSeconds ?? this.config.fetchCooldownSeconds,
0,
300
),
continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300),
responsePolicy: {
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
@@ -151,22 +204,21 @@ export class Puppet {
return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist };
}
isTrusted(userId: string): boolean {
return isTrusted(userId, this.ownerId, this.whitelistIds);
readActionLog(limit = 100) {
return this.actionLog.read(limit);
}
addToWhitelist(userIds: string[]): string[] {
const next = new Set(this.whitelistIds);
for (const id of userIds) if (id && id !== this.ownerId) next.add(id);
addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record<string, TrustTier> {
for (const id of userIds) {
if (id && id !== this.ownerId) this.tiers.set(id, tier);
}
this.whitelistIds = [...next];
this.save();
return this.whitelist;
}
removeFromWhitelist(userIds: string[]): string[] {
const remove = new Set(userIds);
this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id));
removeFromWhitelist(userIds: string[]): Record<string, TrustTier> {
for (const id of userIds) this.tiers.delete(id);
this.save();
return this.whitelist;
}
@@ -176,13 +228,14 @@ export class Puppet {
writeFileSync(
STATE_FILE,
JSON.stringify(
{ ownerId: this.ownerId, whitelist: this.whitelistIds, config: this.config },
{ ownerId: this.ownerId, whitelist: this.whitelist, config: this.config },
null,
4
)
);
} catch (error) {
console.error("[puppet] failed to save state:", error);
this.actionLog.append({ type: "error", scope: "state-save", message: String(error) });
}
}
@@ -190,8 +243,12 @@ export class Puppet {
private async refreshCache(channelId: string): Promise<CachedMessage[]> {
const cached = this.cache.get(channelId);
const newest = cached.at(-1)?.id;
const lastFetch = this.lastFetchAt.get(channelId) ?? 0;
if (Date.now() - lastFetch < this.config.fetchCooldownSeconds * 1000) return cached;
this.lastFetchAt.set(channelId, Date.now());
const newest = cached.at(-1)?.id;
const raw = await paced(() =>
newest
? this.core.fetchMessages(channelId, 100, { after: newest })
@@ -227,6 +284,28 @@ export class Puppet {
/* Triggers */
/** Process events one at a time. Order matters for continuation windows. */
enqueueEvent(event: any): Promise<void> {
this.queue = this.queue
.then(() => this.handleEvent(event))
.catch(error => {
console.error("[puppet] event failed:", error);
this.actionLog.append({ type: "error", scope: "event", message: String(error) });
});
return this.queue;
}
private remember(id: string) {
this.seen.add(id);
this.seenOrder.push(id);
if (this.seenOrder.length > 1000) {
const oldest = this.seenOrder.shift()!;
this.seen.delete(oldest);
}
}
async handleEvent(event: any) {
if (event?.type !== "MESSAGE_CREATE") return;
@@ -234,17 +313,52 @@ export class Puppet {
if (!author || !channelId) return;
if (author.bot) return;
// Drop replayed events after a reconnect.
if (event.id) {
if (this.seen.has(event.id)) return;
this.remember(event.id);
}
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 tier = this.tierOf(author.id);
if (!tier) return;
const isGroupDm = channelType === GROUP_DM_CHANNEL;
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null));
const mentioned = new Set<string>(mentions ?? []).has(this.selfId);
const isTrigger = isDirectDm || mentioned;
const mentionIds = new Set<string>(mentions ?? []);
if (!isDirectDm && !mentionIds.has(this.selfId)) return;
const key = `${channelId}:${author.id}`;
// A rushed follow-up message joins the previous request as context.
if (!isTrigger) {
const pending = this.pending.get(key);
if (pending && Date.now() < pending.expiresAt) {
const entry = this.log.find(pending.entryId);
if (entry) {
const followUp: CachedMessage = {
id: event.id,
authorId: author.id,
authorName: author.username,
content: content ?? "",
timestamp: event.timestamp ?? new Date().toISOString()
};
entry.context = [...(entry.context ?? []), followUp];
pending.expiresAt = Date.now() + this.config.continuationSeconds * 1000;
this.actionLog.append({
type: "continuation",
messageId: event.id,
entryId: entry.id,
channelId,
authorId: author.id
});
}
}
return;
}
const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text);
@@ -261,20 +375,37 @@ export class Puppet {
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.actionLog.append({ type: "error", scope: "context", channelId, message: String(error) });
}
}
this.log.add(entry);
if (command?.name === "whitelist" && author.id === this.ownerId) {
await this.handleWhitelistCommand(command, author.id, channelId);
if (this.config.continuationSeconds > 0) {
this.pending.set(key, {
entryId: entry.id,
expiresAt: Date.now() + this.config.continuationSeconds * 1000
});
}
this.actionLog.append({
type: "trigger",
messageId: entry.id,
channelId,
guildId: guildId ?? null,
authorId: author.id,
tier,
command: entry.command,
contextCount: entry.context?.length ?? 0
});
if (command?.name === "whitelist") {
await this.handleWhitelistCommand(command, author.id, tier, channelId);
}
}
@@ -285,38 +416,72 @@ export class Puppet {
return [...ids];
}
private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) {
private async handleWhitelistCommand(
command: ParsedCommand,
authorId: string,
tier: TrustTier,
channelId: string
) {
if (!hasTier(tier, "admin")) {
return this.respond(authorId, channelId, "whitelist", "You do not have permission for this command.");
}
const [sub, ...rest] = command.args;
switch (sub) {
case "list":
case "list": {
const lines = [this.ownerId, ...this.tiers.keys()].map(id => `${id} — ${this.tierOf(id)}`);
this.actionLog.append({ type: "command", command: "whitelist list", requesterId: authorId, result: "ok" });
return this.respond(authorId, channelId, "whitelist", `Trusted users:\n${lines.join("\n")}`);
}
case "add": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) {
return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user [user|trusted|admin]");
}
const last = rest.at(-1)?.toLowerCase();
const requestedTier = (last && parseTier(last)) || "user";
this.addToWhitelist(ids, requestedTier);
this.actionLog.append({
type: "command",
command: `whitelist add ${ids.join(",")} ${requestedTier}`,
requesterId: authorId,
result: "ok"
});
return this.respond(
authorId,
channelId,
"whitelist",
`Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}`
`Added:\n${ids.map(id => `${id} — ${requestedTier}`).join("\n")}`
);
case "add": {
const ids = this.extractUserIds(rest.join(" "));
if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user");
this.addToWhitelist(ids);
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.respond(authorId, channelId, "whitelist", "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);
this.actionLog.append({
type: "command",
command: `whitelist remove ${removed.join(",")}`,
requesterId: authorId,
result: "ok"
});
return this.respond(
authorId,
channelId,
"whitelist",
[
removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.",
removed.length ? `Removed:\n${removed.join("\n")}` : "Nothing removed.",
ids.length !== removed.length ? "The owner cannot be removed." : ""
]
.filter(Boolean)
@@ -331,16 +496,6 @@ export class Puppet {
/* 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.core.sendMessage(channelId, content);
} catch (error) {
console.error("[puppet] reply failed:", error);
}
}
private async respond(
requesterId: string,
originChannelId: string,
@@ -352,17 +507,29 @@ export class Puppet {
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 new Promise(resolve => setTimeout(resolve, randomActionDelay()));
await this.reply(originChannelId, content);
try {
if (mode === "dm") await this.core.sendDm(requesterId, content);
else await this.core.sendMessage(originChannelId, content);
this.actionLog.append({
type: "reply",
command: commandName,
mode,
target: mode === "dm" ? requesterId : originChannelId,
result: "ok"
});
} catch (error) {
// Never fall back to a public channel for sensitive content.
this.actionLog.append({
type: "reply",
command: commandName,
mode,
target: mode === "dm" ? requesterId : originChannelId,
result: "error",
error: String(error)
});
}
}
}
+39
View File
@@ -14,9 +14,12 @@ import {
compareSnowflakes,
extractMentionIds,
formatContextLine,
hasTier,
isTrusted,
parseCommand,
parseTier,
randomActionDelay,
redactSecrets,
stripMentions,
toCachedMessage,
type CachedMessage,
@@ -137,4 +140,40 @@ function entry(id: number, trusted: boolean): PuppetMessage {
});
}
// timestamps in context lines
{
const withTime: CachedMessage = {
...cached(1, "hi"),
timestamp: "2026-09-11T14:03:00.000Z"
};
assert.match(formatContextLine(withTime), /^\[\d\d:\d\d\] a: hi$/);
assert.equal(formatContextLine(cached(2, "no time")), "a: no time");
}
// secret redaction
{
const fakeToken = `${"x".repeat(24)}.${"abcde"}.${"y".repeat(25)}`;
assert.equal(redactSecrets(`token ${fakeToken} here`), "token [redacted] here");
assert.equal(redactSecrets("mfa." + "z".repeat(30)), "[redacted]");
assert.equal(redactSecrets("nothing to hide"), "nothing to hide");
}
// trust tiers
{
assert.equal(hasTier("admin", "admin"), true);
assert.equal(hasTier("trusted", "admin"), false);
assert.equal(hasTier("trusted", "user"), true);
assert.equal(hasTier(undefined, "user"), false);
assert.equal(parseTier("trusted"), "trusted");
assert.equal(parseTier("bogus"), undefined);
}
// log lookup by ID
{
const log = new MessageLog();
log.add({ ...entry(1, true), context: [] });
assert.equal(log.find("1")?.id, "1");
assert.equal(log.find("nope"), undefined);
}
console.log("all communication tests passed");
+3 -2
View File
@@ -16,6 +16,7 @@ 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 mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0";
const event = {
type: "MESSAGE_CREATE",
@@ -24,8 +25,8 @@ const event = {
guildId: guildId ?? null,
channelType: isGuild ? 0 : 1,
author: { id: authorId, username: "zzib", bot: false },
content: isGuild ? `<@999> ${content}` : content,
mentions: isGuild ? ["999"] : [],
content: mention ? `<@999> ${content}` : content,
mentions: mention ? ["999"] : [],
timestamp: new Date().toISOString()
};