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
+9 -6
View File
@@ -50,22 +50,25 @@ before you stop.
| Main bridge, event push, hooks | Implemented | | Main bridge, event push, hooks | Implemented |
| Daemon on the core-bridge transport | Implemented and verified live | | Daemon on the core-bridge transport | Implemented and verified live |
| QR login, pacing, trigger log | Implemented | | QR login, pacing, trigger log | Implemented |
| Trusted-only triggers and context cache | Implemented | | Trusted-only triggers, trust tiers | Implemented |
| Context cache, fetch cooldown, continuation | Implemented |
| Response policy (channel or DM) | Implemented | | Response policy (channel or DM) | Implemented |
| Persistent action log | Implemented |
| Voice state read | Implemented |
| Voice actions, notification parsing | Not started |
| Auth persistence in the core | Implemented and verified live | | Auth persistence in the core | Implemented and verified live |
| CDP transport | Kept as a development fallback | | CDP transport | Kept as a development fallback |
| Notification parsing, persistent log | Not started |
| Voice, audio, video | Not started |
## Work items ## Work items
See [CURRENT.md](CURRENT.md) and [TODO.md](TODO.md) for the live list. The main items are: See [CURRENT.md](CURRENT.md) and [TODO.md](TODO.md) for the live list. The main items are:
1. Notification parsing: edits, deletes, reactions, and replies. 1. Notification parsing: edits, deletes, reactions, and replies.
2. Persistent trigger log with rotation. 2. Reply-to-puppet as a trigger.
3. Per-user rate limit and command cooldowns. 3. Per-user rate limit and command cooldowns.
4. MCP event delivery for agents. 4. Setup command with reaction-based multiple choice.
5. Later: voice, audio, video, client lightening, web debug UI. 5. MCP event delivery decision.
6. Collector account with read-only voice membership.
## Dev commands ## Dev commands
+18 -16
View File
@@ -6,36 +6,38 @@ This file tracks the work state. Read it first. Update it before you stop.
## Current focus (broad) ## Current focus (broad)
Build the text-based communication layer: trusted-only triggers, conversation context, response Build the text-based communication layer: trusted tiers, context, response routing, MCP tools, and
routing, and MCP tools. Voice, audio, and video stay deferred. persistent logging. Voice, audio, and video stay deferred.
## In progress ## In progress
- None. The text milestone is complete and verified with the mock core. - None. The latest text milestone is complete and verified with the mock core.
## Done ## Done
- Validated core bridge: protocol, actions, validation, structured errors, hard outbound rate limit. - Validated core bridge: protocol, actions, validation, structured errors, hard outbound rate limit.
- Core bridge wired: renderer events, main bridge, and the upstream hooks.
- Auth persistence: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`. - Auth persistence: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`.
- Daemon moved to the core-bridge transport. CDP stays as a development fallback. - Daemon on the core-bridge transport. CDP stays as a development fallback.
- Trusted-only triggers: direct messages and direct mentions. - Trusted-only triggers: DMs and direct mentions. Trust tiers: user, trusted, admin.
- Context: 15 messages or 4000 characters, file names only, incremental fetch, tmp cache with a - Context: 15 messages or 4000 characters, time stamps, file names only, incremental fetch, tmp
size limit. cache with a size limit, fetch cooldown.
- Continuation window: a rushed follow-up joins the request context.
- Response policy: origin channel or DM. Sensitive commands default to DM. - Response policy: origin channel or DM. Sensitive commands default to DM.
- MCP tools: context and config tools beside the earlier state and log tools. - Persistent action log: triggers, commands, replies, errors, rotation, and secret redaction.
- Unit tests for the pure logic and the cache. Mock end-to-end tests passed. - MCP tools: context, config, voice state, action log, and a read-only batch tool.
- Event processing is serialized. Replayed events are dropped.
- Unit tests for the pure logic and the cache. Mock end-to-end tests pass.
- Live verification: state, DM send, and a synthetic trigger through the real event path. - Live verification: state, DM send, and a synthetic trigger through the real event path.
## Next (in order) ## Next (in order)
1. Notification parsing: edits, deletes, reactions, replies, and message references. 1. Notification parsing: edits, deletes, reactions, and message references.
2. Persistent trigger log with rotation. 2. Reply-to-puppet counts as a trigger.
3. Per-user rate limit and command cooldowns. 3. Per-user rate limit and command cooldowns.
4. More commands: `ask`, `summarize`, `context`, and `config`. 4. Setup command with reaction-based multiple choice.
5. MCP event delivery for agents. Push or a stateful stream. 5. MCP event delivery decision: poll, stateful session, or a local event stream.
6. Multi-identity: a read-only collector account beside the puppet. 6. Collector account: read-only modules for voice membership fallback.
7. Later: voice, audio, video, client lightening, web debug UI, remote authentication. 7. Later: voice actions, audio, video, client lightening, web debug UI, remote authentication.
## Blocked / needs input ## Blocked / needs input
+31 -23
View File
@@ -6,17 +6,17 @@ Ordered backlog. `CURRENT.md` tracks the live session state.
- The validated core bridge is live: a fixed action list, input validation, structured errors, and a - The validated core bridge is live: a fixed action list, input validation, structured errors, and a
hard outbound rate limit. See `docs/core-bridge.md`. hard outbound rate limit. See `docs/core-bridge.md`.
- The daemon uses the core bridge. QR login, pacing, the trigger log, the trust list, the context - The daemon uses the core bridge. QR login, pacing, the trigger log, trust tiers, context, the
cache, and the response policy work. response policy, the continuation window, the persistent action log, and the read-only voice
- Mock tests and a live test passed: state, DM send, and a synthetic trigger through the real event state all work.
path. - Mock end-to-end tests pass: tiers, continuation, sensitive replies, batch MCP, and voice state.
- Repo note: use `zib/Veskslop-t` (remote `veskslop-t`) until the original repo's server hook is - Repo note: use `zib/Veskslop-t` (remote `veskslop-t`) until the original repo's server hook is
fixed. Pushes to the original land, but its UI and fetch path are broken. fixed.
## Deferred ## Deferred
- Voice join and leave, invite-link joins, audio in and out, and video streaming. Text features come - Voice join and leave, invite-link joins, audio in and out, and video streaming. Voice state
first. reads exist. Voice actions do not.
- Client lightening. Low priority. The client must stay stable. - Client lightening. Low priority. The client must stay stable.
- Web debug UI and remote client authentication. - Web debug UI and remote client authentication.
@@ -25,25 +25,33 @@ Ordered backlog. `CURRENT.md` tracks the live session state.
Done: Done:
- Trusted-only triggers: direct messages and direct mentions. - Trusted-only triggers: direct messages and direct mentions.
- Context: the last 15 messages or 4000 characters, whichever is lower. File names only for videos - Trust tiers: `user`, `trusted`, `admin`. The owner is an admin.
and images. Incremental fetch with an old-message cache. - Context: the last 15 messages or 4000 characters, whichever is lower. File names only.
- Context cache in a tmp folder with a size limit. Time stamps. Incremental fetch with an old-message cache.
- Response policy: reply in the origin channel or by DM. Sensitive commands default to DM. - Fetch cooldown: `fetchCooldownSeconds`, default 5.
- MCP tools for context and config. - Continuation window: `continuationSeconds`, default 10. A rushed follow-up joins the request
context.
- Response policy: origin channel or DM. Sensitive commands default to DM.
- Persistent action log with rotation and secret redaction.
- MCP batch tool for read-only commands, plus a voice state tool.
Next: Next:
- Notification parsing: edits, deletes, reactions, replies, and message references. - Notification parsing: edits, deletes, reactions, and message references.
- Persistent trigger log with rotation. - Reply-to-puppet counts as a trigger.
- Per-user rate limit and command cooldowns. - Per-user rate limit and command cooldowns.
- More commands: `ask`, `summarize`, `context`, and `config`. - Setup command with reaction-based multiple choice. The puppet reacts 1, 2, 3, 4 to its own
- MCP event delivery for agents. Push or a stateful stream. message and waits for the user reaction. This also serves as a small poll for group chats.
- Multi-identity: a read-only collector account beside the puppet. - More chat commands: `ask`, `summarize`, `context`, `config`.
- Collector account: stripped-down read-only modules. Use it when the puppet cannot find a user's
voice channel or the channel members.
## Open design questions ## MCP event delivery (open)
- MCP event delivery: the current Streamable HTTP mode is stateless, so the server cannot push The current Streamable HTTP mode is stateless. The server cannot push events. Options:
events. Options are polling, SSE, or a stateful session.
- Untrusted interactions: ignored for now. The daemon keeps the 30-entry untrusted buffer for later. 1. Poll `get_message_log` and `get_action_log` with a cursor. No new transport work.
- Context freshness: the daemon fetches on each mention. Add a TTL or a minimum gap if the query 2. A stateful Streamable HTTP session. The server can send notifications.
cost is too high. 3. A separate local event stream over SSE or a WebSocket.
Option 1 works today. Options 2 and 3 need a decision before an LLM attaches.
+68 -20
View File
@@ -1,27 +1,31 @@
# Communication # Communication
This document describes the trigger rules, the trust model, the commands, the trigger log, the This document describes the trigger rules, trust tiers, commands, the trigger log, context, the
context cache, and the response policy. continuation window, and the response policy.
## Trigger rules ## Trigger rules
A message is a trigger when both conditions are true: A message is a trigger when both conditions are true:
- The author is trusted. The owner counts as trusted. - The author is trusted. Each tier counts as trusted.
- One of these conditions is true: - One of these conditions is true:
- The message is a one-to-one DM to the puppet. - The message is a one-to-one DM to the puppet.
- The message mentions the puppet in a guild channel or a group DM. - The message mentions the puppet in a guild channel or a group DM.
All other messages are ignored. Untrusted messages do not enter the log at this time. The daemon All other messages are ignored. Untrusted messages do not enter the log at this time. Bots and the
ignores bots and its own messages. puppet's own messages are ignored.
## Trust model The daemon processes events one at a time. This order matters for continuation windows.
The puppet has one owner and a trust list. ## Trust tiers
- The owner is always trusted. The default owner is `177117296140091393` (zzib). | Tier | Meaning |
- The trust list holds more user IDs. | --- | --- |
- The owner controls the trust list. Commands from other users do not change the list. | `user` | Can trigger the puppet. This is the default tier. |
| `trusted` | Can trigger the puppet. Future chat commands need this tier. |
| `admin` | Can manage the trust list and the config. The owner is always an admin. |
The owner controls the trust list. Commands from other users do not change the list.
## Commands ## Commands
@@ -30,14 +34,15 @@ mention the puppet first.
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `whitelist list` | Show the owner and the trusted users. | | `whitelist list` | Show the owner and the trusted users with their tiers. |
| `whitelist add @user` | Add one or more users to the trust list. | | `whitelist add @user [user\|trusted\|admin]` | Add one or more users. The default tier is `user`. |
| `whitelist remove @user` | Remove one or more users from the trust list. | | `whitelist remove @user` | Remove one or more users from the trust list. |
Notes: Notes:
- The owner cannot be removed. - The owner cannot be removed.
- The command accepts mentions and raw user IDs. - The command accepts mentions and raw user IDs.
- `whitelist` commands need the admin tier.
- The reply follows the response policy. - The reply follows the response policy.
## Response policy ## Response policy
@@ -62,7 +67,7 @@ The log has two ring buffers.
| Buffer | Limit | Content | | Buffer | Limit | Content |
| --- | --- | --- | | --- | --- | --- |
| Trusted | 100 entries | Triggers from the owner and trusted users | | Trusted | 100 entries | Triggers from trusted users |
| Untrusted | 30 entries | Reserved. No triggers enter this buffer at this time. | | Untrusted | 30 entries | Reserved. No triggers enter this buffer at this time. |
One entry holds these fields: One entry holds these fields:
@@ -78,7 +83,7 @@ One entry holds these fields:
| `timestamp` | The message timestamp | | `timestamp` | The message timestamp |
| `trusted` | True for a trusted author | | `trusted` | True for a trusted author |
| `command` | The parsed command text, when the message looks like a command | | `command` | The parsed command text, when the message looks like a command |
| `context` | Prior messages. Present on mention triggers. | | `context` | Prior messages. Present on mention triggers. Follow-ups join this list. |
## Context ## Context
@@ -87,7 +92,8 @@ The daemon collects prior messages for a mention trigger. Direct messages do not
- Message limit: `contextMessages`, default 15. - Message limit: `contextMessages`, default 15.
- Character limit: `contextChars`, default 4000. - Character limit: `contextChars`, default 4000.
- The lower limit stops the collection. - The lower limit stops the collection.
- The content is the message text and the attachment file names. Media bodies stay on Discord. - Each line holds a time stamp, the author, the message text, and attachment file names.
- Media bodies stay on Discord.
- The trigger message is not part of its own context. - The trigger message is not part of its own context.
The cache makes the fetch small. The cache makes the fetch small.
@@ -97,6 +103,9 @@ The cache makes the fetch small.
3. It merges the new messages into the cache. 3. It merges the new messages into the cache.
4. It assembles the context from the merged list. 4. It assembles the context from the merged list.
The fetch cooldown stops a burst of queries. The setting `fetchCooldownSeconds` (default 5) is the
minimum gap between two fetches for one channel.
Cache settings: Cache settings:
| Setting | Default | | Setting | Default |
@@ -107,15 +116,53 @@ Cache settings:
Eviction removes the least recently written channel files until the folder fits the size limit. Eviction removes the least recently written channel files until the folder fits the size limit.
## Continuation window
A user can send a second message right after a request. The second message often adds a missing
detail. The daemon appends such messages to the request context.
- Window: `continuationSeconds`, default 10.
- The window starts at the trigger and resets on each follow-up.
- Only messages from the same author in the same channel count.
- A follow-up does not need a mention.
- A follow-up needs a trusted author.
## Action log
The daemon writes a persistent log beside the state file: `actions.jsonl`. One JSON line holds one
event. The log rotates at the size limit.
| Event | Content |
| --- | --- |
| `trigger` | Message ID, channel, guild, author, tier, command, context count |
| `continuation` | Follow-up message ID and the parent entry ID |
| `command` | Command text, requester, result |
| `reply` | Command, mode, target, result, error |
| `error` | Scope and message |
Secret-looking strings are redacted before the write. Discord token shapes and `mfa.` strings
become `[redacted]`.
Settings:
| Setting | Default |
| --- | --- |
| `VESKSLOP_ACTION_LOG` | `<state dir>/actions.jsonl` |
| `VESKSLOP_ACTION_LOG_MAX_BYTES` | 5 MiB |
Read the log with the `get_action_log` MCP tool.
## Event flow ## Event flow
1. The client receives a `MESSAGE_CREATE` gateway event. 1. The client receives a `MESSAGE_CREATE` gateway event.
2. The core normalizes the event. 2. The core normalizes the event.
3. The core bridge pushes the event to the daemon. 3. The core bridge pushes the event to the daemon.
4. The daemon applies the trigger rules. 4. The daemon queues the event. Events run one at a time.
5. The daemon collects context for a mention trigger. 5. The daemon drops a replayed event.
6. The daemon adds the entry to the log. 6. The daemon applies the trigger rules.
7. The daemon runs a trusted command and replies through the response policy. 7. The daemon collects context for a mention trigger.
8. The daemon adds the entry to the log and writes the action log.
9. The daemon runs a trusted command and replies through the response policy.
## State file ## State file
@@ -123,7 +170,8 @@ The daemon stores the owner ID, the trust list, and the config in JSON.
- Default path: `poc/.veskslop-state.json`. - Default path: `poc/.veskslop-state.json`.
- Override: `VESKSLOP_STATE_FILE`. - Override: `VESKSLOP_STATE_FILE`.
- The trigger log is in memory only. The log resets at restart. - The trust list is an object: user ID to tier.
- The trigger log is in memory only. The action log is persistent.
## How to add a trigger rule or a command ## How to add a trigger rule or a command
+2
View File
@@ -57,6 +57,7 @@ Event line:
| `ping` | none | `"pong"` | | `ping` | none | `"pong"` |
| `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. | | `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. |
| `state.get` | none | The current user, the guilds, and the channels. Cache-only. | | `state.get` | none | The current user, the guilds, and the channels. Cache-only. |
| `voice.get` | optional `userId`, optional `guildId` | The voice channel and its members, or `channelId: null` |
| `message.send` | `channelId`, `content` | `{ channelId, sent: true }` | | `message.send` | `channelId`, `content` | `{ channelId, sent: true }` |
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. | | `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. |
| `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. | | `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. |
@@ -76,6 +77,7 @@ The core validates all input. The daemon cannot bypass these rules.
- `limit` must be an integer from 1 to 100. - `limit` must be an integer from 1 to 100.
- `before` and `after` must be snowflakes. They are mutually exclusive. `message.fetch` uses them - `before` and `after` must be snowflakes. They are mutually exclusive. `message.fetch` uses them
to walk history. to walk history.
- `voice.get` accepts snowflakes only. It reads the client cache. It never joins a call.
- A request line must not be larger than 64 KiB. - A request line must not be larger than 64 KiB.
## Core rate limit ## Core rate limit
+6
View File
@@ -10,6 +10,7 @@ The daemon runs outside Vesktop. It holds all logic. It talks to the core bridge
| `poc/src/puppet.ts` | Trust list, command handling, trigger log, response policy | Implemented | | `poc/src/puppet.ts` | Trust list, command handling, trigger log, response policy | Implemented |
| `poc/src/communication.ts` | Pure log, parser, and context logic | Implemented | | `poc/src/communication.ts` | Pure log, parser, and context logic | Implemented |
| `poc/src/message-cache.ts` | Channel message cache in a tmp folder | Implemented | | `poc/src/message-cache.ts` | Channel message cache in a tmp folder | Implemented |
| `poc/src/action-log.ts` | Persistent JSONL action log with rotation | Implemented |
| `poc/src/pacing.ts` | Paced queue and query coalescing | Implemented | | `poc/src/pacing.ts` | Paced queue and query coalescing | Implemented |
| `poc/src/core-client.ts` | Client actions over the core bridge | Implemented (primary transport) | | `poc/src/core-client.ts` | Client actions over the core bridge | Implemented (primary transport) |
| `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Development fallback | | `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Development fallback |
@@ -48,7 +49,10 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127.
| `send_message` | Send a message to a channel. | | `send_message` | Send a message to a channel. |
| `read_messages` | Read cached messages from a channel. | | `read_messages` | Read cached messages from a channel. |
| `send_dm` | Send a DM to a user. | | `send_dm` | Send a DM to a user. |
| `get_voice_state` | Which voice channel a user is in and who else is there. |
| `get_message_log` | Read the trigger log. | | `get_message_log` | Read the trigger log. |
| `get_action_log` | Read the persistent action log. |
| `run_commands` | Run several read-only tools in one call, in order. |
| `get_context` | Assemble conversation context for a channel. Fetches new messages by default. | | `get_context` | Assemble conversation context for a channel. Fetches new messages by default. |
| `get_puppet_config` | Read the response policy and context settings. | | `get_puppet_config` | Read the response policy and context settings. |
| `set_puppet_config` | Change the response policy and context settings. | | `set_puppet_config` | Change the response policy and context settings. |
@@ -94,6 +98,8 @@ CDP_EXPRESSION='...' pnpm cdp:dispatch
| --- | --- | --- | | --- | --- | --- |
| `VESKSLOP_SOCKET` | `$XDG_RUNTIME_DIR/vesktop-puppet.sock` | Core bridge socket | | `VESKSLOP_SOCKET` | `$XDG_RUNTIME_DIR/vesktop-puppet.sock` | Core bridge socket |
| `VESKSLOP_STATE_FILE` | `poc/.veskslop-state.json` | Owner, trust list, and config | | `VESKSLOP_STATE_FILE` | `poc/.veskslop-state.json` | Owner, trust list, and config |
| `VESKSLOP_ACTION_LOG` | `<state dir>/actions.jsonl` | Persistent action log |
| `VESKSLOP_ACTION_LOG_MAX_BYTES` | 5 MiB | Action log rotation limit |
| `VESKSLOP_CACHE_DIR` | `<tmp>/veskslop-cache` | Context cache folder | | `VESKSLOP_CACHE_DIR` | `<tmp>/veskslop-cache` | Context cache folder |
| `VESKSLOP_CACHE_MAX_BYTES` | 50 MiB | Context cache size limit | | `VESKSLOP_CACHE_MAX_BYTES` | 50 MiB | Context cache size limit |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8788` | MCP listen address | | `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8788` | MCP listen address |
+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() { snapshot() {
return { trusted: [...this.trusted], untrusted: [...this.untrusted] }; 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; const USER_MENTION = /<@!?(\d+)>/g;
@@ -113,7 +117,18 @@ export function formatContextLine(message: CachedMessage): string {
const parts: string[] = []; const parts: string[] = [];
if (message.content) parts.push(message.content); if (message.content) parts.push(message.content);
for (const name of message.attachments ?? []) parts.push(`[file: ${name}]`); 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. */ /** 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. */ /** Convert a core `message.fetch` or `message.read` message into a cached message. */
export function toCachedMessage(raw: any): CachedMessage { export function toCachedMessage(raw: any): CachedMessage {
return { return {
+86 -5
View File
@@ -34,7 +34,7 @@ async function connectLoop() {
puppet = new Puppet(client); puppet = new Puppet(client);
client.onEvent(event => { client.onEvent(event => {
puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error)); void puppet!.enqueueEvent(event);
}); });
coreConnected = true; coreConnected = true;
@@ -62,6 +62,23 @@ function jsonText(value: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; 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 { function buildServer(): McpServer {
const server = new McpServer({ name: "veskslop-control", version: "0.0.0" }); const server = new McpServer({ name: "veskslop-control", version: "0.0.0" });
@@ -160,6 +177,8 @@ function buildServer(): McpServer {
config: z.object({ config: z.object({
contextMessages: z.number().int().min(1).max(50).optional(), contextMessages: z.number().int().min(1).max(50).optional(),
contextChars: z.number().int().min(200).max(20000).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 responsePolicy: z
.object({ .object({
defaultMode: z.enum(["channel", "dm"]).optional(), defaultMode: z.enum(["channel", "dm"]).optional(),
@@ -173,10 +192,70 @@ function buildServer(): McpServer {
async ({ config }) => jsonText(requirePuppet().updateConfig(config)) 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( server.registerTool(
"whitelist_list", "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 }) async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist })
); );
@@ -184,12 +263,14 @@ function buildServer(): McpServer {
server.registerTool( server.registerTool(
"whitelist_add", "whitelist_add",
{ {
description: "Add users to the puppet whitelist.", description: "Add users to the puppet whitelist with an optional tier. Defaults to 'user'.",
inputSchema: { 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( server.registerTool(
+7
View File
@@ -135,6 +135,13 @@ export class CoreClient {
return this.request("message.fetch", { channelId, limit, ...options }); return this.request("message.fetch", { channelId, limit, ...options });
} }
async voiceGet(userId?: string, guildId?: string) {
return this.request("voice.get", {
...(userId ? { userId } : {}),
...(guildId ? { guildId } : {})
});
}
close() { close() {
this.socket.destroy(); this.socket.destroy();
} }
+10
View File
@@ -50,6 +50,16 @@ function handle(action: string | undefined, params: any) {
return "pong"; return "pong";
case "state.get": case "state.get":
return { user: { id: "999", username: "mockuser" }, guilds: FAKE_GUILDS }; 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": case "message.send":
return { channelId: params?.channelId, sent: true }; return { channelId: params?.channelId, sent: true };
case "message.read": case "message.read":
+229 -62
View File
@@ -1,26 +1,30 @@
/* /*
* Veskslop daemon — puppet state: trust list, trigger log, response policy, * Veskslop daemon — puppet state: trust tiers, trigger log, response policy,
* conversation context, and Discord-facing command handling. * conversation context, continuation windows, and Discord-facing commands.
* *
* Trigger scope for now: direct messages from trusted users and direct @ * Trigger scope: direct messages and direct mentions from trusted users.
* mentions from trusted users. Mentions in guild channels and group DMs also * Trust tiers: user, trusted, admin. The owner is an admin. Whitelist
* collect recent context for later LLM use. * management requires the admin tier.
*/ */
import { readFileSync, writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { ActionLog } from "./action-log.js";
import { import {
assembleContext, assembleContext,
isTrusted, hasTier,
MessageLog, MessageLog,
parseCommand, parseCommand,
parseTier,
randomActionDelay, randomActionDelay,
stripMentions, stripMentions,
toCachedMessage, toCachedMessage,
type AssembledContext, type AssembledContext,
type CachedMessage, type CachedMessage,
type ParsedCommand, type ParsedCommand,
type PuppetMessage type PuppetMessage,
type TrustTier
} from "./communication.js"; } from "./communication.js";
import { MessageCache } from "./message-cache.js"; import { MessageCache } from "./message-cache.js";
import { paced } from "./pacing.js"; import { paced } from "./pacing.js";
@@ -41,12 +45,16 @@ export interface ResponsePolicy {
export interface PuppetConfig { export interface PuppetConfig {
contextMessages: number; contextMessages: number;
contextChars: number; contextChars: number;
fetchCooldownSeconds: number;
continuationSeconds: number;
responsePolicy: ResponsePolicy; responsePolicy: ResponsePolicy;
} }
export interface PuppetConfigPatch { export interface PuppetConfigPatch {
contextMessages?: number; contextMessages?: number;
contextChars?: number; contextChars?: number;
fetchCooldownSeconds?: number;
continuationSeconds?: number;
responsePolicy?: { responsePolicy?: {
defaultMode?: "channel" | "dm"; defaultMode?: "channel" | "dm";
sensitiveCommands?: string[]; sensitiveCommands?: string[];
@@ -57,6 +65,8 @@ export interface PuppetConfigPatch {
const DEFAULT_CONFIG: PuppetConfig = { const DEFAULT_CONFIG: PuppetConfig = {
contextMessages: 15, contextMessages: 15,
contextChars: 4000, contextChars: 4000,
fetchCooldownSeconds: 5,
continuationSeconds: 10,
responsePolicy: { responsePolicy: {
defaultMode: "channel", defaultMode: "channel",
sensitiveCommands: ["whitelist", "config"], sensitiveCommands: ["whitelist", "config"],
@@ -66,7 +76,7 @@ const DEFAULT_CONFIG: PuppetConfig = {
interface PersistedState { interface PersistedState {
ownerId?: string; ownerId?: string;
whitelist?: string[]; whitelist?: string[] | Record<string, TrustTier>;
config?: Partial<PuppetConfig>; config?: Partial<PuppetConfig>;
} }
@@ -89,21 +99,50 @@ function clamp(value: number, min: number, max: number): number {
export class Puppet { export class Puppet {
readonly log = new MessageLog(); 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 ownerId = DEFAULT_OWNER_ID;
private whitelistIds: string[] = [];
private config: PuppetConfig = structuredClone(DEFAULT_CONFIG); private config: PuppetConfig = structuredClone(DEFAULT_CONFIG);
private selfId?: string; private selfId?: string;
private readonly cache = new MessageCache(); private queue: Promise<void> = Promise.resolve();
constructor(private readonly core: PuppetTransport) { constructor(private readonly core: PuppetTransport) {
try { try {
const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState; const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState;
if (state.ownerId) this.ownerId = state.ownerId; 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) { if (state.config) {
this.config = { this.config = {
contextMessages: clamp(state.config.contextMessages ?? DEFAULT_CONFIG.contextMessages, 1, 50), contextMessages: clamp(state.config.contextMessages ?? DEFAULT_CONFIG.contextMessages, 1, 50),
contextChars: clamp(state.config.contextChars ?? DEFAULT_CONFIG.contextChars, 200, 20000), 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: { responsePolicy: {
defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode, defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode,
sensitiveCommands: sensitiveCommands:
@@ -118,14 +157,22 @@ export class Puppet {
} }
} }
get whitelist(): string[] { get whitelist(): Record<string, TrustTier> {
return [...this.whitelistIds]; return Object.fromEntries(this.tiers);
} }
get owner() { get owner() {
return this.ownerId; 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 { getConfig(): PuppetConfig {
return structuredClone(this.config); return structuredClone(this.config);
} }
@@ -136,6 +183,12 @@ export class Puppet {
this.config = { this.config = {
contextMessages: clamp(patch.contextMessages ?? this.config.contextMessages, 1, 50), contextMessages: clamp(patch.contextMessages ?? this.config.contextMessages, 1, 50),
contextChars: clamp(patch.contextChars ?? this.config.contextChars, 200, 20000), 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: { responsePolicy: {
defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode, defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode,
sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands, sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands,
@@ -151,22 +204,21 @@ export class Puppet {
return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist }; return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist };
} }
isTrusted(userId: string): boolean { readActionLog(limit = 100) {
return isTrusted(userId, this.ownerId, this.whitelistIds); return this.actionLog.read(limit);
} }
addToWhitelist(userIds: string[]): string[] { addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record<string, TrustTier> {
const next = new Set(this.whitelistIds); for (const id of userIds) {
for (const id of userIds) if (id && id !== this.ownerId) next.add(id); if (id && id !== this.ownerId) this.tiers.set(id, tier);
}
this.whitelistIds = [...next];
this.save(); this.save();
return this.whitelist; return this.whitelist;
} }
removeFromWhitelist(userIds: string[]): string[] { removeFromWhitelist(userIds: string[]): Record<string, TrustTier> {
const remove = new Set(userIds); for (const id of userIds) this.tiers.delete(id);
this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id));
this.save(); this.save();
return this.whitelist; return this.whitelist;
} }
@@ -176,13 +228,14 @@ export class Puppet {
writeFileSync( writeFileSync(
STATE_FILE, STATE_FILE,
JSON.stringify( JSON.stringify(
{ ownerId: this.ownerId, whitelist: this.whitelistIds, config: this.config }, { ownerId: this.ownerId, whitelist: this.whitelist, config: this.config },
null, null,
4 4
) )
); );
} catch (error) { } catch (error) {
console.error("[puppet] failed to save state:", 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[]> { private async refreshCache(channelId: string): Promise<CachedMessage[]> {
const cached = this.cache.get(channelId); 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(() => const raw = await paced(() =>
newest newest
? this.core.fetchMessages(channelId, 100, { after: newest }) ? this.core.fetchMessages(channelId, 100, { after: newest })
@@ -227,6 +284,28 @@ export class Puppet {
/* Triggers */ /* 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) { async handleEvent(event: any) {
if (event?.type !== "MESSAGE_CREATE") return; if (event?.type !== "MESSAGE_CREATE") return;
@@ -234,17 +313,52 @@ export class Puppet {
if (!author || !channelId) return; if (!author || !channelId) return;
if (author.bot) 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(); this.selfId ??= await this.core.getSelfId();
if (!this.selfId || author.id === this.selfId) return; if (!this.selfId || author.id === this.selfId) return;
// Whitelist-only for now. const tier = this.tierOf(author.id);
if (!this.isTrusted(author.id)) return; if (!tier) return;
const isGroupDm = channelType === GROUP_DM_CHANNEL; const isGroupDm = channelType === GROUP_DM_CHANNEL;
const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null)); 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 ?? []); const key = `${channelId}:${author.id}`;
if (!isDirectDm && !mentionIds.has(this.selfId)) return;
// 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 text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId]));
const command = parseCommand(text); const command = parseCommand(text);
@@ -261,20 +375,37 @@ export class Puppet {
command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined 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) { if (!isDirectDm) {
try { try {
const context = await this.collectContext(channelId, event.id); const context = await this.collectContext(channelId, event.id);
if (context.messages.length) entry.context = context.messages; if (context.messages.length) entry.context = context.messages;
} catch (error) { } catch (error) {
console.error("[puppet] context collection failed:", error); this.actionLog.append({ type: "error", scope: "context", channelId, message: String(error) });
} }
} }
this.log.add(entry); this.log.add(entry);
if (command?.name === "whitelist" && author.id === this.ownerId) { if (this.config.continuationSeconds > 0) {
await this.handleWhitelistCommand(command, author.id, channelId); 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]; 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; const [sub, ...rest] = command.args;
switch (sub) { 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( return this.respond(
authorId, authorId,
channelId, channelId,
"whitelist", "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": { case "remove": {
const ids = this.extractUserIds(rest.join(" ")); 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); const removed = ids.filter(id => id !== this.ownerId);
this.removeFromWhitelist(removed); this.removeFromWhitelist(removed);
this.actionLog.append({
type: "command",
command: `whitelist remove ${removed.join(",")}`,
requesterId: authorId,
result: "ok"
});
return this.respond( return this.respond(
authorId, authorId,
channelId, channelId,
"whitelist", "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." : "" ids.length !== removed.length ? "The owner cannot be removed." : ""
] ]
.filter(Boolean) .filter(Boolean)
@@ -331,16 +496,6 @@ export class Puppet {
/* Replies */ /* 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( private async respond(
requesterId: string, requesterId: string,
originChannelId: string, originChannelId: string,
@@ -352,17 +507,29 @@ export class Puppet {
const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName); const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName);
const mode = override ?? (sensitive ? "dm" : policy.defaultMode); const mode = override ?? (sensitive ? "dm" : policy.defaultMode);
if (mode === "dm") {
await new Promise(resolve => setTimeout(resolve, randomActionDelay())); await new Promise(resolve => setTimeout(resolve, randomActionDelay()));
try { try {
await this.core.sendDm(requesterId, content); 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) { } catch (error) {
// Never fall back to a public channel for sensitive content. // Never fall back to a public channel for sensitive content.
console.error("[puppet] sensitive reply by DM failed:", error); this.actionLog.append({
} type: "reply",
return; command: commandName,
} mode,
target: mode === "dm" ? requesterId : originChannelId,
await this.reply(originChannelId, content); result: "error",
error: String(error)
});
}
} }
} }
+39
View File
@@ -14,9 +14,12 @@ import {
compareSnowflakes, compareSnowflakes,
extractMentionIds, extractMentionIds,
formatContextLine, formatContextLine,
hasTier,
isTrusted, isTrusted,
parseCommand, parseCommand,
parseTier,
randomActionDelay, randomActionDelay,
redactSecrets,
stripMentions, stripMentions,
toCachedMessage, toCachedMessage,
type CachedMessage, 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"); 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 content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list";
const guildId = process.env.TEST_TRIGGER_GUILD; const guildId = process.env.TEST_TRIGGER_GUILD;
const isGuild = !!guildId; const isGuild = !!guildId;
const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0";
const event = { const event = {
type: "MESSAGE_CREATE", type: "MESSAGE_CREATE",
@@ -24,8 +25,8 @@ const event = {
guildId: guildId ?? null, guildId: guildId ?? null,
channelType: isGuild ? 0 : 1, channelType: isGuild ? 0 : 1,
author: { id: authorId, username: "zzib", bot: false }, author: { id: authorId, username: "zzib", bot: false },
content: isGuild ? `<@999> ${content}` : content, content: mention ? `<@999> ${content}` : content,
mentions: isGuild ? ["999"] : [], mentions: mention ? ["999"] : [],
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
+1
View File
@@ -34,6 +34,7 @@ const KNOWN_ACTIONS = new Set<string>([
PuppetAction.Ping, PuppetAction.Ping,
PuppetAction.EventsSubscribe, PuppetAction.EventsSubscribe,
PuppetAction.StateGet, PuppetAction.StateGet,
PuppetAction.VoiceGet,
PuppetAction.MessageSend, PuppetAction.MessageSend,
PuppetAction.MessageRead, PuppetAction.MessageRead,
PuppetAction.MessageFetch, PuppetAction.MessageFetch,
+43 -1
View File
@@ -9,7 +9,13 @@
import { openPrivateChannel, sendMessage } from "@vencord/types/utils"; import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
import { findByPropsLazy } from "@vencord/types/webpack"; import { findByPropsLazy } from "@vencord/types/webpack";
import { ChannelStore, GuildStore, MessageStore, UserStore } from "@vencord/types/webpack/common"; import {
ChannelStore,
GuildStore,
MessageStore,
UserStore,
VoiceStateStore
} from "@vencord/types/webpack/common";
import { isSnowflake, PUPPET_LIMITS, PuppetAction, PuppetErrorCode, type PuppetResult } from "shared/puppetProtocol"; import { isSnowflake, PUPPET_LIMITS, PuppetAction, PuppetErrorCode, type PuppetResult } from "shared/puppetProtocol";
/** Channel types that can receive text messages. */ /** Channel types that can receive text messages. */
@@ -154,6 +160,42 @@ export async function handlePuppetAction(
return ok(state); return ok(state);
} }
case PuppetAction.VoiceGet: {
const { guildId, userId } = params;
if (guildId !== undefined) {
assert(isSnowflake(guildId), PuppetErrorCode.InvalidSnowflake, "guildId must be a snowflake");
}
if (userId !== undefined) {
assert(isSnowflake(userId), PuppetErrorCode.InvalidSnowflake, "userId must be a snowflake");
}
const targetId = (userId as string | undefined) ?? UserStore.getCurrentUser()?.id;
assert(targetId, PuppetErrorCode.NotLoggedIn, "no user is logged in");
const voiceState = VoiceStateStore.getVoiceState((guildId as string | null) ?? null, targetId);
if (!voiceState?.channelId) {
return ok({ guildId: guildId ?? null, channelId: null, channelName: null, members: [] });
}
const channel = ChannelStore.getChannel(voiceState.channelId);
const states = VoiceStateStore.getVoiceStatesForChannel(voiceState.channelId);
return ok({
guildId: channel?.guild_id ?? guildId ?? null,
channelId: voiceState.channelId,
channelName: channel?.name ?? null,
members: Object.values(states).map(state => {
const user = UserStore.getUser(state.userId);
return {
id: state.userId,
username: user?.username ?? state.userId,
mute: state.mute,
deaf: state.deaf
};
})
});
}
case PuppetAction.MessageSend: { case PuppetAction.MessageSend: {
const { channelId } = params; const { channelId } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake"); assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
+2
View File
@@ -10,6 +10,7 @@ export const enum PuppetAction {
Ping = "ping", Ping = "ping",
EventsSubscribe = "events.subscribe", EventsSubscribe = "events.subscribe",
StateGet = "state.get", StateGet = "state.get",
VoiceGet = "voice.get",
MessageSend = "message.send", MessageSend = "message.send",
MessageRead = "message.read", MessageRead = "message.read",
MessageFetch = "message.fetch", MessageFetch = "message.fetch",
@@ -20,6 +21,7 @@ export const PuppetActions: readonly PuppetAction[] = [
PuppetAction.Ping, PuppetAction.Ping,
PuppetAction.EventsSubscribe, PuppetAction.EventsSubscribe,
PuppetAction.StateGet, PuppetAction.StateGet,
PuppetAction.VoiceGet,
PuppetAction.MessageSend, PuppetAction.MessageSend,
PuppetAction.MessageRead, PuppetAction.MessageRead,
PuppetAction.MessageFetch, PuppetAction.MessageFetch,