diff --git a/AGENTS.md b/AGENTS.md index e025e51..0754859 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ before you stop. | Trusted-only triggers, trust tiers | Implemented | | Context cache, fetch cooldown, continuation | Implemented | | Response policy (channel or DM) | Implemented | +| Chat commands (`poll`) | Implemented | | Persistent action log | Implemented | | Voice state read | Implemented | | Voice actions, notification parsing | Not started | diff --git a/CURRENT.md b/CURRENT.md index 8aebd16..b6bb411 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -24,6 +24,7 @@ persistent logging. Voice, audio, and video stay deferred. - Continuation window: a rushed follow-up joins the request context. - Response policy: origin channel or DM. Sensitive commands default to DM. - Persistent action log: triggers, commands, replies, errors, rotation, and secret redaction. +- Chat command: `poll` with number-emoji reactions, one vote per user, and auto results. - 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. diff --git a/TODO.md b/TODO.md index a6887e6..ea3cbb3 100644 --- a/TODO.md +++ b/TODO.md @@ -34,6 +34,8 @@ Done: - 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. +- Reaction poll command: `poll | opt 1 | opt 2 [| opt 3 [| opt 4]]`. Number emojis, + one vote per user, auto results after `pollSeconds`. Next: diff --git a/docs/communication.md b/docs/communication.md index 94394a4..bd0dcaf 100644 --- a/docs/communication.md +++ b/docs/communication.md @@ -37,6 +37,7 @@ mention the puppet first. | `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. | +| `poll \| \| [\| opt 3 [\| opt 4]]` | Start a reaction poll. | Notes: @@ -45,6 +46,21 @@ Notes: - `whitelist` commands need the admin tier. - The reply follows the response policy. +## Polls + +The `poll` command posts a message and adds one number emoji per option. Discord users vote by +reacting. The poll closes after `pollSeconds` (default 120) and the daemon posts the result. + +- Options: 2 to 4. Each option is at most 80 characters. +- Emojis: `1️⃣`, `2️⃣`, `3️⃣`, `4️⃣`. +- One vote per user. A new vote replaces the old vote. +- Removing the reaction removes the vote. +- The puppet's own reactions do not count. +- Votes do not need a trust tier. Friends can vote. +- The result message goes to the poll channel. + +Example: `@puppet poll Lunch? | Pizza | Sushi` + ## Response policy The policy decides where a reply goes. Sensitive replies go by DM. diff --git a/docs/core-bridge.md b/docs/core-bridge.md index 4ef66fb..b8e799e 100644 --- a/docs/core-bridge.md +++ b/docs/core-bridge.md @@ -58,9 +58,10 @@ Event line: | `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.send` | `channelId`, `content` | `{ channelId, messageId, sent: true }` | | `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. | | `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. | +| `reaction.add` | `channelId`, `messageId`, `emoji` | `{ added: true }` | | `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` | ## Validation rules @@ -78,6 +79,8 @@ The core validates all input. The daemon cannot bypass these rules. - `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. +- `reaction.add` accepts a short emoji string without line breaks. It counts as an outbound action + for the rate limit. - A request line must not be larger than 64 KiB. ## Core rate limit @@ -104,6 +107,7 @@ The core rate limit is a safety net. It does not replace the daemon pacing. | `CHANNEL_NOT_FOUND` | The channel is not in the client cache. | | `NOT_SENDABLE` | The channel type cannot receive messages. | | `SEND_FAILED` | Discord rejected the message. | +| `REACTION_FAILED` | Discord rejected the reaction. | | `DM_UNAVAILABLE` | The client cannot create a DM channel. | | `NOT_SUPPORTED` | The client does not offer the internal action. | | `RATE_LIMITED` | The core rate limit rejected the action. | diff --git a/poc/src/communication.ts b/poc/src/communication.ts index e348b8e..2694507 100644 --- a/poc/src/communication.ts +++ b/poc/src/communication.ts @@ -187,6 +187,26 @@ export function parseTier(value: string): TrustTier | undefined { return value === "user" || value === "trusted" || value === "admin" ? value : undefined; } +/* Polls */ + +export const POLL_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣"]; + +export interface ParsedPoll { + question: string; + options: string[]; +} + +/** Parse `question | option 1 | option 2 [| option 3 [| option 4]]`. */ +export function parsePoll(text: string): ParsedPoll | undefined { + const parts = text.split("|").map(part => part.trim()).filter(Boolean); + if (parts.length < 3 || parts.length > 5) return undefined; + + const [question, ...options] = parts; + if (!question || options.length < 2 || options.some(option => option.length > 80)) return undefined; + + return { question: question.slice(0, 200), options: options.map(option => option.slice(0, 80)) }; +} + /* Secret redaction for persistent logs. Keep it minimal. */ const SECRET_PATTERNS = [ diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index 3221f99..065ab33 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -179,6 +179,7 @@ function buildServer(): McpServer { 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(), + pollSeconds: z.number().int().min(10).max(3600).optional(), responsePolicy: z .object({ defaultMode: z.enum(["channel", "dm"]).optional(), diff --git a/poc/src/core-client.ts b/poc/src/core-client.ts index b8889ea..ea0aaaa 100644 --- a/poc/src/core-client.ts +++ b/poc/src/core-client.ts @@ -120,7 +120,11 @@ export class CoreClient { } async sendMessage(channelId: string, content: string) { - await this.request("message.send", { channelId, content }); + return this.request<{ messageId?: string }>("message.send", { channelId, content }); + } + + async reactionAdd(channelId: string, messageId: string, emoji: string) { + return this.request("reaction.add", { channelId, messageId, emoji }); } async sendDm(userId: string, content: string) { diff --git a/poc/src/mock-core.ts b/poc/src/mock-core.ts index bc425f1..a45fa9c 100644 --- a/poc/src/mock-core.ts +++ b/poc/src/mock-core.ts @@ -61,7 +61,9 @@ function handle(action: string | undefined, params: any) { ] }; case "message.send": - return { channelId: params?.channelId, sent: true }; + return { channelId: params?.channelId, messageId: "msg-1", sent: true }; + case "reaction.add": + return { channelId: params?.channelId, messageId: params?.messageId, emoji: params?.emoji, added: true }; case "message.read": return { cached: FAKE_MESSAGES.length, messages: FAKE_MESSAGES }; case "message.fetch": { diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts index 8cdb840..bb9b4af 100644 --- a/poc/src/puppet.ts +++ b/poc/src/puppet.ts @@ -16,7 +16,9 @@ import { hasTier, MessageLog, parseCommand, + parsePoll, parseTier, + POLL_EMOJIS, randomActionDelay, stripMentions, toCachedMessage, @@ -47,6 +49,7 @@ export interface PuppetConfig { contextChars: number; fetchCooldownSeconds: number; continuationSeconds: number; + pollSeconds: number; responsePolicy: ResponsePolicy; } @@ -55,6 +58,7 @@ export interface PuppetConfigPatch { contextChars?: number; fetchCooldownSeconds?: number; continuationSeconds?: number; + pollSeconds?: number; responsePolicy?: { defaultMode?: "channel" | "dm"; sensitiveCommands?: string[]; @@ -67,6 +71,7 @@ const DEFAULT_CONFIG: PuppetConfig = { contextChars: 4000, fetchCooldownSeconds: 5, continuationSeconds: 10, + pollSeconds: 120, responsePolicy: { defaultMode: "channel", sensitiveCommands: ["whitelist", "config"], @@ -83,13 +88,24 @@ interface PersistedState { /** The transport methods that the puppet logic needs. */ export interface PuppetTransport { getSelfId(): Promise; - sendMessage(channelId: string, content: string): Promise; + sendMessage(channelId: string, content: string): Promise<{ messageId?: string } | void>; sendDm(userId: string, content: string): Promise; fetchMessages( channelId: string, limit: number, options?: { before?: string; after?: string } ): Promise<{ messages?: unknown[] }>; + reactionAdd(channelId: string, messageId: string, emoji: string): Promise; +} + +interface PollState { + channelId: string; + authorId: string; + question: string; + options: string[]; + votes: Map; + endsAt: number; + timer: ReturnType; } function clamp(value: number, min: number, max: number): number { @@ -108,6 +124,7 @@ export class Puppet { private readonly seenOrder: string[] = []; private readonly lastFetchAt = new Map(); private readonly pending = new Map(); + private readonly polls = new Map(); private ownerId = DEFAULT_OWNER_ID; private config: PuppetConfig = structuredClone(DEFAULT_CONFIG); @@ -143,6 +160,7 @@ export class Puppet { 0, 300 ), + pollSeconds: clamp(state.config.pollSeconds ?? DEFAULT_CONFIG.pollSeconds, 10, 3600), responsePolicy: { defaultMode: state.config.responsePolicy?.defaultMode ?? DEFAULT_CONFIG.responsePolicy.defaultMode, sensitiveCommands: @@ -189,6 +207,7 @@ export class Puppet { 300 ), continuationSeconds: clamp(patch.continuationSeconds ?? this.config.continuationSeconds, 0, 300), + pollSeconds: clamp(patch.pollSeconds ?? this.config.pollSeconds, 10, 3600), responsePolicy: { defaultMode: policy.defaultMode ?? this.config.responsePolicy.defaultMode, sensitiveCommands: policy.sensitiveCommands ?? this.config.responsePolicy.sensitiveCommands, @@ -307,6 +326,11 @@ export class Puppet { } async handleEvent(event: any) { + if (event?.type === "MESSAGE_REACTION_ADD" || event?.type === "MESSAGE_REACTION_REMOVE") { + await this.handleReactionEvent(event); + return; + } + if (event?.type !== "MESSAGE_CREATE") return; const { author, channelId, guildId, channelType, content, mentions } = event; @@ -406,6 +430,8 @@ export class Puppet { if (command?.name === "whitelist") { await this.handleWhitelistCommand(command, author.id, tier, channelId); + } else if (command?.name === "poll") { + await this.handlePollCommand(author.id, channelId, text); } } @@ -494,6 +520,102 @@ export class Puppet { } } + /* Polls */ + + private async handlePollCommand(authorId: string, channelId: string, text: string) { + const body = text.replace(/^poll\s+/i, ""); + const poll = parsePoll(body); + + if (!poll) { + return this.respond( + authorId, + channelId, + "poll", + "Usage: poll |