From 82f78bb2b4379d2bda00015561bc89878fb408d6 Mon Sep 17 00:00:00 2001 From: Fisto Date: Fri, 11 Sep 2026 15:31:23 +0200 Subject: [PATCH] Add text features: trusted triggers, context cache, response policy, MCP config tools --- AGENTS.md | 22 ++-- CURRENT.md | 47 ++++--- TODO.md | 49 +++++++ docs/architecture.md | 1 + docs/communication.md | 87 ++++++++++--- docs/core-bridge.md | 4 + docs/daemon.md | 20 ++- poc/package.json | 3 +- poc/src/communication.ts | 90 +++++++++++++ poc/src/control-bridge.ts | 45 +++++++ poc/src/core-client.ts | 4 + poc/src/message-cache.ts | 99 ++++++++++++++ poc/src/mock-core.ts | 25 +++- poc/src/puppet.ts | 228 +++++++++++++++++++++++++++++---- poc/src/test-cache.ts | 51 ++++++++ poc/src/test-communication.ts | 65 ++++++++++ poc/src/test-trigger.ts | 15 ++- src/main/puppetBridge.ts | 1 + src/renderer/puppet/actions.ts | 69 ++++++++++ src/shared/puppetProtocol.ts | 4 + 20 files changed, 841 insertions(+), 88 deletions(-) create mode 100644 TODO.md create mode 100644 poc/src/message-cache.ts create mode 100644 poc/src/test-cache.ts diff --git a/AGENTS.md b/AGENTS.md index 47e3425..965400a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CURRENT.md b/CURRENT.md index 2a0dcb0..efd7695 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -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/` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..f26bb9b --- /dev/null +++ b/TODO.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index b192356..ee83a1d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/communication.md b/docs/communication.md index 1909e16..267e832 100644 --- a/docs/communication.md +++ b/docs/communication.md @@ -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` | `/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`. diff --git a/docs/core-bridge.md b/docs/core-bridge.md index 48a1e1c..4a8abc0 100644 --- a/docs/core-bridge.md +++ b/docs/core-bridge.md @@ -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. | diff --git a/docs/daemon.md b/docs/daemon.md index 26c977d..7279757 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -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` | `/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). diff --git a/poc/package.json b/poc/package.json index 2decba8..219b9e2 100644 --- a/poc/package.json +++ b/poc/package.json @@ -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", diff --git a/poc/src/communication.ts b/poc/src/communication.ts index 0c05c16..d306408 100644 --- a/poc/src/communication.ts +++ b/poc/src/communication.ts @@ -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 + }; +} diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index b4f85b8..aab06ad 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -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", { diff --git a/poc/src/core-client.ts b/poc/src/core-client.ts index 57e0281..e809c84 100644 --- a/poc/src/core-client.ts +++ b/poc/src/core-client.ts @@ -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(); } diff --git a/poc/src/message-cache.ts b/poc/src/message-cache.ts new file mode 100644 index 0000000..64c77b5 --- /dev/null +++ b/poc/src/message-cache.ts @@ -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(); + 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); + } + } +} diff --git a/poc/src/mock-core.ts b/poc/src/mock-core.ts index 9096331..77ab0de 100644 --- a/poc/src/mock-core.ts +++ b/poc/src/mock-core.ts @@ -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); diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts index 1821241..fc26c58 100644 --- a/poc/src/puppet.ts +++ b/poc/src/puppet.ts @@ -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; - sendMessage(channelId: string, content: string): Promise; -} +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; +} + +export interface PuppetConfig { + contextMessages: number; + contextChars: number; + responsePolicy: ResponsePolicy; +} + +export interface PuppetConfigPatch { + contextMessages?: number; + contextChars?: number; + responsePolicy?: { + defaultMode?: "channel" | "dm"; + sensitiveCommands?: string[]; + overrides?: Record; + }; +} + +const DEFAULT_CONFIG: PuppetConfig = { + contextMessages: 15, + contextChars: 4000, + responsePolicy: { + defaultMode: "channel", + sensitiveCommands: ["whitelist", "config"], + overrides: {} + } +}; + interface PersistedState { ownerId?: string; whitelist?: string[]; + config?: Partial; +} + +/** The transport methods that the puppet logic needs. */ +export interface PuppetTransport { + getSelfId(): Promise; + sendMessage(channelId: string, content: string): Promise; + sendDm(userId: string, content: string): Promise; + 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 { + 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 { + 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 { + 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(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); + } } diff --git a/poc/src/test-cache.ts b/poc/src/test-cache.ts new file mode 100644 index 0000000..12d45df --- /dev/null +++ b/poc/src/test-cache.ts @@ -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"); diff --git a/poc/src/test-communication.ts b/poc/src/test-communication.ts index 539069a..0d538b7 100644 --- a/poc/src/test-communication.ts +++ b/poc/src/test-communication.ts @@ -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"); diff --git a/poc/src/test-trigger.ts b/poc/src/test-trigger.ts index e1b4b78..d01cc29 100644 --- a/poc/src/test-trigger.ts +++ b/poc/src/test-trigger.ts @@ -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() }; diff --git a/src/main/puppetBridge.ts b/src/main/puppetBridge.ts index 37c3633..e2e27c8 100644 --- a/src/main/puppetBridge.ts +++ b/src/main/puppetBridge.ts @@ -36,6 +36,7 @@ const KNOWN_ACTIONS = new Set([ PuppetAction.StateGet, PuppetAction.MessageSend, PuppetAction.MessageRead, + PuppetAction.MessageFetch, PuppetAction.DmSend ]); diff --git a/src/renderer/puppet/actions.ts b/src/renderer/puppet/actions.ts index 6d729d2..45043e1 100644 --- a/src/renderer/puppet/actions.ts +++ b/src/renderer/puppet/actions.ts @@ -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"); diff --git a/src/shared/puppetProtocol.ts b/src/shared/puppetProtocol.ts index 245e6e0..02ccc03 100644 --- a/src/shared/puppetProtocol.ts +++ b/src/shared/puppetProtocol.ts @@ -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,