[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/DigitalPulseSoftware/NotaBot/master/module_poll.lua [Back]  [Original]

-- Copyright (C) 2020 Antoine James Tournepiche & Jrme Leclercq
-- This file is part of the "Not a Bot" application
-- For conditions of distribution and use, see copyright notice in LICENSE

local client = Client
local discordia = Discordia
local bot = Bot
local enums = discordia.enums

Module.Name = "poll"

function Module:IsAllowedToSpecifyChannel(member, config)
	return member:hasPermission(enums.permission.administrator)
		or (config.SpecifyChannelAllowedRoles ~= nil
			and util.MemberHasAnyRole(member, config.SpecifyChannelAllowedRoles))
end

function Module:FormatChoiceResult(choiceVotes, barScale, totalVotes, asProgressBars)
	-- Pluralization
	local voteText = choiceVotes > 1 and "votes" or "vote"

	-- Configuration says we use progress bars.
	if asProgressBars then
		local progressLength = 20 -- Determines the length (in characters) of the progress bar
		local progressCharacter = '='
		local ratio = 0
		if barScale > 0 then
			ratio = choiceVotes / barScale -- barScale is the number of votes needed to have a full length bar
		end
		local choiceProgressLength = math.floor(ratio * progressLength)

		local progressText = string.rep(progressCharacter, choiceProgressLength) .. string.rep(' ', progressLength - choiceProgressLength)
		local percentage = choiceVotes / totalVotes * 100

		return string.format('`[%s]` **%d**   %s (%d%%)', progressText, choiceVotes, voteText, percentage)
	end

	-- Configuration says we use normal output.
	return string.format('**%d** %s', choiceVotes, voteText)
end

function Module:GetPollFooter(member, duration, isResults)
	local text = "Poll requested by " .. member.tag

	if duration == nil then
		return text
	end

	local verb = isResults and "Lasted" or "Lasts"

	if duration < 60 then
		duration = 60
	end

	text = string.format("%s. %s for %s.", text, verb, util.FormatTime(duration))
	return text
end

function Module:AddEmbedReactions(member, message)
	local data = self:GetData(member.guild)
	local poll = data.Polls[member.id]

	if poll == nil or #poll.choices == 0 then
		return
	end

	for _, choice in ipairs(poll.choices) do
		if choice.emoji ~= nil then
			message:addReaction(choice.emoji.Emoji or choice.emoji.Id)
		end
	end
end

function Module:CheckPermissions(member)
	if member:hasPermission(enums.permission.administrator) then
		return true
	end
	return util.MemberHasAnyRole(member, self:GetConfig(member.guild).AllowedRoles)
end

-- TODO? Ajouter option de cooldown entre 2 sondages pour un mme membre
-- TODO? (plus tard) Ajouter option pour changer la couleur (param par dfaut et action 'color')
function Module:GetConfigTable()
	return {
		{
			Array = true,
			Name = "AllowedRoles",
			Description = "Roles allowed to create polls",
			Type = bot.ConfigType.Role,
			Default = {}
		},
		{
			Array = true,
			Name = "SpecifyChannelAllowedRoles",
			Description = "Roles allowed to specify where to send a poll",
			Type = bot.ConfigType.Role,
			Default = {}
		},
		{
			Name = "DefaultPollChannel",
			Description = "Where should polls be sent if no channel is set on init",
			Type = bot.ConfigType.Channel,
			Optional = true
		},
		{
			Name = "DefaultPollDuration",
			Description = "Default poll duration if no duration is set on init",
			Type = bot.ConfigType.Duration,
			Default = 24 * 60 * 60
		},
		{
			Name = "DeletePollOnExpiration",
			Description = "Delete original poll message on expiration",
			Type = bot.ConfigType.Boolean,
			Default = true
		},
		{
			Name = "UseProgressBars",
			Description = "Use progress bars to fancy out results",
			Type = bot.ConfigType.Boolean,
			Default = true
		},
		{
			Name = "MostVotedRelative",
			Description = "Make Progress bars relative to the most voted choice, instead of being relative to total votes",
			Type = bot.ConfigType.Boolean,
			Default = false,
		}
	}
end

function Module:OnUnload()
	self.Timer:Stop()
end

function Module:OnEnable(guild)
	local data = self:GetData(guild)
	data.Polls = {}

	return true
end

function Module:OnLoaded()
	self.Timer = Bot:CreateRepeatTimer(1, -1, function ()
		local now = os.time()

		self:ForEachGuild(function(guildId, config, data, persistentData)
			local guild = client:getGuild(guildId)
			local config = self:GetConfig(guild)

			if persistentData.runningPolls == nil then
				return -- This is a callback so return instead of break
			end
			for index, poll in ipairs(persistentData.runningPolls) do
				local pollTime = poll[2]
				local duration = poll[3]

				-- Return if the poll isn't finished.
				if now >= (pollTime + duration) then
					local channel = guild:getChannel(poll[4])
					local member = guild:getMember(poll[1])
					if (not channel or not member) then
						goto remove
					end

					do
						local message = channel:getMessage(poll[5])
						if (not message) then
							goto remove
						end
						
						local totalVotes = 0
						local mostVotedCount = 0
						local map = {}

						local reactions = message.reactions:toArray()
						local fields = message.embed.fields

						local emojiNames = poll[6] -- This is stored in the same order as fields
						for _, reaction in ipairs(reactions) do
							local rEmojiData = Bot:GetEmojiData(guild, reaction.emojiId or reaction.emojiName)
							-- This is nil when it is an extern emoji
							if not rEmojiData then
								break
							end
							local rEmojiName = rEmojiData.Name
							for i, emojiName in ipairs(emojiNames) do
								if rEmojiName == emojiName then
									table.insert(map, {
										count = reaction.count - 1,
										title = fields[i].value
									})

									local choiceVotes = reaction.count - 1

									if choiceVotes > mostVotedCount then
										mostVotedCount = choiceVotes
									end

									totalVotes = totalVotes + choiceVotes
									break
								end
							end
						end

						assert(#fields >= #map)

						if #fields > #map then
							for _, field in ipairs(fields) do
								local wasIn = false
								for _, mapElem in ipairs(map) do
									if mapElem.title == field.value then
										wasIn = true
										break
									end
								end
								if not wasIn then
									table.insert(map, {
										count = 0,
										title = field.value .. " *(**deleted**)*"
									})
								end
							end
						end

						local results = {
							author = {
								name = "Poll results",
								icon_url = member.avatarURL
							},
							title = message.embed.title,
							fields = {},
							footer = {text = self:GetPollFooter(member, duration, true)}
						}

						table.sort(map, function(a, b) return a.count > b.count end)

						local barScale
						if config.MostVotedRelative then
							barScale = mostVotedCount
						else
							barScale = totalVotes
						end

						for _, choice in ipairs(map) do
							table.insert(results.fields, {
								name = choice.title,
								value = self:FormatChoiceResult(choice.count, barScale, totalVotes, config.UseProgressBars)
							})
						end
						if not config.DeletePollOnExpiration then
							results.url = message.link
						end

						channel:send({
							embed = results
						})

						if config.DeletePollOnExpiration then
							local succeed = message:delete()
							if not succeed then
								channel:send("**ERROR** Failed to delete original poll message!")
							end
						end
					end

					::remove::
					table.remove(persistentData.runningPolls, index)
				end
			end
		end)
	end)

	self:RegisterCommand({
		Name = "createpoll",
		Args = {
			{Name = "title", Type = bot.ConfigType.String},
			{Name = "channel", Type = bot.ConfigType.Channel, Optional = true},
			{Name = "duration", Type = bot.ConfigType.Duration, Optional = true}
		},
		PrivilegeCheck = function (member) return self:CheckPermissions(member) end,

		Help = "Creates a poll (title format: \"title\")",
		Func = function (commandMessage, title, channel, duration)
			local member = commandMessage.member
			local guild = member.guild
			local data = self:GetData(guild)
			local polls = data.Polls

			local config = self:GetConfig(member.guild)
			local pollChannel = channel or config.DefaultPollChannel
			local pollDuration = duration or config.DefaultPollDuration

			if pollChannel == nil then
				commandMessage:reply("You need to either specify a channel, or configure one with the `config poll` command.")
				return
			end

			if channel ~= nil and not self:IsAllowedToSpecifyChannel(member, config) then
				commandMessage:reply("You are not allowed to specify a channel.")
				return
			end

			if (not polls[member.id]) then
				polls[member.id] = {
					title = title,
					channel = pollChannel,
					duration = pollDuration,
					choices = {}
				}

				commandMessage:reply('Poll created! Set it up using the `poll` command.')
			else
				commandMessage:reply("You are already setting up a poll.\nUse `cancelpoll` to abort the previous poll.")
			end
		end
	})

	self:RegisterCommand({
		Name = "cancelpoll",
		Args = {},
		PrivilegeCheck = function(member) return self:CheckPermissions(member) end,

		Help = "Cancels your current pending poll",
		Func = function(commandMessage)
			local member = commandMessage.member
			local data = self:GetData(member.guild)
			local polls = data.Polls

			if (polls[member.id]) then
				polls[member.id] = nil
				commandMessage:reply("You can now create a new poll.")
			else
				commandMessage:reply("You don't have a pending poll.")
			end
		end
	})

	self:RegisterCommand({
		Name = "poll",
		Args = {
			{Name = "action", Type = bot.ConfigType.String},
			{Name = "emoji", Type = bot.ConfigType.Emoji, Optional = true},
			{Name = "text", Type = bot.ConfigType.String, Optional = true}
		},
		PrivilegeCheck = function(member) return self:CheckPermissions(member) end,

		Help = "Sets up a poll",
		Func = function(commandMessage, action, emoji, text)
			local member = commandMessage.member
			local guild = member.guild
			local data = self:GetData(member.guild)
			local polls = data.Polls
			local poll = polls[member.id]

			if (not poll) then
				commandMessage:reply("You must create a poll in order to use this command!")
				return
			end

			if action == "add" then
				if #poll.choices >= 20 then
					commandMessage:reply("You can't add more than 20 choices!")
					return
				end

				if text == nil or text == '' then
					commandMessage:reply("You can't add a choice without text!")
					return
				end

				if emoji ~= nil then
					if self:IsAChoice(poll, emoji) then
						commandMessage:reply("This emoji is already used for a choice! Can't add it : use `update` action if you want to update it!\n")
						return
					end

					table.insert(poll.choices, {emoji = emoji, text = text})
				else
					commandMessage:reply("This emoji is unknown. If it is a Discord one, please contact Lynix for him to update the internal emoji list.")
					return
				end

				local message = commandMessage:reply({
					embed = self:FormatPoll(member, {}, nil, true)
				})
				self:AddEmbedReactions(member, message)
				return
			end

			if action == "remove" then
				local function RemoveChoice(emoji)
					local choices = {}
					local wasIn = false

					for _, choice in ipairs(poll.choices) do
						if choice.emoji.Name ~= emoji.Name then
							table.insert(choices, choice)
						else
							wasIn = true
						end
					end

					poll.choices = choices
					return wasIn
				end

				local reply = ""

				if text ~= nil then
					reply = reply .. "**WARN** The specified text is useless and will be ignored!\n"
				end

				if (RemoveChoice(emoji)) then
					reply = reply .. emoji.MentionString .. " has been removed!\n"
				else
					reply = reply .. emoji.MentionString .. " doesn't match any choice. It was not removed.\n"
				end

				local message = commandMessage:reply({
					embed = self:FormatPoll(member, {}, reply, true)
				})
				self:AddEmbedReactions(member, message)
				return
			end

			if action == "update" then
				if text == nil then
					commandMessage:reply("Can't update a choice without text! To remove a choice, use the `remove` action.")
					return
				end

				local reply = emoji.MentionString .. " text update has failed."
				for _, choice in ipairs(poll.choices) do
					if choice.emoji.Name == emoji.Name then
						choice.text = text
						reply = emoji.MentionString .. " text updated successfully."
						break
					end
				end

				local message = commandMessage:reply({
					embed = self:FormatPoll(member, {}, reply, true)
				})
				self:AddEmbedReactions(member, message)
				return
			end

			if action == "title" then
				if text == nil then
					commandMessage:reply("Invalid title! No title set!")
					return
				end

				polls[member.id].title = text
				commandMessage:reply("Title set to `" .. text .. "`")
				return
			end

			if action == "send" then
				if #polls[member.id].choices < 2 then
					commandMessage:reply("You can't send a poll without at least 2 choices! Set some using the `add` action!")
					return
				end

				local channel = guild:getChannel(poll.channel)
				local data = self:GetPersistentData(guild)
				local message = channel:send({
					embed = self:FormatPoll(member, {}, nil, false)
				})
				self:AddEmbedReactions(member, message)

				data.runningPolls = data.runningPolls or {}
				-- TODO? Ajouter option pour empcher un membre de faire un sondage s'il en a dj un en cours
				local emojiNames = {}
				for i, choice in ipairs(poll.choices) do
					emojiNames[i] = choice.emoji.Name
				end

				table.insert(data.runningPolls, {member.id, os.time(), poll.duration, channel.id, message.id, emojiNames})

				polls[member.id] = nil

				commandMessage:reply(string.format("Poll successfully sent to %s (#%s).", channel.mentionString, channel.name))
				return
			end

			commandMessage:reply("Invalid action. It can only be `add`, `remove`, `update`, `title` or `send`.")
		end
	})

	return true
end

function Module:IsAChoice(poll, emoji)
	for _, choice in ipairs(poll.choices) do
		if choice.emoji.Id == emoji.Id then
			return true
		end
	end

	return false
end

-- TODO Respect limitations : https://birdie0.github.io/discord-webhooks-guide/other/field_limits.html
function Module:FormatPoll(member, embed, footer, preview)
	local guild = member.guild
	local data = self:GetData(guild)

	local fields = {}
	local poll = data.Polls[member.id]
	local title = preview and "[Preview] " .. poll.title or poll.title

	for i, choice in ipairs(poll.choices) do
		if Bot:GetEmojiData(guild, choice.emoji.Name) ~= nil then
			table.insert(fields, {
				name = "Choice n" .. i,
				value = string.format("%s  %s", choice.emoji.MentionString, choice.text)
			})
		else
			-- Deinit the poll
			data.Polls[member.id] = nil
			client:info("An emoji was deleted during the configuration of a poll that was using it.")

			return {
				title = "An emoji is broken.",
				fields = {
					{
						name = "This is not a bot error.",
						value = "This happens when an emoji in the poll is deleted during its configuration."
					},
					{
						name = "How to fix it?",
						value = "You can't! Your poll has been cancelled."
					},
					{
						name = "What to do now?",
						value = "Just use the command `createpoll` and redo everything."
					}
				}
			}
		end
	end

	-- TODO? Add expiration date to the footer OR add launch time!
	embed.title = title
	embed.fields = fields

	if footer ~= nil then
		embed.footer = { text = footer }
	else
		embed.footer = { text = self:GetPollFooter(member, poll.duration) }
	end

	return embed
end

Web Proxy Viewer  |  New URL  |  Original Page