Skip to content

Commit fc25f76

Browse files
authored
Persist Poll answers (#546)
* Add PollAnswersTable * Add migration * Add/remove * Sketch logic * Simplify * Add determineOptionIndex * Rewrite to toggle * Optimize query * Use optionIndex * Use addOrToggleAnswer * Return result * . * Add removeAllOtherReactionsFromUser * Simplify fading messages * Use corrent timestamps * Fix single-choice polls * Move * Update deps * Fix filename
1 parent abb682e commit fc25f76

File tree

9 files changed

+334
-191
lines changed

9 files changed

+334
-191
lines changed

package-lock.json

Lines changed: 120 additions & 120 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"dependencies": {
2828
"@discordjs/voice": "^0.19.0",
2929
"@js-temporal/polyfill": "^0.5.1",
30-
"@napi-rs/canvas": "0.1.80",
30+
"@napi-rs/canvas": "0.1.81",
3131
"@resvg/resvg-js": "^2.6.2",
3232
"@sentry/node": "^10.22.0",
3333
"@snazzah/davey": "^0.1.7",
@@ -36,7 +36,7 @@
3636
"chrono-node": "^2.9.0",
3737
"comment-json": "^4.4.1",
3838
"croner": "^9.1.0",
39-
"discord.js": "^14.24.0",
39+
"discord.js": "^14.24.1",
4040
"get-audio-duration": "^4.0.1",
4141
"graphviz-wasm": "^3.0.2",
4242
"jsdom": "^27.0.1",
@@ -49,12 +49,12 @@
4949
"youtube-dl-exec": "^3.0.26"
5050
},
5151
"devDependencies": {
52-
"@biomejs/biome": "^2.3.0",
52+
"@biomejs/biome": "^2.3.2",
5353
"@types/better-sqlite3": "^7.6.13",
5454
"@types/jsdom": "^27.0.0",
55-
"@types/node": "^24.9.1",
55+
"@types/node": "^24.9.2",
5656
"@types/node-cron": "^3.0.11",
57-
"@typescript/native-preview": "^7.0.0-dev.20251024.1",
57+
"@typescript/native-preview": "^7.0.0-dev.20251027.1",
5858
"expect": "^30.2.0",
5959
"lefthook": "^2.0.1"
6060
},

src/commands/poll.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,12 @@ Optionen:
8181
return "Bruder da ist keine Umfrage :c";
8282
}
8383

84-
const pollArray = pollService.parsePollOptionString(positionals.join(" "));
85-
86-
const question = pollArray[0];
84+
const [question, ...pollOptions] = pollService.parsePollOptionString(positionals.join(" "));
8785
if (question.length > pollEmbedService.TEXT_LIMIT) {
8886
return "Bruder die Frage ist ja länger als mein Schwands :c";
8987
}
9088

91-
const pollOptions = pollArray.slice(1);
9289
let pollOptionsTextLength = 0;
93-
94-
const isExtendable = options.extendable;
9590
for (const pollOption of pollOptions) {
9691
pollOptionsTextLength += pollOption.length;
9792
}
@@ -100,7 +95,7 @@ Optionen:
10095
return "Bruder da sind keine Antwortmöglichkeiten :c";
10196
}
10297

