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