diff --git a/TODO.md b/TODO.md index ea3cbb3..2b336cc 100644 --- a/TODO.md +++ b/TODO.md @@ -34,8 +34,10 @@ 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`. +- Reaction poll command: `poll "Question" opt 1 // opt 2 [// opt 3 [// opt 4]]` or the pipe form. + Number emojis, multiple reactions per user, bare command shows help, auto results after + `pollSeconds`. +- Bare `whitelist` and bare `poll` show help with usage and formatting. Next: diff --git a/docs/communication.md b/docs/communication.md index bd0dcaf..9eecee5 100644 --- a/docs/communication.md +++ b/docs/communication.md @@ -44,6 +44,7 @@ Notes: - The owner cannot be removed. - The command accepts mentions and raw user IDs. - `whitelist` commands need the admin tier. +- A bare `whitelist` or `whitelist help` shows the help. - The reply follows the response policy. ## Polls @@ -51,15 +52,34 @@ Notes: 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. +Format: + +``` +poll "Question" Option 1 // Option 2 [// Option 3 [// Option 4]] +poll Question | Option 1 | Option 2 +``` + +- The question can be in quotes. Then the first option follows after the closing quote. +- Separators are `|` or `//`. The `//` separator needs whitespace on both sides. This keeps URLs + such as `https://example.com` in one piece. +- A bare `poll` or `poll help` shows the help. - Options: 2 to 4. Each option is at most 80 characters. + +Voting: + - Emojis: `1️⃣`, `2️⃣`, `3️⃣`, `4️⃣`. -- One vote per user. A new vote replaces the old vote. -- Removing the reaction removes the vote. +- Multiple reactions per user are allowed. Each reaction counts. +- Removing a reaction removes that one selection. - 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` +Examples: + +``` +@puppet poll "What country is best" Sweden // Denmark // Madagaskar +@puppet poll Lunch? | Pizza | Sushi +``` ## Response policy diff --git a/poc/src/communication.ts b/poc/src/communication.ts index 2694507..2b33cde 100644 --- a/poc/src/communication.ts +++ b/poc/src/communication.ts @@ -198,13 +198,37 @@ export interface ParsedPoll { /** 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; + // A quoted question lets the first option follow without a separator: + // "What country is best" Sweden // Denmark // Madagaskar + const quoted = text.match(/^\s*["'“”](.+?)["'“”]\s*([\s\S]*)$/); - const [question, ...options] = parts; - if (!question || options.length < 2 || options.some(option => option.length > 80)) return undefined; + let question: string; + let optionParts: string[]; - return { question: question.slice(0, 200), options: options.map(option => option.slice(0, 80)) }; + if (quoted) { + question = quoted[1].trim(); + optionParts = splitPollParts(quoted[2]); + } else { + const parts = splitPollParts(text); + question = parts[0]?.trim() ?? ""; + optionParts = parts.slice(1); + } + + if (!question || optionParts.length < 2 || optionParts.length > 4) return undefined; + if (optionParts.some(option => option.length > 80)) return undefined; + + return { question: question.slice(0, 200), options: optionParts.map(option => option.slice(0, 80)) }; +} + +/** + * Split poll parts on `|` or on `//` with whitespace on both sides. The + * whitespace guard keeps URLs such as `https://example.com` in one piece. + */ +function splitPollParts(text: string): string[] { + return text + .split(/\s+\/\/\s+|\s*\|\s*/) + .map(part => part.trim()) + .filter(Boolean); } /* Secret redaction for persistent logs. Keep it minimal. */ diff --git a/poc/src/puppet.ts b/poc/src/puppet.ts index bb9b4af..b66835a 100644 --- a/poc/src/puppet.ts +++ b/poc/src/puppet.ts @@ -103,11 +103,21 @@ interface PollState { authorId: string; question: string; options: string[]; - votes: Map; + /** One set of option indexes per user. Multiple reactions are allowed. */ + votes: Map>; endsAt: number; timer: ReturnType; } +const WHITELIST_HELP = [ + "whitelist — manage who can use the puppet.", + "Usage:", + " whitelist Show this help", + " whitelist list Show the owner and the trusted users", + " whitelist add @user [tier] Add a user. Tier: user, trusted, admin (default user)", + " whitelist remove @user Remove a user" +].join("\n"); + function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } @@ -448,12 +458,17 @@ export class Puppet { tier: TrustTier, channelId: string ) { + const [sub, ...rest] = command.args; + + // No arguments or "help" shows the help. No permission needed for help. + if (!sub || sub === "help") { + return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP); + } + 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": { const lines = [this.ownerId, ...this.tiers.keys()].map(id => `${id} — ${this.tierOf(id)}`); @@ -516,23 +531,23 @@ export class Puppet { } default: - return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add|remove|list"); + return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP); } } /* Polls */ private async handlePollCommand(authorId: string, channelId: string, text: string) { - const body = text.replace(/^poll\s+/i, ""); + const body = text.replace(/^poll\s*/i, "").trim(); + + if (!body || /^help$/i.test(body)) { + return this.respond(authorId, channelId, "poll", this.pollHelp()); + } + const poll = parsePoll(body); if (!poll) { - return this.respond( - authorId, - channelId, - "poll", - "Usage: poll |