From 8e8c15e8bc02cfdaa4c8908932ce059e324072f0 Mon Sep 17 00:00:00 2001 From: Fisto Date: Fri, 11 Sep 2026 15:53:06 +0200 Subject: [PATCH] Add tiers, continuation, fetch cooldown, persistent action log, voice read, batch MCP --- AGENTS.md | 15 +- CURRENT.md | 34 ++-- TODO.md | 54 +++--- docs/communication.md | 88 +++++++--- docs/core-bridge.md | 2 + docs/daemon.md | 6 + poc/src/action-log.ts | 56 +++++++ poc/src/communication.ts | 57 ++++++- poc/src/control-bridge.ts | 91 +++++++++- poc/src/core-client.ts | 7 + poc/src/mock-core.ts | 10 ++ poc/src/puppet.ts | 297 +++++++++++++++++++++++++-------- poc/src/test-communication.ts | 39 +++++ poc/src/test-trigger.ts | 5 +- src/main/puppetBridge.ts | 1 + src/renderer/puppet/actions.ts | 44 ++++- src/shared/puppetProtocol.ts | 2 + 17 files changed, 669 insertions(+), 139 deletions(-) create mode 100644 poc/src/action-log.ts diff --git a/AGENTS.md b/AGENTS.md index 965400a..e025e51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,22 +50,25 @@ 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 | +| Trusted-only triggers, trust tiers | Implemented | +| Context cache, fetch cooldown, continuation | Implemented | | Response policy (channel or DM) | Implemented | +| Persistent action log | Implemented | +| Voice state read | Implemented | +| Voice actions, notification parsing | Not started | | Auth persistence in the core | Implemented and verified live | | 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) and [TODO.md](TODO.md) for the live list. The main items are: 1. Notification parsing: edits, deletes, reactions, and replies. -2. Persistent trigger log with rotation. +2. Reply-to-puppet as a trigger. 3. Per-user rate limit and command cooldowns. -4. MCP event delivery for agents. -5. Later: voice, audio, video, client lightening, web debug UI. +4. Setup command with reaction-based multiple choice. +5. MCP event delivery decision. +6. Collector account with read-only voice membership. ## Dev commands diff --git a/CURRENT.md b/CURRENT.md index efd7695..8aebd16 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -6,36 +6,38 @@ This file tracks the work state. Read it first. Update it before you stop. ## Current focus (broad) -Build the text-based communication layer: trusted-only triggers, conversation context, response -routing, and MCP tools. Voice, audio, and video stay deferred. +Build the text-based communication layer: trusted tiers, context, response routing, MCP tools, and +persistent logging. Voice, audio, and video stay deferred. ## In progress -- None. The text milestone is complete and verified with the mock core. +- None. The latest text milestone is complete and verified with the mock core. ## Done - 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. -- 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. +- Daemon on the core-bridge transport. CDP stays as a development fallback. +- Trusted-only triggers: DMs and direct mentions. Trust tiers: user, trusted, admin. +- Context: 15 messages or 4000 characters, time stamps, file names only, incremental fetch, tmp + cache with a size limit, fetch cooldown. +- Continuation window: a rushed follow-up joins the request context. - Response policy: origin channel or DM. Sensitive commands default to DM. -- 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. +- Persistent action log: triggers, commands, replies, errors, rotation, and secret redaction. +- MCP tools: context, config, voice state, action log, and a read-only batch tool. +- Event processing is serialized. Replayed events are dropped. +- Unit tests for the pure logic and the cache. Mock end-to-end tests pass. - Live verification: state, DM send, and a synthetic trigger through the real event path. ## Next (in order) -1. Notification parsing: edits, deletes, reactions, replies, and message references. -2. Persistent trigger log with rotation. +1. Notification parsing: edits, deletes, reactions, and message references. +2. Reply-to-puppet counts as a trigger. 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. +4. Setup command with reaction-based multiple choice. +5. MCP event delivery decision: poll, stateful session, or a local event stream. +6. Collector account: read-only modules for voice membership fallback. +7. Later: voice actions, audio, video, client lightening, web debug UI, remote authentication. ## Blocked / needs input diff --git a/TODO.md b/TODO.md index f26bb9b..a6887e6 100644 --- a/TODO.md +++ b/TODO.md @@ -6,17 +6,17 @@ Ordered backlog. `CURRENT.md` tracks the live session state. - The validated core bridge is live: a fixed action list, input validation, structured errors, and a 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. +- The daemon uses the core bridge. QR login, pacing, the trigger log, trust tiers, context, the + response policy, the continuation window, the persistent action log, and the read-only voice + state all work. +- Mock end-to-end tests pass: tiers, continuation, sensitive replies, batch MCP, and voice state. - Repo note: use `zib/Veskslop-t` (remote `veskslop-t`) until the original repo's server hook is - fixed. Pushes to the original land, but its UI and fetch path are broken. + fixed. ## Deferred -- Voice join and leave, invite-link joins, audio in and out, and video streaming. Text features come - first. +- Voice join and leave, invite-link joins, audio in and out, and video streaming. Voice state + reads exist. Voice actions do not. - Client lightening. Low priority. The client must stay stable. - Web debug UI and remote client authentication. @@ -25,25 +25,33 @@ Ordered backlog. `CURRENT.md` tracks the live session state. 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. +- Trust tiers: `user`, `trusted`, `admin`. The owner is an admin. +- Context: the last 15 messages or 4000 characters, whichever is lower. File names only. + Time stamps. Incremental fetch with an old-message cache. +- Fetch cooldown: `fetchCooldownSeconds`, default 5. +- Continuation window: `continuationSeconds`, default 10. A rushed follow-up joins the request + context. +- Response policy: origin channel or DM. Sensitive commands default to DM. +- Persistent action log with rotation and secret redaction. +- MCP batch tool for read-only commands, plus a voice state tool. Next: -- Notification parsing: edits, deletes, reactions, replies, and message references. -- Persistent trigger log with rotation. +- Notification parsing: edits, deletes, reactions, and message references. +- Reply-to-puppet counts as a trigger. - 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. +- Setup command with reaction-based multiple choice. The puppet reacts 1, 2, 3, 4 to its own + message and waits for the user reaction. This also serves as a small poll for group chats. +- More chat commands: `ask`, `summarize`, `context`, `config`. +- Collector account: stripped-down read-only modules. Use it when the puppet cannot find a user's + voice channel or the channel members. -## Open design questions +## MCP event delivery (open) -- MCP event delivery: the current Streamable HTTP mode is stateless, so the server cannot push - 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. +The current Streamable HTTP mode is stateless. The server cannot push events. Options: + +1. Poll `get_message_log` and `get_action_log` with a cursor. No new transport work. +2. A stateful Streamable HTTP session. The server can send notifications. +3. A separate local event stream over SSE or a WebSocket. + +Option 1 works today. Options 2 and 3 need a decision before an LLM attaches. diff --git a/docs/communication.md b/docs/communication.md index 267e832..94394a4 100644 --- a/docs/communication.md +++ b/docs/communication.md @@ -1,27 +1,31 @@ # Communication -This document describes the trigger rules, the trust model, the commands, the trigger log, the -context cache, and the response policy. +This document describes the trigger rules, trust tiers, commands, the trigger log, context, the +continuation window, and the response policy. ## Trigger rules A message is a trigger when both conditions are true: -- The author is trusted. The owner counts as trusted. +- The author is trusted. Each tier counts as trusted. - One of these conditions is true: - 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. Untrusted messages do not enter the log at this time. The daemon -ignores bots and its own messages. +All other messages are ignored. Untrusted messages do not enter the log at this time. Bots and the +puppet's own messages are ignored. -## Trust model +The daemon processes events one at a time. This order matters for continuation windows. -The puppet has one owner and a trust list. +## Trust tiers -- The owner is always trusted. The default owner is `177117296140091393` (zzib). -- The trust list holds more user IDs. -- The owner controls the trust list. Commands from other users do not change the list. +| Tier | Meaning | +| --- | --- | +| `user` | Can trigger the puppet. This is the default tier. | +| `trusted` | Can trigger the puppet. Future chat commands need this tier. | +| `admin` | Can manage the trust list and the config. The owner is always an admin. | + +The owner controls the trust list. Commands from other users do not change the list. ## Commands @@ -30,14 +34,15 @@ mention the puppet first. | Command | Effect | | --- | --- | -| `whitelist list` | Show the owner and the trusted users. | -| `whitelist add @user` | Add one or more users to the trust list. | +| `whitelist list` | Show the owner and the trusted users with their tiers. | +| `whitelist add @user [user\|trusted\|admin]` | Add one or more users. The default tier is `user`. | | `whitelist remove @user` | Remove one or more users from the trust list. | Notes: - The owner cannot be removed. - The command accepts mentions and raw user IDs. +- `whitelist` commands need the admin tier. - The reply follows the response policy. ## Response policy @@ -62,7 +67,7 @@ The log has two ring buffers. | Buffer | Limit | Content | | --- | --- | --- | -| Trusted | 100 entries | Triggers from the owner and trusted users | +| Trusted | 100 entries | Triggers from trusted users | | Untrusted | 30 entries | Reserved. No triggers enter this buffer at this time. | One entry holds these fields: @@ -78,7 +83,7 @@ One entry holds these fields: | `timestamp` | The message timestamp | | `trusted` | True for a trusted author | | `command` | The parsed command text, when the message looks like a command | -| `context` | Prior messages. Present on mention triggers. | +| `context` | Prior messages. Present on mention triggers. Follow-ups join this list. | ## Context @@ -87,7 +92,8 @@ The daemon collects prior messages for a mention trigger. Direct messages do not - 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. +- Each line holds a time stamp, the author, the message text, and attachment file names. +- Media bodies stay on Discord. - The trigger message is not part of its own context. The cache makes the fetch small. @@ -97,6 +103,9 @@ The cache makes the fetch small. 3. It merges the new messages into the cache. 4. It assembles the context from the merged list. +The fetch cooldown stops a burst of queries. The setting `fetchCooldownSeconds` (default 5) is the +minimum gap between two fetches for one channel. + Cache settings: | Setting | Default | @@ -107,15 +116,53 @@ Cache settings: Eviction removes the least recently written channel files until the folder fits the size limit. +## Continuation window + +A user can send a second message right after a request. The second message often adds a missing +detail. The daemon appends such messages to the request context. + +- Window: `continuationSeconds`, default 10. +- The window starts at the trigger and resets on each follow-up. +- Only messages from the same author in the same channel count. +- A follow-up does not need a mention. +- A follow-up needs a trusted author. + +## Action log + +The daemon writes a persistent log beside the state file: `actions.jsonl`. One JSON line holds one +event. The log rotates at the size limit. + +| Event | Content | +| --- | --- | +| `trigger` | Message ID, channel, guild, author, tier, command, context count | +| `continuation` | Follow-up message ID and the parent entry ID | +| `command` | Command text, requester, result | +| `reply` | Command, mode, target, result, error | +| `error` | Scope and message | + +Secret-looking strings are redacted before the write. Discord token shapes and `mfa.` strings +become `[redacted]`. + +Settings: + +| Setting | Default | +| --- | --- | +| `VESKSLOP_ACTION_LOG` | `/actions.jsonl` | +| `VESKSLOP_ACTION_LOG_MAX_BYTES` | 5 MiB | + +Read the log with the `get_action_log` MCP tool. + ## Event flow 1. The client receives a `MESSAGE_CREATE` gateway 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 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. +4. The daemon queues the event. Events run one at a time. +5. The daemon drops a replayed event. +6. The daemon applies the trigger rules. +7. The daemon collects context for a mention trigger. +8. The daemon adds the entry to the log and writes the action log. +9. The daemon runs a trusted command and replies through the response policy. ## State file @@ -123,7 +170,8 @@ 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. +- The trust list is an object: user ID to tier. +- The trigger log is in memory only. The action log is persistent. ## How to add a trigger rule or a command diff --git a/docs/core-bridge.md b/docs/core-bridge.md index 4a8abc0..4ef66fb 100644 --- a/docs/core-bridge.md +++ b/docs/core-bridge.md @@ -57,6 +57,7 @@ Event line: | `ping` | none | `"pong"` | | `events.subscribe` | none | `{ "subscribed": true }`. The socket becomes an event subscriber. | | `state.get` | none | The current user, the guilds, and the channels. Cache-only. | +| `voice.get` | optional `userId`, optional `guildId` | The voice channel and its members, or `channelId: null` | | `message.send` | `channelId`, `content` | `{ channelId, sent: true }` | | `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. | | `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. | @@ -76,6 +77,7 @@ The core validates all input. The daemon cannot bypass these rules. - `limit` must be an integer from 1 to 100. - `before` and `after` must be snowflakes. They are mutually exclusive. `message.fetch` uses them to walk history. +- `voice.get` accepts snowflakes only. It reads the client cache. It never joins a call. - A request line must not be larger than 64 KiB. ## Core rate limit diff --git a/docs/daemon.md b/docs/daemon.md index 7279757..fddc81a 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -10,6 +10,7 @@ The daemon runs outside Vesktop. It holds all logic. It talks to the core bridge | `poc/src/puppet.ts` | Trust list, command handling, trigger log, response policy | Implemented | | `poc/src/communication.ts` | Pure log, parser, and context logic | Implemented | | `poc/src/message-cache.ts` | Channel message cache in a tmp folder | Implemented | +| `poc/src/action-log.ts` | Persistent JSONL action log with rotation | Implemented | | `poc/src/pacing.ts` | Paced queue and query coalescing | Implemented | | `poc/src/core-client.ts` | Client actions over the core bridge | Implemented (primary transport) | | `poc/src/cdp-client.ts` | Chrome DevTools Protocol client | Development fallback | @@ -48,7 +49,10 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127. | `send_message` | Send a message to a channel. | | `read_messages` | Read cached messages from a channel. | | `send_dm` | Send a DM to a user. | +| `get_voice_state` | Which voice channel a user is in and who else is there. | | `get_message_log` | Read the trigger log. | +| `get_action_log` | Read the persistent action log. | +| `run_commands` | Run several read-only tools in one call, in order. | | `get_context` | Assemble conversation context for a channel. Fetches new messages by default. | | `get_puppet_config` | Read the response policy and context settings. | | `set_puppet_config` | Change the response policy and context settings. | @@ -94,6 +98,8 @@ CDP_EXPRESSION='...' pnpm cdp:dispatch | --- | --- | --- | | `VESKSLOP_SOCKET` | `$XDG_RUNTIME_DIR/vesktop-puppet.sock` | Core bridge socket | | `VESKSLOP_STATE_FILE` | `poc/.veskslop-state.json` | Owner, trust list, and config | +| `VESKSLOP_ACTION_LOG` | `/actions.jsonl` | Persistent action log | +| `VESKSLOP_ACTION_LOG_MAX_BYTES` | 5 MiB | Action log rotation limit | | `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 | diff --git a/poc/src/action-log.ts b/poc/src/action-log.ts new file mode 100644 index 0000000..991a3b1 --- /dev/null +++ b/poc/src/action-log.ts @@ -0,0 +1,56 @@ +/* + * Veskslop daemon — persistent action log. + * + * Writes one JSON line per event: triggers, commands, replies, and errors. + * The file rotates at a size limit. Secret-looking strings are redacted. + */ + +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs"; +import { dirname } from "node:path"; + +import { redactValue } from "./communication.js"; + +export const ACTION_LOG_MAX_BYTES = 5 * 1024 * 1024; + +export class ActionLog { + constructor( + private readonly file = process.env.VESKSLOP_ACTION_LOG ?? ".veskslop-actions.jsonl", + private readonly maxBytes = Number(process.env.VESKSLOP_ACTION_LOG_MAX_BYTES ?? ACTION_LOG_MAX_BYTES) + ) { + try { + mkdirSync(dirname(this.file), { recursive: true }); + } catch { + // the current directory works + } + } + + append(entry: Record & { type: string }) { + const line = JSON.stringify(redactValue({ timestamp: new Date().toISOString(), ...entry })); + + try { + if (existsSync(this.file) && statSync(this.file).size + line.length + 1 > this.maxBytes) { + renameSync(this.file, `${this.file}.1`); + } + appendFileSync(this.file, line + "\n"); + } catch (error) { + console.error("[action-log] write failed:", error); + } + } + + read(limit = 100): unknown[] { + try { + if (!existsSync(this.file)) return []; + const lines = readFileSync(this.file, "utf8").split("\n").filter(Boolean); + return lines.slice(-limit).map(line => { + try { + return JSON.parse(line); + } catch { + return { type: "unparsed", line }; + } + }); + } catch (error) { + console.error("[action-log] read failed:", error); + return []; + } + } +} diff --git a/poc/src/communication.ts b/poc/src/communication.ts index d306408..e348b8e 100644 --- a/poc/src/communication.ts +++ b/poc/src/communication.ts @@ -50,6 +50,10 @@ export class MessageLog { snapshot() { return { trusted: [...this.trusted], untrusted: [...this.untrusted] }; } + + find(id: string): PuppetMessage | undefined { + return this.trusted.find(message => message.id === id) ?? this.untrusted.find(message => message.id === id); + } } const USER_MENTION = /<@!?(\d+)>/g; @@ -113,7 +117,18 @@ export function formatContextLine(message: CachedMessage): string { const parts: string[] = []; if (message.content) parts.push(message.content); for (const name of message.attachments ?? []) parts.push(`[file: ${name}]`); - return `${message.authorName}: ${parts.join(" ")}`.trim(); + + let stamp = ""; + if (message.timestamp) { + const date = new Date(message.timestamp); + if (!Number.isNaN(date.getTime())) { + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + stamp = `[${hours}:${minutes}] `; + } + } + + return `${stamp}${message.authorName}: ${parts.join(" ")}`.trim(); } /** Assemble the newest messages inside a message count limit and a character limit. */ @@ -158,6 +173,46 @@ export function compareSnowflakes(a: string, b: string): number { } } +/* Trust tiers */ + +export type TrustTier = "user" | "trusted" | "admin"; + +const TIER_RANK: Record = { user: 0, trusted: 1, admin: 2 }; + +export function hasTier(tier: TrustTier | undefined, required: TrustTier): boolean { + return !!tier && TIER_RANK[tier] >= TIER_RANK[required]; +} + +export function parseTier(value: string): TrustTier | undefined { + return value === "user" || value === "trusted" || value === "admin" ? value : undefined; +} + +/* Secret redaction for persistent logs. Keep it minimal. */ + +const SECRET_PATTERNS = [ + /[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{5,7}\.[A-Za-z0-9_-]{25,}/g, + /mfa\.[A-Za-z0-9_-]{20,}/g +]; + +export function redactSecrets(text: string): string { + let output = text; + for (const pattern of SECRET_PATTERNS) output = output.replace(pattern, "[redacted]"); + return output; +} + +export function redactValue(value: unknown, depth = 0): unknown { + if (typeof value === "string") return redactSecrets(value); + if (depth > 4 || value === null || typeof value !== "object") return value; + + if (Array.isArray(value)) return value.map(item => redactValue(item, depth + 1)); + + const result: Record = {}; + for (const [key, item] of Object.entries(value as Record)) { + result[key] = redactValue(item, depth + 1); + } + return result; +} + /** Convert a core `message.fetch` or `message.read` message into a cached message. */ export function toCachedMessage(raw: any): CachedMessage { return { diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index aab06ad..3221f99 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -34,7 +34,7 @@ async function connectLoop() { puppet = new Puppet(client); client.onEvent(event => { - puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error)); + void puppet!.enqueueEvent(event); }); coreConnected = true; @@ -62,6 +62,23 @@ function jsonText(value: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; } +/** Read-only tools that the batch tool can run. Mutating tools stay out. */ +const READ_ONLY_TOOLS: Record Promise | unknown> = { + get_state: () => requireCore().getState(), + get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId), + get_context: args => + requirePuppet().getContext(args?.channelId, { + maxMessages: args?.maxMessages, + maxChars: args?.maxChars, + refresh: args?.refresh + }), + get_message_log: () => requirePuppet().getLog(), + get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100), + get_puppet_config: () => requirePuppet().getConfig(), + whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }), + read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20)) +}; + function buildServer(): McpServer { const server = new McpServer({ name: "veskslop-control", version: "0.0.0" }); @@ -160,6 +177,8 @@ function buildServer(): McpServer { config: z.object({ contextMessages: z.number().int().min(1).max(50).optional(), contextChars: z.number().int().min(200).max(20000).optional(), + fetchCooldownSeconds: z.number().int().min(0).max(300).optional(), + continuationSeconds: z.number().int().min(0).max(300).optional(), responsePolicy: z .object({ defaultMode: z.enum(["channel", "dm"]).optional(), @@ -173,10 +192,70 @@ function buildServer(): McpServer { async ({ config }) => jsonText(requirePuppet().updateConfig(config)) ); + server.registerTool( + "get_voice_state", + { + description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.", + inputSchema: { + userId: z.string().optional().describe("User ID. Defaults to the logged-in user."), + guildId: z.string().optional().describe("Guild ID. Omit for a DM call.") + } + }, + async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId)) + ); + + server.registerTool( + "get_action_log", + { + description: "Read the persistent action log: triggers, commands, replies, and errors.", + inputSchema: { + limit: z.number().int().min(1).max(500).default(100).describe("How many entries") + } + }, + async ({ limit }) => jsonText(requirePuppet().readActionLog(limit)) + ); + + server.registerTool( + "run_commands", + { + description: "Run several read-only commands in one call, in order.", + inputSchema: { + commands: z + .array( + z.object({ + tool: z.string().describe("Read-only tool name"), + arguments: z.record(z.string(), z.any()).optional() + }) + ) + .min(1) + .max(10) + } + }, + async ({ commands }) => { + const results: Array> = []; + + for (const item of commands) { + const handler = READ_ONLY_TOOLS[item.tool]; + if (!handler) { + results.push({ tool: item.tool, ok: false, error: "unknown or non-read-only tool" }); + continue; + } + + try { + results.push({ tool: item.tool, ok: true, result: await handler((item.arguments ?? {}) as any) }); + } catch (error) { + results.push({ tool: item.tool, ok: false, error: String((error as Error).message) }); + } + } + + return jsonText({ results }); + } + ); + server.registerTool( "whitelist_list", { - description: "List the puppet owner and trusted (whitelisted) user IDs." + description: "List the puppet owner and trusted users with their tiers." }, async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }) ); @@ -184,12 +263,14 @@ function buildServer(): McpServer { server.registerTool( "whitelist_add", { - description: "Add users to the puppet whitelist.", + description: "Add users to the puppet whitelist with an optional tier. Defaults to 'user'.", inputSchema: { - userIds: z.array(z.string()).min(1).describe("User IDs to trust") + userIds: z.array(z.string()).min(1).describe("User IDs to trust"), + tier: z.enum(["user", "trusted", "admin"]).optional().describe("Trust tier") } }, - async ({ userIds }) => jsonText({ whitelist: requirePuppet().addToWhitelist(userIds) }) + async ({ userIds, tier }) => + jsonText({ whitelist: requirePuppet().addToWhitelist(userIds, tier ?? "user") }) ); server.registerTool( diff --git a/poc/src/core-client.ts b/poc/src/core-client.ts index e809c84..b8889ea 100644 --- a/poc/src/core-client.ts +++ b/poc/src/core-client.ts @@ -135,6 +135,13 @@ export class CoreClient { return this.request("message.fetch", { channelId, limit, ...options }); } + async voiceGet(userId?: string, guildId?: string) { + return this.request("voice.get", { + ...(userId ? { userId } : {}), + ...(guildId ? { guildId } : {}) + }); + } + close() { this.socket.destroy(); } diff --git a/poc/src/mock-core.ts b/poc/src/mock-core.ts index 77ab0de..bc425f1 100644 --- a/poc/src/mock-core.ts +++ b/poc/src/mock-core.ts @@ -50,6 +50,16 @@ function handle(action: string | undefined, params: any) { return "pong"; case "state.get": return { user: { id: "999", username: "mockuser" }, guilds: FAKE_GUILDS }; + case "voice.get": + return { + guildId: params?.guildId ?? "100000000000000001", + channelId: "200000000000000002", + channelName: "voice", + members: [ + { id: params?.userId ?? "999", username: "mockuser", mute: false, deaf: false }, + { id: "177117296140091393", username: "zzib", mute: false, deaf: false } + ] + }; case "message.send": return { channelId: params?.channelId, sent: true }; case "message.read": diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts index fc26c58..8cdb840 100644 --- a/poc/src/puppet.ts +++ b/poc/src/puppet.ts @@ -1,26 +1,30 @@ /* - * Veskslop daemon — puppet state: trust list, trigger log, response policy, - * conversation context, and Discord-facing command handling. + * Veskslop daemon — puppet state: trust tiers, trigger log, response policy, + * conversation context, continuation windows, and Discord-facing commands. * - * Trigger scope for now: direct messages from trusted users and direct @ - * mentions from trusted users. Mentions in guild channels and group DMs also - * collect recent context for later LLM use. + * Trigger scope: direct messages and direct mentions from trusted users. + * Trust tiers: user, trusted, admin. The owner is an admin. Whitelist + * management requires the admin tier. */ import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { ActionLog } from "./action-log.js"; import { assembleContext, - isTrusted, + hasTier, MessageLog, parseCommand, + parseTier, randomActionDelay, stripMentions, toCachedMessage, type AssembledContext, type CachedMessage, type ParsedCommand, - type PuppetMessage + type PuppetMessage, + type TrustTier } from "./communication.js"; import { MessageCache } from "./message-cache.js"; import { paced } from "./pacing.js"; @@ -41,12 +45,16 @@ export interface ResponsePolicy { export interface PuppetConfig { contextMessages: number; contextChars: number; + fetchCooldownSeconds: number; + continuationSeconds: number; responsePolicy: ResponsePolicy; } export interface PuppetConfigPatch { contextMessages?: number; contextChars?: number; + fetchCooldownSeconds?: number; + continuationSeconds?: number; responsePolicy?: { defaultMode?: "channel" | "dm"; sensitiveCommands?: string[]; @@ -57,6 +65,8 @@ export interface PuppetConfigPatch { const DEFAULT_CONFIG: PuppetConfig = { contextMessages: 15, contextChars: 4000, + fetchCooldownSeconds: 5, + continuationSeconds: 10, responsePolicy: { defaultMode: "channel", sensitiveCommands: ["whitelist", "config"], @@ -66,7 +76,7 @@ const DEFAULT_CONFIG: PuppetConfig = { interface PersistedState { ownerId?: string; - whitelist?: string[]; + whitelist?: string[] | Record; config?: Partial; } @@ -89,21 +99,50 @@ function clamp(value: number, min: number, max: number): number { export class Puppet { readonly log = new MessageLog(); + private readonly actionLog = new ActionLog( + process.env.VESKSLOP_ACTION_LOG ?? join(dirname(STATE_FILE), "actions.jsonl") + ); + private readonly cache = new MessageCache(); + private readonly tiers = new Map(); + private readonly seen = new Set(); + private readonly seenOrder: string[] = []; + private readonly lastFetchAt = new Map(); + private readonly pending = new Map(); + private ownerId = DEFAULT_OWNER_ID; - private whitelistIds: string[] = []; private config: PuppetConfig = structuredClone(DEFAULT_CONFIG); private selfId?: string; - private readonly cache = new MessageCache(); + private queue: Promise = Promise.resolve(); constructor(private readonly core: PuppetTransport) { try { const state = JSON.parse(readFileSync(STATE_FILE, "utf8")) as PersistedState; if (state.ownerId) this.ownerId = state.ownerId; - if (Array.isArray(state.whitelist)) this.whitelistIds = state.whitelist; + + // Accept the old array shape and the new tier map. + if (Array.isArray(state.whitelist)) { + for (const id of state.whitelist) this.tiers.set(id, "user"); + } else if (state.whitelist) { + for (const [id, tier] of Object.entries(state.whitelist)) { + const parsed = parseTier(tier); + if (parsed) this.tiers.set(id, parsed); + } + } + if (state.config) { this.config = { contextMessages: clamp(state.config.contextMessages ?? DEFAULT_CONFIG.contextMessages, 1, 50), contextChars: clamp(state.config.contextChars ?? DEFAULT_CONFIG.contextChars, 200, 20000), + fetchCooldownSeconds: clamp( + state.config.fetchCooldownSeconds ?? DEFAULT_CONFIG.fetchCooldownSeconds, + 0, + 300 + ), + continuationSeconds: clamp( + state.config.continuationSeconds ?? DEFAULT_CONFIG.continuationSeconds, + 0, + 300 + ), responsePolicy: { defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode, sensitiveCommands: @@ -118,14 +157,22 @@ export class Puppet { } } - get whitelist(): string[] { - return [...this.whitelistIds]; + get whitelist(): Record { + return Object.fromEntries(this.tiers); } get owner() { return this.ownerId; } + tierOf(userId: string): TrustTier | undefined { + return userId === this.ownerId ? "admin" : this.tiers.get(userId); + } + + isTrusted(userId: string): boolean { + return this.tierOf(userId) !== undefined; + } + getConfig(): PuppetConfig { return structuredClone(this.config); } @@ -136,6 +183,12 @@ export class Puppet { this.config = { contextMessages: clamp(patch.contextMessages ?? this.config.contextMessages, 1, 50), contextChars: clamp(patch.contextChars ?? this.config.contextChars, 200, 20000), + fetchCooldownSeconds: clamp( + patch.fetchCooldownSeconds ?? this.config.fetchCooldownSeconds, + 0, + 300 + ), + continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300), responsePolicy: { defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode, sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands, @@ -151,22 +204,21 @@ export class Puppet { return { ...this.log.snapshot(), ownerId: this.ownerId, whitelist: this.whitelist }; } - isTrusted(userId: string): boolean { - return isTrusted(userId, this.ownerId, this.whitelistIds); + readActionLog(limit = 100) { + return this.actionLog.read(limit); } - addToWhitelist(userIds: string[]): string[] { - const next = new Set(this.whitelistIds); - for (const id of userIds) if (id && id !== this.ownerId) next.add(id); + addToWhitelist(userIds: string[], tier: TrustTier = "user"): Record { + for (const id of userIds) { + if (id && id !== this.ownerId) this.tiers.set(id, tier); + } - this.whitelistIds = [...next]; this.save(); return this.whitelist; } - removeFromWhitelist(userIds: string[]): string[] { - const remove = new Set(userIds); - this.whitelistIds = this.whitelistIds.filter(id => !remove.has(id)); + removeFromWhitelist(userIds: string[]): Record { + for (const id of userIds) this.tiers.delete(id); this.save(); return this.whitelist; } @@ -176,13 +228,14 @@ export class Puppet { writeFileSync( STATE_FILE, JSON.stringify( - { ownerId: this.ownerId, whitelist: this.whitelistIds, config: this.config }, + { ownerId: this.ownerId, whitelist: this.whitelist, config: this.config }, null, 4 ) ); } catch (error) { console.error("[puppet] failed to save state:", error); + this.actionLog.append({ type: "error", scope: "state-save", message: String(error) }); } } @@ -190,8 +243,12 @@ export class Puppet { private async refreshCache(channelId: string): Promise { const cached = this.cache.get(channelId); - const newest = cached.at(-1)?.id; + const lastFetch = this.lastFetchAt.get(channelId) ?? 0; + if (Date.now() - lastFetch < this.config.fetchCooldownSeconds * 1000) return cached; + this.lastFetchAt.set(channelId, Date.now()); + + const newest = cached.at(-1)?.id; const raw = await paced(() => newest ? this.core.fetchMessages(channelId, 100, { after: newest }) @@ -227,6 +284,28 @@ export class Puppet { /* Triggers */ + /** Process events one at a time. Order matters for continuation windows. */ + enqueueEvent(event: any): Promise { + this.queue = this.queue + .then(() => this.handleEvent(event)) + .catch(error => { + console.error("[puppet] event failed:", error); + this.actionLog.append({ type: "error", scope: "event", message: String(error) }); + }); + + return this.queue; + } + + private remember(id: string) { + this.seen.add(id); + this.seenOrder.push(id); + + if (this.seenOrder.length > 1000) { + const oldest = this.seenOrder.shift()!; + this.seen.delete(oldest); + } + } + async handleEvent(event: any) { if (event?.type !== "MESSAGE_CREATE") return; @@ -234,17 +313,52 @@ export class Puppet { if (!author || !channelId) return; if (author.bot) return; + // Drop replayed events after a reconnect. + if (event.id) { + if (this.seen.has(event.id)) return; + this.remember(event.id); + } + this.selfId ??= await this.core.getSelfId(); if (!this.selfId || author.id === this.selfId) return; - // Whitelist-only for now. - if (!this.isTrusted(author.id)) return; + const tier = this.tierOf(author.id); + if (!tier) return; const isGroupDm = channelType === GROUP_DM_CHANNEL; const isDirectDm = !guildId && (channelType === DM_CHANNEL || (!isGroupDm && channelType === null)); + const mentioned = new Set(mentions ?? []).has(this.selfId); + const isTrigger = isDirectDm || mentioned; - const mentionIds = new Set(mentions ?? []); - if (!isDirectDm && !mentionIds.has(this.selfId)) return; + const key = `${channelId}:${author.id}`; + + // A rushed follow-up message joins the previous request as context. + if (!isTrigger) { + const pending = this.pending.get(key); + if (pending && Date.now() < pending.expiresAt) { + const entry = this.log.find(pending.entryId); + if (entry) { + const followUp: CachedMessage = { + id: event.id, + authorId: author.id, + authorName: author.username, + content: content ?? "", + timestamp: event.timestamp ?? new Date().toISOString() + }; + + entry.context = [...(entry.context ?? []), followUp]; + pending.expiresAt = Date.now() + this.config.continuationSeconds * 1000; + this.actionLog.append({ + type: "continuation", + messageId: event.id, + entryId: entry.id, + channelId, + authorId: author.id + }); + } + } + return; + } const text = isDirectDm ? (content ?? "") : stripMentions(content ?? "", new Set([this.selfId])); const command = parseCommand(text); @@ -261,20 +375,37 @@ export class Puppet { command: command ? `${command.name}${command.args.length ? " " + command.args.join(" ") : ""}` : undefined }; - // Mentions need surrounding context for later LLM use. Direct messages do not. if (!isDirectDm) { try { const context = await this.collectContext(channelId, event.id); if (context.messages.length) entry.context = context.messages; } catch (error) { - console.error("[puppet] context collection failed:", error); + this.actionLog.append({ type: "error", scope: "context", channelId, message: String(error) }); } } this.log.add(entry); - if (command?.name === "whitelist" && author.id === this.ownerId) { - await this.handleWhitelistCommand(command, author.id, channelId); + if (this.config.continuationSeconds > 0) { + this.pending.set(key, { + entryId: entry.id, + expiresAt: Date.now() + this.config.continuationSeconds * 1000 + }); + } + + this.actionLog.append({ + type: "trigger", + messageId: entry.id, + channelId, + guildId: guildId ?? null, + authorId: author.id, + tier, + command: entry.command, + contextCount: entry.context?.length ?? 0 + }); + + if (command?.name === "whitelist") { + await this.handleWhitelistCommand(command, author.id, tier, channelId); } } @@ -285,38 +416,72 @@ export class Puppet { return [...ids]; } - private async handleWhitelistCommand(command: ParsedCommand, authorId: string, channelId: string) { + private async handleWhitelistCommand( + command: ParsedCommand, + authorId: string, + tier: TrustTier, + channelId: string + ) { + if (!hasTier(tier, "admin")) { + return this.respond(authorId, channelId, "whitelist", "You do not have permission for this command."); + } + const [sub, ...rest] = command.args; switch (sub) { - case "list": + case "list": { + const lines = [this.ownerId, ...this.tiers.keys()].map(id => `${id} — ${this.tierOf(id)}`); + this.actionLog.append({ type: "command", command: "whitelist list", requesterId: authorId, result: "ok" }); + return this.respond(authorId, channelId, "whitelist", `Trusted users:\n${lines.join("\n")}`); + } + + case "add": { + const ids = this.extractUserIds(rest.join(" ")); + if (!ids.length) { + return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user [user|trusted|admin]"); + } + + const last = rest.at(-1)?.toLowerCase(); + const requestedTier = (last && parseTier(last)) || "user"; + this.addToWhitelist(ids, requestedTier); + + this.actionLog.append({ + type: "command", + command: `whitelist add ${ids.join(",")} ${requestedTier}`, + requesterId: authorId, + result: "ok" + }); + return this.respond( authorId, channelId, "whitelist", - `Trusted users:\n${[this.ownerId, ...this.whitelistIds].join("\n")}` + `Added:\n${ids.map(id => `${id} — ${requestedTier}`).join("\n")}` ); - - case "add": { - const ids = this.extractUserIds(rest.join(" ")); - if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add @user"); - - this.addToWhitelist(ids); - return this.respond(authorId, channelId, "whitelist", `Added to whitelist:\n${ids.join("\n")}`); } case "remove": { const ids = this.extractUserIds(rest.join(" ")); - if (!ids.length) return this.respond(authorId, channelId, "whitelist", "Usage: whitelist remove @user"); + if (!ids.length) { + return this.respond(authorId, channelId, "whitelist", "Usage: whitelist remove @user"); + } const removed = ids.filter(id => id !== this.ownerId); this.removeFromWhitelist(removed); + + this.actionLog.append({ + type: "command", + command: `whitelist remove ${removed.join(",")}`, + requesterId: authorId, + result: "ok" + }); + return this.respond( authorId, channelId, "whitelist", [ - removed.length ? `Removed from whitelist:\n${removed.join("\n")}` : "Nothing removed.", + removed.length ? `Removed:\n${removed.join("\n")}` : "Nothing removed.", ids.length !== removed.length ? "The owner cannot be removed." : "" ] .filter(Boolean) @@ -331,16 +496,6 @@ export class Puppet { /* Replies */ - private async reply(channelId: string, content: string) { - // Autonomous replies use the same 500-2800 ms pacing as everything else. - await new Promise(resolve => setTimeout(resolve, randomActionDelay())); - try { - await this.core.sendMessage(channelId, content); - } catch (error) { - console.error("[puppet] reply failed:", error); - } - } - private async respond( requesterId: string, originChannelId: string, @@ -352,17 +507,29 @@ export class Puppet { const sensitive = !!commandName && policy.sensitiveCommands.includes(commandName); const mode = override ?? (sensitive ? "dm" : policy.defaultMode); - if (mode === "dm") { - await new Promise(resolve => setTimeout(resolve, randomActionDelay())); - try { - await this.core.sendDm(requesterId, content); - } catch (error) { - // Never fall back to a public channel for sensitive content. - console.error("[puppet] sensitive reply by DM failed:", error); - } - return; - } + await new Promise(resolve => setTimeout(resolve, randomActionDelay())); - await this.reply(originChannelId, content); + try { + if (mode === "dm") await this.core.sendDm(requesterId, content); + else await this.core.sendMessage(originChannelId, content); + + this.actionLog.append({ + type: "reply", + command: commandName, + mode, + target: mode === "dm" ? requesterId : originChannelId, + result: "ok" + }); + } catch (error) { + // Never fall back to a public channel for sensitive content. + this.actionLog.append({ + type: "reply", + command: commandName, + mode, + target: mode === "dm" ? requesterId : originChannelId, + result: "error", + error: String(error) + }); + } } } diff --git a/poc/src/test-communication.ts b/poc/src/test-communication.ts index 0d538b7..a27b8ee 100644 --- a/poc/src/test-communication.ts +++ b/poc/src/test-communication.ts @@ -14,9 +14,12 @@ import { compareSnowflakes, extractMentionIds, formatContextLine, + hasTier, isTrusted, parseCommand, + parseTier, randomActionDelay, + redactSecrets, stripMentions, toCachedMessage, type CachedMessage, @@ -137,4 +140,40 @@ function entry(id: number, trusted: boolean): PuppetMessage { }); } +// timestamps in context lines +{ + const withTime: CachedMessage = { + ...cached(1, "hi"), + timestamp: "2026-09-11T14:03:00.000Z" + }; + assert.match(formatContextLine(withTime), /^\[\d\d:\d\d\] a: hi$/); + assert.equal(formatContextLine(cached(2, "no time")), "a: no time"); +} + +// secret redaction +{ + const fakeToken = `${"x".repeat(24)}.${"abcde"}.${"y".repeat(25)}`; + assert.equal(redactSecrets(`token ${fakeToken} here`), "token [redacted] here"); + assert.equal(redactSecrets("mfa." + "z".repeat(30)), "[redacted]"); + assert.equal(redactSecrets("nothing to hide"), "nothing to hide"); +} + +// trust tiers +{ + assert.equal(hasTier("admin", "admin"), true); + assert.equal(hasTier("trusted", "admin"), false); + assert.equal(hasTier("trusted", "user"), true); + assert.equal(hasTier(undefined, "user"), false); + assert.equal(parseTier("trusted"), "trusted"); + assert.equal(parseTier("bogus"), undefined); +} + +// log lookup by ID +{ + const log = new MessageLog(); + log.add({ ...entry(1, true), context: [] }); + assert.equal(log.find("1")?.id, "1"); + assert.equal(log.find("nope"), undefined); +} + console.log("all communication tests passed"); diff --git a/poc/src/test-trigger.ts b/poc/src/test-trigger.ts index d01cc29..990fa3b 100644 --- a/poc/src/test-trigger.ts +++ b/poc/src/test-trigger.ts @@ -16,6 +16,7 @@ const authorId = process.env.TEST_TRIGGER_AUTHOR ?? "177117296140091393"; const content = process.env.TEST_TRIGGER_CONTENT ?? "whitelist list"; const guildId = process.env.TEST_TRIGGER_GUILD; const isGuild = !!guildId; +const mention = isGuild && process.env.TEST_TRIGGER_MENTION !== "0"; const event = { type: "MESSAGE_CREATE", @@ -24,8 +25,8 @@ const event = { guildId: guildId ?? null, channelType: isGuild ? 0 : 1, author: { id: authorId, username: "zzib", bot: false }, - content: isGuild ? `<@999> ${content}` : content, - mentions: isGuild ? ["999"] : [], + content: mention ? `<@999> ${content}` : content, + mentions: mention ? ["999"] : [], timestamp: new Date().toISOString() }; diff --git a/src/main/puppetBridge.ts b/src/main/puppetBridge.ts index e2e27c8..40d7bb8 100644 --- a/src/main/puppetBridge.ts +++ b/src/main/puppetBridge.ts @@ -34,6 +34,7 @@ const KNOWN_ACTIONS = new Set([ PuppetAction.Ping, PuppetAction.EventsSubscribe, PuppetAction.StateGet, + PuppetAction.VoiceGet, PuppetAction.MessageSend, PuppetAction.MessageRead, PuppetAction.MessageFetch, diff --git a/src/renderer/puppet/actions.ts b/src/renderer/puppet/actions.ts index 45043e1..829df9f 100644 --- a/src/renderer/puppet/actions.ts +++ b/src/renderer/puppet/actions.ts @@ -9,7 +9,13 @@ import { openPrivateChannel, sendMessage } from "@vencord/types/utils"; import { findByPropsLazy } from "@vencord/types/webpack"; -import { ChannelStore, GuildStore, MessageStore, UserStore } from "@vencord/types/webpack/common"; +import { + ChannelStore, + GuildStore, + MessageStore, + UserStore, + VoiceStateStore +} from "@vencord/types/webpack/common"; import { isSnowflake, PUPPET_LIMITS, PuppetAction, PuppetErrorCode, type PuppetResult } from "shared/puppetProtocol"; /** Channel types that can receive text messages. */ @@ -154,6 +160,42 @@ export async function handlePuppetAction( return ok(state); } + case PuppetAction.VoiceGet: { + const { guildId, userId } = params; + if (guildId !== undefined) { + assert(isSnowflake(guildId), PuppetErrorCode.InvalidSnowflake, "guildId must be a snowflake"); + } + if (userId !== undefined) { + assert(isSnowflake(userId), PuppetErrorCode.InvalidSnowflake, "userId must be a snowflake"); + } + + const targetId = (userId as string | undefined) ?? UserStore.getCurrentUser()?.id; + assert(targetId, PuppetErrorCode.NotLoggedIn, "no user is logged in"); + + const voiceState = VoiceStateStore.getVoiceState((guildId as string | null) ?? null, targetId); + if (!voiceState?.channelId) { + return ok({ guildId: guildId ?? null, channelId: null, channelName: null, members: [] }); + } + + const channel = ChannelStore.getChannel(voiceState.channelId); + const states = VoiceStateStore.getVoiceStatesForChannel(voiceState.channelId); + + return ok({ + guildId: channel?.guild_id ?? guildId ?? null, + channelId: voiceState.channelId, + channelName: channel?.name ?? null, + members: Object.values(states).map(state => { + const user = UserStore.getUser(state.userId); + return { + id: state.userId, + username: user?.username ?? state.userId, + mute: state.mute, + deaf: state.deaf + }; + }) + }); + } + case PuppetAction.MessageSend: { const { channelId } = params; assert(isSnowflake(channelId), PuppetErrorCode.InvalidSnowflake, "channelId must be a snowflake"); diff --git a/src/shared/puppetProtocol.ts b/src/shared/puppetProtocol.ts index 02ccc03..3c1be3d 100644 --- a/src/shared/puppetProtocol.ts +++ b/src/shared/puppetProtocol.ts @@ -10,6 +10,7 @@ export const enum PuppetAction { Ping = "ping", EventsSubscribe = "events.subscribe", StateGet = "state.get", + VoiceGet = "voice.get", MessageSend = "message.send", MessageRead = "message.read", MessageFetch = "message.fetch", @@ -20,6 +21,7 @@ export const PuppetActions: readonly PuppetAction[] = [ PuppetAction.Ping, PuppetAction.EventsSubscribe, PuppetAction.StateGet, + PuppetAction.VoiceGet, PuppetAction.MessageSend, PuppetAction.MessageRead, PuppetAction.MessageFetch,