feat: add polls feature #148

Open
anish wants to merge 25 commits from polls into main
anish commented 2026-04-10 18:44:14 +00:00 (Migrated from codeberg.org)

Adds /poll command with six subcommands:

  • setup (configure guild polls channel)
  • create (create a poll with up to 10 options, optional multi-select)
  • results (view vote counts)
  • delete (for admins or poll authors to remove polls)
  • close (to manually close the poll before expiry)
  • myvote (check your own vote on an anonymous poll)

Closes https://github.com/ScottyLabs/dalmatian/issues/48

Adds `/poll` command with six subcommands: - `setup` (configure guild polls channel) - `create` (create a poll with up to 10 options, optional multi-select) - `results` (view vote counts) - `delete` (for admins or poll authors to remove polls) - `close` (to manually close the poll before expiry) - `myvote` (check your own vote on an anonymous poll) Closes https://github.com/ScottyLabs/dalmatian/issues/48
Ghost commented 2026-04-13 20:29:28 +00:00 (Migrated from codeberg.org)

just some things, not sure if some are feasible at all

  • Should we have polls autoclose? What if people just want the polls to be open indefinitely?
  • I think the results page should reference the original poll perhaps
    • is there a way to make it so you can see your own vote on anonymous polls? obviously we wouldn't be able to use ephemeral messages for that
  • For me the polls are saying Poll by <@649388957007609879>•Today at 16:21 instead of mentioning the user
  • i think we should put at least rolling counts on the ongoing polls
  • does the poll embed builder just add every single voter to the voting list? it feels like you might hit embed limits on massive polls, and/or it crowds the answer choices
  • what if i want to unvote from a poll
just some things, not sure if some are feasible at all - Should we have polls autoclose? What if people just want the polls to be open indefinitely? - I think the results page should reference the original poll perhaps - is there a way to make it so you can see your own vote on anonymous polls? obviously we wouldn't be able to use ephemeral messages for that - For me the polls are saying `Poll by <@649388957007609879>•Today at 16:21` instead of mentioning the user - i think we should put at least rolling counts on the ongoing polls - does the poll embed builder just add every single voter to the voting list? it feels like you might hit embed limits on massive polls, and/or it crowds the answer choices - what if i want to unvote from a poll
anish commented 2026-04-13 20:48:06 +00:00 (Migrated from codeberg.org)

just some things, not sure if some are feasible at all

* Should we have polls autoclose? What if people just want the polls to be open indefinitely?

* I think the results page should reference the original poll perhaps
  
  * is there a way to make it so you can see your own vote on anonymous polls? obviously we wouldn't be able to use ephemeral messages for that

* For me the polls are saying `Poll by <@649388957007609879>•Today at 16:21` instead of mentioning the user

* i think we should put at least rolling counts on the ongoing polls

* does the poll embed builder just add every single voter to the voting list? it feels like you might hit embed limits on massive polls, and/or it crowds the answer choices

* what if i want to unvote from a poll
  • made duration optional so there's no expiry by default
  • results embed links to the original poll message
  • /poll myvote to check your vote on anonymous polls
  • "Poll by" moved from footer to description
  • rolling vote counts shown on ongoing poll embeds now
  • voter list capped at 10 with "+ X more"
  • allows unvoting now by removing the minimum selected values
> just some things, not sure if some are feasible at all > > * Should we have polls autoclose? What if people just want the polls to be open indefinitely? > > * I think the results page should reference the original poll perhaps > > * is there a way to make it so you can see your own vote on anonymous polls? obviously we wouldn't be able to use ephemeral messages for that > > * For me the polls are saying `Poll by <@649388957007609879>•Today at 16:21` instead of mentioning the user > > * i think we should put at least rolling counts on the ongoing polls > > * does the poll embed builder just add every single voter to the voting list? it feels like you might hit embed limits on massive polls, and/or it crowds the answer choices > > * what if i want to unvote from a poll - made duration optional so there's no expiry by default - results embed links to the original poll message - `/poll myvote` to check your vote on anonymous polls - "Poll by" moved from footer to description - rolling vote counts shown on ongoing poll embeds now - voter list capped at 10 with "+ X more" - allows unvoting now by removing the minimum selected values
Ghost commented 2026-04-13 20:53:02 +00:00 (Migrated from codeberg.org)

