diff --git a/classes/bot.py b/classes/bot.py index 7d9fee4..b93ef32 100644 --- a/classes/bot.py +++ b/classes/bot.py @@ -14,6 +14,7 @@ from classes.dis_events import DiscordEvents from classes.subscribe import Subscribe from classes.subscribemenu import SubscribeView from classes.config import Config +from classes.reputation import Reputation from dataclasses import dataclass @@ -25,6 +26,7 @@ from dataclasses import dataclass class OMFBot(AutoShardedBot): + # each guild has the following elements: # - a list of scheduled Events (EventsList) # - a list of Messages that will be sent at idle times @@ -36,6 +38,7 @@ class OMFBot(AutoShardedBot): EventsList = {} idle_messages=[] channel_idle_timer=0 + ReputationFilter:Reputation = None # the guildDataList contains one GuildData class per item. # the key is the guild ID @@ -63,6 +66,7 @@ class OMFBot(AutoShardedBot): self.prefix="!" self.configData=Config('config.json') + # The subscribe command will add/remove the notification roles # based on the scheduled events @self.tree.command(name="subscribe", description="(un)subscribe to Events)") @@ -123,8 +127,9 @@ class OMFBot(AutoShardedBot): guildNode=self.configData.readGuild(interaction.guild.id) eventNodes=guildNode["AUTO_EVENTS"] numEvents=len(eventNodes) + numRoles=len(self.guildDataList[f'{interaction.guild.id}'].ReputationFilter.reputationRoles) - await interaction.response.send_message(f'{numEvents} Events and {numMessages} Message templates\nThank you for using my services!', ephemeral=True) + await interaction.response.send_message(f'{numRoles} reputation roles, {numEvents} Events and {numMessages} Message templates\nThank you for using my services!', ephemeral=True) except Exception as e: print(f"ERROR in update command: {e}") await interaction.response.send_message(f'Ooops, there was a glitch!', ephemeral=True) @@ -145,6 +150,16 @@ class OMFBot(AutoShardedBot): print(f"ERROR in say_ralf: {e}") await interaction.response.send_message(f'Ooops, there was a glitch!', ephemeral=True) + @self.tree.command(name="reputation", description="shows your reputation") + async def update(interaction: discord.Interaction): + + try: + + userReputation=self.guildDataList[f'{interaction.guild.id}'].ReputationFilter.getReputation(interaction.user,True) + await interaction.response.send_message(userReputation, ephemeral=True) + except Exception as e: + print(f"ERROR in update command: {e}") + await interaction.response.send_message(f'Ooops, there was a glitch!', ephemeral=True) # ################################ # the bot run command just starts @@ -215,27 +230,58 @@ class OMFBot(AutoShardedBot): async def readMessageTemplates(self,theGuild:discord.Guild): # we init the guild data with a new GuildData object - self.guildDataList[f'{theGuild.id}'] = self.GuildData() + self.guildDataList[f'{theGuild.id}'] = self.GuildData(None) guildNode=self.configData.readGuild(theGuild.id) if guildNode is None: print (f"Guild {theGuild.id} has no setup") return + + # if we have data for that guild then we try to read in the template messages from + # the predefined template channel + theTemplateChannel:discord.TextChannel theTemplateChannel=theGuild.get_channel(int(guildNode["CONFIG_CHANNEL_ID"])) message:discord.Message messages = theTemplateChannel.history(limit=50) self.guildDataList[f'{theGuild.id}'].idle_messages=[] eventNodes=[] + + # configuration for events and reputation are json objects + # everything else is considered to be an idle message that + # is randomly being sent into the configured channel + async for message in messages: messageContent:str messageContent=message.content try: + + # if the message is a dictionary then it is either an event + # or a list of reputation roles + someDict=ast.literal_eval(messageContent) if isinstance(someDict, dict): - eventNodes.append(someDict) + try: + if (someDict["REPUTATION"] is not None): + allKeys=someDict["REPUTATION"] + + # the "MUTE" key contains the role that is assigned to a user + # who sent too many messages and ignored the warnings + + self.guildDataList[f'{theGuild.id}'].ReputationFilter = Reputation(theGuild,40,allKeys["MUTE"]) + for rep in allKeys: + if (rep != "MUTE" ): + + # other roles increaese the reputation + print("Role ", str(rep)," adds Reputation ",allKeys[rep]) + self.guildDataList[f'{theGuild.id}'].ReputationFilter.addReputationRole(int(rep),int(allKeys[rep])) + except Exception as ee: + eventNodes.append(someDict) except Exception as e: self.guildDataList[f'{theGuild.id}'].idle_messages.append(message.content) + + # the "AUTO_EVENTS" node contains all the planned events + guildNode["AUTO_EVENTS"]=eventNodes self.configData.writeGuild(theGuild.id,guildNode) @@ -348,9 +394,17 @@ class OMFBot(AutoShardedBot): if message.author == self.user: return + reputationFilter=self.guildDataList[f'{message.guild.id}'].ReputationFilter + if not (reputationFilter is None): + theScore=await reputationFilter.checkMessage(message) + # user is throtteled or muted + if (theScore<=0): + await message.reply("Hold your horses - **you are sending too many messages**\nHave you sent a lot of links?\nIf you continue to send then **you will be muted**\n**contact an admin** in order to have you added to a trusted role\n") + if (theScore<=-1): + await message.reply("**You have sent too many messages**\nHave you sent a lot of links?\n**you are now muted**\nAdmins have been alerted and will examine the situation\nIf this was a false alert then you will be unblocked soon") + # reset the idle timer if a message has been sent or received self.guildDataList[f'{message.guild.id}'].channel_idle_timer=0 - await self.process_commands(message) diff --git a/classes/reputation.py b/classes/reputation.py new file mode 100644 index 0000000..6888219 --- /dev/null +++ b/classes/reputation.py @@ -0,0 +1,183 @@ +import string +import discord +from dataclasses import dataclass +import datetime + + +# The impact of a role on reputation +@dataclass +class ReputationRole: + roleid:int + score:int + +# what we retain from a message +# the Content, if it contains a link and the user who sent the message +@dataclass +class MessageData: + Messagecontent:string + user:discord.Member + +# ########################################################## +# Reputation class +# the class that manages the user's reputations. +# Each user has an initial reputation +# that is influenced by the time he/she is on the server +# and roles that are assigned to the user +# (e.g. VIPs can have a high reputation, new users a very +# low one) +# each message that a user sends gives a penalty on the reputation +# each link gives a higher penalty +# only the last x messages are evaluated, i.e. after a certain +# amount of messages the reputation goes back to the initial +# stage (unless you had been muted in between, then you need +# an admin to unblock) +# ########################################################## + + +class Reputation: + + # the initialReputation you have + initialReputation=10 + + # the penalty in reputation for posting a link + linkPenalty=-5 + + # the penalty in reputation for posting a message + messagePenalty=-1 + + # the reputation threshold that will throttle a user + throttleThreshold=5 + + # the reputation threshold that will mute a user + muteThreshold=0 + + # the number of messages that one can send in the buffer timeframe that will trigger checks + triggerCheckThreshold=1 + + # other objects that we may want to refer to + + # the bot that called us + theGuild:discord.Guild + + # the number of last seen messages which we keep in a ring buffer + messageBufferSize:int + + # this is the list of the last messages + lastMessages = [] + messageRingBufferCounter:int + + # this is the list of the reputation relevant roles + reputationRoles = {} + + muteRole:discord.Role + + def __init__(self, + theGuild:discord.Guild, + messageBufferSize:int, + muteRole:int) -> None: + self.theGuild=theGuild + self.messageBufferSize=messageBufferSize + self.messageRingBufferCounter=0 + + for role in theGuild.roles: + if int(muteRole) == int(role.id): + self.muteRole=role + + # ########################################################## + # getReputation returns the reputation value for a given + # user either as an int value or as a human readable + # text version + # ########################################################## + + + def getReputation(self,discordUser:discord.Member,textValue:bool): + + # users get initial reputation based on age + now=datetime.datetime.now().replace(tzinfo=None) + userJoin=discordUser.joined_at.replace(tzinfo=None) + userAge = now - userJoin + + theReputation=userAge.days + theReputationText="Your Reputation:\n\nInitial (age): " + str(theReputation) + "\n" + + # calculate user's score based on reputation roles he/she is in + + r:ReputationRole + for r in self.reputationRoles: + theRole=discordUser.get_role(r) + if not (theRole is None): + theReputation += self.reputationRoles[r].score + theReputationText += "Role " + theRole.name + ": " + str(self.reputationRoles[r].score) + "\n" + + # add the penalties from the message buffer + + messageCount=0 + linkCount=0 + m:MessageData + for m in self.lastMessages: + if discordUser == m.user: + messageCount +=1 + if ('://' in m.Messagecontent): + linkCount +=1 + + # calculate the remaining reputation credits and construct + # the human readable version + + theReputation = theReputation + messageCount * self.messagePenalty + theReputationText += "Message penalty: " + str(messageCount * self.messagePenalty) + "\n" + theReputation = theReputation + linkCount * self.linkPenalty + theReputationText += "Link usage penalty: " + str(linkCount * self.linkPenalty) + "\n" + + theReputationText += "\nTotal Reputation: " + str(theReputation) + "\n" + + if textValue == True: + return(theReputationText) + else: + return(theReputation) + + # ########################################################## + # addReputationRole adds a role that has reputation impact + # to the class + # ########################################################## + + + def addReputationRole(self,discordRoleID:int,roleScore:int): + theNewRole=ReputationRole(discordRoleID,roleScore) + self.reputationRoles[discordRoleID]=theNewRole + + # ########################################################## + # checkMessage analyzes the Message ring buffer and checks + # the user's remaining reputation credits + # If the user reaches the Throttle limit then the user will + # be warned after each message. + # ignoring the warnings will lead to a MUTE role being + # assigned to the user + # ########################################################## + + async def checkMessage(self,newMessage:discord.Message): + + hasLink=False + theNewMessage=MessageData(newMessage.content,newMessage.author) + + # add the new message to the ring buffer + + if len(self.lastMessages) < self.messageBufferSize: + self.lastMessages.append(theNewMessage) + else: + self.lastMessages[self.messageRingBufferCounter]=theNewMessage + self.messageRingBufferCounter +=1 + if self.messageRingBufferCounter >= self.messageBufferSize: + self.messageRingBufferCounter =0 + + # count the number of messages for that user in the ring buffer in order + # to evaluate if we need to do a reputation check + + m:MessageData + userMessageCount = sum(1 for m in self.lastMessages if m.user == newMessage.author) + if userMessageCount >= self.triggerCheckThreshold: + r = self.getReputation(newMessage.author,False) + if r <= self.muteThreshold: + await newMessage.author.add_roles(self.muteRole) + return(-1) + if r <= self.throttleThreshold: return(0) + return(1)