From caf74417b587406c24d80850cf78ab004906717c Mon Sep 17 00:00:00 2001 From: CyberL1 Date: Thu, 25 Jun 2026 21:18:04 +0200 Subject: [PATCH] feat: add poll ended messages --- assets/openapi.json | Bin 981250 -> 981293 bytes assets/schemas.json | Bin 443786 -> 443821 bytes .../#channel_id/polls/#poll_id/expire.ts | 94 +++++++++++++++++- src/api/util/handlers/Message.ts | 93 +++++++++++++++++ src/schemas/api/messages/Embeds.ts | 1 + src/util/imports/Polls.ts | 25 +++++ src/util/imports/index.ts | 1 + 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 src/util/imports/Polls.ts diff --git a/assets/openapi.json b/assets/openapi.json index f9f6f540b27b0c3544df4a1d5fec5e511235e023..424ef6d800bffa099324a027aa69fde50e10ddb5 100644 GIT binary patch delta 75 zcmZqrWWDy2bwdkd3sVbo3rh>@7PelS=^N~rS=i!>Qj1G-CVx~>Z11&U17da{<^W<& UAm##MZXo6XV&3h&HhiUf05zN&pa1{> delta 59 zcmZ4c$-3#2bwdkd3sVbo3rh>@7PelS_8m5CK+F!r96-zo#9Tnk4a7V^%)5Pu4PWUV E0K%LXJ^%m! diff --git a/assets/schemas.json b/assets/schemas.json index a6a73abfc082da2e2984230e6cb98894fea10c1b..c49f1375e1e98130892c48e8efa2cc5aece744ee 100644 GIT binary patch delta 45 zcmeC`mR{Q}-O$3=!qmdt!m@>RraMCX1C7667Z B5yt=k delta 29 lcmZ46E#1{E-O$3=!qmdt!m@>RrhEGych>ED+}S?X0sy5>3v>Vg diff --git a/src/api/routes/channels/#channel_id/polls/#poll_id/expire.ts b/src/api/routes/channels/#channel_id/polls/#poll_id/expire.ts index 29f57c69a..a24af9909 100644 --- a/src/api/routes/channels/#channel_id/polls/#poll_id/expire.ts +++ b/src/api/routes/channels/#channel_id/polls/#poll_id/expire.ts @@ -19,7 +19,9 @@ import { Request, Response, Router } from "express"; import { route } from "@spacebar/api/util/handlers/route"; import { Message } from "#database"; -import { DiscordApiErrors, emitEvent, MessageUpdateEvent } from "#util"; +import { DiscordApiErrors, emitEvent, MessageUpdateEvent, pendingPolls } from "#util"; +import { EmbedType, MessageReferenceType, MessageType, PollAnswerCount } from "#schemas"; +import { sendMessage } from "#api/util"; const router: Router = Router({ mergeParams: true }); @@ -43,13 +45,101 @@ router.post("/", route({ permission: "VIEW_CHANNEL" }), async (req: Request, res message.poll.expiry = new Date(); await message.save(); - res.send(message); await emitEvent({ channel_id, data: message.toJSON(), event: "MESSAGE_UPDATE", } satisfies MessageUpdateEvent); + + if (!message.poll.results) { + return; + } + + const allAnswerCounts = message.poll.results.answer_counts as unknown as (Omit & { voters: string[] })[]; + + const totalVotes = allAnswerCounts.map((a) => a.voters).length; + const winningAnswerCounts = allAnswerCounts.filter((a) => (a.count * totalVotes) / 100); + + const pollResultsMessage = { + type: MessageType.POLL_RESULT, + channel_id: message.channel_id, + author_id: message.author_id, + message_reference: { + type: MessageReferenceType.DEFAULT, + message_id: message.id, + channel_id: message.channel_id, + }, + embeds: [ + { + type: EmbedType.poll_result, + id: message.id, + fields: [ + { + name: "poll_question_text", + value: message.poll.question.text!, + }, + { + name: "total_votes", + value: totalVotes.toString(), + }, + ], + }, + ], + }; + + if (winningAnswerCounts) { + const winningAnswer = message.poll.answers.find((a) => a.answer_id === Number(winningAnswerCounts[0]?.id))!; + + if (winningAnswerCounts.length === 0) { + pollResultsMessage.embeds[0].fields.push({ + name: "victor_answer_votes", + value: "0", + }); + } else if (winningAnswerCounts.length === 1) { + pollResultsMessage.embeds[0].fields.push( + { + name: "victor_answer_votes", + value: winningAnswerCounts[0].count.toString(), + }, + { + name: "victor_answer_id", + value: winningAnswerCounts[0].id, + }, + { + name: "victor_answer_text", + value: winningAnswer.poll_media.text!, + }, + ); + } else if (winningAnswerCounts.length > 1) { + pollResultsMessage.embeds[0].fields.push({ + name: "victor_answer_votes", + value: winningAnswerCounts[0].count.toString(), + }); + } + + if (winningAnswer?.poll_media.emoji) { + pollResultsMessage.embeds[0].fields.push( + { + name: "victor_answer_emoji_id", + value: winningAnswer.poll_media.emoji.id!.toString()!, + }, + { + name: "victor_answer_emoji_name", + value: winningAnswer.poll_media.emoji.name!, + }, + { + name: "victor_answer_emoji_animated", + value: `${winningAnswer.poll_media.emoji.animated}`, + }, + ); + } + } + + await sendMessage(pollResultsMessage); + pendingPolls.delete(message.id); + + res.send(message); }); export default router; diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index 53360ed78..9a4ce43ec 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -37,6 +37,7 @@ import { makeObjectErrorContent, MessageCreateEvent, MessageFlags, + pendingPolls, Permissions, ROLE_MENTION, Snowflake, @@ -58,6 +59,7 @@ import { MessageCreateSchema, MessageReferenceType, MessageType, + PollAnswerCount, Reaction, ReadStateType, UnfurledMediaItem, @@ -494,6 +496,97 @@ export async function handleMessage(opts: MessageOptions): Promise { if (opts.poll?.duration) { message.poll.expiry = new Date(Date.now() + opts.poll.duration * 3600000); + + const pollTimeout = setTimeout(async () => { + if (!message.poll?.results) { + return; + } + + const allAnswerCounts = message.poll.results.answer_counts as unknown as (Omit & { voters: string[] })[]; + + const totalVotes = allAnswerCounts.map((a) => a.voters).length; + const winningAnswerCounts = allAnswerCounts.filter((a) => (a.count * totalVotes) / 100); + + const pollResultsMessage = { + type: MessageType.POLL_RESULT, + channel_id: message.channel_id, + author_id: message.author_id, + message_reference: { + type: MessageReferenceType.DEFAULT, + message_id: message.id, + channel_id: message.channel_id, + }, + embeds: [ + { + type: EmbedType.poll_result, + id: message.id, + fields: [ + { + name: "poll_question_text", + value: message.poll.question.text!, + }, + { + name: "total_votes", + value: totalVotes.toString(), + }, + ], + }, + ], + }; + + if (winningAnswerCounts) { + const winningAnswer = message.poll.answers.find((a) => a.answer_id === Number(winningAnswerCounts[0]?.id))!; + + if (winningAnswerCounts.length === 0) { + pollResultsMessage.embeds[0].fields.push({ + name: "victor_answer_votes", + value: "0", + }); + } else if (winningAnswerCounts.length === 1) { + pollResultsMessage.embeds[0].fields.push( + { + name: "victor_answer_votes", + value: winningAnswerCounts[0].count.toString(), + }, + { + name: "victor_answer_id", + value: winningAnswerCounts[0].id, + }, + { + name: "victor_answer_text", + value: winningAnswer.poll_media.text!, + }, + ); + } else if (winningAnswerCounts.length > 1) { + pollResultsMessage.embeds[0].fields.push({ + name: "victor_answer_votes", + value: winningAnswerCounts[0].count.toString(), + }); + } + + if (winningAnswer?.poll_media.emoji) { + pollResultsMessage.embeds[0].fields.push( + { + name: "victor_answer_emoji_id", + value: winningAnswer.poll_media.emoji.id!.toString()!, + }, + { + name: "victor_answer_emoji_name", + value: winningAnswer.poll_media.emoji.name!, + }, + { + name: "victor_answer_emoji_animated", + value: `${winningAnswer.poll_media.emoji.animated}`, + }, + ); + } + } + + await sendMessage(pollResultsMessage); + pendingPolls.delete(message.id); + }, opts.poll.duration * 3600000); + + pendingPolls.set(message.id, { timeout: pollTimeout }); } if (opts.poll?.answers) { diff --git a/src/schemas/api/messages/Embeds.ts b/src/schemas/api/messages/Embeds.ts index 0f6019a65..ba27accb8 100644 --- a/src/schemas/api/messages/Embeds.ts +++ b/src/schemas/api/messages/Embeds.ts @@ -55,6 +55,7 @@ export enum EmbedType { gifv = "gifv", article = "article", link = "link", + poll_result = "poll_result", } export interface EmbedImage { diff --git a/src/util/imports/Polls.ts b/src/util/imports/Polls.ts new file mode 100644 index 000000000..bad100859 --- /dev/null +++ b/src/util/imports/Polls.ts @@ -0,0 +1,25 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Snowflake } from "@spacebar/util"; + +interface PendingPoll { + timeout: NodeJS.Timeout; +} + +export const pendingPolls = new Map(); diff --git a/src/util/imports/index.ts b/src/util/imports/index.ts index 5e690d4a6..a2162208d 100644 --- a/src/util/imports/index.ts +++ b/src/util/imports/index.ts @@ -19,3 +19,4 @@ export * from "./OrmUtils"; export * from "./Jimp"; export * from "./Interactions"; +export * from "./Polls";