Poll: help fallback, // separators, quoted questions, multi-vote

This commit is contained in:
Fisto
2026-09-11 16:07:21 +02:00
parent ace0bb18ff
commit 1dd573e334
5 changed files with 114 additions and 24 deletions
+4 -2
View File
@@ -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 <question> | 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:
+23 -3
View File
@@ -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
+29 -5
View File
@@ -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. */
+49 -14
View File
@@ -103,11 +103,21 @@ interface PollState {
authorId: string;
question: string;
options: string[];
votes: Map<string, number>;
/** One set of option indexes per user. Multiple reactions are allowed. */
votes: Map<string, Set<number>>;
endsAt: number;
timer: ReturnType<typeof setTimeout>;
}
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 <question> | <option 1> | <option 2> [| option 3 [| option 4]]"
);
return this.respond(authorId, channelId, "poll", this.pollHelp());
}
const lines = [
@@ -581,6 +596,19 @@ export class Puppet {
});
}
private pollHelp(): string {
return [
"poll — start a reaction poll.",
"Usage:",
' poll "Question" Option 1 // Option 2 [// Option 3 [// Option 4]]',
" poll Question | Option 1 | Option 2 (pipe works too)",
"Examples:",
' poll "What country is best" Sweden // Denmark // Madagaskar',
" poll Lunch? | Pizza | Sushi",
`Users vote with the number reactions. The poll closes after ${this.config.pollSeconds}s.`
].join("\n");
}
private async handleReactionEvent(event: any) {
const poll = this.polls.get(event.messageId);
if (!poll) return;
@@ -592,8 +620,13 @@ export class Puppet {
const index = POLL_EMOJIS.indexOf(emoji);
if (index < 0 || index >= poll.options.length) return;
if (event.type === "MESSAGE_REACTION_ADD") poll.votes.set(event.userId, index);
else if (poll.votes.get(event.userId) === index) poll.votes.delete(event.userId);
const votes = poll.votes.get(event.userId) ?? new Set<number>();
if (event.type === "MESSAGE_REACTION_ADD") votes.add(index);
else votes.delete(index);
if (votes.size) poll.votes.set(event.userId, votes);
else poll.votes.delete(event.userId);
}
private async finishPoll(messageId: string) {
@@ -604,7 +637,9 @@ export class Puppet {
clearTimeout(poll.timer);
const counts = poll.options.map(() => 0);
for (const index of poll.votes.values()) counts[index]++;
for (const votes of poll.votes.values()) {
for (const index of votes) counts[index]++;
}
const lines = [
`📊 Results: ${poll.question}`,
+9
View File
@@ -181,6 +181,15 @@ function entry(id: number, trusted: boolean): PuppetMessage {
{
assert.deepEqual(parsePoll("Lunch? | Pizza | Sushi"), { question: "Lunch?", options: ["Pizza", "Sushi"] });
assert.deepEqual(parsePoll("Q | A | B | C | D"), { question: "Q", options: ["A", "B", "C", "D"] });
assert.deepEqual(parsePoll('"What country is best" Sweden // Denmark // Madagaskar'), {
question: "What country is best",
options: ["Sweden", "Denmark", "Madagaskar"]
});
// the whitespace guard keeps URLs together
assert.deepEqual(parsePoll('"Links?" https://a.example // https://b.example'), {
question: "Links?",
options: ["https://a.example", "https://b.example"]
});
assert.equal(parsePoll("Q | A"), undefined);
assert.equal(parsePoll("Q | A | B | C | D | E"), undefined);
assert.equal(parsePoll(`Q | ${"x".repeat(100)} | B`), undefined);