summary refs log tree commit diff
path: root/src/discord/interactions/poll.js
blob: 6d7a0153ea1d28c4eeadae4909955098228b6b88 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// @ts-check

const DiscordTypes = require("discord-api-types/v10")
const {discord, sync, select, from, db} = require("../../passthrough")
const assert = require("assert/strict")
const {id: botID} = require("../../../addbot")
const {InteractionMethods} = require("snowtransfer")

/** @type {import("../../matrix/api")} */
const api = sync.require("../../matrix/api")
/** @type {import("../../matrix/utils")} */
const utils = sync.require("../../matrix/utils")
/** @type {import("../../m2d/converters/poll-components")} */
const pollComponents = sync.require("../../m2d/converters/poll-components")
/** @type {import("../../d2m/actions/poll-vote")} */
const vote = sync.require("../../d2m/actions/poll-vote")

/**
 * @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction
 * @param {{api: typeof api}} di
 * @returns {AsyncGenerator<{[k in keyof InteractionMethods]?: Parameters<InteractionMethods[k]>[2]}>}
 */
async function* _interact({data, message, member, user}, {api}) {
	if (!member?.user) return
	const userID = member.user.id

	const pollRow = select("poll", ["question_text", "max_selections"], {message_id: message.id}).get()
	if (!pollRow) return

	// Definitely supposed to be a poll button click. We can use assertions now.

	const matrixPollEvent = select("event_message", "event_id", {message_id: message.id}).pluck().get()
	assert(matrixPollEvent)

	const maxSelections = pollRow.max_selections
	const alreadySelected = select("poll_vote", "matrix_option", {discord_or_matrix_user_id: userID, message_id: message.id}).pluck().all()

	// Show modal (if no capacity or if requested)
	if (data.custom_id === "POLL_VOTE" || (maxSelections > 1 && alreadySelected.length === maxSelections)) {
		const options = select("poll_option", ["matrix_option", "option_text", "seq"], {message_id: message.id}, "ORDER BY seq").all().map(option => ({
			value: option.matrix_option,
			label: option.option_text,
			default: alreadySelected.includes(option.matrix_option)
		}))
		const checkboxGroupExtras = maxSelections === 1 && options.length > 1 ? {} : {
			/** @type {DiscordTypes.ComponentType.CheckboxGroup} */
			type: DiscordTypes.ComponentType.CheckboxGroup,
			min_values: 0,
			max_values: maxSelections
		}
		return yield {createInteractionResponse: {
			type: DiscordTypes.InteractionResponseType.Modal,
			data: {
				custom_id: "POLL_MODAL",
				title: "Poll",
				components: [{
					type: DiscordTypes.ComponentType.TextDisplay,
					content: `-# ${pollComponents.getMultiSelectString(pollRow.max_selections, options.length)}`
				}, {
					type: DiscordTypes.ComponentType.Label,
					label: pollRow.question_text,
					component: {
						type: DiscordTypes.ComponentType.RadioGroup,
						custom_id: "POLL_MODAL_SELECTION",
						options,
						required: false,
						...checkboxGroupExtras
					}
				}]
			}
		}}
	}

	if (data.custom_id === "POLL_MODAL") {
		// Clicked options via modal
		/** @type {DiscordTypes.APIModalSubmitRadioGroupComponent | DiscordTypes.APIModalSubmitCheckboxGroupComponent} */ // @ts-ignore - close enough to the real thing
		const component = data.components[1].component
		assert.equal(component.custom_id, "POLL_MODAL_SELECTION")
		const values = "values" in component ? component.values : [component.value]

		// Replace votes with selection
		db.transaction(() => {
			db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID)
			for (const option of values) {
				db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, option)
			}
		})()

		// Update counts on message
		yield {createInteractionResponse: {
			type: DiscordTypes.InteractionResponseType.UpdateMessage,
			data: pollComponents.getPollComponentsFromDatabase(message.id)
		}}

		// Sync changes to Matrix
		await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent)
	} else {
		// Clicked buttons on message
		const optionPrefix = "POLL_OPTION#" // we use a prefix to prevent someone from sending a Matrix poll that intentionally collides with other elements of the embed
		const matrixOption = select("poll_option", "matrix_option", {matrix_option: data.custom_id.substring(optionPrefix.length), message_id: message.id}).pluck().get()
		assert(matrixOption)

		// Remove a vote
		if (alreadySelected.includes(matrixOption)) {
			db.prepare("DELETE FROM poll_vote WHERE discord_or_matrix_user_id = ? AND message_id = ? AND matrix_option = ?").run(userID, message.id, matrixOption)
		}
		// Replace votes (if only one selection is allowed)
		else if (maxSelections === 1 && alreadySelected.length === 1) {
			db.transaction(() => {
				db.prepare("DELETE FROM poll_vote WHERE message_id = ? AND discord_or_matrix_user_id = ?").run(message.id, userID)
				db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption)
			})()
		}
		// Add a vote (if capacity)
		else if (alreadySelected.length < maxSelections) {
			db.prepare("INSERT OR IGNORE INTO poll_vote (discord_or_matrix_user_id, message_id, matrix_option) VALUES (?, ?, ?)").run(userID, message.id, matrixOption)
		}

		// Update counts on message
		yield {createInteractionResponse: {
			type: DiscordTypes.InteractionResponseType.UpdateMessage,
			data: pollComponents.getPollComponentsFromDatabase(message.id)
		}}

		// Sync changes to Matrix
		await vote.sendVotes(member.user, message.channel_id, message.id, matrixPollEvent)
	}
}

/* c8 ignore start */

/** @param {DiscordTypes.APIMessageComponentButtonInteraction} interaction */
async function interact(interaction) {
	for await (const response of _interact(interaction, {api})) {
		if (response.createInteractionResponse) {
			await discord.snow.interaction.createInteractionResponse(interaction.id, interaction.token, response.createInteractionResponse)
		} else if (response.editOriginalInteractionResponse) {
			await discord.snow.interaction.editOriginalInteractionResponse(botID, interaction.token, response.editOriginalInteractionResponse)
		}
	}
}

module.exports.interact = interact
module.exports._interact = _interact