wait how do you unvote
it doesn't seem like i'm able to send a selection with 0 options selected or anything

wait how do you unvote it doesn't seem like i'm able to send a selection with 0 options selected or anything
anish commented 2026-04-13 20:59:38 +00:00 (Migrated from codeberg.org)

nevermind seems like that's not a thing, i just added an unvote button instead

nevermind seems like that's not a thing, i just added an unvote button instead
Ghost commented 2026-04-21 02:47:14 +00:00 (Migrated from codeberg.org)

Wait does this have ranked choice implemented

Wait does this have ranked choice implemented
zeyuyaoy commented 2026-06-26 04:22:52 +00:00 (Migrated from codeberg.org)

anish lock in bro

anish lock in bro
gostmeaper commented 2026-07-04 23:12:33 +00:00 (Migrated from codeberg.org)

Polls: scaling, rendering, and UX fixes

Addresses review feedback on the polls feature (scaling to more options/voters, visual
polish, permission/validation gaps, and the /pollsetup flow), plus three follow-up
rounds of feedback after each pass.

Scaling to more options

  • Max options raised from 10 to 20.
  • Option editing no longer requires popping from the end. The old flow only offered
    "Add option" / "Remove last," so fixing a typo in option 3 meant deleting everything
    after it and retyping. Editing now opens a dedicated "Options" view where every
    existing option gets its own delete button (-style, one per option, laid out five
    per row).
    • This is a separate view rather than inline on the main create form because Discord
      caps messages at 40 total components. A delete button per option alongside the
      rest of the form (question, duration, settings, role selects) doesn't fit once
      there are more than a handful of options — a Section (text + accessory button)
      per option would cost 3 components each, blowing the budget well before 20
      options. Plain buttons in the dedicated view cost roughly 1.2 components per
      option, which fits.

Progress bars and the results table

  • Options and their vote bars now render as a monospaced, aligned table (in a code
    block) instead of the old stacked "label, then bar on the next line" layout, so bars
    line up in a column regardless of label length.
  • Labels are never truncated — they're padded to align, up to a cap (36 chars).
    Padding past the cap would make one long option blow up the whole table's width, so
    labels beyond the cap just don't get further padding — but the full text is always
    shown.
  • Long options no longer break the layout. A label long enough to wrap inside the
    code block (e.g. a full sentence) used to drag its bar and percentage along with it,
    leaving them visually stranded mid-wrap. Any label over the cap now gets its
    bar/percentage moved to their own indented line underneath instead of trailing it
    inline — short options still get the single-line aligned layout, long ones just wrap
    cleanly with their stats underneath.
  • Voter mention lists (on poll close) use the -# small-text markdown trick, so a
    poll with a lot of voters doesn't produce a wall of full-size @mentions.

Option markers (letters/numbers)

  • Every option now gets a marker — either a regional-indicator letter emoji (🇦, 🇧, …)
    or a number/keycap emoji (1️⃣, 2️⃣, …) — shown beside the option in both the results
    table and the voting dropdown.
  • Markers are assigned by original option order and stay stable even when options
    are later displayed sorted by vote percentage, so "option B" always means the same
    option.
  • Style (letters vs. numbers) is a per-guild default set in /pollsetup, snapshotted
    onto each poll at creation time so changing the guild default later doesn't change
    how existing polls render.
  • Numbers beyond 10 don't have a single-glyph keycap emoji in Unicode, so 11+ is
    built by concatenating per-digit keycaps (e.g. 1️⃣1️⃣) — this only works as plain text,
    not as a button/select-option icon (Discord only accepts one emoji there), which is
    why the marker has both a text form and an "icon" form used in different places.