103-
if (pollOptions.length < 2 && !isExtendable) {
98+
if (pollOptions.length < 2 && !options.extendable) {
10499
return "Bruder du musst schon mehr als eine Antwortmöglichkeit geben 🙄";
105100
}
106101

src/handler/reaction/pollReactionHandler.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@ export default {
4242
return;
4343
}
4444

45-
const validVoteReactions = dbPoll.multipleChoices ? POLL_EMOJIS : VOTE_EMOJIS;
46-
if (!validVoteReactions.includes(reactionName)) {
45+
if (VOTE_EMOJIS.includes(reactionName)) {
46+
// this is a .vote poll -> TODO
47+
return;
48+
}
49+
50+
if (!POLL_EMOJIS.includes(reactionName)) {
4751
return;
4852
}
4953

src/service/poll.ts

Lines changed: 78 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import type { BotContext } from "@/context.js";
77
import * as polls from "@/storage/poll.js";
88
import * as fadingMessage from "@/storage/fadingMessage.js";
99
import * as additionalMessageData from "@/storage/additionalMessageData.js";
10-
import type { ProcessableMessage } from "./command.js";
1110
import { EMOJI } from "@/service/pollEmbed.js";
1211

12+
import log from "@log";
13+
1314
export const POLL_EMOJIS = EMOJI;
1415
export const VOTE_EMOJIS = ["👍", "👎"];
1516

@@ -94,47 +95,25 @@ export async function countDelayedVote(
9495
return;
9596
}
9697

97-
const reactionName = reaction.emoji.name;
98-
if (reactionName === null) {
99-
throw new Error("Could not find reaction name");
100-
}
101-
102-
if (poll.multipleChoices) {
103-
// TODO: Toogle user vote with DB backing
104-
105-
// Old code:
106-
const delayedPollReactions = delayedPoll.reactions[VOTE_EMOJIS.indexOf(reactionName)];
107-
const hasVoted = delayedPollReactions.some(x => x === invoker.id);
108-
if (!hasVoted) {
109-
delayedPollReactions.push(invoker.id);
110-
} else {
111-
delayedPollReactions.splice(delayedPollReactions.indexOf(invoker.id), 1);
112-
}
113-
114-
const msg = await message.channel.send(
115-
hasVoted ? "🗑 Deine Reaktion wurde gelöscht." : "💾 Deine Reaktion wurde gespeichert.",
116-
);
117-
await fadingMessage.startFadingMessage(msg as ProcessableMessage, 2500);
118-
} else {
119-
// TODO: Set user vote with DB backing
120-
121-
// Old code:
122-
for (const reactionList of delayedPoll.reactions) {
123-
reactionList.forEach((x, i) => {
124-
if (x === invoker.id) reactionList.splice(i);
125-
});
126-
}
127-
const delayedPollReactions = delayedPoll.reactions[POLL_EMOJIS.indexOf(reactionName)];
128-
delayedPollReactions.push(invoker.id);
98+
const optionIndex = determineOptionIndex(reaction);
99+
if (optionIndex === undefined) {
100+
return;
129101
}
130102

131-
// It's a delayed poll, we clear all Reactions
132-
const allUserReactions = message.reactions.cache.filter(r => {
133-
const emojiName = r.emoji.name;
134-
return emojiName && r.users.cache.has(invoker.id) && POLL_EMOJIS.includes(emojiName);
135-
});
103+
const addedOrRemoved = await polls.addOrToggleAnswer(
104+
poll.id,
105+
optionIndex,
106+
invoker.id,
107+
!poll.multipleChoices,
108+
);
136109

137-
await Promise.allSettled(allUserReactions.map(r => r.users.remove(invoker.id)));
110+
const msg = await message.channel.send(
111+
addedOrRemoved === "removed"
112+
? "🗑 Deine Reaktion wurde gelöscht."
113+
: "💾 Deine Reaktion wurde gespeichert.",
114+
);
115+
await fadingMessage.startFadingMessage(msg, 2500);
116+
await removeAllReactions(message, invoker);
138117

139118
await additionalMessageData.upsertForMessage(
140119
message,
@@ -143,29 +122,75 @@ export async function countDelayedVote(
143122
);
144123
}
145124

125+
async function removeAllReactions(message: Message<true>, invoker: GuildMember) {
126+
const allUserReactions = message.reactions.cache.filter(r => {
127+
const emojiName = r.emoji.name;
128+
return emojiName && POLL_EMOJIS.includes(emojiName);
129+
});
130+
131+
await Promise.allSettled(allUserReactions.map(r => r.users.remove(invoker.id)));
132+
}
133+
146134
export async function countVote(
147135
poll: Poll,
148-
message: Message<true>,
136+
_message: Message<true>,
149137
invoker: GuildMember,
150138
reaction: MessageReaction,
151139
) {
152140
console.assert(poll.endsAt === null, "Poll is a delayed poll");
153-
// TODO: Set user vote with DB backing
154141

155-
// Old code:
156-
return await Promise.allSettled(
142+
const optionIndex = determineOptionIndex(reaction);
143+
if (optionIndex === undefined) {
144+
log.info(reaction, "Unknown option index"); // TODO: Remove
145+
return;
146+
}
147+
148+
await polls.addOrToggleAnswer(poll.id, optionIndex, invoker.id, !poll.multipleChoices);
149+
log.info("Counted vote");
150+
151+
if (poll.multipleChoices) {
152+
return;
153+
}
154+
155+
await removeAllOtherReactionsFromUser(invoker, reaction);
156+
}
157+
158+
async function removeAllOtherReactionsFromUser(
159+
invoker: GuildMember,
160+
reactionToKeep: MessageReaction,
161+
): Promise<void> {
162+
const message = await reactionToKeep.message.fetch();
163+
164+
const nameToKeep = reactionToKeep.emoji.name;
165+
if (nameToKeep === null || nameToKeep.length === 0) {
166+
throw new Error("`nameToKeep` was null or empty.");
167+
}
168+
169+
const results = await Promise.allSettled(
157170
message.reactions.cache
158-
.filter(r => {
159-
const emojiName = r.emoji.name;
160-
return (
161-
!!emojiName &&
162-
r.users.cache.has(invoker.id) &&
163-
emojiName !== reaction.emoji.name &&
164-
POLL_EMOJIS.includes(emojiName)
165-
);
166-
})
167-
.map(reaction => reaction.users.remove(invoker.id)),
171+
.filter(
172+
r =>
173+
r.emoji.name &&
174+
r.emoji.name !== nameToKeep &&
175+
POLL_EMOJIS.includes(r.emoji.name),
176+
)
177+
.map(r => r.users.remove(invoker.id)),
168178
);
179+
180+
const failedTasks = results.filter(r => r.status === "rejected").length;
181+
if (failedTasks) {
182+
throw new Error(`Failed to update ${failedTasks} reaction users`);
183+
}
184+
}
185+
186+
function determineOptionIndex(reaction: MessageReaction) {
187+
const reactionName = reaction.emoji.name;
188+
if (reactionName === null) {
189+
throw new Error("Reaction does not have a name.");
190+
}
191+
192+
const index = POLL_EMOJIS.indexOf(reactionName);
193+
return index < 0 ? undefined : index;
169194
}
170195

171196
export function parsePollOptionString(value: string): string[] {

src/storage/db/model.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface Database {
3838
pets: PetsTable;
3939
polls: PollsTable;
4040
pollOptions: PollOptionsTable;
41+
pollAnswers: PollAnswersTable;
4142
}
4243

4344
export type OneBasedMonth = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
@@ -436,3 +437,14 @@ export interface PollOptionsTable extends AuditedTable {
436437

437438
authorId: Snowflake;
438439
}
440+
441+
export type PollAnswerId = number;
442+
443+
export type PollAnswer = Selectable<PollAnswersTable>;
444+
445+
export interface PollAnswersTable extends AuditedTable {
446+
id: GeneratedAlways<PollAnswerId>;
447+
448+
optionId: PollOptionId;
449+
userId: Snowflake;
450+
}

src/storage/fadingMessage.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { ProcessableMessage } from "@/service/command.js";
1+
import type { Message } from "discord.js";
2+
23
import type { FadingMessage } from "./db/model.js";
34
import db from "@db";
45

56
export function startFadingMessage(
6-
message: ProcessableMessage,
7+
message: Message<true>,
78
deleteInMs: number,
89
ctx = db(),
910
): Promise<FadingMessage> {
@@ -13,7 +14,7 @@ export function startFadingMessage(
1314
.values({
1415
beginTime: now.toISOString(),
1516
// adding milliseconds to a date is a hassle in sqlite, so we're doing it in JS
16-
endTime: new Date(now.getTime() + deleteInMs).toDateString(),
17+
endTime: new Date(now.getTime() + deleteInMs).toISOString(),
1718
guildId: message.guild.id,
1819
channelId: message.channel.id,
1920
messageId: message.id,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { sql, type Kysely } from "kysely";
2+
3+
export async function up(db: Kysely<any>) {
4+
await db.schema
5+
.createTable("pollAnswers")
6+
.addColumn("id", "integer", c => c.primaryKey().autoIncrement())
7+
.addColumn("optionId", "integer", c =>
8+
c.references("pollOptions.id").notNull().onDelete("cascade"),
9+
)
10+
.addColumn("userId", "text", c => c.notNull())
11+
.addColumn("createdAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`))
12+
.addColumn("updatedAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`))
13+
.execute();
14+
15+
await db.schema
16+
.createIndex("pollAnswers_optionId_userId_index")
17+
.on("pollAnswers")
18+
.columns(["optionId", "userId"])
19+
.unique()
20+
.execute();
21+
22+
await createUpdatedAtTrigger(db, "pollAnswers");
23+
}
24+
25+
function createUpdatedAtTrigger(db: Kysely<any>, tableName: string) {
26+
return sql
27+
.raw(`
28+
create trigger ${tableName}_updatedAt
29+
after update on ${tableName} for each row
30+
begin
31+
update ${tableName}
32+
set updatedAt = current_timestamp
33+
where id = old.id;
34+
end;
35+
`)
36+
.execute(db);
37+
}
38+
39+
export async function down(_db: Kysely<any>) {
40+
throw new Error("Not supported lol");
41+
}

0 commit comments

Comments
 (0)