diff --git a/assets/openapi.json b/assets/openapi.json index f9f6f540b..424ef6d80 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index a6a73abfc..c49f1375e 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ 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";