diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eb05d9c3..71beb8ec 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,9 +11,9 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v3 with: python-version: '3.10.6' diff --git a/docker-compose.yml b/docker-compose.yml index c021a0de..aae79c58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,16 +9,9 @@ services: - "5432:5432" volumes: - database:/var/lib/postgresql/data - lavalink: - image: fredboat/lavalink:latest - environment: - LAVALINK_SERVER_PASSWORD: youshallnotpass - SERVER_PORT: 2333 - SERVER_HOST: 0.0.0.0 dozer: depends_on: - postgres - - lavalink build: . volumes: - ".:/app" diff --git a/dozer/Components/CustomJoinLeaveMessages.py b/dozer/Components/CustomJoinLeaveMessages.py index c6c683b2..dab103d5 100644 --- a/dozer/Components/CustomJoinLeaveMessages.py +++ b/dozer/Components/CustomJoinLeaveMessages.py @@ -25,15 +25,31 @@ async def send_log(member): def format_join_leave(template: str, member: discord.Member): """Formats join leave message templates {guild} = guild name - {user} = user's name plus discriminator ex. SnowPlow#5196 - {user_name} = user's name without discriminator + {user} = user's name {user_mention} = user's mention {user_id} = user's ID """ template = template or "{user_mention}\n{user} ({user_id})" - return template.format(guild=member.guild, user=str(member), user_name=member.name, - user_mention=member.mention, user_id=member.id) + subst = [("{guild}", member.guild.name), + ("{user}", member), + ("{user_mention}", member.mention), + ("{user_id}", member.id)] + + def helper(s: str, subst: list): + if not subst: + # base case: return self + return s + cur = subst[0] + # split the current string on cur[0]. + # for each split segment call the helper on the rest of the substitutions. + # then rejoin on the substitutions (this avoids the substituted values from matching substitution keywords) + + # we could make this not recursive but there's an O(1) number of possible recursions anyway + # recursion depth is limited to 5 since the subst list is limited + # breadth is limited by template size (indirectly limited by discord message size) + return str(cur[1]).join([helper(bit, subst[1:]) for bit in s.split(cur[0])]) + return helper(template, subst) class CustomJoinLeaveMessages(db.DatabaseTable): """Holds custom join leave messages""" diff --git a/dozer/cogs/actionlogs.py b/dozer/cogs/actionlogs.py index 4951e6e4..13867cb2 100644 --- a/dozer/cogs/actionlogs.py +++ b/dozer/cogs/actionlogs.py @@ -300,7 +300,7 @@ async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent): embed = discord.Embed(title="Message Edited", description=f"[MESSAGE]({link}) From {mention}\nEdited In: {mchannel.mention}", color=0xFFC400) - embed.set_author(name=f"{author['username']}#{author['discriminator']}", icon_url=avatar_link) + embed.set_author(name=f"{author['username']}{'#' + author['discriminator'] if author['discriminator'] != '0' else ''}", icon_url=avatar_link) embed.add_field(name="Original", value="N/A", inline=False) if content: embed.add_field(name="Edited", value=content[0:1023], inline=False) @@ -547,8 +547,7 @@ async def help(self, e.set_footer(text='Triggered by ' + escape_markdown(ctx.author.display_name)) e.description = """ `{guild}` = guild name - `{user}` = user's name plus discriminator ex. SnowPlow#5196 - `{user_name}` = user's name without discriminator + `{user}` = user's name `{user_mention}` = user's mention `{user_id}` = user's ID """ diff --git a/dozer/cogs/firstqa.py b/dozer/cogs/firstqa.py new file mode 100644 index 00000000..70d39865 --- /dev/null +++ b/dozer/cogs/firstqa.py @@ -0,0 +1,183 @@ +"""Provides commands that pull information from First Q&A Form.""" +from typing import Union +import re +import datetime +import json + +import aiohttp +from bs4 import BeautifulSoup +import discord +from discord.ext import commands +from discord import app_commands + +from dozer.context import DozerContext +from ._utils import * + + +async def data(ctx: DozerContext, level: str, question: int) -> Union[str, None]: + """Returns QA Forum info for specified FTC/FRC""" + if level.lower() == "ftc": + async with ctx.cog.ses.get('https://ftc-qa.firstinspires.org/onepage.html') as response: + html_data = await response.text() + forum_url = "https://ftc-qa.firstinspires.org/qa/" + elif level.lower() == "frc": + async with ctx.cog.ses.get('https://frc-qa.firstinspires.org/onepage.html') as response: + html_data = await response.text() + forum_url = "https://frc-qa.firstinspires.org/qa/" + else: + return None + + answers = BeautifulSoup(html_data, 'html.parser').get_text() + + start = answers.find(f'Q{question} ') + a = "" + if start > 0: + finish = answers.find('answered', start) + 24 + a = answers[start:finish] + # remove newlines + a = a.replace("\n", " ") + # remove multiple spaces + a = " ".join(a.split()) + embed = discord.Embed( + title=a[:a.find(" Q: ")], + url=forum_url + str(question), + color=discord.Color.blue() + ) + embed.add_field( + name="Question", + value=a[a.find(" Q: ") + 1:a.find(" A: ")], + inline=False + ) + embed.add_field( + name="Answer", + value=a[a.find(" A: ") + 1:a.find(" ( Asked by ")], + inline=False + ) + embed.set_footer(text=a[a.find(" ( Asked by ") + 1:]) + return embed + + else: + return f"That question was not answered or does not exist.\n{forum_url + str(question)}" + +def createRuleEmbed(rulenumber, text): + """Returns an embed for a given rule number and text""" + year = datetime.datetime.now().year + embed = discord.Embed( + title=f"Rule {rulenumber}", + url=f"https://rules-search.pages.dev/{year}/rule/{rulenumber}", + color=discord.Color.blue() + ) + + truncated_text = "```\n" + ' '.join(text[:1016].splitlines()) + "```" + embed.add_field( + name="Summary", + value=truncated_text + ) + return embed + + +class QA(commands.Cog): + """QA commands""" + + def __init__(self, bot) -> None: + self.ses = aiohttp.ClientSession() + super().__init__() + self.bot = bot + + @command(name="ftcqa", aliases=["ftcqaforum"], pass_context=True) + @bot_has_permissions(embed_links=True) + @app_commands.describe(question="The number of the question you want to look up") + async def ftcqa(self, ctx: DozerContext, question: int): + """ + Shows Answers from the FTC Q&A + """ + result = await data(ctx, "ftc", question) + if isinstance(result, discord.Embed): + await ctx.send(embed=result) + else: + await ctx.send(result) + + ftcqa.example_usage = """ + `{prefix}ftcqa 19` - show information on FTC Q&A #19 + """ + + @command(name = "frcqa", aliases = ["frcqaforum"], pass_context = True) + @bot_has_permissions(embed_links = True) + @app_commands.describe(question = "The number of the question you want to look up") + async def frcqa(self, ctx: DozerContext, question: int): + """ + Shows Answers from the FRC Q&A + """ + result = await data(ctx, "frc", question) + if isinstance(result, discord.Embed): + await ctx.send(embed=result) + else: + await ctx.send(result) + + frcqa.example_usage = """ + `{prefix}frcqa 19` - show information on FRC Q&A #19 + """ + + + @command(name = "frcrule", pass_context = True) + @bot_has_permissions(embed_links = True) + @app_commands.describe(rule = "The rule number") + async def frcrule(self, ctx: DozerContext, *, rule: str): + """ + Shows rules from a rule number or search query + """ + matches = re.match(r'^(?P[a-zA-Z])(?P\d{3})$', rule) + ephemeral = False + + embed = discord.Embed( + title="Error", + color=discord.Color.blue() + ) + + if matches is None: + await ctx.defer() + async with ctx.cog.ses.post('https://search.grahamsh.com/search',json={'query': rule}) as response: + json_data = await response.content.read() + json_parsed = json.loads(json_data) + + if "error" not in json_parsed: + embeds = [] + page = 1 + for currRule in json_parsed["data"]: + currEmbed = createRuleEmbed(currRule["text"], currRule["textContent"]) + currEmbed.set_footer(text=f"Page {page} of 5") + embeds.append(currEmbed) + + await paginate(ctx, embeds) + return + + else: + letter_part = matches.group('letter') + number_part = matches.group('number') + year = datetime.datetime.now().year + async with ctx.cog.ses.get(f'https://rules-search.pages.dev/api/rule?query={letter_part}{number_part}') as response: + json_data = await response.content.read() + + json_parsed = json.loads(json_data) + + if "error" not in json_parsed: + text = json_parsed["textContent"] + embed = createRuleEmbed(letter_part.upper() + number_part, text) + + else: + ephemeral = True + embed.add_field( + name="Error", + value="No such rule" + ) + + await ctx.send(embed=embed, ephemeral=ephemeral) + frcrule.example_usage = """ + `{prefix}frcrule g301` - sends the summary and link to rule G301 + `{prefix}frcrule can i cross the line before teleop` - sends the summary and link to the rule matching the query + + """ + +async def setup(bot): + """Adds the QA cog to the bot.""" + await bot.add_cog(QA(bot)) diff --git a/dozer/cogs/ftc.py b/dozer/cogs/ftc.py index 451ec601..6d1f7a6d 100755 --- a/dozer/cogs/ftc.py +++ b/dozer/cogs/ftc.py @@ -2,7 +2,7 @@ import json from asyncio import sleep -from datetime import datetime +import datetime from urllib.parse import urljoin, urlencode import base64 @@ -17,6 +17,11 @@ embed_color = discord.Color(0xed791e) +__all__ = ['FTCEventsClient', 'FTCInfo', 'setup'] + +def get_none_strip(s, key): + """Ensures that a get always returns a stripped string.""" + return (str(s.get(key, "")) or "").strip() class FTCEventsClient: """ @@ -25,7 +30,7 @@ class FTCEventsClient: def __init__(self, username: str, token: str, aiohttp_session: aiohttp.ClientSession, base_url: str = "https://ftc-api.firstinspires.org/v2.0", ratelimit: bool = True): - self.last_req: datetime = datetime.now() + self.last_req: datetime.datetime = datetime.datetime.now() self.ratelimit: bool = ratelimit self.base: str = base_url self.http: aiohttp.ClientSession = aiohttp_session @@ -35,15 +40,15 @@ async def req(self, endpoint, season=None): """Make an async request at the specified endpoint, waiting to let the ratelimit cool off.""" if season is None: - season = self.get_season() + season = FTCEventsClient.get_season() if self.ratelimit: # this will delay a request to avoid the ratelimit - now = datetime.now() + now = datetime.datetime.now() diff = (now - self.last_req).total_seconds() self.last_req = now - if diff < 2.2: # have a 200 ms fudge factor - await sleep(2.2 - diff) + if diff < 0.5: # have a 200 ms fudge factor + await sleep(0.5 - diff) tries = 0 while True: try: @@ -52,17 +57,126 @@ async def req(self, endpoint, season=None): tries += 1 if tries > 3: raise + + async def reqjson(self, endpoint, season=None, on_400=None, on_other=None): + """Reqjson.""" + res = await self.req(endpoint, season=season) + async with res: + if res.status == 400 and on_400: + await on_400(res) + return None + elif res.status >= 400: + if on_other: + await on_other(res) + return None + return await res.json(content_type=None) + - def get_season(self): + @staticmethod + def get_season(): """Fetches the current season, based on typical kickoff date.""" - today = datetime.today() + today = datetime.datetime.today() year = today.year # ftc kickoff is always the 2nd saturday of september - kickoff = [d for d in [datetime(year=year, month=9, day=i) for i in range(8, 15)] if d.weekday() == 5][0] + kickoff = [d for d in [datetime.datetime(year=year, month=9, day=i) for i in range(8, 15)] if d.weekday() == 5][0] if kickoff > today: return today.year - 1 return today.year + + @staticmethod + def date_parse(date_str): + """Takes in date strings from FTC-Events and parses them into a datetime.datetime""" + return datetime.datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S") + + @staticmethod + def team_fmt(team, team_num=None): + """TBA-formats a team.""" + t = str(team['teamNumber']) + if team['surrogate']: + # indicate surrogate status by italicizing them + t = f"*{t}*" + if team['teamNumber'] == team_num: + # underline the team + t = f"__{t}__" + if team['noShow'] or team['dq']: + # cross out the team + t = f"~~{t}~~" + return t + + @staticmethod + def get_url_for_match(szn: int, ecode: str, match: dict): + """Produces a URL for a match dict + some other info.""" + base = f"https://ftc-events.firstinspires.org/{szn}/{ecode}/" + if match['tournamentLevel'] == "SEMIFINAL": + return base + f"semifinal/{match['series']}/{match['matchNumber']}" + if match['tournamentLevel'] == "FINAL": + return base + f"final/{match['series']}/{match['matchNumber']}" + else: + return base + f"qualifications/{match['matchNumber']}" + + @staticmethod + def add_schedule_to_embed(embed: discord.Embed, schedule: list, team_num: int, szn: int, ecode: str): + """Adds a schedule to an embed conditioned on a team number.""" + for m in schedule: + played = m['scoreRedFinal'] is not None + red_alliance = [] + blu_alliance = [] + team_alliance = None + for team in m['teams']: + alliance = "blue" + if (team['station'] or "").startswith("Red"): + alliance = "red" + red_alliance.append(team) + else: + blu_alliance.append(team) + team_alliance = team_alliance or (team['teamNumber'] == team_num and alliance) + + if not team_alliance: + continue + red_fmt = [] + blu_fmt = [] + for team in red_alliance: + red_fmt.append(FTCEventsClient.team_fmt(team, team_num=team_num)) + for team in blu_alliance: + blu_fmt.append(FTCEventsClient.team_fmt(team, team_num=team_num)) + + red_fmt = ", ".join(red_fmt) + blu_fmt = ", ".join(blu_fmt) + red_score = m['scoreRedFinal'] or "0" + blu_score = m['scoreBlueFinal'] or "0" + + wincode = "⬜" + if m['redWins']: + red_fmt = f"**{red_fmt}**" + red_score = f"**{red_score}**" + wincode = "🟥" + if m['blueWins']: + blu_fmt = f"**{blu_fmt}**" + blu_score = f"**{blu_score}**" + wincode = "🟦" + + field_desc = f"{red_fmt} vs. {blu_fmt}" + + if not played: + field_title = f"{m['description']}: unplayed" + embed.add_field(name=field_title, value=field_desc, inline=False) + continue + else: + if (m['redWins'] and team_alliance == "red") or (m['blueWins'] and team_alliance == 'blue'): + field_title = f"{m['description']}: Win 🟨" + elif not m['redWins'] and not m['blueWins']: + field_title = f"{m['description']}: Tie ⬜" + else: + field_title = f"{m['description']}: Loss 🇱" + if team_alliance == "red": + red_score = f"__{red_score}__" + else: + blu_score = f"__{blu_score}__" + + field_desc = field_desc + f" {wincode} {red_score}-{blu_score}" + embed.add_field(name=field_title, value=f"[{field_desc}]({FTCEventsClient.get_url_for_match(szn, ecode, m)})", inline=False) + class FTCInfo(Cog): @@ -93,24 +207,25 @@ async def team(self, ctx: DozerContext, team_num: int): await ctx.send("Invalid team number specified!") res = await self.ftcevents.req("teams?" + urlencode({'teamNumber': str(team_num)})) async with res: - print(res.status) if res.status == 400: await ctx.send("This team either did not compete this season, or it does not exist!") return - team_data = (await res.json(content_type=None))['teams'][0] - - print(team_data) + team_data = await res.json(content_type=None) + if not team_data: + await ctx.send(f"FTC-Events returned nothing on request with HTTP response code {res.status}.") + return + team_data = team_data['teams'][0] # many team entries lack a valid url - website = (team_data.get('website', "")).strip() + website = get_none_strip(team_data, 'website') if website and not (website.startswith("http://") or website.startswith("https://")): website = "http://" + website e = discord.Embed(color=embed_color, title=f'FIRST® Tech Challenge Team {team_num}', - url=f"https://ftc-events.firstinspires.org/{self.ftcevents.get_season()}/team/{team_num}") - e.add_field(name='Name', value=team_data.get('nameShort', "").strip() or "_ _") - e.add_field(name='Rookie Year', value=team_data.get('rookieYear', "Unknown")) + url=f"https://ftc-events.firstinspires.org/{FTCEventsClient.get_season()}/team/{team_num}") + e.add_field(name='Name', value=get_none_strip(team_data, 'nameShort') or "_ _") + e.add_field(name='Rookie Year', value=get_none_strip(team_data, 'rookieYear') or "Unknown") e.add_field(name='Location', value=', '.join((team_data['city'], team_data['stateProv'], team_data['country'])) or "Unknown") e.add_field(name='Org/Sponsors', value=team_data.get('nameFull', "").strip() or "_ _") @@ -121,6 +236,106 @@ async def team(self, ctx: DozerContext, team_num: int): `{prefix}ftc team 7244` - show information on team 7244, Out of the Box Robotics """ + @ftc.command() + @bot_has_permissions(embed_links=True) + async def matches(self, ctx: DozerContext, team_num: int, event_name: str = "latest"): + """Get a match schedule, defaulting to the latest listed event on FTC-Events""" + szn = FTCEventsClient.get_season() + events = await self.ftcevents.reqjson("events?" + urlencode({'teamNumber': str(team_num)}), + on_400=lambda r: ctx.send("This team either did not compete this season, or it does not exist!"), + on_other=lambda r: ctx.send(f"FTC-Events returned an HTTP error status of: {r.status}. Something is broken.")) + if events is None: + return + + events = events['events'] + if len(events) == 0: + await ctx.send("This team did not attend any events this season!") + return + + event = None + if event_name == "latest": + # sort all events by date start + events = sorted(events, key=lambda e: FTCEventsClient.date_parse(e['dateStart']), reverse=True) + event = events[0] + + divisions = {} + # the event that should be used is the latest unpublished event. divisioned events are considered 1 event. + + # event division filter: + for e in events: + if e['divisionCode']: + if e['divisionCode'] not in divisions: + divisions[e['divisionCode']] = [e['code']] + else: + divisions[e['divisionCode']].append(e['code']) + + for e in events: + if e['code'] in divisions: + continue + event = e + break + + else: + for e in events: + if e['code'] == event_name: + event = e + if event is None: + await ctx.send(f"Team {team_num} did not attend {event_name}!") + return + # + event_url = f"https://ftc-events.firstinspires.org/{szn}/{event['code']}" + + # fetch the rankings + rank_res = await self.ftcevents.reqjson(f"rankings/{event['code']}?" + urlencode({'teamNumber': str(team_num)}), + on_400=lambda r: ctx.send(f"This team somehow competed at an event ({event_url}) that it is not ranked in -- did it no show?"), + on_other=lambda r: ctx.send(f"FTC-Events returned an HTTP error status of: {r.status}. Something is broken.") + ) + if rank_res is None: + return + rank_res = rank_res['Rankings'] + + if not rank_res: + rank = None + description = "_No rankings are available for this event._" + else: + rank = rank_res[0] + description = f"Rank **{rank['rank']}**\nWLT **{rank['wins']}-{rank['losses']}-{rank['ties']}**\n"\ + f"QP/TBP1 **{rank['sortOrder1']} / {rank['sortOrder2']}** " + + embed = discord.Embed(color=embed_color, title=f"FTC Team {team_num} @ {event['name']}", url=event_url, description=description) + has_matches_at_all = False + + # fetch the quals match schedule + req = await self.ftcevents.reqjson(f"schedule/{event['code']}/qual/hybrid", + on_other=lambda r: ctx.send(f"FTC-Events returned an HTTP error status of: {req.status}. Something is broken.")) + + if req is None: + return + res = req['schedule'] + has_matches_at_all = has_matches_at_all or bool(res) + FTCEventsClient.add_schedule_to_embed(embed, res, team_num, szn, event['code']) + + # fetch the playoffs match schedule + req = await self.ftcevents.reqjson(f"schedule/{event['code']}/playoff/hybrid", + on_other=lambda r: ctx.send(f"FTC-Events returned an HTTP error status of: {req.status}. Something is broken.")) + + if req is None: + return + res = req['schedule'] + has_matches_at_all = has_matches_at_all or bool(res) + FTCEventsClient.add_schedule_to_embed(embed, res, team_num, szn, event['code']) + + if not has_matches_at_all: + embed.description = "_No match schedule is available yet._" + + await ctx.send(embed=embed) + + matches.example_usage = """ + `{prefix}ftc matches 16377` - show matches for the latest event by team 16377, Spicy Ketchup + `{prefix}ftc matches 8393 USPACMP` - show matches for the Pennsylvania championship by team 8393, BrainSTEM + """ + + async def setup(bot): """Adds the FTC information cog to the bot.""" diff --git a/dozer/cogs/info.py b/dozer/cogs/info.py index 2af84e4f..c6e3d251 100755 --- a/dozer/cogs/info.py +++ b/dozer/cogs/info.py @@ -3,9 +3,12 @@ import typing from datetime import timezone, datetime, date from difflib import SequenceMatcher +import time +import re import discord import humanize +from discord.ext import commands from discord.ext.commands import cooldown, BucketType, guild_only from discord.utils import escape_markdown @@ -15,11 +18,22 @@ blurple = discord.Color.blurple() datetime_format = '%Y-%m-%d %H:%M:%S\nUTC' +startup_time = time.time() + +try: + with open("/etc/os-release") as f: + os_name = re.findall(r'PRETTY_NAME=\"(.+?)\"', f.read())[0] +except Exception: + os_name = "Windows probably" class Info(Cog): """Commands for getting information about people and things on Discord.""" + def __init__(self, bot): + super().__init__(bot) + self.bot = bot + @command(aliases=['user', 'memberinfo', 'userinfo']) @guild_only() @bot_has_permissions(embed_links=True) @@ -29,7 +43,6 @@ async def member(self, ctx: DozerContext, *, member: discord.Member = None): **This command works without mentions.** Remove the '@' before your mention so you don't ping the person unnecessarily. You can pick a member by: - Username (`cooldude`) - - Username and discriminator (`cooldude#1234`) - ID (`326749693969301506`) - Nickname - must be exact and is case-sensitive (`"Mr. Cool Dude III | Team 1234"`) - Mention (not recommended) (`@Mr Cool Dude III | Team 1234`) @@ -42,7 +55,8 @@ async def member(self, ctx: DozerContext, *, member: discord.Member = None): levels_settings = await GuildXPSettings.get_by(guild_id=ctx.guild.id) levels_enabled = levels_settings[0].enabled if len(levels_settings) else False - embed = discord.Embed(title=escape_markdown(member.display_name), description=f'{member!s} ({member.id})', color=member.color) + embed = discord.Embed(title=escape_markdown(member.display_name), description=f'{member!s} ({member.id})', + color=member.color) embed.set_thumbnail(url=member.display_avatar) embed.add_field(name='Bot Created' if member.bot else 'Account Created', value=f"", inline=True) @@ -131,7 +145,8 @@ def pluralize(values: typing.List[str]) -> str: @cooldown(1, 10, BucketType.channel) async def role(self, ctx: DozerContext, role: discord.Role): """Retrieve info about a role in this guild""" - embed = discord.Embed(title=f"Info for role: {role.name}", description=f"{role.mention} ({role.id})", color=role.color) + embed = discord.Embed(title=f"Info for role: {role.name}", description=f"{role.mention} ({role.id})", + color=role.color) embed.add_field(name="Created on", value=f"") embed.add_field(name="Position", value=role.position) embed.add_field(name="Color", value=str(role.color).upper()) @@ -153,31 +168,51 @@ async def rolemembers(self, ctx: DozerContext, role: discord.Role): @guild_only() @cooldown(1, 10, BucketType.channel) - @command(aliases=['server', 'guildinfo', 'serverinfo']) + @command(name="server", aliases=['guild', 'guildinfo', 'serverinfo']) async def guild(self, ctx: DozerContext): """Retrieve information about this guild.""" guild = ctx.guild static_emoji = sum(not e.animated for e in ctx.guild.emojis) animated_emoji = sum(e.animated for e in ctx.guild.emojis) - embed = discord.Embed(title=f"Info for guild: {guild.name}", description=f"Members: {guild.member_count}", - color=blurple) - - embed.set_thumbnail(url=guild.icon.url if guild.icon is not None else None) + e = discord.Embed(color=blurple) + e.set_thumbnail(url=guild.icon.url if guild.icon else None) + e.title = guild.name + e.description = f"{guild.member_count} members, {len(guild.channels)} channels, {len(guild.roles) - 1} roles" + e.add_field(name='ID', value=guild.id) + e.add_field(name='Created on', value=discord.utils.format_dt(guild.created_at)) + e.add_field(name='Owner', value=guild.owner.mention) + e.add_field(name='Emoji', value=f"{static_emoji} static, {animated_emoji} animated") + e.add_field(name='Nitro Boost', value=f'Level {ctx.guild.premium_tier}, ' + f'{ctx.guild.premium_subscription_count} booster(s)\n' + f'{ctx.guild.filesize_limit // 1024 ** 2}MiB files, ' + f'{ctx.guild.bitrate_limit / 1000:0.1f}kbps voice') + await ctx.send(embed=e) - embed.add_field(name='Created on', value=f"") - embed.add_field(name='Owner', value=guild.owner) - embed.add_field(name='Emoji', value=f"{static_emoji} static, {animated_emoji} animated") - embed.add_field(name='Roles', value=str(len(guild.roles) - 1)) # Remove @everyone - embed.add_field(name='Channels', value=str(len(guild.channels))) - embed.add_field(name='Nitro Boost Info', value=f'Level {ctx.guild.premium_tier}, ' - f'{ctx.guild.premium_subscription_count} booster(s), ' - f'{ctx.guild.filesize_limit // 1024 ** 2}MiB files, ' - f'{ctx.guild.bitrate_limit / 1000:0.1f}kbps voice') + guild.example_usage = """ + `{prefix}guild` - get information about this guild + """ + @command() + async def stats(self, ctx: DozerContext): + """Get current running internal/host stats for the bot""" + info = await ctx.bot.application_info() + + frame = "\n".join( + map(lambda x: f"{str(x[0]):<24}{str(x[1])}", { + "Users:": len(ctx.bot.users), + "Channels:": len(list(ctx.bot.get_all_channels())), + "Servers:": len(ctx.bot.guilds), + "": "", + f"{' Host stats ':=^48}": "", + "Operating system:": os_name, + "Process uptime": str(datetime.timedelta(seconds=round(time.time() - startup_time))) + }.items())) + embed = discord.Embed(title=f"Stats for {info.name}", + description=f"Bot owner: {info.owner.mention}```{frame}```", color=blurple) await ctx.send(embed=embed) - guild.example_usage = """ - `{prefix}guild` - get information about this guild + stats.example_usage = """ + `{prefix}stats` - get current bot/host stats """ diff --git a/dozer/cogs/levels.py b/dozer/cogs/levels.py index 151b52ef..0500b688 100644 --- a/dozer/cogs/levels.py +++ b/dozer/cogs/levels.py @@ -341,6 +341,7 @@ async def checkrolelevels(self, ctx: DozerContext): unsorted = self._level_roles.get(ctx.guild.id) embed = discord.Embed(title=f"Level roles for {ctx.guild}", color=blurple) if unsorted: + await ctx.defer() roles = sorted(unsorted, key=lambda entry: entry.level) # Sort roles based on level embeds = [] diff --git a/dozer/cogs/maintenance.py b/dozer/cogs/maintenance.py index dcd2b592..da61fe49 100755 --- a/dozer/cogs/maintenance.py +++ b/dozer/cogs/maintenance.py @@ -24,7 +24,7 @@ def cog_check(self, ctx: DozerContext): # All of this cog is only available to async def shutdown(self, ctx: DozerContext): """Force-stops the bot.""" await ctx.send('Shutting down') - logger.info(f'Shutting down at request of {ctx.author.name}#{ctx.author.discriminator} ' + logger.info(f'Shutting down at request of {ctx.author.name}{"#" + ctx.author.discriminator if ctx.author.discriminator != "0" else ""} ' f'(in {ctx.guild.name}, #{ctx.channel.name})') await self.bot.shutdown() diff --git a/dozer/cogs/moderation.py b/dozer/cogs/moderation.py index 29d04b68..5f1d2eaf 100755 --- a/dozer/cogs/moderation.py +++ b/dozer/cogs/moderation.py @@ -26,8 +26,6 @@ MAX_PURGE = 1000 - - class SafeRoleConverter(RoleConverter): """Allows for @everyone to be specified without pinging everyone""" @@ -118,6 +116,13 @@ async def mod_log(self, actor: discord.Member, action: str, target: Union[discor await orig_channel.send("Failed to DM modlog to user") else: logger.warning(f"Failed to DM modlog to user {target} ({target.id})") + except discord.HTTPException as e: + if orig_channel is not None: + await orig_channel.send(f"Failed to DM modlog to user: `{e}`") + else: + logger.warning(f"Failed to DM modlog to user {target} ({target.id}): {e}") + except AttributeError: + pass finally: modlog_embed.remove_field(2) modlog_channel = await GuildModLog.get_by(guild_id=actor.guild.id) if guild_override is None else \ @@ -140,16 +145,31 @@ async def mod_log(self, actor: discord.Member, action: str, target: Union[discor async def perm_override(self, member: discord.Member, **overwrites): """Applies the given overrides to the given member in their guild.""" - for channel in member.guild.channels: + logger.debug(f"Applying overrides to {member} ({member.id})") + overwrite_count = 0 + guild = await self.bot.fetch_guild(member.guild.id) + channels = await guild.fetch_channels() + # For some reason guild.me is returning None only sometimes, so this is a workaround to get perm_overrides working + me = await guild.fetch_member(self.bot.user.id) + for channel in channels: overwrite = channel.overwrites_for(member) - if channel.permissions_for(member.guild.me).manage_roles: + if channel.permissions_for(me).manage_roles: overwrite.update(**overwrites) try: await channel.set_permissions(target=member, overwrite=None if overwrite.is_empty() else overwrite) + overwrite_count += 1 except discord.Forbidden as e: logger.error( f"Failed to catch missing perms in {channel} ({channel.id}) Guild: {channel.guild.id}; Error: {e}") - + except discord.HTTPException as e: + logger.error( + f"Failed to catch discords horrid permissions system in " + f"{channel} ({channel.id}) Guild: {channel.guild.id}; Error: {e}") + except Exception as e: + logger.error(f"Failed to catch some unknown or unexpected error: {e}") + else: + logger.warning(f"Missing permissions to manage roles in {channel} ({channel.id})") + logger.debug(f"Applied {overwrite_count}/({len(channels)}) overrides to {member} ({member.id})") hm_regex = re.compile(r"((?P\d+)y)?((?P\d+)M)?((?P\d+)w)?((?P\d+)d)?((?P\d+)h)?((?P\d+)m)?((" r"?P\d+)s)?") @@ -177,16 +197,23 @@ async def start_punishment_timers(self): except discord.NotFound: logger.warning(f"Guild {r.guild_id} not found, skipping punishment timer") continue - actor = guild.get_member(r.actor_id) - target = guild.get_member(r.target_id) + try: + actor = await guild.fetch_member(r.actor_id) + except discord.NotFound: + actor = None + try: + target = await guild.fetch_member(r.target_id) + except discord.NotFound: + logger.warning(f"Target {r.target_id} not found, skipping punishment timer") + continue orig_channel = self.bot.get_channel(r.orig_channel_id) punishment_type = r.type_of_punishment reason = r.reason or "" seconds = max(int(r.target_ts - time.time()), 0.01) - await PunishmentTimerRecords.delete(id=r.id) + # await PunishmentTimerRecords.delete(id=r.id) self.bot.loop.create_task( self.punishment_timer(seconds, target, PunishmentTimerRecords.type_map[punishment_type], reason, actor, - orig_channel)) + orig_channel, timer_id=r.id)) logger.info( f"Restarted {PunishmentTimerRecords.type_map[punishment_type].__name__} of {target} in {guild}") @@ -203,7 +230,7 @@ async def restart_all_timers(self): async def punishment_timer(self, seconds: int, target: discord.Member, punishment, reason: str, actor: discord.Member, orig_channel=None, - global_modlog: bool = True): + global_modlog: bool = True, timer_id: int = None): """Asynchronous task that sleeps for a set time to unmute/undeafen a member for a set period of time.""" # Add this task to the list of active timer tasks @@ -216,36 +243,48 @@ async def punishment_timer(self, seconds: int, target: discord.Member, punishmen if seconds == 0: return - # register the timer - ent = PunishmentTimerRecords( - guild_id=target.guild.id, - actor_id=actor.id, - target_id=target.id, - orig_channel_id=orig_channel.id if orig_channel else 0, - type_of_punishment=punishment.type, - reason=reason, - target_ts=int(seconds + time.time()), - self_inflicted=not global_modlog - ) - await ent.update_or_add() + if timer_id is None: + # register the timer + ent = PunishmentTimerRecords( + guild_id=target.guild.id, + actor_id=actor.id, + target_id=target.id, + orig_channel_id=orig_channel.id if orig_channel else 0, + type_of_punishment=punishment.type, + reason=reason, + target_ts=int(seconds + time.time()), + self_inflicted=not global_modlog + ) + await ent.update_or_add() + else: + ent = (await PunishmentTimerRecords.get_by(id=timer_id))[0] + # seconds = max(int(ent.target_ts - time.time()), 0.01) await asyncio.sleep(seconds) + logger.info(f"Finished{' self' if not global_modlog else ''} {punishment.__name__} " + f"timer of \"{target}\" in \"{target.guild}\", preforming un-punishment") user = await punishment.get_by(member_id=target.id) - if len(user) != 0: - await self.mod_log(actor=actor, - action="un" + punishment.past_participle, - target=target, - reason=reason, - orig_channel=orig_channel, - embed_color=discord.Color.green(), - global_modlog=global_modlog) - - self.punishment_timer_tasks.remove(asyncio.current_task()) - self.bot.loop.create_task(coro=punishment.finished_callback(self, target)) - if ent: - await PunishmentTimerRecords.delete(guild_id=target.guild.id, target_id=target.id, - type_of_punishment=punishment.type) + try: + if len(user) != 0: + await self.mod_log(actor=actor, + action="un" + punishment.past_participle, + target=target, + reason=reason, + orig_channel=orig_channel, + embed_color=discord.Color.green(), + global_modlog=global_modlog) + self.punishment_timer_tasks.remove(asyncio.current_task()) + self.bot.loop.create_task(coro=punishment.finished_callback(self, target)) + else: + logger.warning(f"User {target} was not found in the {punishment.__name__} database, skipping un-punishment") + + if ent: + await PunishmentTimerRecords.delete(guild_id=target.guild.id, target_id=target.id, + type_of_punishment=punishment.type) + except Exception as e: + logger.error(f"Error while un-punishing {target} in {target.guild}, {e}") + logger.exception(e) async def _check_links_warn(self, msg: discord.Message, role: discord.Role): """Warns a user that they can't send links.""" @@ -1327,7 +1366,7 @@ async def initial_create(cls): )""") def __init__(self, guild_id: int, actor_id: int, target_id: int, type_of_punishment: int, target_ts: int, - orig_channel_id: int = None, reason: str = None, input_id: int = None, self_inflicted: bool =False): + orig_channel_id: int = None, reason: str = None, input_id: int = None, self_inflicted: bool = False): super().__init__() self.id = input_id self.guild_id = guild_id diff --git a/dozer/cogs/modmail.py b/dozer/cogs/modmail.py index 82ddf3dd..dcafdae4 100644 --- a/dozer/cogs/modmail.py +++ b/dozer/cogs/modmail.py @@ -35,8 +35,16 @@ class StartModmailModal(ui.Modal): subject = ui.TextInput(label='Subject', custom_id="subject") message = ui.TextInput(label='Message', style=discord.TextStyle.paragraph, custom_id="message") + def __init__(self, *args, **kwargs): + + super().__init__(title="New Modmail") + for key, value in kwargs.items(): + setattr(self, key, value) + async def on_submit(self, interaction: discord.Interaction): # pylint: disable=arguments-differ """Handles when a modal is submitted""" + user = self.custom_user if hasattr(self, "custom_user") else interaction.user + subject = interaction.data['components'][0]['components'][0]['value'] message = interaction.data['components'][1]['components'][0]['value'] @@ -49,12 +57,16 @@ async def on_submit(self, interaction: discord.Interaction): # pylint: disable= timestamp=datetime.datetime.utcnow(), ) new_ticket_embed.set_footer( - text=f"{interaction.user.name}#{interaction.user.discriminator} | {interaction.user.id}", - icon_url=interaction.user.avatar.url if interaction.user.avatar is not None else None, + text=f"{user.name}" + f"{'#' + user.discriminator if user.discriminator != '0' else ''} " + f"| {user.id}", + icon_url=user.avatar.url if user.avatar is not None else None, ) target_record = await ModmailConfig.get_by(guild_id=interaction.guild_id) mod_channel = interaction.client.get_channel(target_record[0].target_channel) - user_string = f"{interaction.user.name}#{interaction.user.discriminator} ({interaction.user.id})" + user_string = f"{user.name}" \ + f"{'#' + user.discriminator if user.discriminator != '0' else ''} " \ + f"({user.id})" if len(user_string) > 100: user_string = user_string[:96] + "..." mod_message = await mod_channel.send(user_string) @@ -63,7 +75,7 @@ async def on_submit(self, interaction: discord.Interaction): # pylint: disable= await interaction.response.send_message("Creating private modmail thread!", ephemeral=True) user_thread = await interaction.channel.create_thread(name=subject) - await user_thread.add_user(interaction.user) + await user_thread.add_user(user) await user_thread.join() await user_thread.send(embed=new_ticket_embed) thread_record = ModmailThreads(user_thread=user_thread.id, mod_thread=mod_thread.id) @@ -105,7 +117,7 @@ async def send_modmail_embeds(self, source_channel, message_content, author, rec if len(to_send) > 3071: embed.add_field(name="Message (continued)", value=to_send[3072:4000]) embed.set_footer( - text=f"{author.name}#{author.discriminator} | {author.id} | {guild.name}", + text=f"{author.name}{'#' + author.discriminator if author.discriminator != '0' else ''} | {author.id} | {guild.name}", icon_url=author.avatar.url if author.avatar is not None else None, ) files = [] @@ -145,6 +157,21 @@ async def configure_modmail(self, ctx: DozerContext, target_channel): await config.update_or_add() await ctx.reply("Configuration saved!") + @command() + @has_permissions(manage_roles=True) + async def start_modmail_with_user(self, ctx: DozerContext, member: discord.Member): + """Start modmail with a user, should be used in channel with modmail button""" + if ctx.interaction is not None: + target_record = await ModmailConfig.get_by(guild_id=ctx.interaction.guild_id) + if len(target_record) == 0: + await ctx.reply("Sorry, this server has not configured modmail correctly yet!", ephemeral=True) + else: + await ctx.interaction.response.send_modal(StartModmailModal(custom_user=member)) + else: + await ctx.reply("This command only works via slash command") + + + @command() @has_permissions(administrator=True) async def create_modmail_button(self, ctx): diff --git a/dozer/cogs/music.py b/dozer/cogs/music.py deleted file mode 100644 index f5ac1e1f..00000000 --- a/dozer/cogs/music.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Music commands, currently disabled""" -import lavaplayer -from discord.ext import commands -from loguru import logger - -from dozer.cogs._utils import command - - -class Music(commands.Cog): - """Music commands cog""" - - def __init__(self, bot): - self.bot = bot - if not self.bot.config['lavalink']['enabled']: - return - - llconfig = self.bot.config['lavalink'] - self.lavalink = lavaplayer.Lavalink( - host=llconfig['host'], - port=llconfig['port'], - password=llconfig['password'], - ) - self.lavalink.set_user_id(bot.user.id) - self.lavalink.connect() - - @command() - async def disconnect(self, ctx: commands.Context): - """Disconnects from voice channel""" - await ctx.guild.change_voice_state(channel=None) - await self.lavalink.wait_for_remove_connection(ctx.guild.id) - await ctx.send("Left the voice channel.") - - async def join(self, ctx: commands.Context): - """Joins the voice channel""" - print(ctx.author.voice) - await ctx.guild.change_voice_state(channel=ctx.author.voice.channel, self_deaf=True, self_mute=False) - print("Awaiting connection") - await self.lavalink.wait_for_connection(ctx.guild.id) - print("Join successful") - - @command() - async def play(self, ctx: commands.Context, *, query: str): - """Plays a song""" - await self.join(ctx) - tracks = await self.lavalink.auto_search_tracks(query) - - if not tracks: - return await ctx.send("No results found.") - elif isinstance(tracks, lavaplayer.TrackLoadFailed): - await ctx.send("Track load failed. Try again.\n```" + tracks.message + "```") - # Playlist - elif isinstance(tracks, lavaplayer.PlayList): - msg = await ctx.send("Playlist found, Adding to queue, Please wait...") - await self.lavalink.add_to_queue(ctx.guild.id, tracks.tracks, ctx.author.id) - await msg.edit(content=f"Added to queue, tracks: {len(tracks.tracks)}, name: {tracks.name}") - return - await self.lavalink.wait_for_connection(ctx.guild.id) - await self.lavalink.play(ctx.guild.id, tracks[0], ctx.author.id) - await ctx.send(f"Now playing: {tracks[0].title}") - - @command() - async def pause(self, ctx: commands.Context): - """Pauses the current song""" - await self.lavalink.pause(ctx.guild.id, True) - await ctx.send("Paused the track.") - - @command() - async def resume(self, ctx: commands.Context): - """Resume the current song""" - await self.lavalink.pause(ctx.guild.id, False) - await ctx.send("Resumed the track.") - - @command() - async def stop(self, ctx: commands.Context): - """Stop the current song""" - await self.lavalink.stop(ctx.guild.id) - await ctx.send("Stopped the track.") - - @command() - async def skip(self, ctx: commands.Context): - """Skip the current song""" - await self.lavalink.skip(ctx.guild.id) - await ctx.send("Skipped the track.") - - @command() - async def queue(self, ctx: commands.Context): - """Get queue info""" - queue = await self.lavalink.queue(ctx.guild.id) - if not queue: - return await ctx.send("No tracks in queue.") - tracks = [f"**{i + 1}.** {t.title}" for (i, t) in enumerate(queue)] - await ctx.send("\n".join(tracks)) - - @command() - async def volume(self, ctx: commands.Context, volume: int): - """Set the volume""" - await self.lavalink.volume(ctx.guild.id, volume) - await ctx.send(f"Set the volume to {volume}%.") - - @command() - async def seek(self, ctx: commands.Context, seconds: int): - """Seek to a timestamp in the current song""" - await self.lavalink.seek(ctx.guild.id, seconds) - await ctx.send(f"Seeked to {seconds} seconds.") - - @command() - async def shuffle(self, ctx: commands.Context): - """Shuffle the queue""" - await self.lavalink.shuffle(ctx.guild.id) - await ctx.send("Shuffled the queue.") - - @command() - async def remove(self, ctx: commands.Context, index: int): - """Removes a song from the queue""" - await self.lavalink.remove(ctx.guild.id, index) - await ctx.send(f"Removed track {index}.") - - @command() - async def clear(self, ctx: commands.Context): - """Clears the queue""" - await self.lavalink.stop(ctx.guild.id) - await ctx.send("Cleared the queue.") - - @command() - async def repeat(self, ctx: commands.Context, status: bool): - """Repeats the queue""" - await self.lavalink.repeat(ctx.guild.id, status) - await ctx.send("Repeated the queue.") - - -async def setup(bot: commands.Bot): - """Adds the cog to the bot""" - # await bot.add_cog(Music(bot)) - logger.info("Music cog is temporarily disabled due to code bugs.") diff --git a/dozer/cogs/news.py b/dozer/cogs/news.py index e005eaa6..273fe330 100644 --- a/dozer/cogs/news.py +++ b/dozer/cogs/news.py @@ -124,9 +124,8 @@ async def startup(self): if self.http_source: await self.http_source.close() self.http_source = aiohttp.ClientSession( - headers={'Connection': 'keep-alive', 'User-Agent': 'Dozer RSS Feed Reader'}) + headers={'Connection': 'keep-alive'}) self.bot.add_aiohttp_ses(self.http_source) - # JVN's blog will 403 you if you use the default user agent, so replacing it with this will yield a parsable result. for source in self.enabled_sources: try: self.sources[source.short_name] = source(aiohttp_session=self.http_source, bot=self.bot) diff --git a/dozer/cogs/profile_menus.py b/dozer/cogs/profile_menus.py new file mode 100644 index 00000000..e9d58ed5 --- /dev/null +++ b/dozer/cogs/profile_menus.py @@ -0,0 +1,111 @@ +"""Provides the ability to add commands to user profiles in servers""" +import discord +from discord.ext import commands +from discord import app_commands +from discord.utils import escape_markdown + + +from .. import db + +embed_color = discord.Color(0xed791e) + + +class ProfileMenus(commands.Cog): + """ + Creates a profile menu object for the bot + """ + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + bot.tree.add_command(profile) + bot.tree.add_command(onteam) + + async def cog_unload(self) -> None: + self.bot.tree.remove_command(profile) + self.bot.tree.remove_command(onteam) + + +@app_commands.context_menu(name = 'View Profile') +async def profile(interaction: discord.Interaction, member: discord.Member): + """Creates the ephemeral response that will be sent to the user when they interact with the 'View Profile' button""" + # await interaction.response.send_message(f'{member} joined at {discord.utils.format_dt(member.joined_at)}', + # ephemeral = False) # temp false for testing + if member is None: + member = interaction.user + + icon_url = member.avatar.replace(static_format = 'png', size = 32) or None + + embed = discord.Embed(title = member.display_name, description = f'{member!s} ({member.id}) | {member.mention}', + color = member.color) + embed.add_field(name = 'Bot Created' if member.bot else 'Account Created', + value = discord.utils.format_dt(member.created_at), inline = True) + embed.add_field(name = 'Member Joined', value = discord.utils.format_dt(member.joined_at), inline = True) + if member.premium_since is not None: + embed.add_field(name = 'Member Boosted', value = discord.utils.format_dt(member.premium_since), + inline = True) + if len(member.roles) > 1: + role_string = ' '.join([r.mention for r in member.roles][1:]) + else: + role_string = member.roles[0].mention + s = "s" if len(member.roles) >= 2 else "" + embed.add_field(name = f"Role{s}: ", value = role_string, inline = False) + embed.set_thumbnail(url = icon_url) + await interaction.response.send_message(embed = embed, ephemeral = True) + + +@app_commands.context_menu(name = 'Teams User is On') +async def onteam(interaction: discord.Interaction, user: discord.Member): + """Creates the ephemeral response that will be sent to the user when they interact with the 'View Team' button""" + teams = await TeamNumbers.get_by(user_id = user.id) + + if len(teams) == 0: + await interaction.response.send_message("This user is not on any teams", ephemeral = True) + else: + embed = discord.Embed(color = discord.Color.blue()) + embed.title = f'{user.display_name} is on the following team(s):' + embed.description = "Teams: \n" + for i in teams: + embed.description = f"{embed.description} {i.team_type.upper()} Team {i.team_number} \n" + if len(embed.description) > 4000: + embed.description = embed.description[:4000] + "..." + await interaction.response.send_message(embed = embed, ephemeral = False) + + +async def setup(bot): + """Adds the profile context menus cog to the bot.""" + await bot.add_cog(ProfileMenus(bot)) + + +class TeamNumbers(db.DatabaseTable): + """Database operations for tracking team associations.""" + __tablename__ = 'team_numbers' + __uniques__ = ('user_id', 'team_number', 'team_type',) + + @classmethod + async def initial_create(cls): + """Create the table in the database""" + async with db.Pool.acquire() as conn: + await conn.execute(f""" + CREATE TABLE {cls.__tablename__} ( + user_id bigint NOT NULL, + team_number text NOT NULL, + team_type VARCHAR NOT NULL, + PRIMARY KEY (user_id, team_number, team_type) + )""") + + def __init__(self, user_id, team_number, team_type): + super().__init__() + self.user_id = user_id + self.team_number = team_number + self.team_type = team_type + + @classmethod + async def get_by(cls, **kwargs): + results = await super().get_by(**kwargs) + result_list = [] + for result in results: + obj = TeamNumbers(user_id = result.get("user_id"), + team_number = result.get("team_number"), + team_type = result.get("team_type")) + result_list.append(obj) + return result_list + \ No newline at end of file diff --git a/dozer/cogs/roles.py b/dozer/cogs/roles.py index 5d481c2a..4695d3d8 100755 --- a/dozer/cogs/roles.py +++ b/dozer/cogs/roles.py @@ -12,6 +12,7 @@ from dozer.context import DozerContext from ._utils import * from .actionlogs import CustomJoinLeaveMessages +from .moderation import MemberRole from .. import db from ..db import * @@ -152,6 +153,17 @@ async def on_member_join(self, member: discord.Member): if len(restore) == 0: return # New member - nothing to restore + # try and prioritize the member role first for Speed + member_role_id = None + stg = await MemberRole.get_by(guild_id=member.guild.id) + if stg: + member_role_id = stg[0].member_role + member_role = member.guild.get_role(member_role_id) + if member_role is not None and any(s.role_id == member_role_id for s in restore) and member_role.position <= top_restorable: + await member.add_roles(member_role) + else: + member_role_id = None + valid, cant_give, missing = set(), set(), set() for missing_role in restore: role = member.guild.get_role(missing_role.role_id) @@ -159,7 +171,7 @@ async def on_member_join(self, member: discord.Member): missing.add(missing_role.role_name) elif role.position > top_restorable: cant_give.add(role.name) - else: + elif role.id != member_role_id: valid.add(role) for entry in restore: # Not missing anymore - remove the record to free up the primary key diff --git a/dozer/cogs/starboard.py b/dozer/cogs/starboard.py index df554b62..e1dfb54e 100644 --- a/dozer/cogs/starboard.py +++ b/dozer/cogs/starboard.py @@ -39,7 +39,7 @@ def make_starboard_embed(msg: discord.Message, reaction_count: int): """Makes a starboard embed.""" e = discord.Embed(color=msg.author.color, title=f"New Starred Message in #{msg.channel.name}", description=msg.content, url=msg.jump_url) - e.set_author(name=escape_markdown(msg.author.display_name), icon_url=msg.author.display_avatar) + e.set_author(name=escape_markdown(msg.author.display_name).replace('\\', ''), icon_url=msg.author.display_avatar) view_link = f" [[view]]({msg.jump_url})" e.add_field(name="Link:", value=view_link) @@ -254,8 +254,8 @@ async def showconfig(self, ctx: DozerContext): @bot_has_permissions(add_reactions=True, embed_links=True) @starboard.command() async def config(self, ctx: DozerContext, channel: discord.TextChannel, - star_emoji: discord.Emoji, - threshold: int, cancel_emoji: discord.Emoji = None): + star_emoji, + threshold: int, cancel_emoji = None): """Modify the settings for this server's starboard""" if str(star_emoji) == str(cancel_emoji): await ctx.send("The Star Emoji and Cancel Emoji cannot be the same!") @@ -263,14 +263,15 @@ async def config(self, ctx: DozerContext, channel: discord.TextChannel, for emoji in [emoji for emoji in [star_emoji, cancel_emoji] if emoji is not None]: try: # try adding it to make sure it's a real emoji. This covers both custom emoijs & unicode emojis - await ctx.message.add_reaction(emoji) - await ctx.message.remove_reaction(emoji, ctx.guild.me) + message = await channel.send('Testing Reaction') + await message.add_reaction(emoji) + await message.remove_reaction(emoji, ctx.guild.me) if isinstance(emoji, discord.Emoji) and emoji.guild_id != ctx.guild.id: await ctx.send(f"The emoji {emoji} is a custom emoji not from this server!") return - except discord.HTTPException: + except discord.HTTPException as err: await ctx.send(f"{ctx.author.mention}, bad argument: '{emoji}' is not an emoji, or isn't from a server " - f"{ctx.me.name} is in.") + f"{ctx.me.name} is in, error: {err}") return config = StarboardConfig(guild_id=ctx.guild.id, channel_id=channel.id, star_emoji=str(star_emoji), diff --git a/dozer/cogs/toa.py b/dozer/cogs/toa.py index 4e0cab04..6d225731 100755 --- a/dozer/cogs/toa.py +++ b/dozer/cogs/toa.py @@ -8,8 +8,8 @@ import aiohttp import async_timeout import discord +from discord import app_commands from discord.ext import commands -from discord.utils import escape_markdown from dozer.context import DozerContext from ._utils import * @@ -49,6 +49,8 @@ async def req(self, endpoint): try: async with async_timeout.timeout(5) as _, self.http.get(urljoin(self.base, endpoint), headers=self.headers) as response: + if response.status == 404: + return "[]" return await response.text() except aiohttp.ClientError: tries += 1 @@ -56,28 +58,17 @@ async def req(self, endpoint): raise -class TOA(Cog): +class TOA(commands.Cog): """TOA commands""" def __init__(self, bot: commands.Bot): - super().__init__(bot) + super().__init__() self.http_session = bot.add_aiohttp_ses(aiohttp.ClientSession()) self.parser = TOAParser(bot.config['toa']['key'], self.http_session, app_name=bot.config['toa']['app_name']) - @group(invoke_without_command=True) - async def toa(self, ctx: DozerContext, team_num: int): - """ - Get FTC-related information from The Orange Alliance. - If no subcommand is specified, the `team` subcommand is inferred, and the argument is taken as a team number. - """ - await self.team.callback(self, ctx, team_num) # This works but Pylint throws an error - - toa.example_usage = """ - `{prefix}toa 5667` - show information on team 5667, Robominers - """ - - @toa.command() + @command(name="toateam", aliases=["toa", "ftcteam", "ftcteaminfo"]) @bot_has_permissions(embed_links=True) + @app_commands.describe(team_num="The team you want to see toa info about") async def team(self, ctx: DozerContext, team_num: int): """Get information on an FTC team by number.""" res = json.loads(await self.parser.req("team/" + str(team_num))) @@ -96,7 +87,6 @@ async def team(self, ctx: DozerContext, team_num: int): value=', '.join((team_data['city'], team_data['state_prov'], team_data['country']))) e.add_field(name='Website', value=team_data['website'] or 'n/a') e.add_field(name='Team Info Page', value=f'https://theorangealliance.org/teams/{team_data["team_key"]}') - e.set_footer(text='Triggered by ' + escape_markdown(ctx.author.display_name)) await ctx.send('', embed=e) team.example_usage = """ diff --git a/ftcqa.py b/ftcqa.py new file mode 100644 index 00000000..94deda13 --- /dev/null +++ b/ftcqa.py @@ -0,0 +1,132 @@ +"""Provides commands that pull information from First Q&A Form.""" +import discord +import aiohttp + +from bs4 import BeautifulSoup +from discord.ext import commands + +from context import DozerContext + +from dozer.cogs._utils import bot_has_permissions, command +from ._utils import * +from discord import app_commands + + +class QA(commands.Cog): + """QA commands""" + + def __init__(self, bot) -> None: + self.ses = aiohttp.ClientSession() + super().__init__() + self.bot = bot + + @command(name="ftcqa", aliases=["ftcqaforum"], pass_context=True) + @bot_has_permissions(embed_links=True) + @app_commands.describe(question="The number of the question you want to look up") + async def ftcqa(self, ctx: DozerContext, question: int): + """ + Shows Answers from the FTC Q&A + """ + async with self.ses.get('https://ftc-qa.firstinspires.org/onepage.html') as response: + html_data = await response.text() + + answers = BeautifulSoup(html_data, 'html.parser').get_text() + + start = answers.find('Q' + str(question) + ' ') + a = "" + if start > 0: + + finish = answers.find('answered', start) + 24 + a = answers[start:finish] + + # remove newlines + a = a.replace("\n", " ") + + # remove multiple spaces + a = " ".join(a.split()) + + embed = discord.Embed( + title=a[:a.find(" Q: ")], + url="https://ftc-qa.firstinspires.org/qa/" + str(question), + color=discord.Color.blue()) + + embed.add_field(name="Question", + value=a[a.find(" Q: ") + 1:a.find(" A: ")], + inline=False) + embed.add_field(name="Answer", + value=a[a.find(" A: ") + 1:a.find(" ( Asked by ")], + inline=False) + + embed.set_footer( + text=a[a.find(" ( Asked by ") + 1:]) + + await ctx.send(embed=embed, ephemeral=True) + + else: + a = "That question was not answered or does not exist." + + # add url + a += "\nhttps://ftc-qa.firstinspires.org/qa/" + question + await ctx.send(a, ephemeral=True) + + ftcqa.example_usage = """ + `{prefix}ftcqa 19` - show information on FTC Q&A #19 + """ + + @command(name="frcqa", aliases=["frcqaforum"], pass_context=True) + @bot_has_permissions(embed_links=True) + @app_commands.describe(question="The number of the question you want to look up") + async def frcqa(self, ctx: DozerContext, question: int): + """ + Shows Answers from the FTC Q&A + """ + async with self.ses.get('https://frc-qa.firstinspires.org/onepage.html') as response: + html_data = await response.text() + + answers = BeautifulSoup(html_data, 'html.parser').get_text() + + start = answers.find('Q' + str(question) + ' ') + a = "" + if start > 0: + + finish = answers.find('answered', start) + 24 + a = answers[start:finish] + + # remove newlines + a = a.replace("\n", " ") + + # remove multiple spaces + a = " ".join(a.split()) + + embed = discord.Embed( + title=a[:a.find(" Q: ")], + url="https://frc-qa.firstinspires.org/qa/" + str(question), + color=discord.Color.blue()) + + embed.add_field(name="Question", + value=a[a.find(" Q: ") + 1:a.find(" A: ")], + inline=False) + embed.add_field(name="Answer", + value=a[a.find(" A: ") + 1:a.find(" ( Asked by ")], + inline=False) + + embed.set_footer( + text=a[a.find(" ( Asked by ") + 1:]) + + await ctx.send(embed=embed, ephemeral=True) + + else: + a = "That question was not answered or does not exist." + + # add url + a += "\nhttps://frc-qa.firstinspires.org/qa/" + question + await ctx.send(a, ephemeral=True) + + frcqa.example_usage = """ + `{prefix}frcqa 19` - show information on FRC Q&A #19 + """ + + +async def setup(bot): + """Adds the QA cog to the bot.""" + await bot.add_cog(QA(bot)) diff --git a/pylintrc b/pylintrc index cedd549e..00c72328 100755 --- a/pylintrc +++ b/pylintrc @@ -442,7 +442,7 @@ max-attributes=25 max-bool-expr=5 # Maximum number of branch for function / method body -max-branches=20 +max-branches=25 # Maximum number of lines in a module max-module-lines=1500 @@ -505,4 +505,4 @@ known-third-party=enchant # Exceptions that will emit a warning when being caught. Defaults to # "Exception" -overgeneral-exceptions=Exception \ No newline at end of file +overgeneral-exceptions=builtins.Exception \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5afc5e1d..0618fa55 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ asyncpg==0.26.0 -discord.py[speed,voice]==2.1.0 +discord.py[speed,voice]==2.3.0 aiotba~=0.0.3.post1 tbapi~=1.3.1b5 googlemaps~=4.4.2 @@ -12,5 +12,5 @@ sentry-sdk rstcloth~=0.3.1 humanize~=3.8.0 pre-commit~=2.20.0 -lavaplayer -loguru~=0.6.0 \ No newline at end of file +loguru~=0.6.0 +bs4