Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 120 additions & 120 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"dependencies": {
"@discordjs/voice": "^0.19.0",
"@js-temporal/polyfill": "^0.5.1",
"@napi-rs/canvas": "0.1.80",
"@napi-rs/canvas": "0.1.81",
"@resvg/resvg-js": "^2.6.2",
"@sentry/node": "^10.22.0",
"@snazzah/davey": "^0.1.7",
Expand All @@ -36,7 +36,7 @@
"chrono-node": "^2.9.0",
"comment-json": "^4.4.1",
"croner": "^9.1.0",
"discord.js": "^14.24.0",
"discord.js": "^14.24.1",
"get-audio-duration": "^4.0.1",
"graphviz-wasm": "^3.0.2",
"jsdom": "^27.0.1",
Expand All @@ -49,12 +49,12 @@
"youtube-dl-exec": "^3.0.26"
},
"devDependencies": {
"@biomejs/biome": "^2.3.0",
"@biomejs/biome": "^2.3.2",
"@types/better-sqlite3": "^7.6.13",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.9.1",
"@types/node": "^24.9.2",
"@types/node-cron": "^3.0.11",
"@typescript/native-preview": "^7.0.0-dev.20251024.1",
"@typescript/native-preview": "^7.0.0-dev.20251027.1",
"expect": "^30.2.0",
"lefthook": "^2.0.1"
},
Expand Down
9 changes: 2 additions & 7 deletions src/commands/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,12 @@ Optionen:
return "Bruder da ist keine Umfrage :c";
}

const pollArray = pollService.parsePollOptionString(positionals.join(" "));

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

const pollOptions = pollArray.slice(1);
let pollOptionsTextLength = 0;

const isExtendable = options.extendable;
for (const pollOption of pollOptions) {
pollOptionsTextLength += pollOption.length;
}
Expand All @@ -100,7 +95,7 @@ Optionen:
return "Bruder da sind keine Antwortmöglichkeiten :c";
}

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

Expand Down
8 changes: 6 additions & 2 deletions src/handler/reaction/pollReactionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ export default {
return;
}