/pollsetup redesign

  • Moved out of /poll setup (a subcommand) into a standalone /pollsetup command.
    This was required, not stylistic: Discord's native permission system
    (.setDefaultMemberPermissions()) only exists on top-level slash commands, not on
    subcommands, and the review asked for native permission checks instead of a manual
    if (!hasPermission) check. /pollsetup now declares ManageChannels as its
    required permission natively.
  • Rebuilt as buttons in a container, matching the look of /poll create, instead of
    the generic form wizard used elsewhere in the bot.
  • Added two new settings:
    • Show progress bars — if off, results show just the percentage (no bar).
    • Option marker style — letters or numbers (see above).
  • Each setting is two mutually exclusive buttons (e.g. "Progress bars on" /
    "Progress bars off") rather than one button that toggles and relabels itself, so the
    current choice is always visible at a glance without reading a label.
  • Removed decorative checkmarks (toggle labels, the setup-complete confirmation) that
    didn't add information.

Permissions and input validation

  • Closing/deleting a poll now requires Manage Messages instead of Administrator
    — a better fit for the action, since you don't need full admin rights to moderate a
    poll.
  • Non-numeric message_id input no longer throws. message_id is stored as a
    bigint column; passing something like "a" straight into a query threw a raw
    Postgres cast error (caught by the bot's top-level handler, but with no useful
    message to the user). Every subcommand that takes a message_id now validates it
    against a snowflake pattern (/^\d{1,20}$/) first and replies with a clear error
    otherwise.
  • Every option/vote query now has an explicit .orderBy(asc(...)), since Postgres
    doesn't guarantee row order without one — needed for option markers to stay
    consistently assigned across requests.

Visual polish

  • Real divider under the title. The reviewer asked for a horizontal line between
    the title and the rest of the poll, "not just dashes" — classic Discord
    embeds/messages have no native horizontal rule, so getting a real one required
    converting the live/closed poll message from an EmbedBuilder to a Components V2
    ContainerBuilder
    , which supports a genuine SeparatorBuilder divider. (Everything
    that sends or edits that message — poll creation, vote updates, and closing — was
    updated to use the new container instead of an embed.)
  • Only one separator remains (under the title) — an earlier pass had also added
    separators after the "Poll by" line and after the options table, which turned out to
    be more dividers than needed.
  • Replaced non-ASCII symbols (×, , ·) with plain equivalents throughout.
  • The poll's timestamp — lost when the message moved off EmbedBuilder (which has a
    built-in .setTimestamp(), unlike a container) — is back, rendered as a Discord
    timestamp (<t:...:f>).
  • The whitelist/blacklist line is now bold and lives in the message body instead of
    an embed footer, since footers can't render markdown. It now shares one small-text
    line with the timestamp
    (Whitelisted: role - <timestamp>) instead of two
    separate lines.

Notification pings

  • Poll messages mention the creator (Poll by @user) and any restricted roles, which
    was pinging the poll's creator and role members every time. Every send/edit/reply
    that can contain a mention (poll creation, vote updates, closing, viewing voters) now
    sets allowedMentions: { parse: [] }, so the mention still renders as a normal pill
    but doesn't trigger a notification.

