Skip to content

Commit 72ac01b

Browse files
committed
First commit.
1 parent e6766ee commit 72ac01b

File tree

4 files changed

+297
-0
lines changed

4 files changed

+297
-0
lines changed

config.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[IMAGE_ONLY]
2+
# Test channel
3+
956319519725469706 = You need to provide a direct link to your render or upload it as an attachment!
4+
# Chunky #renders channel
5+
549680988989423631 = You need to provide a direct link to your render or upload it as an attachment!
6+
7+
[GITHUB]
8+
repository = chunky-dev/chunky

main.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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()

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
discord.py~=1.7.3
2+
discord-py-slash-command~=3.0.3
3+
PyGithub~=1.55

utils.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from typing import *
2+
import logging
3+
import re
4+
import urllib.parse
5+
6+
import discord
7+
import github
8+
import github.Repository
9+
10+
IMAGE_SUFFIXES = [
11+
"jpg", "jpeg", "png", "tif", "tiff", "webp", "gif", "mp4"
12+
]
13+
14+
URL_REGEX = re.compile(r"http\S*")
15+
16+
17+
def _match_fname(filename: str) -> bool:
18+
fname = filename.lower()
19+
for suffix in IMAGE_SUFFIXES:
20+
if fname.endswith(suffix):
21+
return True
22+
return False
23+
24+
25+
def is_image(message: discord.Message) -> bool:
26+
# Image(s) were uploaded
27+
if len(message.attachments) > 0:
28+
for attachment in message.attachments:
29+
assert isinstance(attachment.filename, str)
30+
if _match_fname(attachment.filename):
31+
return True
32+
33+
# Check for an image URL
34+
urls = URL_REGEX.findall(message.content)
35+
for url in urls:
36+
try:
37+
url = urllib.parse.urlparse(url)
38+
if _match_fname(url.path):
39+
return True
40+
except ValueError:
41+
pass
42+
43+
return False
44+
45+
46+
def generate_gh_embed(number: int, repo: github.Repository.Repository) -> Optional[discord.Embed]:
47+
try:
48+
issue = repo.get_issue(number)
49+
embed = discord.Embed(
50+
title=issue.html_url,
51+
url=issue.html_url,
52+
type="rich",
53+
description=issue.title,
54+
)
55+
embed.add_field(
56+
name="By",
57+
value=issue.user.login,
58+
inline=True
59+
)
60+
embed.add_field(
61+
name="Status",
62+
value=issue.state,
63+
inline=True
64+
)
65+
embed.add_field(
66+
name="Description",
67+
value=issue.body,
68+
inline=False
69+
)
70+
return embed
71+
except github.GithubException as e:
72+
logging.getLogger("github").warning(f"Failed to fetch object number {number}. {e}")
73+
return None
74+
75+
76+
def generate_gh_embed_snippet(embed: discord.Embed, number: id, repo: github.Repository.Repository):
77+
try:
78+
issue = repo.get_issue(number)
79+
embed.add_field(
80+
name="Link",
81+
value=issue.html_url,
82+
inline=False
83+
)
84+
embed.add_field(
85+
name="Title",
86+
value=issue.title,
87+
inline=True
88+
)
89+
embed.add_field(
90+
name="By",
91+
value=issue.user.login,
92+
inline=True
93+
)
94+
embed.add_field(
95+
name="Status",
96+
value=issue.state,
97+
inline=True
98+
)
99+
except github.GithubException as e:
100+
logging.getLogger("github").warning(f"Failed to fetch object number {number}. {e}")

0 commit comments

Comments
 (0)