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

-- Copyright (C) 2018 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
local bit = require("bit")

Module.Name = "modo"

function Module:GetConfigTable()
	return {
		{
			Name = "Trigger",
			Description = "Triggering emoji",
			Type = bot.ConfigType.Emoji,
			Default = "modo"
		},
		{
			Name = "AlertChannel",
			Description = "Channel where alerts will be posted",
			Type = bot.ConfigType.Channel,
			Default = ""
		},
		{
			Array = true,
			Name = "ImmunityRoles",
			Description = "Roles immune to moderators reactions",
			Type = bot.ConfigType.Role,
			Default = {}
		},
		{
			Name = "ModeratorPingThreshold",
			Description = "How many moderation emoji reactions are required to trigger a moderator ping (0 to disable)",
			Type = bot.ConfigType.Integer,
			Default = 5
		},
		{
			Name = "ModeratorRole",
			Description = "Which role should be pinged when a message reach the moderator ping threshold",
			Type = bot.ConfigType.Role,
			Default = ""
		},
		{
			Name = "MuteThreshold",
			Description = "How many moderation emoji reactions are required to auto-mute the original poster (0 to disable)",
			Type = bot.ConfigType.Integer,
			Default = 10
		},
		{
			Name = "MuteDuration",
			Description = "Duration of auto-mute",
			Type = bot.ConfigType.Duration,
			Default = 10 * 60
		},
		{
			Name = "MuteRole",
			Description = "Auto-mute role to be applied (no need to configure its permissions)",
			Type = bot.ConfigType.Role,
			Default = ""
		}
	}
end

function Module:OnLoaded()
	self.Clock = discordia.Clock()
	self.Clock:on("min", function ()
		local now = os.time()
		self:ForEachGuild(function (guildId, config, data, persistentData)
			local guild = client:getGuild(guildId)
			if (guild) then
				for userId,endTime in pairs(persistentData.MutedUsers) do
					if (now >= endTime) then
						self:Unmute(guild, userId)
					end
				end
			end
		end)
	end)

	return true
end

function Module:OnReady()
	self.Clock:start()
end

function Module:OnEnable(guild)
	local config = self:GetConfig(guild)

	local mentionEmoji = bot:GetEmojiData(guild, config.Trigger)
	if (not mentionEmoji) then
		return false, "Emoji \"" .. config.Trigger .. "\" not found (check your configuration)"
	end

	local alertChannel = guild:getChannel(config.AlertChannel)
	if (not alertChannel) then
		return false, "Alert channel not found (check your configuration)"
	end

	local data = self:GetPersistentData(guild)
	data.MutedUsers = data.MutedUsers or {}
	data.ReportedMessages = data.ReportedMessages or {}
	data.AlertMessages = data.AlertMessages or {}

	self:LogInfo(guild, "Checking mute role permission on all channels...")

	if (config.MuteRole) then
		local mutedRole = guild:getRole(config.MuteRole)
		if (mutedRole) then
			for _, channel in pairs(guild.textChannels) do
				self:CheckTextMutePermissions(channel)
			end
	
			for _, channel in pairs(guild.voiceChannels) do
				self:CheckVoiceMutePermissions(channel)
			end
		else
			self:LogError(guild, "Invalid muted role")
			config.MuteRole = nil
			self:SaveGuildConfig(guild)
		end
	else
		self:LogWarning(guild, "No mute role has been set")
	end

	return true
end

function Module:OnUnload()
	if (self.Clock) then
		self.Clock:stop()
	end
end

function Module:CheckTextMutePermissions(channel)
	local config = self:GetConfig(channel.guild)
	local mutedRole = channel.guild:getRole(config.MuteRole)
	if (not mutedRole) then
		self:LogError(channel.guild, "Invalid muted role")
		return
	end

	local permissions = channel:getPermissionOverwriteFor(mutedRole)
	assert(permissions)

	local deniedPermissions = permissions:getDeniedPermissions()
	-- :enable here just sets the bit, disabling the permissions
	deniedPermissions:enable(enums.permission.addReactions, enums.permission.sendMessages, enums.permission.usePublicThreads, enums.permission.sendMessagesInThreads)

	if permissions:getAllowedPermissions() ~= discordia.Permissions() or permissions:getDeniedPermissions() ~= deniedPermissions then
		permissions:setPermissions('0', deniedPermissions)
	end