const validVoteReactions = dbPoll.multipleChoices ? POLL_EMOJIS : VOTE_EMOJIS;
if (!validVoteReactions.includes(reactionName)) {
if (VOTE_EMOJIS.includes(reactionName)) {
// this is a .vote poll -> TODO
return;
}

if (!POLL_EMOJIS.includes(reactionName)) {
return;
}

Expand Down
131 changes: 78 additions & 53 deletions src/service/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import type { BotContext } from "@/context.js";
import * as polls from "@/storage/poll.js";
import * as fadingMessage from "@/storage/fadingMessage.js";
import * as additionalMessageData from "@/storage/additionalMessageData.js";
import type { ProcessableMessage } from "./command.js";
import { EMOJI } from "@/service/pollEmbed.js";

import log from "@log";

export const POLL_EMOJIS = EMOJI;
export const VOTE_EMOJIS = ["👍", "👎"];

Expand Down Expand Up @@ -94,47 +95,25 @@ export async function countDelayedVote(
return;
}

const reactionName = reaction.emoji.name;
if (reactionName === null) {
throw new Error("Could not find reaction name");
}

if (poll.multipleChoices) {
// TODO: Toogle user vote with DB backing

// Old code:
const delayedPollReactions = delayedPoll.reactions[VOTE_EMOJIS.indexOf(reactionName)];
const hasVoted = delayedPollReactions.some(x => x === invoker.id);
if (!hasVoted) {
delayedPollReactions.push(invoker.id);
} else {
delayedPollReactions.splice(delayedPollReactions.indexOf(invoker.id), 1);
}

const msg = await message.channel.send(
hasVoted ? "🗑 Deine Reaktion wurde gelöscht." : "💾 Deine Reaktion wurde gespeichert.",
);
await fadingMessage.startFadingMessage(msg as ProcessableMessage, 2500);
} else {
// TODO: Set user vote with DB backing

// Old code:
for (const reactionList of delayedPoll.reactions) {
reactionList.forEach((x, i) => {
if (x === invoker.id) reactionList.splice(i);
});
}
const delayedPollReactions = delayedPoll.reactions[POLL_EMOJIS.indexOf(reactionName)];
delayedPollReactions.push(invoker.id);
const optionIndex = determineOptionIndex(reaction);
if (optionIndex === undefined) {
return;
}

// It's a delayed poll, we clear all Reactions
const allUserReactions = message.reactions.cache.filter(r => {
const emojiName = r.emoji.name;
return emojiName && r.users.cache.has(invoker.id) && POLL_EMOJIS.includes(emojiName);
});
const addedOrRemoved = await polls.addOrToggleAnswer(
poll.id,
optionIndex,
invoker.id,
!poll.multipleChoices,
);

await Promise.allSettled(allUserReactions.map(r => r.users.remove(invoker.id)));
const msg = await message.channel.send(
addedOrRemoved === "removed"
? "🗑 Deine Reaktion wurde gelöscht."
: "💾 Deine Reaktion wurde gespeichert.",
);
await fadingMessage.startFadingMessage(msg, 2500);
await removeAllReactions(message, invoker);

await additionalMessageData.upsertForMessage(
message,
Expand All @@ -143,29 +122,75 @@ export async function countDelayedVote(
);
}

async function removeAllReactions(message: Message<true>, invoker: GuildMember) {
const allUserReactions = message.reactions.cache.filter(r => {
const emojiName = r.emoji.name;
return emojiName && POLL_EMOJIS.includes(emojiName);
});

await Promise.allSettled(allUserReactions.map(r => r.users.remove(invoker.id)));
}

export async function countVote(
poll: Poll,
message: Message<true>,
_message: Message<true>,
invoker: GuildMember,
reaction: MessageReaction,
) {
console.assert(poll.endsAt === null, "Poll is a delayed poll");
// TODO: Set user vote with DB backing

// Old code:
return await Promise.allSettled(
const optionIndex = determineOptionIndex(reaction);
if (optionIndex === undefined) {
log.info(reaction, "Unknown option index"); // TODO: Remove
return;
}

await polls.addOrToggleAnswer(poll.id, optionIndex, invoker.id, !poll.multipleChoices);
log.info("Counted vote");

if (poll.multipleChoices) {
return;
}

await removeAllOtherReactionsFromUser(invoker, reaction);
}

async function removeAllOtherReactionsFromUser(
invoker: GuildMember,
reactionToKeep: MessageReaction,
): Promise<void> {
const message = await reactionToKeep.message.fetch();

const nameToKeep = reactionToKeep.emoji.name;
if (nameToKeep === null || nameToKeep.length === 0) {
throw new Error("`nameToKeep` was null or empty.");
}

const results = await Promise.allSettled(
message.reactions.cache
.filter(r => {
const emojiName = r.emoji.name;
return (
!!emojiName &&
r.users.cache.has(invoker.id) &&
emojiName !== reaction.emoji.name &&
POLL_EMOJIS.includes(emojiName)
);
})
.map(reaction => reaction.users.remove(invoker.id)),
.filter(
r =>
r.emoji.name &&
r.emoji.name !== nameToKeep &&
POLL_EMOJIS.includes(r.emoji.name),
)
.map(r => r.users.remove(invoker.id)),
);

const failedTasks = results.filter(r => r.status === "rejected").length;
if (failedTasks) {
throw new Error(`Failed to update ${failedTasks} reaction users`);
}
}

function determineOptionIndex(reaction: MessageReaction) {
const reactionName = reaction.emoji.name;
if (reactionName === null) {
throw new Error("Reaction does not have a name.");
}

const index = POLL_EMOJIS.indexOf(reactionName);
return index < 0 ? undefined : index;
}

export function parsePollOptionString(value: string): string[] {
Expand Down
12 changes: 12 additions & 0 deletions src/storage/db/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface Database {
pets: PetsTable;
polls: PollsTable;
pollOptions: PollOptionsTable;
pollAnswers: PollAnswersTable;
}

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

authorId: Snowflake;
}

export type PollAnswerId = number;

export type PollAnswer = Selectable<PollAnswersTable>;

export interface PollAnswersTable extends AuditedTable {
id: GeneratedAlways<PollAnswerId>;

optionId: PollOptionId;
userId: Snowflake;
}
7 changes: 4 additions & 3 deletions src/storage/fadingMessage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { ProcessableMessage } from "@/service/command.js";
import type { Message } from "discord.js";

import type { FadingMessage } from "./db/model.js";
import db from "@db";

export function startFadingMessage(
message: ProcessableMessage,
message: Message<true>,
deleteInMs: number,
ctx = db(),
): Promise<FadingMessage> {
Expand All @@ -13,7 +14,7 @@ export function startFadingMessage(
.values({
beginTime: now.toISOString(),
// adding milliseconds to a date is a hassle in sqlite, so we're doing it in JS
endTime: new Date(now.getTime() + deleteInMs).toDateString(),
endTime: new Date(now.getTime() + deleteInMs).toISOString(),
guildId: message.guild.id,
channelId: message.channel.id,
messageId: message.id,
Expand Down
41 changes: 41 additions & 0 deletions src/storage/migrations/23-polls-answers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { sql, type Kysely } from "kysely";

export async function up(db: Kysely<any>) {
await db.schema
.createTable("pollAnswers")
.addColumn("id", "integer", c => c.primaryKey().autoIncrement())
.addColumn("optionId", "integer", c =>
c.references("pollOptions.id").notNull().onDelete("cascade"),
)
.addColumn("userId", "text", c => c.notNull())
.addColumn("createdAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`))
.addColumn("updatedAt", "timestamp", c => c.notNull().defaultTo(sql`current_timestamp`))
.execute();

await db.schema
.createIndex("pollAnswers_optionId_userId_index")
.on("pollAnswers")
.columns(["optionId", "userId"])
.unique()
.execute();

await createUpdatedAtTrigger(db, "pollAnswers");
}

function createUpdatedAtTrigger(db: Kysely<any>, tableName: string) {
return sql
.raw(`
create trigger ${tableName}_updatedAt
after update on ${tableName} for each row
begin
update ${tableName}
set updatedAt = current_timestamp
where id = old.id;
end;
`)
.execute(db);
}

export async function down(_db: Kysely<any>) {
throw new Error("Not supported lol");
}
Loading