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.
|
- 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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}`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user