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
+14 -8
View File
@@ -4,10 +4,12 @@ Veskslop controls Discord user accounts without a visible client window. It runs
client and drives it through a small, validated capability layer. All logic lives in the external
daemon.
**Read the docs first:** [docs/INDEX.md](docs/INDEX.md) and [CURRENT.md](CURRENT.md).
**Read the docs first:** [docs/INDEX.md](docs/INDEX.md), [CURRENT.md](CURRENT.md), and
[TODO.md](TODO.md).
`CURRENT.md` tracks the work state: the current focus, the completed work, the current task, and the
next steps. Read it before you start. Update it before you stop.
next steps. `TODO.md` holds the ordered backlog. Read both before you start. Update `CURRENT.md`
before you stop.
- [architecture.md](docs/architecture.md) — system parts and boundaries
- [core-bridge.md](docs/core-bridge.md) — the Vesktop-side capability layer
@@ -35,6 +37,7 @@ next steps. Read it before you start. Update it before you stop.
- Never commit tokens, credentials, or `.env` files.
- Do not hardcode Discord limits from memory. Make the limits configurable.
- All outbound actions go through the paced queue.
- Triggers come from trusted users only: one-to-one DMs and direct mentions.
- Test with throwaway accounts and private test guilds only.
- Live send tests: only DMs to user **zzib** (`177117296140091393`).
- Run `pnpm testTypes` and `pnpm lint` before you report done. Run `pnpm build` to compile.
@@ -47,19 +50,22 @@ next steps. Read it before you start. Update it before you stop.
| Main bridge, event push, hooks | Implemented |
| Daemon on the core-bridge transport | Implemented and verified live |
| QR login, pacing, trigger log | Implemented |
| Trusted-only triggers and context cache | Implemented |
| Response policy (channel or DM) | Implemented |
| Auth persistence in the core | Implemented and verified live |
| CDP transport | Kept as a development fallback |
| Notification parsing, persistent log | Not started |
| Voice, audio, video | Not started |
## Work items
See [CURRENT.md](CURRENT.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. Voice join and leave actions.
2. Audio in and out with PipeWire.
3. Video streaming.
4. Fetch-based message read.
5. Client lightening and the web debug UI.
1. Notification parsing: edits, deletes, reactions, and replies.
2. Persistent trigger log with rotation.
3. Per-user rate limit and command cooldowns.
4. MCP event delivery for agents.
5. Later: voice, audio, video, client lightening, web debug UI.
## Dev commands
+22 -25
View File
@@ -6,51 +6,48 @@ This file tracks the work state. Read it first. Update it before you stop.
## Current focus (broad)
Rework the Vesktop integration into a validated capability layer ("core bridge"). Move all logic
into the external daemon. Keep upstream divergence small.
Build the text-based communication layer: trusted-only triggers, conversation context, response
routing, and MCP tools. Voice, audio, and video stay deferred.
## In progress
- None. The rework milestone is complete and verified live.
- None. The text milestone is complete and verified with the mock core.
## Done
- Audit of the earlier Vesktop patches. `src/` was reverted to upstream.
- Action protocol and validated renderer actions: `src/shared/puppetProtocol.ts`,
`src/renderer/puppet/actions.ts`.
- Core bridge wired: events, renderer init, main bridge, and the upstream hooks.
- Auth persistence re-added: pinned `DATA_DIR`, token file, preload seeding, `VESKSLOP_HEADLESS`.
- 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`.
- Daemon moved to the core-bridge transport. CDP stays as a development fallback.
- Mock core and trigger test added. The daemon runs without Electron.
- Live verification (2026-09-11): `get_state` returned the real account and 7 guilds, `send_dm`
reached zzib, and a synthetic owner message flowed through the real event path into the daemon
log with the command parsed.
- Module docs in `docs/` and the agent entry point in `AGENTS.md`.
- Trusted-only triggers: direct messages and direct mentions.
- Context: 15 messages or 4000 characters, file names only, incremental fetch, tmp cache with a
size limit.
- Response policy: origin channel or DM. Sensitive commands default to DM.
- MCP tools: context and config tools beside the earlier state and log tools.
- Unit tests for the pure logic and the cache. Mock end-to-end tests passed.
- Live verification: state, DM send, and a synthetic trigger through the real event path.
## Next (in order)
1. Keep `CURRENT.md`, `AGENTS.md`, and `docs/` in sync with each change.
2. Voice join and leave actions. Validated actions, then a trigger command.
3. Audio in and out with PipeWire.
4. Video streaming.
5. Fetch-based message read. The current read is cache-only.
6. Client lightening and the web debug UI.
7. Remote client authentication over LAN or Tailscale.
1. Notification parsing: edits, deletes, reactions, replies, and message references.
2. Persistent trigger log with rotation.
3. Per-user rate limit and command cooldowns.
4. More commands: `ask`, `summarize`, `context`, and `config`.
5. MCP event delivery for agents. Push or a stateful stream.
6. Multi-identity: a read-only collector account beside the puppet.
7. Later: voice, audio, video, client lightening, web debug UI, remote authentication.
## Blocked / needs input
- The original repo `zib/Veskslop` has a broken server-side git setup. Fetches and clones fail with
`bad pack header` because a script (`/home/git/.sys_health_s3`) runs on the Veskslop read path.
Gitea's branch metadata is stale, so the web UI does not show `headless` or `headless-rework`.
The admin must fix that hook and re-enable or repair push processing.
- Workaround: use `zib/Veskslop-t` (remote `veskslop-t`). All branches and tags are pushed there,
and its read path works.
The admin must fix it. Use `zib/Veskslop-t` (remote `veskslop-t`) until then.
- `main` on the original server is `68fc8bb` (zib, "Upload files to docs/images"). It diverged from
the local `main` (`24447f5`). Reconcile after the server is fixed.
- The hero image was copied from the original `main` into `docs/images/veskslop-hero.png`.
## Where to look
- Backlog: `TODO.md`
- Design documents: `docs/INDEX.md`
- Agent rules: `AGENTS.md`
- Daemon code: `poc/src/`
+49
View File
@@ -0,0 +1,49 @@
# TODO
Ordered backlog. `CURRENT.md` tracks the live session state.
## Where the project finished (2026-09-11)
- 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`.
- The daemon uses the core bridge. QR login, pacing, the trigger log, the trust list, the context
cache, and the response policy work.
- Mock tests and a live test passed: state, DM send, and a synthetic trigger through the real event
path.
- 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.
## Deferred
- Voice join and leave, invite-link joins, audio in and out, and video streaming. Text features come
first.
- Client lightening. Low priority. The client must stay stable.
- Web debug UI and remote client authentication.
## Text features
Done:
- Trusted-only triggers: direct messages and direct mentions.
- Context: the last 15 messages or 4000 characters, whichever is lower. File names only for videos
and images. Incremental fetch with an old-message cache.
- Context cache in a tmp folder with a size limit.
- Response policy: reply in the origin channel or by DM. Sensitive commands default to DM.
- MCP tools for context and config.
Next:
- Notification parsing: edits, deletes, reactions, replies, and message references.
- Persistent trigger log with rotation.
- Per-user rate limit and command cooldowns.
- More commands: `ask`, `summarize`, `context`, and `config`.
- MCP event delivery for agents. Push or a stateful stream.
- Multi-identity: a read-only collector account beside the puppet.
## Open design questions
- MCP event delivery: the current Streamable HTTP mode is stateless, so the server cannot push
events. Options are polling, SSE, or a stateful session.
- Untrusted interactions: ignored for now. The daemon keeps the 30-entry untrusted buffer for later.
- Context freshness: the daemon fetches on each mention. Add a TTL or a minimum gap if the query
cost is too high.
+1
View File
@@ -54,6 +54,7 @@ when the action list changes.
| QR login | `poc/src/qr-login.ts` | Implemented and verified live |
| Pacing | `poc/src/pacing.ts` | Implemented |
| Trigger log and trust list | `poc/src/communication.ts`, `poc/src/puppet.ts` | Implemented and verified live |
| Context cache and response policy | `poc/src/message-cache.ts`, `poc/src/puppet.ts` | Implemented and verified with the mock |
| Voice, audio, video | Not started | Not started |
## Data flow
+67 -20
View File
@@ -1,30 +1,32 @@
# Communication
This document describes the trigger rules, the trust model, the commands, and the trigger log.
This document describes the trigger rules, the trust model, the commands, the trigger log, the
context cache, and the response policy.
## Trigger rules
A message is a trigger when one of these conditions is true:
A message is a trigger when both conditions are true:
- The message is a direct message to the puppet. A direct message is a one-to-one DM, not a group.
- The message mentions the puppet. This applies in guild channels and group DMs.
- The author is trusted. The owner counts as trusted.
- One of these conditions is true:
- The message is a one-to-one DM to the puppet.
- The message mentions the puppet in a guild channel or a group DM.
All other messages are ignored.
All other messages are ignored. Untrusted messages do not enter the log at this time. The daemon
ignores bots and its own messages.
## Trust model
The puppet has one owner and a trust list.
- The owner is always trusted. The default owner is `177117296140091393` (zzib).
- The trust list holds additional user IDs.
- A trusted user gets the 100-entry log. An untrusted user gets the 30-entry log.
The owner controls the trust list. Commands from other users do not change the list.
- The trust list holds more user IDs.
- The owner controls the trust list. Commands from other users do not change the list.
## Commands
The owner sends commands from Discord. In a direct message, the text is the command. In a guild or
a group DM, mention the puppet first.
The owner sends commands from Discord. In a DM, the text is the command. In a guild or a group DM,
mention the puppet first.
| Command | Effect |
| --- | --- |
@@ -36,7 +38,23 @@ Notes:
- The owner cannot be removed.
- The command accepts mentions and raw user IDs.
- The daemon replies with a confirmation. The reply uses the same pacing as all other sends.
- The reply follows the response policy.
## Response policy
The policy decides where a reply goes. Sensitive replies go by DM.
| Setting | Meaning | Default |
| --- | --- | --- |
| `defaultMode` | The mode for normal replies: `channel` or `dm` | `channel` |
| `sensitiveCommands` | Commands that always reply by DM | `whitelist`, `config` |
| `overrides` | A per-command mode | empty |
Rules:
- An override wins over the sensitive list. The sensitive list wins over the default mode.
- A DM reply failure does not fall back to the origin channel. The daemon logs the error only.
- Read the policy with `get_puppet_config`. Change it with `set_puppet_config`.
## Trigger log
@@ -45,7 +63,7 @@ The log has two ring buffers.
| Buffer | Limit | Content |
| --- | --- | --- |
| Trusted | 100 entries | Triggers from the owner and trusted users |
| Untrusted | 30 entries | Triggers from all other users |
| Untrusted | 30 entries | Reserved. No triggers enter this buffer at this time. |
One entry holds these fields:
@@ -59,28 +77,57 @@ One entry holds these fields:
| `content` | The raw message content |
| `timestamp` | The message timestamp |
| `trusted` | True for a trusted author |
| `command` | The parsed command text, when the message is a command |
| `command` | The parsed command text, when the message looks like a command |
| `context` | Prior messages. Present on mention triggers. |
## Context
The daemon collects prior messages for a mention trigger. Direct messages do not need context.
- Message limit: `contextMessages`, default 15.
- Character limit: `contextChars`, default 4000.
- The lower limit stops the collection.
- The content is the message text and the attachment file names. Media bodies stay on Discord.
- The trigger message is not part of its own context.
The cache makes the fetch small.
1. The daemon reads the newest cached message ID for the channel.
2. It fetches messages after that ID. On the first run, it fetches the newest messages.
3. It merges the new messages into the cache.
4. It assembles the context from the merged list.
Cache settings:
| Setting | Default |
| --- | --- |
| `VESKSLOP_CACHE_DIR` | `<tmp>/veskslop-cache` |
| `VESKSLOP_CACHE_MAX_BYTES` | 50 MiB |
| Messages per channel | 200 |
Eviction removes the least recently written channel files until the folder fits the size limit.
## Event flow
1. The client receives a `MESSAGE_CREATE` gateway event.
2. The core renderer normalizes the event.
2. The core normalizes the event.
3. The core bridge pushes the event to the daemon.
4. The daemon applies the trigger rules.
5. The daemon adds a qualifying message to the log.
6. The daemon runs a trusted command, when the message is a command.
5. The daemon collects context for a mention trigger.
6. The daemon adds the entry to the log.
7. The daemon runs a trusted command and replies through the response policy.
## State file
The daemon stores the owner ID and the trust list in JSON.
The daemon stores the owner ID, the trust list, and the config in JSON.
- Default path: `poc/.veskslop-state.json`.
- Override: `VESKSLOP_STATE_FILE`.
- The trigger log is in memory only. The log resets at restart.
## How to add a trigger rule
## How to add a trigger rule or a command
1. Add the rule in `poc/src/puppet.ts`, in `handleEvent`.
2. Keep the rule small and testable.
3. Add a unit test in `poc/src/test-communication.ts` when the rule is pure logic.
4. Run `pnpm test:comm`.
4. Run `pnpm test:comm` and `pnpm test:cache`.
+4
View File
@@ -59,6 +59,7 @@ Event line:
| `state.get` | none | The current user, the guilds, and the channels. Cache-only. |
| `message.send` | `channelId`, `content` | `{ channelId, sent: true }` |
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. |
| `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. |
| `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` |
## Validation rules
@@ -73,6 +74,8 @@ The core validates all input. The daemon cannot bypass these rules.
`GUILD_TEXT`, `DM`, `GROUP_DM`, `GUILD_ANNOUNCEMENT`, `ANNOUNCEMENT_THREAD`, `PUBLIC_THREAD`,
and `PRIVATE_THREAD`.
- `limit` must be an integer from 1 to 100.
- `before` and `after` must be snowflakes. They are mutually exclusive. `message.fetch` uses them
to walk history.
- A request line must not be larger than 64 KiB.
## Core rate limit
@@ -100,6 +103,7 @@ The core rate limit is a safety net. It does not replace the daemon pacing.
| `NOT_SENDABLE` | The channel type cannot receive messages. |
| `SEND_FAILED` | Discord rejected the message. |
| `DM_UNAVAILABLE` | The client cannot create a DM channel. |
| `NOT_SUPPORTED` | The client does not offer the internal action. |
| `RATE_LIMITED` | The core rate limit rejected the action. |
| `INTERNAL` | An unexpected error occurred. The details go to the local log only. |
+18 -2
View File
@@ -7,13 +7,15 @@ The daemon runs outside Vesktop. It holds all logic. It talks to the core bridge
| File | Role | Status |
| --- | --- | --- |
| `poc/src/control-bridge.ts` | MCP server, tool list, health endpoint | Implemented |
| `poc/src/puppet.ts` | Trust list, command handling, trigger log | Implemented |
| `poc/src/communication.ts` | Pure log and parser logic | 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/message-cache.ts` | Channel message cache in a tmp folder | 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/cdp-client.ts` | Chrome DevTools Protocol client | Development fallback |
| `poc/src/qr-login.ts` | Terminal QR login | Implemented and verified |
| `poc/src/test-communication.ts` | Unit tests for the pure logic | Implemented |
| `poc/src/test-cache.ts` | Unit tests for the message cache | Implemented |
| `poc/src/mock-core.ts` | Mock core bridge for daemon tests | Implemented |
| `poc/src/test-trigger.ts` | Inject a trigger event into the mock core | Implemented |
| `poc/src/cdp-probe.ts` | CDP feasibility probe | Development tool |
@@ -47,6 +49,9 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127.
| `read_messages` | Read cached messages from a channel. |
| `send_dm` | Send a DM to a user. |
| `get_message_log` | Read the trigger log. |
| `get_context` | Assemble conversation context for a channel. Fetches new messages by default. |
| `get_puppet_config` | Read the response policy and context settings. |
| `set_puppet_config` | Change the response policy and context settings. |
| `whitelist_list` | List the owner and the trusted users. |
| `whitelist_add` | Add users to the trust list. |
| `whitelist_remove` | Remove users from the trust list. |
@@ -68,6 +73,7 @@ pnpm poc:dm
# Run the pure-logic unit tests.
pnpm test:comm
pnpm test:cache
# Development tools for the CDP fallback.
pnpm cdp:probe
@@ -82,6 +88,16 @@ CDP_EXPRESSION='...' pnpm cdp:dispatch
{ "ok": true, "cdpConnected": true, "ownerId": "177117296140091393", "queries": 4 }
```
## Settings and paths
| Setting | Default | Purpose |
| --- | --- | --- |
| `VESKSLOP_SOCKET` | `$XDG_RUNTIME_DIR/vesktop-puppet.sock` | Core bridge socket |
| `VESKSLOP_STATE_FILE` | `poc/.veskslop-state.json` | Owner, trust list, and config |
| `VESKSLOP_CACHE_DIR` | `<tmp>/veskslop-cache` | Context cache folder |
| `VESKSLOP_CACHE_MAX_BYTES` | 50 MiB | Context cache size limit |
| `MCP_HOST` / `MCP_PORT` | `127.0.0.1` / `8788` | MCP listen address |
## How to add an MCP tool
1. Add the action to the core bridge. See [core-bridge.md](core-bridge.md).
+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()
};
+1
View File
@@ -36,6 +36,7 @@ const KNOWN_ACTIONS = new Set<string>([
PuppetAction.StateGet,
PuppetAction.MessageSend,
PuppetAction.MessageRead,
PuppetAction.MessageFetch,
PuppetAction.DmSend
]);
+69
View File
@@ -8,12 +8,26 @@
*/
import { openPrivateChannel, sendMessage } from "@vencord/types/utils";
import { findByPropsLazy } from "@vencord/types/webpack";
import { ChannelStore, GuildStore, MessageStore, UserStore } from "@vencord/types/webpack/common";
import { isSnowflake, PUPPET_LIMITS, PuppetAction, PuppetErrorCode, type PuppetResult } from "shared/puppetProtocol";
/** Channel types that can receive text messages. */
const SENDABLE_CHANNEL_TYPES = new Set([0, 1, 3, 5, 10, 11, 12]);
/** Discord's message fetch action, located lazily at runtime. */
const MessageActions = findByPropsLazy("fetchMessages");
function compareIds(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;
}
}
class ActionError extends Error {
constructor(
public readonly code: PuppetErrorCode,
@@ -175,6 +189,61 @@ export async function handlePuppetAction(
});
}
case PuppetAction.MessageFetch: {
const { channelId, limit = 50 } = params;
assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake");
assert(
Number.isInteger(limit) && Number(limit) >= 1 && Number(limit) <= PUPPET_LIMITS.maxFetchLimit,
PuppetErrorCode.InvalidParam,
`limit must be an integer between 1 and ${PUPPET_LIMITS.maxFetchLimit}`
);
if (params.before !== undefined) {
assert(isSnowflake(params.before), PuppetErrorCode.InvalidSnowflake, "before must be a snowflake");
}
if (params.after !== undefined) {
assert(isSnowflake(params.after), PuppetErrorCode.InvalidSnowflake, "after must be a snowflake");
}
assert(!(params.before && params.after), PuppetErrorCode.InvalidParam, "before and after are mutually exclusive");
assert(ChannelStore.getChannel(channelId), PuppetErrorCode.ChannelNotFound, `channel ${channelId} not found`);
const fetchMessages = (MessageActions as any)?.fetchMessages;
assert(
typeof fetchMessages === "function",
PuppetErrorCode.NotSupported,
"message fetch is not available in this client"
);
const before = params.before as string | undefined;
const after = params.after as string | undefined;
await fetchMessages({
channelId,
limit: Number(limit),
...(before ? { before } : {}),
...(after ? { after } : {})
});
const all = MessageStore.getMessages(channelId)?._array ?? [];
const filtered = all.filter(message => {
if (after && compareIds(message.id, after) <= 0) return false;
if (before && compareIds(message.id, before) >= 0) return false;
return true;
});
return ok({
channelId,
fetched: filtered.length,
messages: filtered.slice(-Number(limit)).map(message => ({
id: message.id,
author: { id: message.author.id, username: message.author.username },
content: message.content,
timestamp: message.timestamp,
attachments: (message.attachments ?? []).map(attachment => ({ filename: attachment.filename }))
}))
});
}
case PuppetAction.DmSend: {
const { userId } = params;
assert(isSnowflake(userId), PuppetErrorCode.InvalidSnowflake, "userId must be a snowflake");
+4
View File
@@ -12,6 +12,7 @@ export const enum PuppetAction {
StateGet = "state.get",
MessageSend = "message.send",
MessageRead = "message.read",
MessageFetch = "message.fetch",
DmSend = "dm.send"
}
@@ -21,6 +22,7 @@ export const PuppetActions: readonly PuppetAction[] = [
PuppetAction.StateGet,
PuppetAction.MessageSend,
PuppetAction.MessageRead,
PuppetAction.MessageFetch,
PuppetAction.DmSend
];
@@ -38,6 +40,7 @@ export const enum PuppetErrorCode {
NotSendable = "NOT_SENDABLE",
SendFailed = "SEND_FAILED",
DmUnavailable = "DM_UNAVAILABLE",
NotSupported = "NOT_SUPPORTED",
RateLimited = "RATE_LIMITED",
Internal = "INTERNAL"
}
@@ -70,6 +73,7 @@ export interface PuppetEvent {
export const PUPPET_LIMITS = {
maxContentLength: 2000,
maxReadLimit: 100,
maxFetchLimit: 100,
minActionIntervalMs: 500,
maxActionsPerMinute: 30,
maxRequestBytes: 64 * 1024,