class SimpleAsyncDiscord(threading.Thread): """ SimpleAsyncDiscord client which is used to communicate to discord, and provides certain commands in the relay and triggered channels as well as private authentication to the bot to admin the server. """ def __init__(self, version_information: str, logger: logging.Logger): """ Constructor for the SimpleAsyncDiscord client the discord bot runs in. :param: version_information: the plugin's version_information string :param: logger: the logger used for logging, usually passed through from the minqlx plugin. """ super().__init__() self.version_information: str = version_information self.logger: logging.Logger = logger self.discord: Optional[Bot] = None self.discord_bot_token: str = Plugin.get_cvar("qlx_discordBotToken") self.discord_application_id: str = Plugin.get_cvar( "qlx_discordApplicationId", str) self.discord_relay_channel_ids: set[int] = \ SimpleAsyncDiscord.int_set(Plugin.get_cvar("qlx_discordRelayChannelIds", set)) self.discord_relay_team_chat_channel_ids: set[ int] = SimpleAsyncDiscord.int_set( Plugin.get_cvar("qlx_discordRelayTeamchatChannelIds", set)) self.discord_triggered_channel_ids: set[ int] = SimpleAsyncDiscord.int_set( Plugin.get_cvar("qlx_discordTriggeredChannelIds", set)) self.discord_triggered_channel_message_prefix: str = Plugin.get_cvar( "qlx_discordTriggeredChatMessagePrefix") self.discord_command_prefix: str = Plugin.get_cvar( "qlx_discordCommandPrefix") self.discord_help_enabled: bool = Plugin.get_cvar( "qlx_discordEnableHelp", bool) self.discord_version_enabled: bool = Plugin.get_cvar( "qlx_discordEnableVersion", bool) self.discord_message_prefix: str = Plugin.get_cvar( "qlx_discordMessagePrefix") self.discord_show_relay_channel_names: bool = Plugin.get_cvar( "qlx_displayChannelForDiscordRelayChannels", bool) self.discord_replace_relayed_mentions: bool = \ Plugin.get_cvar("qlx_discordReplaceMentionsForRelayedMessages", bool) self.discord_replace_triggered_mentions: bool = \ Plugin.get_cvar("qlx_discordReplaceMentionsForTriggeredMessages", bool) extended_logging_enabled: bool = Plugin.get_cvar( "qlx_discordLogToSeparateLogfile", bool) if extended_logging_enabled: self.setup_extended_logger() @staticmethod def setup_extended_logger() -> None: discord_logger: logging.Logger = logging.getLogger("discord") discord_logger.setLevel(logging.DEBUG) # File file_path = os.path.join(minqlx.get_cvar("fs_homepath"), "minqlx_discord.log") maxlogs: int = minqlx.Plugin.get_cvar("qlx_logs", int) maxlogsize: int = minqlx.Plugin.get_cvar("qlx_logsSize", int) file_fmt: logging.Formatter = \ logging.Formatter("(%(asctime)s) [%(levelname)s @ %(name)s.%(funcName)s] %(message)s", "%H:%M:%S") file_handler: logging.FileHandler = \ RotatingFileHandler(file_path, encoding="utf-8", maxBytes=maxlogsize, backupCount=maxlogs) file_handler.setLevel(logging.DEBUG) file_handler.setFormatter(file_fmt) discord_logger.addHandler(file_handler) # Console console_fmt: logging.Formatter = \ logging.Formatter("[%(name)s.%(funcName)s] %(levelname)s: %(message)s", "%H:%M:%S") console_handler: logging.Handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_handler.setFormatter(console_fmt) discord_logger.addHandler(console_handler) @staticmethod def int_set(string_set: set[str]) -> set[int]: int_set = set() for item in string_set: if item == '': continue value = int(item) int_set.add(value) return int_set def status(self) -> str: if self.discord is None: return "No discord connection set up." if self.is_discord_logged_in(): return "Discord connection up and running." return "Discord client not connected." def run(self) -> None: """ Called when the SimpleAsyncDiscord thread is started. We will set up the bot here with the right commands, and run the discord.py bot in a new event_loop until completed. """ loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() asyncio.set_event_loop(loop) members_intent: bool = self.discord_replace_relayed_mentions or self.discord_replace_triggered_mentions intents: discord.Intents = \ discord.Intents(members=members_intent, guilds=True, bans=False, emojis=False, integrations=False, webhooks=False, invites=False, voice_states=False, presences=True, messages=True, guild_messages=True, dm_messages=True, reactions=False, guild_reactions=False, dm_reactions=False, typing=False, guild_typing=False, dm_typing=False, message_content=True, guild_scheduled_events=True) # init the bot, and init the main discord interactions if self.discord_help_enabled: self.discord = Bot(command_prefix=self.discord_command_prefix, application_id=self.discord_application_id, description=f"{self.version_information}", help_command=MinqlxHelpCommand(), loop=loop, intents=intents) else: self.discord = Bot(command_prefix=self.discord_command_prefix, application_id=self.discord_application_id, description=f"{self.version_information}", help_command=None, loop=loop, intents=intents) self.initialize_bot(self.discord) # connect the now configured bot to discord in the event_loop loop.run_until_complete(self.discord.start(self.discord_bot_token)) def initialize_bot(self, discord_bot: discord.ext.commands.Bot) -> None: """ initializes a discord bot with commands and listeners on this pseudo cog class :param: discord_bot: the discord_bot to initialize """ discord_bot.add_listener(self.on_ready) discord_bot.add_listener(self.on_message) if self.discord_version_enabled: discord_bot.add_command( Command(self.version, name="version", pass_context=True, ignore_extra=False, help="display the plugin's version information")) async def version(self, ctx: Context, *_args, **_kwargs) -> None: """ Triggers the plugin's version information sent to discord :param: ctx: the context the trigger happened in """ await ctx.send(f"```{self.version_information}```") def _format_message_to_quake(self, channel: discord.TextChannel, author: discord.Member, content: str) -> str: """ Format the channel, author, and content of a message so that it will be displayed nicely in the Quake Live console. :param: channel: the channel, the message came from. :param: author: the author of the original message. :param: content: the message itself, ideally taken from message.clean_content to avoid ids of mentioned users and channels on the discord server. :return: the formatted message that may be sent back to Quake Live. """ sender = author.name if author.nick is not None: sender = author.nick if not self.discord_show_relay_channel_names and channel.id in self.discord_relay_channel_ids: return f"{self.discord_message_prefix} ^6{sender}^7:^2 {content}" return f"{self.discord_message_prefix} ^5#{channel.name} ^6{sender}^7:^2 {content}" async def on_ready(self) -> None: """ Function called once the bot connected. Mainly displays status update from the bot in the game console and server logfile, and sets the bot to playing Quake Live on discord. """ extensions = Plugin.get_cvar("qlx_discord_extensions", list) ready_actions = [] for extension in extensions: if len(extension.strip()) > 0: ready_actions.append( self.discord.load_extension( f".{extension}", package="minqlx-plugins.discord_extensions")) self.logger.info( f"Logged in to discord as: {self.discord.user.name} ({self.discord.user.id})" ) Plugin.msg("Connected to discord") ready_actions.append( self.discord.change_presence(activity=discord.Game( name="Quake Live"))) await asyncio.gather(*ready_actions) await self.discord.tree.sync() self.logger.info("Application command tree synced!") async def on_message(self, message) -> None: """ Function called once a message is sent through discord. Here the main interaction points either back to Quake Live or discord happen. :param: message: the message that was sent. """ # guard clause to avoid None messages from processing. if not message: return # if the bot sent the message himself, do nothing. if message.author == self.discord.user: return # relay all messages from the relay channels back to Quake Live. if message.channel.id in self.discord_relay_channel_ids: content: str = message.clean_content if len(content) > 0: minqlx.CHAT_CHANNEL.reply( self._format_message_to_quake(message.channel, message.author, content)) async def on_command_error(self, exception: Exception, ctx: Context) -> None: """ overrides the default command error handler so that no exception is produced for command errors Might be changed in the future to log those problems to the ´´`minqlx.logger``` """ def is_discord_logged_in(self) -> bool: if self.discord is None: return False return not self.discord.is_closed() and self.discord.is_ready() def stop(self) -> None: """ stops the discord client """ if self.discord is None: return asyncio.run_coroutine_threadsafe( self.discord.change_presence(status=discord.Status.offline), loop=self.discord.loop) asyncio.run_coroutine_threadsafe(self.discord.close(), loop=self.discord.loop) def relay_message(self, msg: str) -> None: """ relay a message to the configured relay_channels :param: msg: the message to send to the relay channel """ self.send_to_discord_channels(self.discord_relay_channel_ids, msg) def send_to_discord_channels(self, channel_ids: set[Union[str, int]], content: str) -> None: """ Send a message to a set of channel_ids on discord provided. :param: channel_ids: the ids of the channels the message should be sent to. :param: content: the content of the message to send to the discord channels """ if not self.is_discord_logged_in(): return # if we were not provided any channel_ids, do nothing. if not channel_ids or len(channel_ids) == 0: return # send the message in its own thread to avoid blocking of the server for channel_id in channel_ids: channel = self.discord.get_channel(channel_id) if channel is None: continue asyncio.run_coroutine_threadsafe(channel.send( content, allowed_mentions=AllowedMentions(everyone=False, users=True, roles=True)), loop=self.discord.loop) def relay_chat_message(self, player: minqlx.Player, channel: str, message: str) -> None: """ relay a message to the given channel :param: player: the player that originally sent the message :param: channel: the channel the original message came through :param: message: the content of the message """ if self.discord_replace_relayed_mentions: message = self.replace_user_mentions(message, player) message = self.replace_channel_mentions(message, player) content = f"**{discord.utils.escape_markdown(player.clean_name)}**{channel}: " \ f"{discord.utils.escape_markdown(message)}" self.relay_message(content) def relay_team_chat_message(self, player: minqlx.Player, channel: str, message: str) -> None: """ relay a team_chat message, that might be hidden to the given channel :param: player: the player that originally sent the message :param: channel: the channel the original message came through :param: message: the content of the message """ if self.discord_replace_relayed_mentions: message = self.replace_user_mentions(message, player) message = self.replace_channel_mentions(message, player) content = f"**{discord.utils.escape_markdown(player.clean_name)}**{channel}: " \ f"{discord.utils.escape_markdown(message)}" self.send_to_discord_channels(self.discord_relay_team_chat_channel_ids, content) def replace_user_mentions(self, message: str, player: minqlx.Player = None) -> str: """ replaces a mentioned discord user (indicated by @user-hint with a real mention) :param: message: the message to replace the user mentions in :param: player: (default: None) when several alternatives are found for the mentions used, this player is told what the alternatives are. No replacements for the ambiguous substitutions will happen. :return: the original message replaced by properly formatted user mentions """ if not self.is_discord_logged_in(): return message returned_message = message # this regular expression will make sure that the "@user" has at least three characters, and is either # prefixed by a space or at the beginning of the string matcher = re.compile("(?:^| )@([^ ]{3,})") member_list = list(self.discord.get_all_members()) matches: list[re.Match] = matcher.findall(returned_message) for match in sorted(matches, key=lambda _match: len(str(_match)), reverse=True): if match in ["all", "everyone", "here"]: continue member = SimpleAsyncDiscord.find_user_that_matches( str(match), member_list, player) if member is not None: returned_message = returned_message.replace( f"@{match}", member.mention) return returned_message @staticmethod def find_user_that_matches(match: str, member_list: list[discord.Member], player: minqlx.Player = None) \ -> Optional[discord.Member]: """ find a user that matches the given match :param: match: the match to look for in the username and nick :param: member_list: the list of members connected to the discord server :param: player: (default: None) when several alternatives are found for the mentions used, this player is told what the alternatives are. None is returned in that case. :return: the matching member, or None if none or more than one are found """ # try a direct match for the whole name first member = [ user for user in member_list if user.name.lower() == match.lower() ] if len(member) == 1: return member[0] # then try a direct match at the user's nickname member = [ user for user in member_list if user.nick is not None and user.nick.lower() == match.lower() ] if len(member) == 1: return member[0] # if direct searches for the match fail, we try to match portions of the name or portions of the nick, if set member = [ user for user in member_list if user.name.lower().find(match.lower()) != -1 or ( user.nick is not None and user.nick.lower().find(match.lower()) != -1) ] if len(member) == 1: return list(member)[0] # we found more than one matching member, let's tell the player about this. if len(member) > 1 and player is not None: player.tell( f"Found ^6{len(member)}^7 matching discord users for @{match}:" ) alternatives = "" for alternative_member in member: alternatives += f"@{alternative_member.name} " player.tell(alternatives) return None def replace_channel_mentions(self, message: str, player: minqlx.Player = None) -> str: """ replaces a mentioned discord channel (indicated by #channel-hint with a real mention) :param: message: the message to replace the channel mentions in :param: player: (default: None) when several alternatives are found for the mentions used, this player is told what the alternatives are. No replacements for the ambiguous substitutions will happen. :return: the original message replaced by properly formatted channel mentions """ if not self.is_discord_logged_in(): return message returned_message = message # this regular expression will make sure that the "#channel" has at least three characters, and is either # prefixed by a space or at the beginning of the string matcher = re.compile("(?:^| )#([^ ]{3,})") channel_list = [ ch for ch in self.discord.get_all_channels() if ch.type in [ChannelType.text, ChannelType.voice, ChannelType.group] ] matches: list[re.Match] = matcher.findall(returned_message) for match in sorted(matches, key=lambda _match: len(str(_match)), reverse=True): channel = SimpleAsyncDiscord.find_channel_that_matches( str(match), channel_list, player) if channel is not None: returned_message = returned_message.replace( f"#{match}", channel.mention) return returned_message @staticmethod def find_channel_that_matches( match: str, channel_list: list[discord.TextChannel], player: minqlx.Player = None) -> Optional[discord.TextChannel]: """ find a channel that matches the given match :param: match: the match to look for in the channel name :param: channel_list: the list of channels connected to the discord server :param: player: (default: None) when several alternatives are found for the mentions used, this player is told what the alternatives are. None is returned in that case. :return: the matching channel, or None if none or more than one are found """ # try a direct channel name match case-sensitive first channel = [ch for ch in channel_list if ch.name == match] if len(channel) == 1: return channel[0] # then try a case-insensitive direct match with the channel name channel = [ ch for ch in channel_list if ch.name.lower() == match.lower() ] if len(channel) == 1: return channel[0] # then we try a match with portions of the channel name channel = [ ch for ch in channel_list if ch.name.lower().find(match.lower()) != -1 ] if len(channel) == 1: return channel[0] # we found more than one matching channel, let's tell the player about this. if len(channel) > 1 and player is not None: player.tell( f"Found ^6{len(channel)}^7 matching discord channels for #{match}:" ) alternatives = "" for alternative_channel in channel: alternatives += f"#{alternative_channel.name} " player.tell(alternatives) return None def triggered_message(self, player: minqlx.Player, message: str) -> None: """ send a triggered message to the configured triggered_channel :param: player: the player that originally sent the message :param: message: the content of the message """ if not self.discord_triggered_channel_ids: return if self.discord_replace_triggered_mentions: message = self.replace_user_mentions(message, player) message = self.replace_channel_mentions(message, player) if self.discord_triggered_channel_message_prefix is not None and \ self.discord_triggered_channel_message_prefix != "": content = f"{self.discord_triggered_channel_message_prefix} " \ f"**{discord.utils.escape_markdown(player.clean_name)}**: " \ f"{discord.utils.escape_markdown(message)}" else: content = f"**{discord.utils.escape_markdown(player.clean_name)}**: " \ f"{discord.utils.escape_markdown(message)}" self.send_to_discord_channels(self.discord_triggered_channel_ids, content)
import asyncio from discord.ext.commands import Bot from discord.ext import commands import platform import os client = Bot(description="I corrupt the servers with Chaos", command_prefix="Chaos ", pm_help = True) @client.event async def on_ready(): print('Logged in as '+client.user.name+' (ID:'+client.user.id+') | Connected to '+str(len(client.servers))+' servers | Connected to '+str(len(set(client.get_all_members())))+' users') print('--------------------------------------') print('Successfully Summoned Chaos!') print('Long Live Chaos!') return await client.change_presence(game=discord.Game(name='Crownsreach management)) newUserMessage = """Welcome to Crownsreach. Hope you will be active here. Check <#452740981666742282>, <#453569407558483968> and <#453189578040541205> to know our server rules, announcements and events.""" @client.event async def on_member_join(member): print("In our server" + member.name + " joined just joined") await client.send_message(member, newUserMessage) print("Sent message to " + member.name) @client.event async def on_member_leave(member):