Poll: help fallback, // separators, quoted questions, multi-vote
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user