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