end

function Module:CheckVoiceMutePermissions(channel)
	local config = self:GetConfig(channel.guild)
	local mutedRole = channel.guild:getRole(config.MuteRole)
	if (not mutedRole) then
		self:LogError(channel.guild, "Invalid muted role")
		return
	end

	local permissions = channel:getPermissionOverwriteFor(mutedRole)
	assert(permissions)

	local deniedPermissions = permissions:getDeniedPermissions()
	-- :enable here just sets the bit, disabling the permissions
	deniedPermissions:enable(enums.permission.speak)

	if permissions:getAllowedPermissions() ~= discordia.Permissions() or permissions:getDeniedPermissions() ~= deniedPermissions then
		permissions:setPermissions('0', deniedPermissions)
	end
end

local function GenerateJumpToComponents(message)
	return {
		type = enums.componentType.actionRow,
		components = {
			{
				type = enums.componentType.button,
				style = enums.buttonStyle.link,
				url = bot:GenerateMessageLink(message),
				label = "Jump to message"
			}		
		}
	}
end

function Module:HandleEmojiAdd(userId, message)
	if (message.author.bot) then
		-- Ignore bot
		return
	end

	local messageMember = message.member
	if (not messageMember) then
		-- Ignore PM
		return
	end

	local guild = message.guild
	local config = self:GetConfig(guild)

	for _, roleId in pairs(config.ImmunityRoles) do
		if (messageMember:hasRole(roleId)) then
			return
		end
	end

	local alertChannel = client:getChannel(config.AlertChannel)
	if not alertChannel then
		self:LogError(channel.guild, "Failed to get alert channel")
		return
	end

	local data = self:GetPersistentData(guild)

	local reportedMessage = data.ReportedMessages[message.id]
	if (reportedMessage) then
		-- Check if user already reported this message
		if (table.search(reportedMessage.ReporterIds, userId)) then
			return
		end

		-- Update alert message embed
		table.insert(reportedMessage.ReporterIds, userId)

		local reporters = {}
		for _,reporterId in pairs(reportedMessage.ReporterIds) do
			local user = client:getUser(reporterId)
			table.insert(reporters, user and user.mentionString or "")
		end

		reportedMessage.Embed.title = #reporters .. " users reported a message"
		reportedMessage.Embed.fields[2].name = "Reporters"
		reportedMessage.Embed.fields[2].value = table.concat(reporters, "\n")

		local alertMessage = alertChannel:getMessage(reportedMessage.AlertMessageId)
		if (alertMessage) then
			alertMessage:setEmbed(reportedMessage.Embed)
		end

		if not reportedMessage.Dismissed then
			local reporterCount = #reporters
			if (config.MuteThreshold > 0 and reporterCount >= config.MuteThreshold and not reportedMessage.MuteApplied) then
				-- Auto-mute
				if (config.muteRole) then
					local reportedUser = client:getUser(reportedMessage.ReportedUserId)
					if (self:Mute(guild, reportedMessage.ReportedUserId)) then
						local durationStr = util.DiscordRelativeTime(config.MuteDuration)
						alertChannel:send({
							content = string.format("%s has been auto-muted %s", reportedUser.mentionString, durationStr),
							reference = alertMessage and {
								message = alertMessage.id
							} or nil
						})
						message.channel:send(string.format("%s has been auto-muted for %s due to reporting", reportedUser.mentionString, durationStr, messageLink))
					else
						alertChannel:send({
							content = string.format("Failed to mute %s", reportedUser.mentionString),
							reference = alertMessage and {
								message = alertMessage.id
							} or nil
						})
					end
				end

				reportedMessage.MuteApplied = true
			end

			if (config.ModeratorPingThreshold > 0 and reporterCount >= config.ModeratorPingThreshold and not reportedMessage.ModeratorPinged) then
				-- Ping moderators
				local moderatorRole = guild:getRole(config.ModeratorRole)
				if (moderatorRole) then
					alertChannel:send({
						content = string.format("A message has been reported %d times %s\n", reporterCount, moderatorRole.mentionString, messageLink),
						reference = alertMessage and {
							message = alertMessage.id
						} or nil
					})
				end

				reportedMessage.ModeratorPinged = true
			end
		end
	else
		local reporterUser = client:getUser(userId)

		local content = message.cleanContent
		if (#content > 800) then
			content = content:sub(1, 800) .. "..."
		end

		if (not content or #content == 0) then
			content = ""
		end

		local embedContent = {
			title = "One user reported a message",
			fields = {
				{
					name = "Reported user",
					value = message.author.mentionString,
					inline = true
				},
				{
					name = "Reporter",
					value = reporterUser.mentionString,
					inline = true
				},
				{
					name = "Message channel",
					value = message.channel.mentionString
				},
				{
					name = "Message content",
					value = content
				},
				{
					name = "Action history",
					value = "None"
				}
			},
			timestamp = discordia.Date():toISO('T', 'Z')
		}

		local actionButtons = {
			{
				type = enums.componentType.button,
				custom_id = "alertmodule_dismiss",
				style = enums.buttonStyle.secondary,
				label = "Dismiss alert",
				emoji = { name = "" }
			},
			{
				type = enums.componentType.button,
				custom_id = "alertmodule_deletemessage",
				style = enums.buttonStyle.primary,
				label = "Delete message",
				emoji = { name = "" }
			}
		}

		local components = { GenerateJumpToComponents(message) }
		table.insert(components, {
			type = enums.componentType.actionRow,
			components = actionButtons
		})

		if (Bot:GetModuleForGuild(guild, "modmail")) then
			table.insert(actionButtons, {
				type = enums.componentType.button,
				custom_id = "alertmodule_modmail",
				style = enums.buttonStyle.primary,
				label = "Open a modmail ticket",
				emoji = { name = "" }
			})
		end

		do
			-- Mute
			local muteDurations = { 10 * 60, 60 * 60, 6 * 60 * 60, 24 * 60 * 60, 0 }
			local options = {}
			for _, duration in pairs(muteDurations) do
				table.insert(options, {
					label = duration > 0 and "Mute for " .. util.FormatTime(duration) or "Mute indefinitely",
					value = tostring(duration)
				})
			end

			table.insert(components, {
				type = enums.componentType.actionRow,
				components = {
					{
						type = enums.componentType.stringSelect,
						custom_id = "alertmodule_mute",
						placeholder = " Mute member",
						disabled = Bot:GetModuleForGuild(guild, "mute") == nil,
						options = options
					}
				}
			})
		end

		local tempBanAvailable = Bot:GetModuleForGuild(guild, "mute") ~= nil
		do
			-- Ban
			local banDurations = { 60 * 60, 6 * 60 * 60, 24 * 60 * 60, 7 * 24 * 60 * 60 }
			local options = {}
			for _, duration in pairs(banDurations) do
				table.insert(options, {
					label = "Ban for " .. util.FormatTime(duration),
					value = tostring(duration),
					disabled = not tempBanAvailable
				})
			end

			table.insert(options, {
				label = "Ban permanently",
				value = "0"
			})

			table.insert(options, {
				label = "Ban permanently and delete last 24h messages",
				value = "0_deletemessages"
			})

			table.insert(components, {
				type = enums.componentType.actionRow,
				components = {
					{
						type = enums.componentType.stringSelect,
						custom_id = "alertmodule_ban",
						placeholder = " Ban member",
						options = options
					}
				}
			})
		end

		local alertMessage = alertChannel:send({
			embed = embedContent,
			components = components
		})

		if (not alertMessage) then
			self:LogError(message.guild, "Failed to post alert message (too long?) for %s", bot:GenerateMessageLink(message))
		end

		data.ReportedMessages[message.id] = {
			AlertMessageId = alertMessage and alertMessage.id or nil,
			ChannelId = message.channel.id,
			Components = components,
			Dismissed = false,
			Embed = embedContent,
			MessageId = message.id,
			ReportedUserId = message.author.id,
			ReporterIds = { userId }
		}

		if alertMessage then
			data.AlertMessages[alertMessage.id] = data.ReportedMessages[message.id]
		end
	end
end

function Module:HandleMessageRemove(channel, messageId)
	local data = self:GetPersistentData(channel.guild)

	local reportedMessage = data.ReportedMessages[messageId]
	if (not reportedMessage) then
		return
	end

	local config = self:GetConfig(channel.guild)

	-- Disable "jump to" button
	reportedMessage.Components[1].components[1].disabled = true
	-- Disable "delete message" button
	reportedMessage.Components[2].components[2].disabled = true

	local alertChannel = client:getChannel(config.AlertChannel)
	if (alertChannel and reportedMessage.AlertMessageId) then
		local alertMessage = alertChannel:getMessage(reportedMessage.AlertMessageId)
		if (alertMessage) then
			alertMessage:setComponents(reportedMessage.Components)
		end
	end
end

function Module:Mute(guild, userId)
	local config = self:GetConfig(guild)
	local member = guild:getMember(userId)
	if (member and member:addRole(config.MuteRole)) then
		local data = self:GetPersistentData(guild)

		data.MutedUsers[userId] = os.time() + config.MuteDuration
		return true
	end

	return false
end

function Module:Unmute(guild, userId)
	local config = self:GetConfig(guild)

	local data = self:GetPersistentData(guild)
	data.MutedUsers[userId] = nil

	local member = guild:getMember(userId)
	if (member) then
		if (member:removeRole(config.MuteRole)) then
			return true
		else
			self:LogError(guild, "Failed to unmute %s", member.fullname)
		end
	end

	return false
end

function Module:OnChannelCreate(channel)
	if (channel.type == enums.channelType.text) then
		self:CheckTextMutePermissions(channel)
	elseif (channel.type == enums.channelType.voice) then
		self:CheckVoiceMutePermissions(channel)
	end
end

function Module:OnInteractionCreate(interaction)
	local guild = interaction.guild
	if not guild then
		return
	end

	local data = self:GetPersistentData(guild)
	local alertMessage = data.AlertMessages[interaction.message.id]
	if not alertMessage then
		-- Not our job
		return
	end

	local moderator = interaction.member

	local actionStr

	local interactionType = interaction.data.custom_id
	if interactionType == "alertmodule_dismiss" then
		if not moderator:hasPermission(enums.permission.moderateMembers) then
			interaction:editResponse({
				content = string.format(" You do not have permission to moderate")
			})
			return
		end

		if alertMessage.Dismissed then
			interaction:respond({
				type = enums.interactionResponseType.channelMessageWithSource,
				data = {
					content = " Alert has already been dismissed",
					flags = enums.interactionResponseFlag.ephemeral
				}
			})
	
			return
		end

		alertMessage.Dismissed = true
		-- Disable dismiss button
		alertMessage.Components[2].components[1].disabled = true

		interaction:respond({
			type = enums.interactionResponseType.channelMessageWithSource,
			data = {
				content = " Alert has been dismissed (it won't trigger mute nor ping)",
				flags = enums.interactionResponseFlag.ephemeral
			}
		})

		actionStr = "Dismissed by " .. moderator.mentionString
	elseif interactionType == "alertmodule_deletemessage" then
		-- "Waiting"
		interaction:respond({
			type = enums.interactionResponseType.deferredChannelMessageWithSource,
			data = {
				flags = enums.interactionResponseFlag.ephemeral
			}
		})

		if not moderator:hasPermission(enums.permission.manageMessages) then
			interaction:editResponse({
				content = string.format(" You do not have permission to delete this message")
			})
			return
		end

		local channel, err = client:getChannel(alertMessage.ChannelId)
		if not channel then
			interaction:editResponse({
				content = string.format(" failed to retrieve message channel: %s", err)
			})
			return
		end

		local message, err = channel:getMessage(alertMessage.MessageId)
		if not message then
			interaction:editResponse({
				content = string.format(" failed to retrieve message: %s", err)
			})
			return
		end

		local success, err = message:delete()
		if not success then
			interaction:editResponse({
				content = string.format(" failed to delete message: %s", err)
			})
			return
		end

		interaction:editResponse({
			content = " the message was deleted"
		})

		actionStr = "Deleted by " .. moderator.mentionString
	elseif interactionType == "alertmodule_modmail" then
		local modmail = Bot:GetModuleForGuild(guild, "modmail")
		if not modmail then
			interaction:respond({
				type = enums.interactionResponseType.channelMessageWithSource,
				data = {
					content = " The modmail module isn't enabled on this server",
					flags = enums.interactionResponseFlag.ephemeral
				}
			})
			return
		end

		-- "Waiting"
		interaction:respond({
			type = enums.interactionResponseType.deferredChannelMessageWithSource,
			data = {
				flags = enums.interactionResponseFlag.ephemeral
			}
		})

		local targetMember, err = guild:getMember(alertMessage.ReportedUserId)
		if not targetMember then
			interaction:editResponse({
				content = string.format(" failed to retrieve member: %s", err)
			})
			return
		end

		if not modmail:CheckOpenTicketPermission(moderator, targetMember) then
			interaction:editResponse({
				content = string.format(" You do not have permission to open a modmail ticket.")
			})
			return
		end

		local reason = string.format("%s has opened a ticket following your message ()", moderator.mentionString, guild.id, alertMessage.ChannelId, alertMessage.MessageId)
		local ticketChannel, err = modmail:OpenTicket(moderator, targetMember, reason, true)
		if not ticketChannel then
			interaction:editResponse({
				content = string.format(" failed to open modmail ticket: %s", err)
			})
			return
		end

		interaction:editResponse({
			content = " a modmail ticket has been created: " .. ticketChannel.mentionString
		})

		actionStr = "Modmail ticket opened by " .. moderator.mentionString
	elseif interactionType == "alertmodule_mute" then
		local mute = Bot:GetModuleForGuild(guild, "mute")
		if not mute then
			interaction:respond({
				type = enums.interactionResponseType.channelMessageWithSource,
				data = {
					content = " The mute module isn't enabled on this server",
					flags = enums.interactionResponseFlag.ephemeral
				}
			})
			return
		end

		local duration = interaction.data.values and tonumber(interaction.data.values[1]) or nil
		if not duration then
			interaction:respond({
				type = enums.interactionResponseType.channelMessageWithSource,
				data = {
					content = " an error occurred (invalid duration)",
					flags = enums.interactionResponseFlag.ephemeral
				}
			})
			return
		end

		-- "Waiting"
		interaction:respond({
			type = enums.interactionResponseType.deferredChannelMessageWithSource,
			data = {
				flags = enums.interactionResponseFlag.ephemeral
			}
		})

		if not mute:CheckPermissions(moderator) then
			interaction:editResponse({
				content = string.format(" You do not have permission mute this member")
			})
			return
		end

		local success, err = mute:Mute(guild, alertMessage.ReportedUserId, duration)
		if not success then
			interaction:editResponse({
				content = string.format(" failed to open mute member: %s", err)
			})
			return
		end

		interaction:editResponse({
			content = " the member has been muted for " .. util.FormatTime(duration)
		})

		actionStr = "Muted " .. util.DiscordRelativeTime(duration) .. " by " .. moderator.mentionString
	elseif interactionType == "alertmodule_ban" then
		local duration = interaction.data.values and interaction.data.values[1] or nil

		local ban = Bot:GetModuleForGuild(guild, "ban")
		if (ban and not mute:CheckPermissions(moderator)) or (not ban and not moderator:hasPermission(enums.permission.banMembers)) then
			interaction:editResponse({
				content = string.format(" You do not have permission mute this member")
			})
			return
		end
		
		if duration == "0" or duration == "0_deletemessages" then
			-- "Waiting"
			interaction:respond({
				type = enums.interactionResponseType.deferredChannelMessageWithSource,
				data = {
					flags = enums.interactionResponseFlag.ephemeral
				}
			})

			local purgeDays = duration == "0_deletemessages" and 1 or 0

			local reason = "banned by " .. moderator.name .. " via alert"

			local success, err = guild:banUser(alertMessage.ReportedUserId, "banned by " .. moderator.name .. " via alert", purgeDays)
			if not success then
				interaction:editResponse({
					content = string.format(" failed to open ban user: %s", err)
				})
				return
			end

			if ban then
				ban:RegisterBan(guild, alertMessage.ReportedUserId, moderator, 0, reason)
			end

			interaction:editResponse({
				content = " the member has been banned" .. (purgeDays > 0 and " (and its last 24h message deleted)" or "")
			})
	
			actionStr = "banned permanently by " .. moderator.mentionString .. (purgeDays > 0 and " (last 24h message deleted)" or "")
		else
			duration = duration and tonumber(duration) or nil
			if not duration then
				interaction:respond({
					type = enums.interactionResponseType.channelMessageWithSource,
					data = {
						content = " an error occurred (invalid duration)",
						flags = enums.interactionResponseFlag.ephemeral
					}
				})
				return
			end

			-- Temp ban
			if not ban then
				interaction:respond({
					type = enums.interactionResponseType.channelMessageWithSource,
					data = {
						content = " The ban module isn't enabled on this server (and is required for temporary ban)",
						flags = enums.interactionResponseFlag.ephemeral
					}
				})
				return
			end

			-- "Waiting"
			interaction:respond({
				type = enums.interactionResponseType.deferredChannelMessageWithSource,
				data = {
					flags = enums.interactionResponseFlag.ephemeral
				}
			})

			local durationStr = util.FormatTime(duration)

			local success, err = guild:banUser(alertMessage.ReportedUserId, "banned by " .. moderator.name .. " via alert for " .. durationStr)
			if not success then
				interaction:editResponse({
					content = string.format(" failed to open ban user: %s", err)
				})
				return
			end

			ban:RegisterBan(guild, alertMessage.ReportedUserId, moderator, duration, reason)

			interaction:editResponse({
				content = " the member has been banned for " .. durationStr
			})
	
			actionStr = "banned for " .. durationStr .. " by " .. moderator.mentionString
		end
	else
		interaction:respond({
			type = enums.interactionResponseType.channelMessageWithSource,
			data = {
				content = " an error occurred (unknown interaction type " .. tostring(interactionType) .. ")"
			}
		})
		return
	end

	if actionStr then
		local currentActionStr = alertMessage.Embed.fields[5].value
		if currentActionStr == "None" then
			alertMessage.Embed.fields[5].value = actionStr
		else
			alertMessage.Embed.fields[5].value = currentActionStr .. "\n" .. actionStr
		end

		interaction.message:update({
			components = alertMessage.Components,
			embed = alertMessage.Embed
		})
	end
end

function Module:OnReactionAdd(reaction, userId)
	if (not bot:IsPublicChannel(reaction.message.channel)) then
		return
	end

	local guild = reaction.message.guild
	local config = self:GetConfig(guild)
	local emojiData = bot:GetEmojiData(guild, reaction.emojiId or reaction.emojiName)
	if (not emojiData) then
		self:LogWarning(guild, "Emoji %s was used but not found in guild", reaction.emojiName)
		return
	end

	if (emojiData.Name ~= config.Trigger or (emojiData.Custom and emojiData.FromGuild ~= guild)) then
		return
	end

	self:HandleEmojiAdd(userId, reaction.message)
end

function Module:OnReactionAddUncached(channel, messageId, reactionIdorName, userId)
	if (not bot:IsPublicChannel(channel)) then
		return
	end

	local guild = channel.guild
	local config = self:GetConfig(guild)
	local emojiData = bot:GetEmojiData(guild, reactionIdorName)
	if (not emojiData) then
		self:LogWarning(guild, "Emoji %s was used but not found in guild", reactionIdorName)
		return
	end

	if (emojiData.Name ~= config.Trigger or (emojiData.Custom and emojiData.FromGuild ~= guild)) then
		return
	end

	local message = channel:getMessage(messageId)
	if (not message) then
		return
	end

	self:HandleEmojiAdd(userId, message)
end

function Module:OnMessageDelete(message)
	if (not bot:IsPublicChannel(message.channel)) then
		return
	end

	self:HandleMessageRemove(message.channel, message.id)
end

function Module:OnMessageDeleteUncached(channel, messageId)
	if (not bot:IsPublicChannel(channel)) then
		return
	end

	self:HandleMessageRemove(channel, messageId)
end

Web Proxy Viewer  |  New URL  |  Original Page