-
Notifications
You must be signed in to change notification settings - Fork 10
/
module_poll.lua
556 lines (468 loc) · 15.2 KB
/
module_poll.lua
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
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
-- Copyright (C) 2020 Antoine James Tournepiche & Jérôme 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 même membre
-- TODO? (plus tard) Ajouter option pour changer la couleur (param par défaut 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 empêcher un membre de faire un sondage s'il en a déjà 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