|
| 1 | +import argparse |
| 2 | +import configparser |
| 3 | +import logging |
| 4 | +import re |
| 5 | +from typing import * |
| 6 | + |
| 7 | +import discord |
| 8 | +import discord_slash |
| 9 | +import github |
| 10 | +import github.Repository |
| 11 | + |
| 12 | +import utils |
| 13 | + |
| 14 | +REMOVE_EMOJI = discord.PartialEmoji(name="❌") |
| 15 | + |
| 16 | + |
| 17 | +LOG_LEVEL_MAP = { |
| 18 | + "ALL": logging.NOTSET, |
| 19 | + "DEBUG": logging.DEBUG, |
| 20 | + "INFO": logging.INFO, |
| 21 | + "WARN": logging.WARNING, |
| 22 | + "ERROR": logging.ERROR, |
| 23 | + "FATAL": logging.FATAL |
| 24 | +} |
| 25 | + |
| 26 | + |
| 27 | +class Bot(discord.Client): |
| 28 | + GH_REGEX = re.compile(r"#\d+") |
| 29 | + |
| 30 | + def __init__(self, repo, image_only: List[Tuple[int, str]], *args, **kwargs): |
| 31 | + super().__init__(*args, **kwargs) |
| 32 | + self._repo = repo |
| 33 | + self._image_only = image_only |
| 34 | + self._image_only_channels = set(i[0] for i in image_only) |
| 35 | + self._logger = logging.getLogger("bot") |
| 36 | + |
| 37 | + async def on_message(self, message: discord.Message): |
| 38 | + # Check if the message is from ourselves |
| 39 | + if message.author.id == self.user.id: |
| 40 | + return |
| 41 | + |
| 42 | + # Check if we are in the renderers channel |
| 43 | + if message.channel.id in self._image_only_channels: |
| 44 | + if not utils.is_image(message): |
| 45 | + self._logger.info(f"Removing message {message.id} in {message.channel.id} for not having an image.") |
| 46 | + warn = await message.reply( |
| 47 | + content="You need to provide a direct link to your render or upload it as an attachment!", |
| 48 | + mention_author=True |
| 49 | + ) |
| 50 | + await message.delete() |
| 51 | + await warn.delete(delay=10) |
| 52 | + return |
| 53 | + |
| 54 | + # Look for GitHub issues / pull requests |
| 55 | + numbers = self.GH_REGEX.findall(message.content) |
| 56 | + numbers = [int(number[1:]) for number in numbers] |
| 57 | + |
| 58 | + # Create the embed |
| 59 | + embed = None |
| 60 | + if len(numbers) == 1: |
| 61 | + self._logger.info(f"Message {message.id} with one GitHub number.") |
| 62 | + embed = utils.generate_gh_embed(numbers[0], self._repo) |
| 63 | + elif len(numbers) > 1: |
| 64 | + self._logger.info(f"Message {message.id} with {len(numbers)} GitHub numbers.") |
| 65 | + embed = discord.Embed(title="Issues / pull requests") |
| 66 | + for number in numbers: |
| 67 | + utils.generate_gh_embed_snippet(embed, number, self._repo) |
| 68 | + |
| 69 | + # Send the message |
| 70 | + if embed is not None: |
| 71 | + embed.set_footer(text=f"React with {REMOVE_EMOJI} to remove.\n{message.author.id}") |
| 72 | + m = await message.reply( |
| 73 | + embed=embed, |
| 74 | + mention_author=False |
| 75 | + ) |
| 76 | + await m.add_reaction(REMOVE_EMOJI) |
| 77 | + |
| 78 | + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): |
| 79 | + if payload.user_id == self.user.id: |
| 80 | + return # Event from us |
| 81 | + if payload.emoji != REMOVE_EMOJI: |
| 82 | + return # Incorrect emoji |
| 83 | + if payload.event_type != "REACTION_ADD": |
| 84 | + return # Emoji was not added |
| 85 | + |
| 86 | + # Get the channel |
| 87 | + channel = self.get_channel(payload.channel_id) |
| 88 | + message: discord.Message = await channel.fetch_message(payload.message_id) |
| 89 | + |
| 90 | + if message.author.id != self.user.id: |
| 91 | + return # Original author was not us |
| 92 | + if len(message.embeds) != 1: |
| 93 | + return # Original message had an incorrect number of embeds |
| 94 | + |
| 95 | + # Try and get the user id from the embed |
| 96 | + embed = message.embeds[0] |
| 97 | + if not isinstance(embed.footer.text, str): |
| 98 | + return # Invalid embed |
| 99 | + user = embed.footer.text.split("\n")[-1] |
| 100 | + try: |
| 101 | + user = int(user) |
| 102 | + except ValueError: |
| 103 | + return # Invalid user id |
| 104 | + if user != payload.user_id: |
| 105 | + return # User does not have permission to remove this |
| 106 | + |
| 107 | + self._logger.info(f"React-deleting our message {message.id}") |
| 108 | + await message.delete() |
| 109 | + |
| 110 | + |
| 111 | +class Slash(discord_slash.SlashCommand): |
| 112 | + def __init__(self, repo, image_only: List[Tuple[int, str]], *args, **kwargs): |
| 113 | + super().__init__(*args, **kwargs) |
| 114 | + self._repo = repo |
| 115 | + self._logger = logging.getLogger("bot-slash") |
| 116 | + self._image_only_channels = set(i[0] for i in image_only) |
| 117 | + |
| 118 | + self.add_slash_command(self.gh, name="gh", description="Get a Github pull / issue from its number.") |
| 119 | + |
| 120 | + async def gh(self, ctx, number: int): |
| 121 | + if ctx.channel_id in self._image_only_channels: |
| 122 | + self._logger.info(f"Attempted slash command in protected channel {ctx.channel_id}.") |
| 123 | + await ctx.send(content=f"Cannot send text messages in this channel.", hidden=True) |
| 124 | + return |
| 125 | + |
| 126 | + embed = utils.generate_gh_embed(number, self._repo) |
| 127 | + if embed is not None: |
| 128 | + self._logger.info(f"Slash command with valid GitHub number #{number}.") |
| 129 | + embed.set_footer(text=f"React with {REMOVE_EMOJI} to remove.\n{ctx.author_id}") |
| 130 | + m = await ctx.send(embed=embed, hidden=False) |
| 131 | + await m.add_reaction(REMOVE_EMOJI) |
| 132 | + else: |
| 133 | + self._logger.info(f"Slash command with invalid GitHub number #{number}.") |
| 134 | + await ctx.send(content=f"Invalid pull / issue number: #{number}", hidden=True) |
| 135 | + |
| 136 | + |
| 137 | +def main(): |
| 138 | + parser = argparse.ArgumentParser(description="Chunky Discord Bot") |
| 139 | + parser.add_argument("discord", help="Discord API key.") |
| 140 | + parser.add_argument("--github", help="Github API key.", default=None) |
| 141 | + parser.add_argument("--log-level", help="Log level (default INFO).", default="INFO") |
| 142 | + parser.add_argument("--config", help="Path to the config file.", default="config.ini") |
| 143 | + parser.add_argument("--debug-guild", help="Debug guild id.", default=None) |
| 144 | + args = parser.parse_args() |
| 145 | + |
| 146 | + # Setup logging |
| 147 | + if args.log_level not in LOG_LEVEL_MAP.keys(): |
| 148 | + print("Log level must be one of: ALL, DEBUG, INFO, WARN, ERROR, FATAL") |
| 149 | + return |
| 150 | + logging.basicConfig(level=LOG_LEVEL_MAP.get(args.log_level)) |
| 151 | + |
| 152 | + # Load config |
| 153 | + config = configparser.ConfigParser() |
| 154 | + config.read(args.config) |
| 155 | + |
| 156 | + # Setup GitHub |
| 157 | + if "GITHUB" not in config: |
| 158 | + print("Config must have [GITHUB] section.") |
| 159 | + return |
| 160 | + if "repository" not in config["GITHUB"]: |
| 161 | + print("Config must have \"repository\" under [GITHUB] section.") |
| 162 | + return |
| 163 | + gh = github.Github(login_or_token=args.github) |
| 164 | + repo = gh.get_repo(config["GITHUB"]["repository"]) |
| 165 | + |
| 166 | + # Image only channels |
| 167 | + image_only: List[Tuple[int, str]] = [] |
| 168 | + if "IMAGE_ONLY" in config: |
| 169 | + for key, value in config["IMAGE_ONLY"].items(): |
| 170 | + try: |
| 171 | + image_only.append((int(key), value,)) |
| 172 | + except ValueError: |
| 173 | + logging.getLogger("bot").error(f"Invalid [IMAGE_ONLY] channel {key}.") |
| 174 | + else: |
| 175 | + logging.getLogger("bot").warning("Config does not contain an [IMAGE_ONLY] section. Bot will not filter any channels.") |
| 176 | + |
| 177 | + bot = Bot(repo, image_only) |
| 178 | + slash = Slash(repo, image_only, client=bot, debug_guild=args.debug_guild, sync_commands=True) |
| 179 | + |
| 180 | + # OAUTH2 must have `bot` and `applications.commands` scopes |
| 181 | + # Bot permissions: 274877982784 |
| 182 | + bot.run(args.discord) |
| 183 | + |
| 184 | + |
| 185 | +if __name__ == '__main__': |
| 186 | + main() |
0 commit comments