Bug fixes

  • Adding a long option sometimes added it twice. The "add option" modal listened
    for its submission using a fixed customId; if the modal got triggered twice in a
    row (e.g. a double-tapped button), both listeners matched the same submission and
    both processed it. Each modal invocation now gets a unique, single-use customId
    (via a per-call nonce), so only the listener for the modal actually shown to the user
    can ever match its submission.
  • Two concurrent /poll create sessions for the same user would cross-talk — editing
    options on one would sometimes affect the other. The component collector was scoped
    to the whole channel rather than the specific reply message, so it picked up
    interactions from either session. Collectors are now scoped to their own message, and
    starting a new /poll create session cancels any session already active for that user
    in that guild (editing the old session's message to say it was superseded).

Data model

  • poll_config (guild-wide defaults) and polls (per-poll snapshot) both gained
    show_progress_bars (boolean, default true) and option_marker_style
    ("letter" | "number", default "letter"). Snapshotting onto polls means changing
    a guild's defaults later doesn't retroactively change how already-created polls look.

Commit structure

This PR is organized as 5 commits rather than one, so each concern reviews on its own:

  1. chore: bump devenv and flake lockfiles
  2. feat(db): add progress bar and option marker style poll settings
  3. feat(poll-setup): add standalone /pollsetup command with buttons UI
  4. fix(poll): validate message_id input, weaken close/delete permission
  5. feat(polls): rework poll creation and rendering
# Polls: scaling, rendering, and UX fixes Addresses review feedback on the polls feature (scaling to more options/voters, visual polish, permission/validation gaps, and the `/pollsetup` flow), plus three follow-up rounds of feedback after each pass. ## Scaling to more options - **Max options raised from 10 to 20.** - **Option editing no longer requires popping from the end.** The old flow only offered "Add option" / "Remove last," so fixing a typo in option 3 meant deleting everything after it and retyping. Editing now opens a dedicated "Options" view where every existing option gets its own delete button (`✕`-style, one per option, laid out five per row). - This is a separate view rather than inline on the main create form because Discord caps messages at **40 total components**. A delete button per option alongside the rest of the form (question, duration, settings, role selects) doesn't fit once there are more than a handful of options — a `Section` (text + accessory button) per option would cost 3 components each, blowing the budget well before 20 options. Plain buttons in the dedicated view cost roughly 1.2 components per option, which fits. ## Progress bars and the results table - Options and their vote bars now render as a **monospaced, aligned table** (in a code block) instead of the old stacked "label, then bar on the next line" layout, so bars line up in a column regardless of label length. - Labels are **never truncated** — they're padded to align, up to a cap (36 chars). Padding past the cap would make one long option blow up the whole table's width, so labels beyond the cap just don't get further padding — but the full text is always shown. - **Long options no longer break the layout.** A label long enough to wrap inside the code block (e.g. a full sentence) used to drag its bar and percentage along with it, leaving them visually stranded mid-wrap. Any label over the cap now gets its bar/percentage moved to their own indented line underneath instead of trailing it inline — short options still get the single-line aligned layout, long ones just wrap cleanly with their stats underneath. - **Voter mention lists (on poll close) use the `-# ` small-text markdown trick**, so a poll with a lot of voters doesn't produce a wall of full-size `@mentions`. ## Option markers (letters/numbers) - Every option now gets a marker — either a regional-indicator letter emoji (🇦, 🇧, …) or a number/keycap emoji (1️⃣, 2️⃣, …) — shown beside the option in both the results table and the voting dropdown. - Markers are **assigned by original option order and stay stable** even when options are later displayed sorted by vote percentage, so "option B" always means the same option. - Style (letters vs. numbers) is a per-guild default set in `/pollsetup`, snapshotted onto each poll at creation time so changing the guild default later doesn't change how existing polls render. - Numbers beyond 10 don't have a single-glyph keycap emoji in Unicode, so `11+` is built by concatenating per-digit keycaps (e.g. `1️⃣1️⃣`) — this only works as plain text, not as a button/select-option icon (Discord only accepts one emoji there), which is why the marker has both a text form and an "icon" form used in different places. ## `/pollsetup` redesign - Moved out of `/poll setup` (a subcommand) into a **standalone `/pollsetup` command**. This was required, not stylistic: Discord's native permission system (`.setDefaultMemberPermissions()`) only exists on top-level slash commands, not on subcommands, and the review asked for native permission checks instead of a manual `if (!hasPermission)` check. `/pollsetup` now declares `ManageChannels` as its required permission natively. - Rebuilt as **buttons in a container**, matching the look of `/poll create`, instead of the generic form wizard used elsewhere in the bot. - Added two new settings: - **Show progress bars** — if off, results show just the percentage (no bar). - **Option marker style** — letters or numbers (see above). - Each setting is **two mutually exclusive buttons** (e.g. "Progress bars on" / "Progress bars off") rather than one button that toggles and relabels itself, so the current choice is always visible at a glance without reading a label. - Removed decorative checkmarks (toggle labels, the setup-complete confirmation) that didn't add information. ## Permissions and input validation - **Closing/deleting a poll now requires `Manage Messages` instead of `Administrator`** — a better fit for the action, since you don't need full admin rights to moderate a poll. - **Non-numeric `message_id` input no longer throws.** `message_id` is stored as a `bigint` column; passing something like `"a"` straight into a query threw a raw Postgres cast error (caught by the bot's top-level handler, but with no useful message to the user). Every subcommand that takes a `message_id` now validates it against a snowflake pattern (`/^\d{1,20}$/`) first and replies with a clear error otherwise. - Every option/vote query now has an explicit `.orderBy(asc(...))`, since Postgres doesn't guarantee row order without one — needed for option markers to stay consistently assigned across requests. ## Visual polish - **Real divider under the title.** The reviewer asked for a horizontal line between the title and the rest of the poll, "not just dashes" — classic Discord embeds/messages have no native horizontal rule, so getting a real one required converting the live/closed poll message from an `EmbedBuilder` to a **Components V2 `ContainerBuilder`**, which supports a genuine `SeparatorBuilder` divider. (Everything that sends or edits that message — poll creation, vote updates, and closing — was updated to use the new container instead of an embed.) - Only one separator remains (under the title) — an earlier pass had also added separators after the "Poll by" line and after the options table, which turned out to be more dividers than needed. - Replaced non-ASCII symbols (`×`, `—`, `·`) with plain equivalents throughout. - The poll's timestamp — lost when the message moved off `EmbedBuilder` (which has a built-in `.setTimestamp()`, unlike a container) — is back, rendered as a Discord timestamp (`<t:...:f>`). - The whitelist/blacklist line is now **bold** and lives in the message body instead of an embed footer, since footers can't render markdown. It now shares **one small-text line with the timestamp** (`Whitelisted: role - <timestamp>`) instead of two separate lines. ## Notification pings - Poll messages mention the creator (`Poll by @user`) and any restricted roles, which was pinging the poll's creator and role members every time. Every send/edit/reply that can contain a mention (poll creation, vote updates, closing, viewing voters) now sets `allowedMentions: { parse: [] }`, so the mention still renders as a normal pill but doesn't trigger a notification. ## Bug fixes - **Adding a long option sometimes added it twice.** The "add option" modal listened for its submission using a fixed `customId`; if the modal got triggered twice in a row (e.g. a double-tapped button), both listeners matched the same submission and both processed it. Each modal invocation now gets a unique, single-use `customId` (via a per-call nonce), so only the listener for the modal actually shown to the user can ever match its submission. - **Two concurrent `/poll create` sessions for the same user would cross-talk** — editing options on one would sometimes affect the other. The component collector was scoped to the whole channel rather than the specific reply message, so it picked up interactions from either session. Collectors are now scoped to their own message, and starting a new `/poll create` session cancels any session already active for that user in that guild (editing the old session's message to say it was superseded). ## Data model - `poll_config` (guild-wide defaults) and `polls` (per-poll snapshot) both gained `show_progress_bars` (boolean, default `true`) and `option_marker_style` (`"letter" | "number"`, default `"letter"`). Snapshotting onto `polls` means changing a guild's defaults later doesn't retroactively change how already-created polls look. ## Commit structure This PR is organized as 5 commits rather than one, so each concern reviews on its own: 1. `chore: bump devenv and flake lockfiles` 2. `feat(db): add progress bar and option marker style poll settings` 3. `feat(poll-setup): add standalone /pollsetup command with buttons UI` 4. `fix(poll): validate message_id input, weaken close/delete permission` 5. `feat(polls): rework poll creation and rendering`
This pull request has changes conflicting with the target branch.
  • devenv.lock
  • flake.lock
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin polls:polls
git switch polls

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff polls
git switch polls
git rebase main
git switch main
git merge --ff-only polls
git switch polls
git rebase main
git switch main
git merge --no-ff polls
git switch main
git merge --squash polls
git switch main
git merge --ff-only polls
git switch main
git merge polls
git push origin main
Sign in to join this conversation.
No description provided.