Poll: help fallback, // separators, quoted questions, multi-vote
This commit is contained in:
@@ -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