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. - Response policy: origin channel or DM. Sensitive commands default to DM.
- Persistent action log with rotation and secret redaction. - Persistent action log with rotation and secret redaction.
- MCP batch tool for read-only commands, plus a voice state tool. - 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, - Reaction poll command: `poll "Question" opt 1 // opt 2 [// opt 3 [// opt 4]]` or the pipe form.
one vote per user, auto results after `pollSeconds`. 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: Next:
+23 -3
View File
@@ -44,6 +44,7 @@ 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. - `whitelist` commands need the admin tier.
- A bare `whitelist` or `whitelist help` shows the help.
- The reply follows the response policy. - The reply follows the response policy.
## Polls ## Polls
@@ -51,15 +52,34 @@ Notes:
The `poll` command posts a message and adds one number emoji per option. Discord users vote by 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. 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. - Options: 2 to 4. Each option is at most 80 characters.
Voting:
- Emojis: `1️⃣`, `2️⃣`, `3️⃣`, `4️⃣`. - Emojis: `1️⃣`, `2️⃣`, `3️⃣`, `4️⃣`.
- One vote per user. A new vote replaces the old vote. - Multiple reactions per user are allowed. Each reaction counts.
- Removing the reaction removes the vote. - Removing a reaction removes that one selection.
- The puppet's own reactions do not count. - The puppet's own reactions do not count.
- Votes do not need a trust tier. Friends can vote. - Votes do not need a trust tier. Friends can vote.
- The result message goes to the poll channel. - 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 ## Response policy
+29 -5
View File
@@ -198,13 +198,37 @@ export interface ParsedPoll {
/** Parse `question | option 1 | option 2 [| option 3 [| option 4]]`. */ /** Parse `question | option 1 | option 2 [| option 3 [| option 4]]`. */
export function parsePoll(text: string): ParsedPoll | undefined { export function parsePoll(text: string): ParsedPoll | undefined {
const parts = text.split("|").map(part => part.trim()).filter(Boolean); // A quoted question lets the first option follow without a separator:
if (parts.length < 3 || parts.length > 5) return undefined; // "What country is best" Sweden // Denmark // Madagaskar
const quoted = text.match(/^\s*["'“”](.+?)["'“”]\s*([\s\S]*)$/);
const [question, ...options] = parts; let question: string;
if (!question || options.length < 2 || options.some(option => option.length > 80)) return undefined; 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. */ /* Secret redaction for persistent logs. Keep it minimal. */
+49 -14
View File
@@ -103,11 +103,21 @@ interface PollState {
authorId: string; authorId: string;
question: string; question: string;
options: 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; endsAt: number;
timer: ReturnType<typeof setTimeout>; 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 { function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
@@ -448,12 +458,17 @@ export class Puppet {
tier: TrustTier, tier: TrustTier,
channelId: string 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")) { if (!hasTier(tier, "admin")) {
return this.respond(authorId, channelId, "whitelist", "You do not have permission for this command."); return this.respond(authorId, channelId, "whitelist", "You do not have permission for this command.");
} }
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)}`); const lines = [this.ownerId, ...this.tiers.keys()].map(id => `${id} — ${this.tierOf(id)}`);
@@ -516,23 +531,23 @@ export class Puppet {
} }
default: default:
return this.respond(authorId, channelId, "whitelist", "Usage: whitelist add|remove|list"); return this.respond(authorId, channelId, "whitelist", WHITELIST_HELP);
} }
} }
/* Polls */ /* Polls */
private async handlePollCommand(authorId: string, channelId: string, text: string) { 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); const poll = parsePoll(body);
if (!poll) { if (!poll) {
return this.respond( return this.respond(authorId, channelId, "poll", this.pollHelp());
authorId,
channelId,
"poll",
"Usage: poll <question> | <option 1> | <option 2> [| option 3 [| option 4]]"
);
} }
const lines = [ 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) { private async handleReactionEvent(event: any) {
const poll = this.polls.get(event.messageId); const poll = this.polls.get(event.messageId);
if (!poll) return; if (!poll) return;
@@ -592,8 +620,13 @@ export class Puppet {
const index = POLL_EMOJIS.indexOf(emoji); const index = POLL_EMOJIS.indexOf(emoji);
if (index < 0 || index >= poll.options.length) return; if (index < 0 || index >= poll.options.length) return;
if (event.type === "MESSAGE_REACTION_ADD") poll.votes.set(event.userId, index); const votes = poll.votes.get(event.userId) ?? new Set<number>();
else if (poll.votes.get(event.userId) === index) poll.votes.delete(event.userId);
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) { private async finishPoll(messageId: string) {
@@ -604,7 +637,9 @@ export class Puppet {
clearTimeout(poll.timer); clearTimeout(poll.timer);
const counts = poll.options.map(() => 0); 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 = [ const lines = [
`📊 Results: ${poll.question}`, `📊 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("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("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"), undefined);
assert.equal(parsePoll("Q | A | B | C | D | E"), undefined); assert.equal(parsePoll("Q | A | B | C | D | E"), undefined);
assert.equal(parsePoll(`Q | ${"x".repeat(100)} | B`), undefined); assert.equal(parsePoll(`Q | ${"x".repeat(100)} | B`), undefined);