Compare commits

..

2 commits

Author SHA1 Message Date
結城イヴ 0a8dae2c41 Place hash_allow within reset 2019-04-23 22:09:54 -04:00
結城イヴ 6ec56d56ed Random hash choice preliminary work 2019-04-23 21:32:30 -04:00
81 changed files with 2867 additions and 8600 deletions

View file

@ -1,8 +0,0 @@
**/__pycache__/
.git/
.github/
.gitignore
.dockerignore
**/config.py
**/data/

View file

@ -1,18 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
open-pull-requests-limit: 5
reviewers:
- marysaka
schedule:
interval: weekly
- package-ecosystem: pip
directory: /
open-pull-requests-limit: 5
reviewers:
- marysaka
schedule:
interval: weekly

View file

@ -1,2 +0,0 @@
default:
- TSRBerry

View file

@ -1,59 +0,0 @@
name: Check formatting
on:
pull_request:
permissions:
contents: write
pull-requests: write
checks: write
jobs:
black:
name: Python Black
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install black
run: pip install black
- name: Configure git
run: |
git config --global user.name github-actions[bot]
git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com
- name: Run black
run: python -m black .
- name: Check if files have been modified
id: mod_check
run: |
[[ $(git status -s | wc -l) -le 1 ]] \
&& echo "is-dirty=false" >> "$GITHUB_OUTPUT" \
|| echo "is-dirty=true" >> "$GITHUB_OUTPUT"
- name: Commit and push changes
if: steps.mod_check.outputs.is-dirty == 'true'
run: |
git add .
git commit -m "Apply black formatting"
git push
fork-black:
name: Python Black
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name != github.repository
steps:
- uses: actions/checkout@v4
- uses: psf/black@stable

View file

@ -1,41 +0,0 @@
name: Mako
on:
discussion:
types: [created, edited, answered, unanswered, category_changed]
discussion_comment:
types: [created, edited]
gollum:
issue_comment:
types: [created, edited]
issues:
types: [opened, edited, reopened, pinned, milestoned, demilestoned, assigned, unassigned, labeled, unlabeled]
pull_request_target:
types: [opened, edited, reopened, synchronize, ready_for_review, assigned, unassigned]
jobs:
tasks:
name: Run Ryujinx tasks
permissions:
actions: read
contents: read
discussions: write
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
if: github.event_name == 'pull_request_target'
with:
# Ensure we pin the source origin as pull_request_target run under forks.
fetch-depth: 0
repository: Ryujinx/ryuko-ng
ref: master
- name: Run Mako command
uses: Ryujinx/Ryujinx-Mako@master
with:
command: exec-ryujinx-tasks
args: --event-name "${{ github.event_name }}" --event-path "${{ github.event_path }}" -w "${{ github.workspace }}" "${{ github.repository }}" "${{ github.run_id }}"
app_id: ${{ secrets.MAKO_APP_ID }}
private_key: ${{ secrets.MAKO_PRIVATE_KEY }}
installation_id: ${{ secrets.MAKO_INSTALLATION_ID }}

2
.gitignore vendored
View file

@ -98,7 +98,7 @@ files/*
*.ttf
priv-*
config.py
# Prevent data files from being committed
data/
robocop_ng/config.py

View file

@ -1,12 +0,0 @@
FROM python:3.10-alpine
WORKDIR /usr/src/app
COPY poetry.lock pyproject.toml ./
RUN apk add --no-cache gcc musl-dev python3-dev libffi-dev openssl-dev cargo && pip install --no-cache-dir poetry && poetry config virtualenvs.create false && poetry install --no-root --no-interaction --no-ansi -vvv && apk del gcc musl-dev python3-dev libffi-dev openssl-dev cargo
COPY . .
WORKDIR /usr/src/app
CMD [ "python", "-m", "robocop_ng", "/state" ]

145
README.md
View file

@ -1,43 +1,19 @@
# ryuko-ng
# Robocop-ng
Discord bot for handling Ryujinx moderation tasks and such, (n)ext-(g)en rewrite of Robocop
Next-gen rewrite of Kurisu/Robocop bot used on ReSwitched bot with discord.py rewrite, designed to be relatively clean, consistent and un-bloated.
Code is based on https://github.com/reswitched/robocop-ng.
Code is based on https://gitlab.com/ao/dpybotbase and https://github.com/916253/Kurisu-Reswitched.
---
## How to migrate from discord.py v1 to v2
As of 18.08.2022 this repo is based on discord.py v2.
Only changes needed are updating your cogs and ensuring that all privileged intents are enabled for your bot.
You can find the privileged intents guide here: https://discordpy.readthedocs.io/en/latest/intents.html?highlight=intents#privileged-intents
You can see the migration instructions for your cogs here: https://discordpy.readthedocs.io/en/latest/migrating.html
---
## How to run
- Copy `robocop_ng/config_template.py` to `robocop_ng/config.py` and **configure all necessary parts for your server**.
- Enable all privileged intents ([guide here](https://discordpy.readthedocs.io/en/latest/intents.html?highlight=intents#privileged-intents)) for the bot. You don't need to give Discord your passport as Ryuko-NG is not designed to run in >1 guild at once, let alone >100.
- Add the bot to your guild. There are many resources about this online.
- If you haven't already done this already, **move the bot's role above the roles it'll need to manage, or else it won't function properly**, this is especially important for verification as it doesn't work otherwise.
- If you're moving from Kurisu or Robocop: Follow [Tips for people moving from Kurisu/Robocop](https://github.com/Ryujinx/ryuko-ng#tips-for-people-moving-from-kurisurobocop) below.
### Running with docker
- `docker build . -t robocopng`
- Assuming your robocop-ng repo is on `~/docker/`: `docker run --restart=always -v ~/docker/robocop-ng:/usr/src/app --name robocop_ng robocopng:latest`
For updates, run `git pull;docker rm -f robocop_ng` then run the two commands above again.
### Running manually
- Install python3.8+.
- Install dependencies with [poetry](https://python-poetry.org/) using `poetry install`.
- Run `robocop_ng/__main__.py` (`cd robocop_ng;python3 __main__.py`).
- Copy `config.py.template` to `config.py`, configure all necessary parts to your server.
- Install python3.6+.
- Install python dependencies (`pip3 install -Ur requirements.txt`, you might need to put `sudo -H` before that)
- If you're moving from Kurisu or Robocop: Follow `Tips for people moving from Kurisu/Robocop` below.
- Run `Robocop.py` (`python3 Robocop.py`)
To keep the bot running, you might want to use pm2 or a systemd service.
@ -47,30 +23,115 @@ To keep the bot running, you might want to use pm2 or a systemd service.
If you're moving from Kurisu/Robocop, and want to preserve your data, you'll want to do the following steps:
- Copy your `data` folder over into the `robocop_ng` folder.
- Copy your `data` folder over.
- Rename your `data/warnsv2.json` file to `data/userlog.json`.
- Edit `data/restrictions.json` and replace role names (`"Muted"` etc) with role IDs (`526500080879140874` etc). Make sure to have it as int, not as str (don't wrap role id with `"` or `'`).
---
## Contributing
## TODO
Contributions are welcome. If you're unsure if your PR would be merged or not, ask in the [Ryujinx discord guild](https://discord.gg/ryujinx) pinging Berry.
All Robocop features are now supported.
You're expected to use [black](https://github.com/psf/black) for code formatting before sending a PR. Simply install it with pip (`pip3 install black`), and run it with `black .`.
<details>
<summary>List of added Kurisu/Robocop features</summary>
<p>
- [x] .py configs
- [x] membercount command
- [x] Meme commands and pegaswitch (honestly the easiest part)
- [x] source command
- [x] robocop command
- [x] Verification: Actual verification system
- [x] Verification: Reset command
- [x] Logging: joins
- [x] Logging: leaves
- [x] Logging: role changes
- [x] Logging: bans
- [x] Logging: kicks
- [x] Moderation: speak
- [x] Moderation: ban
- [x] Moderation: silentban
- [x] Moderation: kick
- [x] Moderation: userinfo
- [x] Moderation: approve-revoke (community)
- [x] Moderation: addhacker-removehacker (hacker)
- [x] Moderation: probate-unprobate (participant)
- [x] Moderation: lock-softlock-unlock (channel lockdown)
- [x] Moderation: mute-unmute
- [x] Moderation: playing
- [x] Moderation: botnickname
- [x] Moderation: nickname
- [x] Moderation: clear/purge
- [x] Moderation: restrictions (people who leave with muted role will get muted role on join)
- [x] Warns: warn
- [x] Warns: listwarns-listwarnsid
- [x] Warns: clearwarns-clearwarnsid
- [x] Warns: delwarnid-delwarn
- [x] .serr and .err (thanks tomger!)
</p>
</details>
---
## Credits
The main goal of this project, to get Robocop functionality done, is complete.
Ryuko-NG is a fork of [Robocop-NG](https://github.com/reswitched/robocop-ng) that is mainly maintained by [@TSRBerry](https://github.com/TSRBerry) and [@marysaka](https://github.com/marysaka).
Secondary goal is adding new features:
[Robocop-NG](https://github.com/reswitched/robocop-ng) was initially developed by [@aveao](https://github.com/aveao) and @tumGER. It is currently maintained by [@aveao](https://github.com/aveao). Similarly, the official robocop-ng on the ReSwitched discord guild is hosted by [@aveao](https://github.com/aveao) too.
- [ ] Purge: On purge, send logs in form of txt file to server logs
- [ ] New verification feature: Using log module from akbbot for logging attempts and removing old attempts
- [ ] New feature: Modmail
- [ ] New feature: Submiterr (relies on modmail)
- [ ] New feature: Highlights (problematic words automatically get posted to modmail channel, relies on modmail)
- [ ] Feature creep: Shortlink completion (gl/ao/etc)
- [ ] New moderation feature: timelock (channel lockdown with time, relies on robocronp)
I would like to thank the following, in no particular order:
<details>
<summary>Completed features</summary>
<p>
- [x] Better security, better checks and better guild whitelisting
- [x] Feature creep: Reminds
- [x] A system for running jobs in background with an interval (will be called robocronp)
- [x] Commands to list said jobs and remove them
- [x] New moderation feature: timemute (mute with time, relies on robocronp)
- [x] New moderation feature: timeban (ban with expiry, relies on robocronp)
- [x] Improvements to lockdown to ensure that staff can talk
- [x] New moderation feature: Display of mutes, bans and kicks on listwarns (.userlog now)
- [x] New moderation feature: User notes
- [x] New moderation feature: Reaction removing features (thanks misson20000!)
- [x] New moderation feature: User nickname change
- [x] New moderation feature: watch-unwatch
- [x] New moderation feature: tracking suspicious keywords
- [x] New moderation feature: tracking invites posted
- [x] New self-moderation feature: .mywarns
</p>
</details>
<details>
<summary>TODO for robocronp</summary>
<p>
- [ ] Reduce code repetition on mod_timed.py
- [x] Allow non-hour values on timed bans
the following require me to rethink some of the lockdown code, which I don't feel like
- [ ] lockdown in helper
- [ ] timelock command
- [ ] working cronjob for unlock
</p>
</details>
---
## Thanks to
- ReSwitched community, for being amazing
- ihaveamac/ihaveahax and f916253 for the original kurisu/robocop
- tomGER for working hard on rewriting the .err/.serr commands, those were a nightmare
- misson20000 for adding in reaction removal feature and putting up with my many BS requests on PR reviews
- linuxgemini for helping out with Yubico OTP revocation code (which is based on their work)
- Everyone who contributed to robocop-ng/ryuko-ng in any way (reporting a bug, sending a PR, forking and hosting their own at their own guild, etc).

209
Robocop.py Executable file
View file

@ -0,0 +1,209 @@
import os
import asyncio
import sys
import logging
import logging.handlers
import traceback
import aiohttp
import config
import discord
from discord.ext import commands
script_name = os.path.basename(__file__).split('.')[0]
log_file_name = f"{script_name}.log"
# Limit of discord (non-nitro) is 8MB (not MiB)
max_file_size = 1000 * 1000 * 8
backup_count = 3
file_handler = logging.handlers.RotatingFileHandler(
filename=log_file_name, maxBytes=max_file_size, backupCount=backup_count)
stdout_handler = logging.StreamHandler(sys.stdout)
log_format = logging.Formatter(
'[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s')
file_handler.setFormatter(log_format)
stdout_handler.setFormatter(log_format)
log = logging.getLogger('discord')
log.setLevel(logging.INFO)
log.addHandler(file_handler)
log.addHandler(stdout_handler)
def get_prefix(bot, message):
prefixes = config.prefixes
return commands.when_mentioned_or(*prefixes)(bot, message)
wanted_jsons = ["data/restrictions.json",
"data/robocronptab.json",
"data/userlog.json",
"data/invites.json"]
initial_extensions = ['cogs.common',
'cogs.admin',
'cogs.verification',
'cogs.mod',
'cogs.mod_note',
'cogs.mod_reacts',
'cogs.mod_userlog',
'cogs.mod_timed',
'cogs.mod_watch',
'cogs.basic',
'cogs.logs',
'cogs.err',
'cogs.lockdown',
'cogs.legacy',
'cogs.links',
'cogs.remind',
'cogs.robocronp',
'cogs.meme',
'cogs.pin',
'cogs.invites']
bot = commands.Bot(command_prefix=get_prefix,
description=config.bot_description, pm_help=True)
bot.log = log
bot.loop = asyncio.get_event_loop()
bot.config = config
bot.script_name = script_name
bot.wanted_jsons = wanted_jsons
if __name__ == '__main__':
for extension in initial_extensions:
try:
bot.load_extension(extension)
except Exception as e:
log.error(f'Failed to load extension {extension}.')
log.error(traceback.print_exc())
@bot.event
async def on_ready():
aioh = {"User-Agent": f"{script_name}/1.0'"}
bot.aiosession = aiohttp.ClientSession(headers=aioh)
bot.app_info = await bot.application_info()
bot.botlog_channel = bot.get_channel(config.botlog_channel)
log.info(f'\nLogged in as: {bot.user.name} - '
f'{bot.user.id}\ndpy version: {discord.__version__}\n')
game_name = f"{config.prefixes[0]}help"
# Send "Robocop has started! x has y members!"
guild = bot.botlog_channel.guild
msg = f"{bot.user.name} has started! "\
f"{guild.name} has {guild.member_count} members!"
data_files = [discord.File(fpath) for fpath in wanted_jsons]
await bot.botlog_channel.send(msg, files=data_files)
activity = discord.Activity(name=game_name,
type=discord.ActivityType.listening)
await bot.change_presence(activity=activity)
@bot.event
async def on_command(ctx):
log_text = f"{ctx.message.author} ({ctx.message.author.id}): "\
f"\"{ctx.message.content}\" "
if ctx.guild: # was too long for tertiary if
log_text += f"on \"{ctx.channel.name}\" ({ctx.channel.id}) "\
f"at \"{ctx.guild.name}\" ({ctx.guild.id})"
else:
log_text += f"on DMs ({ctx.channel.id})"
log.info(log_text)
@bot.event
async def on_error(event_method, *args, **kwargs):
log.error(f"Error on {event_method}: {sys.exc_info()}")
@bot.event
async def on_command_error(ctx, error):
error_text = str(error)
err_msg = f"Error with \"{ctx.message.content}\" from "\
f"\"{ctx.message.author} ({ctx.message.author.id}) "\
f"of type {type(error)}: {error_text}"
log.error(err_msg)
if not isinstance(error, commands.CommandNotFound):
err_msg = bot.escape_message(err_msg)
await bot.botlog_channel.send(err_msg)
if isinstance(error, commands.NoPrivateMessage):
return await ctx.send("This command doesn't work on DMs.")
elif isinstance(error, commands.MissingPermissions):
roles_needed = '\n- '.join(error.missing_perms)
return await ctx.send(f"{ctx.author.mention}: You don't have the right"
" permissions to run this command. You need: "
f"```- {roles_needed}```")
elif isinstance(error, commands.BotMissingPermissions):
roles_needed = '\n-'.join(error.missing_perms)
return await ctx.send(f"{ctx.author.mention}: Bot doesn't have "
"the right permissions to run this command. "
"Please add the following roles: "
f"```- {roles_needed}```")
elif isinstance(error, commands.CommandOnCooldown):
return await ctx.send(f"{ctx.author.mention}: You're being "
"ratelimited. Try in "
f"{error.retry_after:.1f} seconds.")
elif isinstance(error, commands.CheckFailure):
return await ctx.send(f"{ctx.author.mention}: Check failed. "
"You might not have the right permissions "
"to run this command, or you may not be able "
"to run this command in the current channel.")
elif isinstance(error, commands.CommandInvokeError) and\
("Cannot send messages to this user" in error_text):
return await ctx.send(f"{ctx.author.mention}: I can't DM you.\n"
"You might have me blocked or have DMs "
f"blocked globally or for {ctx.guild.name}.\n"
"Please resolve that, then "
"run the command again.")
elif isinstance(error, commands.CommandNotFound):
# Nothing to do when command is not found.
return
help_text = f"Usage of this command is: ```{ctx.prefix}"\
f"{ctx.command.signature}```\nPlease see `{ctx.prefix}help "\
f"{ctx.command.name}` for more info about this command."
if isinstance(error, commands.BadArgument):
return await ctx.send(f"{ctx.author.mention}: You gave incorrect "
f"arguments. {help_text}")
elif isinstance(error, commands.MissingRequiredArgument):
return await ctx.send(f"{ctx.author.mention}: You gave incomplete "
f"arguments. {help_text}")
@bot.event
async def on_message(message):
if message.author.bot:
return
if (message.guild) and (message.guild.id not in config.guild_whitelist):
return
# Ignore messages in newcomers channel, unless it's potentially reset
if message.channel.id == config.welcome_channel and\
"reset" not in message.content:
return
ctx = await bot.get_context(message)
await bot.invoke(ctx)
if not os.path.exists("data"):
os.makedirs("data")
for wanted_json in wanted_jsons:
if not os.path.exists(wanted_json):
with open(wanted_json, "w") as f:
f.write("{}")
bot.run(config.token, bot=True, reconnect=True, loop=bot.loop)

View file

@ -1,22 +0,0 @@
# Security Policy
PRs to this file to improve wording are welcome.
Please do not try to exploit public instances if it's going to cause harm, instead, set up your own instance of robocop-ng.
Breaking "database" files, running arbitrary code, using an unprivileged user to do something user can't normally do (editing channels or guild, deleting others' messages, making bot do an @e or @h mention, reading channels that user can't read, writing to channels that user can't write to, etc.) are all considered harmful.
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------------ | ------------------ |
| Latest git | :white_check_mark: |
## Reporting a Vulnerability
If the vulnerability fits into the "harmful" category specified above, then please email arcab [at] ave [dot] zone with details, as creating a public issue may cause it to be abused on public instances.
If not, please open an issue.

160
cogs/admin.py Normal file
View file

@ -0,0 +1,160 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
import traceback
import inspect
import re
from helpers.checks import check_if_bot_manager
class Admin(Cog):
def __init__(self, bot):
self.bot = bot
self.last_eval_result = None
self.previous_eval_code = None
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command(name='exit', aliases=["quit", "bye"])
async def _exit(self, ctx):
"""Shuts down the bot, bot manager only."""
await ctx.send(":wave: Goodbye!")
await self.bot.logout()
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def load(self, ctx, ext: str):
"""Loads a cog, bot manager only."""
try:
self.bot.load_extension("cogs." + ext)
except:
await ctx.send(f':x: Cog loading failed, traceback: '
f'```\n{traceback.format_exc()}\n```')
return
self.bot.log.info(f'Loaded ext {ext}')
await ctx.send(f':white_check_mark: `{ext}` successfully loaded.')
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def fetchlog(self, ctx):
"""Returns log"""
await ctx.send("Here's the current log file:",
file=discord.File(f"{self.bot.script_name}.log"))
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def fetchdata(self, ctx):
"""Returns data files"""
data_files = [discord.File(fpath) for fpath in self.bot.wanted_jsons]
await ctx.send("Here you go:", files=data_files)
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command(name='eval')
async def _eval(self, ctx, *, code: str):
"""Evaluates some code, bot manager only."""
try:
code = code.strip('` ')
env = {
'bot': self.bot,
'ctx': ctx,
'message': ctx.message,
'server': ctx.guild,
'guild': ctx.guild,
'channel': ctx.message.channel,
'author': ctx.message.author,
# modules
'discord': discord,
'commands': commands,
# utilities
'_get': discord.utils.get,
'_find': discord.utils.find,
# last result
'_': self.last_eval_result,
'_p': self.previous_eval_code,
}
env.update(globals())
self.bot.log.info(f"Evaling {repr(code)}:")
result = eval(code, env)
if inspect.isawaitable(result):
result = await result
if result is not None:
self.last_eval_result = result
self.previous_eval_code = code
sliced_message = await self.bot.slice_message(repr(result),
prefix="```",
suffix="```")
for msg in sliced_message:
await ctx.send(msg)
except:
sliced_message = \
await self.bot.slice_message(traceback.format_exc(),
prefix="```",
suffix="```")
for msg in sliced_message:
await ctx.send(msg)
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def pull(self, ctx, auto=False):
"""Does a git pull, bot manager only."""
tmp = await ctx.send('Pulling...')
git_output = await self.bot.async_call_shell("git pull")
await tmp.edit(content=f"Pull complete. Output: ```{git_output}```")
if auto:
cogs_to_reload = re.findall(r'cogs/([a-z_]*).py[ ]*\|', git_output)
for cog in cogs_to_reload:
try:
self.bot.unload_extension("cogs." + cog)
self.bot.load_extension("cogs." + cog)
self.bot.log.info(f'Reloaded ext {cog}')
await ctx.send(f':white_check_mark: `{cog}` '
'successfully reloaded.')
except:
await ctx.send(f':x: Cog reloading failed, traceback: '
f'```\n{traceback.format_exc()}\n```')
return
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def unload(self, ctx, ext: str):
"""Unloads a cog, bot manager only."""
self.bot.unload_extension("cogs." + ext)
self.bot.log.info(f'Unloaded ext {ext}')
await ctx.send(f':white_check_mark: `{ext}` successfully unloaded.')
@commands.check(check_if_bot_manager)
@commands.command()
async def reload(self, ctx, ext="_"):
"""Reloads a cog, bot manager only."""
if ext == "_":
ext = self.lastreload
else:
self.lastreload = ext
try:
self.bot.unload_extension("cogs." + ext)
self.bot.load_extension("cogs." + ext)
except:
await ctx.send(f':x: Cog reloading failed, traceback: '
f'```\n{traceback.format_exc()}\n```')
return
self.bot.log.info(f'Reloaded ext {ext}')
await ctx.send(f':white_check_mark: `{ext}` successfully reloaded.')
def setup(bot):
bot.add_cog(Admin(bot))

62
cogs/basic.py Normal file
View file

@ -0,0 +1,62 @@
import time
import config
import discord
from discord.ext import commands
from discord.ext.commands import Cog
class Basic(Cog):
def __init__(self, bot):
self.bot = bot
@commands.command()
async def hello(self, ctx):
"""Says hello. Duh."""
await ctx.send(f"Hello {ctx.author.mention}!")
@commands.guild_only()
@commands.command()
async def communitycount(self, ctx):
"""Prints the community member count of the server."""
community = ctx.guild.get_role(config.named_roles["community"])
await ctx.send(f"{ctx.guild.name} has "
f"{len(community.members)} community members!")
@commands.guild_only()
@commands.command()
async def membercount(self, ctx):
"""Prints the member count of the server."""
await ctx.send(f"{ctx.guild.name} has "
f"{ctx.guild.member_count} members!")
@commands.command(aliases=["robocopng", "robocop-ng"])
async def robocop(self, ctx):
"""Shows a quick embed with bot info."""
embed = discord.Embed(title="Robocop-NG",
url=config.source_url,
description=config.embed_desc)
embed.set_thumbnail(url=self.bot.user.avatar_url)
await ctx.send(embed=embed)
@commands.command(aliases=['p'])
async def ping(self, ctx):
"""Shows ping values to discord.
RTT = Round-trip time, time taken to send a message to discord
GW = Gateway Ping"""
before = time.monotonic()
tmp = await ctx.send('Calculating ping...')
after = time.monotonic()
rtt_ms = (after - before) * 1000
gw_ms = self.bot.latency * 1000
message_text = f":ping_pong:\n"\
f"rtt: `{rtt_ms:.1f}ms`\n"\
f"gw: `{gw_ms:.1f}ms`"
self.bot.log.info(message_text)
await tmp.edit(content=message_text)
def setup(bot):
bot.add_cog(Basic(bot))

View file

@ -7,7 +7,6 @@ import math
import parsedatetime
from discord.ext.commands import Cog
class Common(Cog):
def __init__(self, bot):
self.bot = bot
@ -31,14 +30,9 @@ class Common(Cog):
res_timestamp = math.floor(time.mktime(time_struct))
return res_timestamp
def get_relative_timestamp(
self,
time_from=None,
time_to=None,
humanized=False,
include_from=False,
include_to=False,
):
def get_relative_timestamp(self, time_from=None, time_to=None,
humanized=False, include_from=False,
include_to=False):
# Setting default value to utcnow() makes it show time from cog load
# which is not what we want
if not time_from:
@ -48,19 +42,17 @@ class Common(Cog):
if humanized:
humanized_string = humanize.naturaltime(time_from - time_to)
if include_from and include_to:
str_with_from_and_to = (
f"{humanized_string} "
f"({str(time_from).split('.')[0]} "
f"- {str(time_to).split('.')[0]})"
)
str_with_from_and_to = f"{humanized_string} "\
f"({str(time_from).split('.')[0]} "\
f"- {str(time_to).split('.')[0]})"
return str_with_from_and_to
elif include_from:
str_with_from = (
f"{humanized_string} " f"({str(time_from).split('.')[0]})"
)
str_with_from = f"{humanized_string} "\
f"({str(time_from).split('.')[0]})"
return str_with_from
elif include_to:
str_with_to = f"{humanized_string} " f"({str(time_to).split('.')[0]})"
str_with_to = f"{humanized_string} "\
f"({str(time_to).split('.')[0]})"
return str_with_to
return humanized_string
else:
@ -68,7 +60,8 @@ class Common(Cog):
epoch_from = (time_from - epoch).total_seconds()
epoch_to = (time_to - epoch).total_seconds()
second_diff = epoch_to - epoch_from
result_string = str(datetime.timedelta(seconds=second_diff)).split(".")[0]
result_string = str(datetime.timedelta(
seconds=second_diff)).split('.')[0]
return result_string
async def aioget(self, url):
@ -79,12 +72,11 @@ class Common(Cog):
self.bot.log.info(f"Data from {url}: {text_data}")
return text_data
else:
self.bot.log.error(f"HTTP Error {data.status} while getting {url}")
self.bot.log.error(f"HTTP Error {data.status} "
"while getting {url}")
except:
self.bot.log.error(
f"Error while getting {url} "
f"on aiogetbytes: {traceback.format_exc()}"
)
self.bot.log.error(f"Error while getting {url} "
f"on aiogetbytes: {traceback.format_exc()}")
async def aiogetbytes(self, url):
try:
@ -94,12 +86,11 @@ class Common(Cog):
self.bot.log.debug(f"Data from {url}: {byte_data}")
return byte_data
else:
self.bot.log.error(f"HTTP Error {data.status} while getting {url}")
self.bot.log.error(f"HTTP Error {data.status} "
"while getting {url}")
except:
self.bot.log.error(
f"Error while getting {url} "
f"on aiogetbytes: {traceback.format_exc()}"
)
self.bot.log.error(f"Error while getting {url} "
f"on aiogetbytes: {traceback.format_exc()}")
async def aiojson(self, url):
try:
@ -107,19 +98,18 @@ class Common(Cog):
if data.status == 200:
text_data = await data.text()
self.bot.log.info(f"Data from {url}: {text_data}")
content_type = data.headers["Content-Type"]
content_type = data.headers['Content-Type']
return await data.json(content_type=content_type)
else:
self.bot.log.error(f"HTTP Error {data.status} while getting {url}")
self.bot.log.error(f"HTTP Error {data.status} "
"while getting {url}")
except:
self.bot.log.error(
f"Error while getting {url} "
f"on aiogetbytes: {traceback.format_exc()}"
)
self.bot.log.error(f"Error while getting {url} "
f"on aiogetbytes: {traceback.format_exc()}")
def hex_to_int(self, color_hex: str):
"""Turns a given hex color into an integer"""
return int("0x" + color_hex.strip("#"), 16)
return int("0x" + color_hex.strip('#'), 16)
def escape_message(self, text: str):
"""Escapes unfun stuff from messages"""
@ -139,12 +129,10 @@ class Common(Cog):
"""Slices a message into multiple messages"""
if len(text) > size * self.max_split_length:
haste_url = await self.haste(text)
return [
f"Message is too long ({len(text)} > "
f"{size * self.max_split_length} "
f"({size} * {self.max_split_length}))"
f", go to haste: <{haste_url}>"
]
return [f"Message is too long ({len(text)} > "
f"{size * self.max_split_length} "
f"({size} * {self.max_split_length}))"
f", go to haste: <{haste_url}>"]
reply_list = []
size_wo_fix = size - len(prefix) - len(suffix)
while len(text) > size_wo_fix:
@ -153,28 +141,28 @@ class Common(Cog):
reply_list.append(f"{prefix}{text}{suffix}")
return reply_list
async def haste(self, text, instance="https://mystb.in/"):
response = await self.bot.aiosession.post(f"{instance}documents", data=text)
async def haste(self, text, instance='https://mystb.in/'):
response = await self.bot.aiosession.post(f"{instance}documents",
data=text)
if response.status == 200:
result_json = await response.json()
return f"{instance}{result_json['key']}"
else:
return f"Error {response.status}: {response.text}"
async def async_call_shell(
self, shell_command: str, inc_stdout=True, inc_stderr=True
):
async def async_call_shell(self, shell_command: str,
inc_stdout=True, inc_stderr=True):
pipe = asyncio.subprocess.PIPE
proc = await asyncio.create_subprocess_shell(
str(shell_command), stdout=pipe, stderr=pipe
)
proc = await asyncio.create_subprocess_shell(str(shell_command),
stdout=pipe,
stderr=pipe)
if not (inc_stdout or inc_stderr):
return "??? you set both stdout and stderr to False????"
proc_result = await proc.communicate()
stdout_str = proc_result[0].decode("utf-8").strip()
stderr_str = proc_result[1].decode("utf-8").strip()
stdout_str = proc_result[0].decode('utf-8').strip()
stderr_str = proc_result[1].decode('utf-8').strip()
if inc_stdout and not inc_stderr:
return stdout_str
@ -182,7 +170,8 @@ class Common(Cog):
return stderr_str
if stdout_str and stderr_str:
return f"stdout:\n\n{stdout_str}\n\n" f"======\n\nstderr:\n\n{stderr_str}"
return f"stdout:\n\n{stdout_str}\n\n"\
f"======\n\nstderr:\n\n{stderr_str}"
elif stdout_str:
return f"stdout:\n\n{stdout_str}"
elif stderr_str:
@ -191,5 +180,5 @@ class Common(Cog):
return "No output."
async def setup(bot):
await bot.add_cog(Common(bot))
def setup(bot):
bot.add_cog(Common(bot))

View file

@ -1,26 +1,23 @@
import re
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.errcodes import *
from helpers.errcodes import *
class Err(Cog):
"""Everything related to Nintendo 3DS, Wii U and Switch error codes"""
def __init__(self, bot):
self.bot = bot
self.dds_re = re.compile(r"0\d{2}\-\d{4}")
self.wiiu_re = re.compile(r"1\d{2}\-\d{4}")
self.switch_re = re.compile(r"2\d{3}\-\d{4}")
self.no_err_desc = (
"It seems like your error code is unknown. "
"You can check on Switchbrew for your error code at "
"<https://switchbrew.org/wiki/Error_codes>"
)
self.rickroll = "https://www.youtube.com/watch?v=z3ZiVn5L9vM"
self.dds_re = re.compile(r'0\d{2}\-\d{4}')
self.wiiu_re = re.compile(r'1\d{2}\-\d{4}')
self.switch_re = re.compile(r'2\d{3}\-\d{4}')
self.no_err_desc = "It seems like your error code is unknown. "\
"You should report relevant details to "\
"<@141532589725974528> (tomGER#7462) "\
"so it can be added to the bot."
self.rickroll = "https://www.youtube.com/watch?v=4uj896lr3-E"
@commands.command(aliases=["3dserr", "3err", "dserr"])
async def dderr(self, ctx, err: str):
@ -32,9 +29,9 @@ class Err(Cog):
else:
err_description = self.no_err_desc
# Make a nice Embed out of it
embed = discord.Embed(
title=err, url=self.rickroll, description=err_description
)
embed = discord.Embed(title=err,
url=self.rickroll,
description=err_description)
embed.set_footer(text="Console: 3DS")
# Send message, crazy
@ -51,7 +48,8 @@ class Err(Cog):
level = (rc >> 27) & 0x1F
embed = discord.Embed(title=f"0x{rc:X}")
embed.add_field(name="Module", value=dds_modules.get(mod, mod))
embed.add_field(name="Description", value=dds_descriptions.get(desc, desc))
embed.add_field(name="Description",
value=dds_descriptions.get(desc, desc))
embed.add_field(name="Summary", value=dds_summaries.get(summ, summ))
embed.add_field(name="Level", value=dds_levels.get(level, level))
embed.set_footer(text="Console: 3DS")
@ -59,15 +57,13 @@ class Err(Cog):
await ctx.send(embed=embed)
return
else:
await ctx.send(
"Unknown Format - This is either "
"no error code or you made some mistake!"
)
await ctx.send("Unknown Format - This is either "
"no error code or you made some mistake!")
@commands.command(aliases=["wiiuserr", "uerr", "wuerr", "mochaerr"])
async def wiiuerr(self, ctx, err: str):
"""Searches for Wii U error codes!
Usage: .wiiuserr/.uerr/.wuerr/.mochaerr <Error Code>"""
Usage: .wiiuserr/.uerr/.wuerr/.mochaerr <Error Code>"""
if self.wiiu_re.match(err): # Wii U
module = err[2:3] # Is that even true, idk just guessing
desc = err[5:8]
@ -77,9 +73,9 @@ class Err(Cog):
err_description = self.no_err_desc
# Make a nice Embed out of it
embed = discord.Embed(
title=err, url=self.rickroll, description=err_description
)
embed = discord.Embed(title=err,
url=self.rickroll,
description=err_description)
embed.set_footer(text="Console: Wii U")
embed.add_field(name="Module", value=module, inline=True)
embed.add_field(name="Description", value=desc, inline=True)
@ -87,17 +83,16 @@ class Err(Cog):
# Send message, crazy
await ctx.send(embed=embed)
else:
await ctx.send(
"Unknown Format - This is either "
"no error code or you made some mistake!"
)
await ctx.send("Unknown Format - This is either "
"no error code or you made some mistake!")
@commands.command(aliases=["nxerr", "serr"])
async def err(self, ctx, err: str):
"""Searches for Switch error codes!
Usage: .serr/.nxerr/.err <Error Code>"""
Usage: .serr/.nxerr/.err <Error Code>"""
if self.switch_re.match(err) or err.startswith("0x"): # Switch
if err.startswith("0x"):
err = err[2:]
errcode = int(err, 16)
@ -108,7 +103,7 @@ class Err(Cog):
desc = int(err[5:9])
errcode = (desc << 9) + module
str_errcode = f"{(module + 2000):04}-{desc:04}"
str_errcode = f'{(module + 2000):04}-{desc:04}'
# Searching for Modules in list
if module in switch_modules:
@ -131,21 +126,19 @@ class Err(Cog):
err_description = errcode_range[2]
# Make a nice Embed out of it
embed = discord.Embed(
title=f"{str_errcode} / {hex(errcode)}",
url=self.rickroll,
description=err_description,
)
embed.add_field(
name="Module", value=f"{err_module} ({module})", inline=True
)
embed = discord.Embed(title=f"{str_errcode} / {hex(errcode)}",
url=self.rickroll,
description=err_description)
embed.add_field(name="Module",
value=f"{err_module} ({module})",
inline=True)
embed.add_field(name="Description", value=desc, inline=True)
if "ban" in err_description:
embed.set_footer(text="F to you | Console: Switch")
embed.set_footer("F to you | Console: Switch")
else:
embed.set_footer(text="Console: Switch")
await ctx.send(embed=embed)
# Special case handling because Nintendo feels like
@ -153,46 +146,45 @@ class Err(Cog):
elif err in switch_game_err:
game, desc = switch_game_err[err].split(":")
embed = discord.Embed(title=err, url=self.rickroll, description=desc)
embed = discord.Embed(title=err,
url=self.rickroll,
description=desc)
embed.set_footer(text="Console: Switch")
embed.add_field(name="Game", value=game, inline=True)
await ctx.send(embed=embed)
else:
await ctx.send(
"Unknown Format - This is either "
"no error code or you made some mistake!"
)
await ctx.send("Unknown Format - This is either "
"no error code or you made some mistake!")
@commands.command(aliases=["e2h"])
async def err2hex(self, ctx, err: str):
"""Converts Nintendo Switch errors to hex
Usage: .err2hex <Error Code>"""
Usage: .err2hex <Error Code>"""
if self.switch_re.match(err):
module = int(err[0:4]) - 2000
desc = int(err[5:9])
errcode = (desc << 9) + module
await ctx.send(hex(errcode))
else:
await ctx.send(
"This doesn't follow the typical Nintendo Switch 2XXX-XXXX format!"
)
await ctx.send("This doesn't follow the typical"
" Nintendo Switch 2XXX-XXXX format!")
@commands.command(aliases=["h2e"])
async def hex2err(self, ctx, err: str):
"""Converts Nintendo Switch errors to hex
Usage: .hex2err <Hex>"""
Usage: .hex2err <Hex>"""
if err.startswith("0x"):
err = err[2:]
err = int(err, 16)
module = err & 0x1FF
desc = (err >> 9) & 0x3FFF
errcode = f"{(module + 2000):04}-{desc:04}"
errcode = f'{(module + 2000):04}-{desc:04}'
await ctx.send(errcode)
else:
await ctx.send("This doesn't look like typical hex!")
async def setup(bot):
await bot.add_cog(Err(bot))
def setup(bot):
bot.add_cog(Err(bot))

43
cogs/invites.py Normal file
View file

@ -0,0 +1,43 @@
from discord.ext import commands
from discord.ext.commands import Cog
from helpers.checks import check_if_collaborator
import config
import json
class Invites(Cog):
def __init__(self, bot):
self.bot = bot
@commands.command()
@commands.guild_only()
@commands.check(check_if_collaborator)
async def invite(self, ctx):
welcome_channel = self.bot.get_channel(config.welcome_channel)
author = ctx.message.author
reason = f"Created by {str(author)} ({author.id})"
invite = await welcome_channel.create_invite(max_age = 0,
max_uses = 1, temporary = True, unique = True, reason = reason)
with open("data/invites.json", "r") as f:
invites = json.load(f)
invites[invite.id] = {
"uses": 0,
"url": invite.url,
"max_uses": 1,
"code": invite.code
}
with open("data/invites.json", "w") as f:
f.write(json.dumps(invites))
await ctx.message.add_reaction("🆗")
try:
await ctx.author.send(f"Created single-use invite {invite.url}")
except discord.errors.Forbidden:
await ctx.send(f"{ctx.author.mention} I could not send you the \
invite. Send me a DM so I can reply to you.")
def setup(bot):
bot.add_cog(Invites(bot))

30
cogs/legacy.py Normal file
View file

@ -0,0 +1,30 @@
from discord.ext import commands
from discord.ext.commands import Cog
class Legacy(Cog):
def __init__(self, bot):
self.bot = bot
@commands.command(hidden=True, aliases=["removehacker"])
async def probate(self, ctx):
"""Use .revoke <user> <role>"""
await ctx.send("This command was replaced with `.revoke <user> <role>`"
" on Robocop-NG, please use that instead.")
@commands.command(hidden=True)
async def softlock(self, ctx):
"""Use .lock True"""
await ctx.send("This command was replaced with `.lock True`"
" on Robocop-NG, please use that instead.\n"
"Also... good luck, and sorry for taking your time. "
"Lockdown rarely means anything good.")
@commands.command(hidden=True, aliases=["addhacker"])
async def unprobate(self, ctx):
"""Use .approve <user> <role>"""
await ctx.send("This command was replaced with `.approve <user> <role>`"
" on Robocop-NG, please use that instead.")
def setup(bot):
bot.add_cog(Legacy(bot))

87
cogs/links.py Normal file
View file

@ -0,0 +1,87 @@
import discord
import config
from discord.ext import commands
from discord.ext.commands import Cog
class Links(Cog):
"""
Commands for easily linking to projects.
"""
def __init__(self, bot):
self.bot = bot
@commands.command(hidden=True)
async def pegaswitch(self, ctx):
"""Link to the Pegaswitch repo"""
await ctx.send("https://github.com/reswitched/pegaswitch")
@commands.command(hidden=True, aliases=["atmos"])
async def atmosphere(self, ctx):
"""Link to the Atmosphere repo"""
await ctx.send("https://github.com/atmosphere-nx/atmosphere")
@commands.command(hidden=True, aliases=["xyproblem"])
async def xy(self, ctx):
"""Link to the "What is the XY problem?" post from SE"""
await ctx.send("<https://meta.stackexchange.com/q/66377/285481>\n\n"
"TL;DR: It's asking about your attempted solution "
"rather than your actual problem.\n"
"It's perfectly okay to want to learn about a "
"solution, but please be clear about your intentions "
"if you're not actually trying to solve a problem.")
@commands.command(hidden=True, aliases=["guides", "link"])
async def guide(self, ctx):
"""Link to the guide(s)"""
await ctx.send("**Generic starter guides:**\n"
"Nintendo Homebrew's Guide: "
"<https://nh-server.github.io/switch-guide/>\n"
"AtlasNX's Guide: "
"<https://guide.teamatlasnx.com>\n"
"Pegaswitch Guide: <https://switch.hacks.guide/> "
"(outdated for anything but Pegaswitch/3.0.0)\n\n"
"**Specific guides:**\n"
"Manually Updating/Downgrading (with HOS): "
"<https://guide.sdsetup.com/usingcfw/manualupgrade>\n"
"Manually Repairing/Downgrading (without HOS): "
"<https://guide.sdsetup.com/usingcfw/manualchoiupgrade>\n"
"How to get started developing Homebrew: "
"<https://gbatemp.net/threads/"
"tutorial-switch-homebrew-development.507284/>\n"
"Getting full RAM in homebrew without NSPs: "
"as of Atmosphere 0.8.6, hold R while opening any game.")
@commands.command()
async def source(self, ctx):
"""Gives link to source code."""
await ctx.send(f"You can find my source at {config.source_url}. "
"Serious PRs and issues welcome!")
@commands.command()
async def rules(self, ctx, *, targetuser: discord.Member = None):
"""Post a link to the Rules"""
if not targetuser:
targetuser = ctx.author
await ctx.send(f"{targetuser.mention}: A link to the rules "
f"can be found here: {config.rules_url}")
@commands.command()
async def community(self, ctx, *, targetuser: discord.Member = None):
"""Post a link to the community section of the rules"""
if not targetuser:
targetuser = ctx.author
await ctx.send(f"{targetuser.mention}: "
"https://reswitched.team/discord/#member-roles-breakdown"
"\n\n"
"Community role allows access to the set of channels "
"on the community category (#off-topic, "
"#homebrew-development, #switch-hacking-general etc)."
"\n\n"
"What you need to get the role is to be around, "
"be helpful and nice to people and "
"show an understanding of rules.")
def setup(bot):
bot.add_cog(Links(bot))

View file

@ -1,49 +1,47 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
import config
import discord
from helpers.checks import check_if_staff
class Lockdown(Cog):
def __init__(self, bot):
self.bot = bot
async def set_sendmessage(
self, channel: discord.TextChannel, role, allow_send, issuer
):
async def set_sendmessage(self, channel: discord.TextChannel,
role, allow_send, issuer):
try:
roleobj = channel.guild.get_role(role)
overrides = channel.overwrites_for(roleobj)
overrides.send_messages = allow_send
await channel.set_permissions(
roleobj, overwrite=overrides, reason=str(issuer)
)
await channel.set_permissions(roleobj,
overwrite=overrides,
reason=str(issuer))
except:
pass
async def unlock_for_staff(self, channel: discord.TextChannel, issuer):
for role in self.bot.config.staff_role_ids:
for role in config.staff_role_ids:
await self.set_sendmessage(channel, role, True, issuer)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def lock(self, ctx, channel: discord.TextChannel = None, soft: bool = False):
async def lock(self, ctx, channel: discord.TextChannel = None,
soft: bool = False):
"""Prevents people from speaking in a channel, staff only.
Defaults to current channel."""
if not channel:
channel = ctx.channel
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
roles = None
for key, lockdown_conf in self.bot.config.lockdown_configs.items():
for key, lockdown_conf in config.lockdown_configs.items():
if channel.id in lockdown_conf["channels"]:
roles = lockdown_conf["roles"]
if roles is None:
roles = self.bot.config.lockdown_configs["default"]["roles"]
roles = config.lockdown_configs["default"]["roles"]
for role in roles:
await self.set_sendmessage(channel, role, False, ctx.author)
@ -52,20 +50,14 @@ class Lockdown(Cog):
public_msg = "🔒 Channel locked down. "
if not soft:
public_msg += (
"Only staff members may speak. "
"Do not bring the topic to other channels or risk "
"disciplinary actions."
)
public_msg += "Only staff members may speak. "\
"Do not bring the topic to other channels or risk "\
"disciplinary actions."
await ctx.send(public_msg)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(ctx.author)
)
msg = (
f"🔒 **Lockdown**: {ctx.channel.mention} by {ctx.author.mention} "
f"| {safe_name}"
)
safe_name = await commands.clean_content().convert(ctx, str(ctx.author))
msg = f"🔒 **Lockdown**: {ctx.channel.mention} by {ctx.author.mention} "\
f"| {safe_name}"
await log_channel.send(msg)
@commands.guild_only()
@ -75,31 +67,26 @@ class Lockdown(Cog):
"""Unlocks speaking in current channel, staff only."""
if not channel:
channel = ctx.channel
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
roles = None
for key, lockdown_conf in self.bot.config.lockdown_configs.items():
for key, lockdown_conf in config.lockdown_configs.items():
if channel.id in lockdown_conf["channels"]:
roles = lockdown_conf["roles"]
if roles is None:
roles = self.bot.config.lockdown_configs["default"]["roles"]
roles = config.lockdown_configs["default"]["roles"]
await self.unlock_for_staff(channel, ctx.author)
for role in roles:
await self.set_sendmessage(channel, role, True, ctx.author)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(ctx.author)
)
safe_name = await commands.clean_content().convert(ctx, str(ctx.author))
await ctx.send("🔓 Channel unlocked.")
msg = (
f"🔓 **Unlock**: {ctx.channel.mention} by {ctx.author.mention} "
f"| {safe_name}"
)
msg = f"🔓 **Unlock**: {ctx.channel.mention} by {ctx.author.mention} "\
f"| {safe_name}"
await log_channel.send(msg)
async def setup(bot):
await bot.add_cog(Lockdown(bot))
def setup(bot):
bot.add_cog(Lockdown(bot))

View file

@ -1,14 +1,10 @@
import json
import os
import re
import discord
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
from robocop_ng.helpers.invites import get_invites, set_invites
from robocop_ng.helpers.restrictions import get_user_restrictions
from robocop_ng.helpers.userlogs import get_userlog
import json
import re
import config
from helpers.restrictions import get_user_restrictions
from helpers.checks import check_if_staff
class Logs(Cog):
@ -18,30 +14,32 @@ class Logs(Cog):
def __init__(self, bot):
self.bot = bot
self.invite_re = re.compile(
r"((discord\.gg|discordapp\.com/" r"+invite)/+[a-zA-Z0-9-]+)", re.IGNORECASE
)
self.invite_re = re.compile(r"((discord\.gg|discordapp\.com/"
r"+invite)/+[a-zA-Z0-9-]+)",
re.IGNORECASE)
self.name_re = re.compile(r"[a-zA-Z0-9].*")
self.clean_re = re.compile(r"[^a-zA-Z0-9_ ]+", re.UNICODE)
self.clean_re = re.compile(r'[^a-zA-Z0-9_ ]+', re.UNICODE)
# All lower case, no spaces, nothing non-alphanumeric
susp_hellgex = "|".join(
[r"\W*".join(list(word)) for word in self.bot.config.suspect_words]
)
self.susp_words = ["sx", "tx", "reinx", # piracy-enabling cfws
"tinfoil", "dz", # title managers
"goldleaf", "lithium", # title managers
"xci"] # "backup" format
susp_hellgex = "|".join([r"\W*".join(list(word)) for
word in self.susp_words])
self.susp_hellgex = re.compile(susp_hellgex, re.IGNORECASE)
self.ok_words = []
@Cog.listener()
async def on_member_join(self, member):
await self.bot.wait_until_ready()
if member.guild.id not in self.bot.config.guild_whitelist:
return
log_channel = self.bot.get_channel(self.bot.config.log_channel)
log_channel = self.bot.get_channel(config.log_channel)
# We use this a lot, might as well get it once
escaped_name = self.bot.escape_message(member)
# Attempt to correlate the user joining with an invite
invites = get_invites(self.bot)
with open("data/invites.json", "r") as f:
invites = json.load(f)
real_invites = await member.guild.invites()
@ -52,7 +50,7 @@ class Logs(Cog):
"uses": 0,
"url": invite.url,
"max_uses": invite.max_uses,
"code": invite.code,
"code": invite.code
}
probable_invites_used = []
@ -75,11 +73,12 @@ class Logs(Cog):
del invites[id]
# Save invites data.
set_invites(self.bot, invites)
with open("data/invites.json", "w") as f:
f.write(json.dumps(invites))
# Prepare the invite correlation message
if len(probable_invites_used) == 1:
invite_used = probable_invites_used[0]["code"]
invite_used = probable_invites_used[0]["url"]
elif len(probable_invites_used) == 0:
invite_used = "Unknown"
else:
@ -88,64 +87,54 @@ class Logs(Cog):
# Check if user account is older than 15 minutes
age = member.joined_at - member.created_at
if age < self.bot.config.min_age:
if age < config.min_age:
try:
await member.send(
f"Your account is too new to "
f"join {member.guild.name}."
" Please try again later."
)
await member.send(f"Your account is too new to "
f"join {member.guild.name}."
" Please try again later.")
sent = True
except discord.errors.Forbidden:
sent = False
await member.kick(reason="Too new")
msg = (
f"🚨 **Account too new**: {member.mention} | "
f"{escaped_name}\n"
f"🗓 __Creation__: {member.created_at}\n"
f"🕓 Account age: {age}\n"
f"✉ Joined with: {invite_used}\n"
f"🏷 __User ID__: {member.id}"
)
msg = f"🚨 **Account too new**: {member.mention} | "\
f"{escaped_name}\n"\
f"🗓 __Creation__: {member.created_at}\n"\
f"🕓 Account age: {age}\n"\
f"✉ Joined with: {invite_used}\n"\
f"🏷 __User ID__: {member.id}"
if not sent:
msg += (
"\nThe user has disabled direct messages, "
"so the reason was not sent."
)
msg += "\nThe user has disabled direct messages,"\
" so the reason was not sent."
await log_channel.send(msg)
return
msg = (
f"✅ **Join**: {member.mention} | "
f"{escaped_name}\n"
f"🗓 __Creation__: {member.created_at}\n"
f"🕓 Account age: {age}\n"
f"✉ Joined with: {invite_used}\n"
f"🏷 __User ID__: {member.id}"
)
msg = f"✅ **Join**: {member.mention} | "\
f"{escaped_name}\n"\
f"🗓 __Creation__: {member.created_at}\n"\
f"🕓 Account age: {age}\n"\
f"✉ Joined with: {invite_used}\n"\
f"🏷 __User ID__: {member.id}"
# Handles user restrictions
# Basically, gives back muted role to users that leave with it.
rsts = get_user_restrictions(self.bot, member.id)
rsts = get_user_restrictions(member.id)
roles = [discord.utils.get(member.guild.roles, id=rst) for rst in rsts]
await member.add_roles(*roles)
# Real hell zone.
warns = get_userlog(self.bot)
with open("data/userlog.json", "r") as f:
warns = json.load(f)
try:
if len(warns[str(member.id)]["warns"]) == 0:
await log_channel.send(msg)
else:
embed = discord.Embed(
color=discord.Color.dark_red(), title=f"Warns for {escaped_name}"
)
embed.set_thumbnail(url=str(member.display_avatar))
embed = discord.Embed(color=discord.Color.dark_red(),
title=f"Warns for {escaped_name}")
embed.set_thumbnail(url=member.avatar_url)
for idx, warn in enumerate(warns[str(member.id)]["warns"]):
embed.add_field(
name=f"{idx + 1}: {warn['timestamp']}",
value=f"Issuer: {warn['issuer_name']}"
f"\nReason: {warn['reason']}",
)
embed.add_field(name=f"{idx + 1}: {warn['timestamp']}",
value=f"Issuer: {warn['issuer_name']}"
f"\nReason: {warn['reason']}")
await log_channel.send(msg, embed=embed)
except KeyError: # if the user is not in the file
await log_channel.send(msg)
@ -158,41 +147,34 @@ class Logs(Cog):
return
alert = False
cleancont = self.clean_re.sub("", message.content).lower()
msg = (
f"🚨 Suspicious message by {message.author.mention} "
f"({message.author.id}):"
)
cleancont = self.clean_re.sub('', message.content).lower()
msg = f"🚨 Suspicious message by {message.author.mention} "\
f"({message.author.id}):"
invites = self.invite_re.findall(message.content)
for invite in invites:
msg += f"\n- Has invite: https://{invite[0]}"
alert = True
for susp_word in self.bot.config.suspect_words:
if susp_word in cleancont and not any(
ok_word in cleancont
for ok_word in self.bot.config.suspect_ignored_words
):
for susp_word in self.susp_words:
if susp_word in cleancont and\
not any(ok_word in cleancont for ok_word in self.ok_words):
msg += f"\n- Contains suspicious word: `{susp_word}`"
alert = True
if alert:
msg += f"\n\nJump: <{message.jump_url}>"
spy_channel = self.bot.get_channel(self.bot.config.spylog_channel)
spy_channel = self.bot.get_channel(config.spylog_channel)
# Bad Code :tm:, blame retr0id
message_clean = message.content.replace("*", "").replace("_", "")
regd = self.susp_hellgex.sub(
lambda w: "**{}**".format(w.group(0)), message_clean
)
regd = self.susp_hellgex.sub(lambda w: "**{}**".format(w.group(0)),
message_clean)
# Show a message embed
embed = discord.Embed(description=regd)
embed.set_author(
name=message.author.display_name,
icon_url=str(message.author.display_avatar),
)
embed.set_author(name=message.author.display_name,
icon_url=message.author.avatar_url)
await spy_channel.send(msg, embed=embed)
@ -201,17 +183,16 @@ class Logs(Cog):
if compliant:
return
msg = (
f"R11 violating name by {message.author.mention} " f"({message.author.id})."
)
msg = f"R11 violating name by {message.author.mention} "\
f"({message.author.id})."
spy_channel = self.bot.get_channel(self.bot.config.spylog_channel)
spy_channel = self.bot.get_channel(config.spylog_channel)
await spy_channel.send(msg)
@Cog.listener()
async def on_message(self, message):
await self.bot.wait_until_ready()
if message.channel.id not in self.bot.config.spy_channels:
if message.channel.id not in config.spy_channels:
return
await self.do_spy(message)
@ -219,7 +200,7 @@ class Logs(Cog):
@Cog.listener()
async def on_message_edit(self, before, after):
await self.bot.wait_until_ready()
if after.channel.id not in self.bot.config.spy_channels or after.author.bot:
if after.channel.id not in config.spy_channels or after.author.bot:
return
# If content is the same, just skip over it
@ -229,18 +210,11 @@ class Logs(Cog):
await self.do_spy(after)
# U+200D is a Zero Width Joiner stopping backticks from breaking the formatting
before_content = before.clean_content.replace("`", "`\u200d")
after_content = after.clean_content.replace("`", "`\u200d")
log_channel = self.bot.get_channel(self.bot.config.log_channel)
msg = (
"📝 **Message edit**: \n"
f"from {self.bot.escape_message(after.author.name)} "
f"({after.author.id}), in {after.channel.mention}:\n"
f"```{before_content}``` → ```{after_content}```"
)
log_channel = self.bot.get_channel(config.log_channel)
msg = "📝 **Message edit**: \n"\
f"from {self.bot.escape_message(after.author.name)} "\
f"({after.author.id}), in {after.channel.mention}:\n"\
f"`{before.clean_content}` → `{after.clean_content}`"
# If resulting message is too long, upload to hastebin
if len(msg) > 2000:
@ -252,16 +226,14 @@ class Logs(Cog):
@Cog.listener()
async def on_message_delete(self, message):
await self.bot.wait_until_ready()
if message.channel.id not in self.bot.config.spy_channels or message.author.bot:
if message.channel.id not in config.spy_channels or message.author.bot:
return
log_channel = self.bot.get_channel(self.bot.config.log_channel)
msg = (
"🗑️ **Message delete**: \n"
f"from {self.bot.escape_message(message.author.name)} "
f"({message.author.id}), in {message.channel.mention}:\n"
f"`{message.clean_content}`"
)
log_channel = self.bot.get_channel(config.log_channel)
msg = "🗑️ **Message delete**: \n"\
f"from {self.bot.escape_message(message.author.name)} "\
f"({message.author.id}), in {message.channel.mention}:\n"\
f"`{message.clean_content}`"
# If resulting message is too long, upload to hastebin
if len(msg) > 2000:
@ -273,46 +245,28 @@ class Logs(Cog):
@Cog.listener()
async def on_member_remove(self, member):
await self.bot.wait_until_ready()
if member.guild.id not in self.bot.config.guild_whitelist:
return
log_channel = self.bot.get_channel(self.bot.config.log_channel)
msg = (
f"⬅️ **Leave**: {member.mention} | "
f"{self.bot.escape_message(member)}\n"
f"🏷 __User ID__: {member.id}"
)
log_channel = self.bot.get_channel(config.log_channel)
msg = f"⬅️ **Leave**: {member.mention} | "\
f"{self.bot.escape_message(member)}\n"\
f"🏷 __User ID__: {member.id}"
await log_channel.send(msg)
@Cog.listener()
async def on_member_ban(self, guild, member):
await self.bot.wait_until_ready()
if guild.id not in self.bot.config.guild_whitelist:
return
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
msg = (
f"⛔ **Ban**: {member.mention} | "
f"{self.bot.escape_message(member)}\n"
f"🏷 __User ID__: {member.id}"
)
log_channel = self.bot.get_channel(config.modlog_channel)
msg = f"⛔ **Ban**: {member.mention} | "\
f"{self.bot.escape_message(member)}\n"\
f"🏷 __User ID__: {member.id}"
await log_channel.send(msg)
@Cog.listener()
async def on_member_unban(self, guild, user):
await self.bot.wait_until_ready()
if guild.id not in self.bot.config.guild_whitelist:
return
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
msg = (
f"⚠️ **Unban**: {user.mention} | "
f"{self.bot.escape_message(user)}\n"
f"🏷 __User ID__: {user.id}"
)
log_channel = self.bot.get_channel(config.modlog_channel)
msg = f"⚠️ **Unban**: {user.mention} | "\
f"{self.bot.escape_message(user)}\n"\
f"🏷 __User ID__: {user.id}"
# if user.id in self.bot.timebans:
# msg += "\nTimeban removed."
# self.bot.timebans.pop(user.id)
@ -327,12 +281,8 @@ class Logs(Cog):
@Cog.listener()
async def on_member_update(self, member_before, member_after):
await self.bot.wait_until_ready()
if member_after.guild.id not in self.bot.config.guild_whitelist:
return
msg = ""
log_channel = self.bot.get_channel(self.bot.config.log_channel)
log_channel = self.bot.get_channel(config.log_channel)
if member_before.roles != member_after.roles:
# role removal
role_removal = []
@ -360,11 +310,9 @@ class Logs(Cog):
msg += ", ".join(roles)
if member_before.name != member_after.name:
msg += (
"\n📝 __Username change__: "
f"{self.bot.escape_message(member_before)}"
f"{self.bot.escape_message(member_after)}"
)
msg += "\n📝 __Username change__: "\
f"{self.bot.escape_message(member_before)}"\
f"{self.bot.escape_message(member_after)}"
if member_before.nick != member_after.nick:
if not member_before.nick:
msg += "\n🏷 __Nickname addition__"
@ -372,17 +320,13 @@ class Logs(Cog):
msg += "\n🏷 __Nickname removal__"
else:
msg += "\n🏷 __Nickname change__"
msg += (
f": {self.bot.escape_message(member_before.nick)}"
f"{self.bot.escape_message(member_after.nick)}"
)
msg += f": {self.bot.escape_message(member_before.nick)}"\
f"{self.bot.escape_message(member_after.nick)}"
if msg:
msg = (
f" **Member update**: {member_after.mention} | "
f"{self.bot.escape_message(member_after)}{msg}"
)
msg = f" **Member update**: {member_after.mention} | "\
f"{self.bot.escape_message(member_after)}{msg}"
await log_channel.send(msg)
async def setup(bot):
await bot.add_cog(Logs(bot))
def setup(bot):
bot.add_cog(Logs(bot))

132
cogs/meme.py Normal file
View file

@ -0,0 +1,132 @@
import random
import discord
from discord.ext import commands
from discord.ext.commands import Cog
import math
import platform
from helpers.checks import check_if_staff_or_ot
class Meme(Cog):
"""
Meme commands.
"""
def __init__(self, bot):
self.bot = bot
def c_to_f(self, c):
"""this is where we take memes too far"""
return math.floor(9.0 / 5.0 * c + 32)
def c_to_k(self, c):
"""this is where we take memes REALLY far"""
return math.floor(c + 273.15)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, name="warm")
async def warm_member(self, ctx, user: discord.Member):
"""Warms a user :3"""
celsius = random.randint(15, 100)
fahrenheit = self.c_to_f(celsius)
kelvin = self.c_to_k(celsius)
await ctx.send(f"{user.mention} warmed."
f" User is now {celsius}°C "
f"({fahrenheit}°F, {kelvin}K).")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, name="chill", aliases=["cold"])
async def chill_member(self, ctx, user: discord.Member):
"""Chills a user >:3"""
celsius = random.randint(-50, 15)
fahrenheit = self.c_to_f(celsius)
kelvin = self.c_to_k(celsius)
await ctx.send(f"{user.mention} chilled."
f" User is now {celsius}°C "
f"({fahrenheit}°F, {kelvin}K).")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, aliases=["thank", "reswitchedgold"])
async def gild(self, ctx, user: discord.Member):
"""Gives a star to a user"""
await ctx.send(f"{user.mention} gets a :star:, yay!")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, aliases=["reswitchedsilver", "silv3r",
"reswitchedsilv3r"])
async def silver(self, ctx, user: discord.Member):
"""Gives a user ReSwitched Silver™"""
embed = discord.Embed(title="ReSwitched Silver™!",
description=f"Here's your ReSwitched Silver™,"
f"{user.mention}!")
embed.set_image(url="https://cdn.discordapp.com/emojis/"
"548623626916724747.png?v=1")
await ctx.send(embed=embed)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def btwiuse(self, ctx):
"""btw i use arch"""
uname = platform.uname()
await ctx.send(f"BTW I use {platform.python_implementation()} "
f"{platform.python_version()} on {uname.system} "
f"{uname.release}")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def yahaha(self, ctx):
"""secret command"""
await ctx.send(f"🍂 you found me 🍂")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def peng(self, ctx):
"""heck tomger"""
await ctx.send(f"🐧")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, aliases=["outstanding"])
async def outstandingmove(self, ctx):
"""Posts the outstanding move meme"""
await ctx.send("https://cdn.discordapp.com/attachments"
"/371047036348268545/528413677007929344"
"/image0-5.jpg")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def bones(self, ctx):
await ctx.send("https://cdn.discordapp.com/emojis/"
"443501365843591169.png?v=1")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def headpat(self, ctx):
await ctx.send("https://cdn.discordapp.com/emojis/"
"465650811909701642.png?v=1")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, aliases=["when", "etawhen",
"emunand", "thermosphere"])
async def eta(self, ctx):
await ctx.send("June 15.")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, name="bam")
async def bam_member(self, ctx, target: discord.Member):
"""Bams a user owo"""
safe_name = await commands.clean_content().convert(ctx, str(target))
await ctx.send(f"{safe_name} is ̶n͢ow b̕&̡.̷ 👍̡")
@commands.command(hidden=True)
async def memebercount(self, ctx):
"""Checks memeber count, as requested by dvdfreitag"""
await ctx.send("There's like, uhhhhh a bunch")
@commands.command(hidden=True)
async def frolics(self, ctx):
"""test"""
await ctx.send("https://www.youtube.com/watch?v=VmarNEsjpDI")
def setup(bot):
bot.add_cog(Meme(bot))

440
cogs/mod.py Normal file
View file

@ -0,0 +1,440 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
import config
from helpers.checks import check_if_staff, check_if_bot_manager
from helpers.userlogs import userlog
from helpers.restrictions import add_restriction, remove_restriction
import io
class Mod(Cog):
def __init__(self, bot):
self.bot = bot
def check_if_target_is_staff(self, target):
return any(r.id in config.staff_role_ids for r in target.roles)
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def setguildicon(self, ctx, url):
"""Changes guild icon, bot manager only."""
img_bytes = await self.bot.aiogetbytes(url)
await ctx.guild.edit(icon=img_bytes, reason=str(ctx.author))
await ctx.send(f"Done!")
log_channel = self.bot.get_channel(config.modlog_channel)
log_msg = f"✏️ **Guild Icon Update**: {ctx.author} "\
"changed the guild icon."
img_filename = url.split("/")[-1].split("#")[0] # hacky
img_file = discord.File(io.BytesIO(img_bytes),
filename=img_filename)
await log_channel.send(log_msg, file=img_file)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def mute(self, ctx, target: discord.Member, *, reason: str = ""):
"""Mutes a user, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't mute this user as "
"they're a member of staff.")
userlog(target.id, ctx.author, reason, "mutes", target.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
dm_message = f"You were muted!"
if reason:
dm_message += f" The given reason is: \"{reason}\"."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents kick issues in cases where user blocked bot
# or has DMs disabled
pass
mute_role = ctx.guild.get_role(config.mute_role)
await target.add_roles(mute_role, reason=str(ctx.author))
chan_message = f"🔇 **Muted**: {ctx.author.mention} muted "\
f"{target.mention} | {safe_name}\n"\
f"🏷 __User ID__: {target.id}\n"
if reason:
chan_message += f"✏️ __Reason__: \"{reason}\""
else:
chan_message += "Please add an explanation below. In the future, "\
"it is recommended to use `.mute <user> [reason]`"\
" as the reason is automatically sent to the user."
log_channel = self.bot.get_channel(config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{target.mention} can no longer speak.")
add_restriction(target.id, config.mute_role)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def unmute(self, ctx, target: discord.Member):
"""Unmutes a user, staff only."""
safe_name = await commands.clean_content().convert(ctx, str(target))
mute_role = ctx.guild.get_role(config.mute_role)
await target.remove_roles(mute_role, reason=str(ctx.author))
chan_message = f"🔈 **Unmuted**: {ctx.author.mention} unmuted "\
f"{target.mention} | {safe_name}\n"\
f"🏷 __User ID__: {target.id}\n"
log_channel = self.bot.get_channel(config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{target.mention} can now speak again.")
remove_restriction(target.id, config.mute_role)
@commands.guild_only()
@commands.bot_has_permissions(kick_members=True)
@commands.check(check_if_staff)
@commands.command()
async def kick(self, ctx, target: discord.Member, *, reason: str = ""):
"""Kicks a user, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't kick this user as "
"they're a member of staff.")
userlog(target.id, ctx.author, reason, "kicks", target.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
dm_message = f"You were kicked from {ctx.guild.name}."
if reason:
dm_message += f" The given reason is: \"{reason}\"."
dm_message += "\n\nYou are able to rejoin the server,"\
" but please be sure to behave when participating again."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents kick issues in cases where user blocked bot
# or has DMs disabled
pass
await target.kick(reason=f"{ctx.author}, reason: {reason}")
chan_message = f"👢 **Kick**: {ctx.author.mention} kicked "\
f"{target.mention} | {safe_name}\n"\
f"🏷 __User ID__: {target.id}\n"
if reason:
chan_message += f"✏️ __Reason__: \"{reason}\""
else:
chan_message += "Please add an explanation below. In the future"\
", it is recommended to use "\
"`.kick <user> [reason]`"\
" as the reason is automatically sent to the user."
log_channel = self.bot.get_channel(config.modlog_channel)
await log_channel.send(chan_message)
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def ban(self, ctx, target: discord.Member, *, reason: str = ""):
"""Bans a user, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't ban this user as "
"they're a member of staff.")
userlog(target.id, ctx.author, reason, "bans", target.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
dm_message = f"You were banned from {ctx.guild.name}."
if reason:
dm_message += f" The given reason is: \"{reason}\"."
dm_message += "\n\nThis ban does not expire."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents ban issues in cases where user blocked bot
# or has DMs disabled
pass
await target.ban(reason=f"{ctx.author}, reason: {reason}",
delete_message_days=0)
chan_message = f"⛔ **Ban**: {ctx.author.mention} banned "\
f"{target.mention} | {safe_name}\n"\
f"🏷 __User ID__: {target.id}\n"
if reason:
chan_message += f"✏️ __Reason__: \"{reason}\""
else:
chan_message += "Please add an explanation below. In the future"\
", it is recommended to use `.ban <user> [reason]`"\
" as the reason is automatically sent to the user."
log_channel = self.bot.get_channel(config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{safe_name} is now b&. 👍")
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command(aliases=["softban"])
async def hackban(self, ctx, target: int, *, reason: str = ""):
"""Bans a user with their ID, doesn't message them, staff only."""
target_user = await self.bot.get_user_info(target)
target_member = ctx.guild.get_member(target)
# Hedge-proofing the code
if target == ctx.author.id:
return await ctx.send("You can't do mod actions on yourself.")
elif target_member and self.check_if_target_is_staff(target_member):
return await ctx.send("I can't ban this user as "
"they're a member of staff.")
userlog(target, ctx.author, reason, "bans", target_user.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
await ctx.guild.ban(target_user,
reason=f"{ctx.author}, reason: {reason}",
delete_message_days=0)
chan_message = f"⛔ **Hackban**: {ctx.author.mention} banned "\
f"{target_user.mention} | {safe_name}\n"\
f"🏷 __User ID__: {target}\n"
if reason:
chan_message += f"✏️ __Reason__: \"{reason}\""
else:
chan_message += "Please add an explanation below. In the future"\
", it is recommended to use "\
"`.hackban <user> [reason]`."
log_channel = self.bot.get_channel(config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{safe_name} is now b&. 👍")
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def silentban(self, ctx, target: discord.Member, *, reason: str = ""):
"""Bans a user, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't ban this user as "
"they're a member of staff.")
userlog(target.id, ctx.author, reason, "bans", target.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
await target.ban(reason=f"{ctx.author}, reason: {reason}",
delete_message_days=0)
chan_message = f"⛔ **Silent ban**: {ctx.author.mention} banned "\
f"{target.mention} | {safe_name}\n"\
f"🏷 __User ID__: {target.id}\n"
if reason:
chan_message += f"✏️ __Reason__: \"{reason}\""
else:
chan_message += "Please add an explanation below. In the future"\
", it is recommended to use `.ban <user> [reason]`"\
" as the reason is automatically sent to the user."
log_channel = self.bot.get_channel(config.modlog_channel)
await log_channel.send(chan_message)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def approve(self, ctx, target: discord.Member,
role: str = "community"):
"""Add a role to a user (default: community), staff only."""
if role not in config.named_roles:
return await ctx.send("No such role! Available roles: " +
','.join(config.named_roles))
log_channel = self.bot.get_channel(config.modlog_channel)
target_role = ctx.guild.get_role(config.named_roles[role])
if target_role in target.roles:
return await ctx.send("Target already has this role.")
await target.add_roles(target_role, reason=str(ctx.author))
await ctx.send(f"Approved {target.mention} to `{role}` role.")
await log_channel.send(f"✅ Approved: {ctx.author.mention} added"
f" {role} to {target.mention}")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["unapprove"])
async def revoke(self, ctx, target: discord.Member,
role: str = "community"):
"""Remove a role from a user (default: community), staff only."""
if role not in config.named_roles:
return await ctx.send("No such role! Available roles: " +
','.join(config.named_roles))
log_channel = self.bot.get_channel(config.modlog_channel)
target_role = ctx.guild.get_role(config.named_roles[role])
if target_role not in target.roles:
return await ctx.send("Target doesn't have this role.")
await target.remove_roles(target_role, reason=str(ctx.author))
await ctx.send(f"Un-approved {target.mention} from `{role}` role.")
await log_channel.send(f"❌ Un-approved: {ctx.author.mention} removed"
f" {role} from {target.mention}")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["clear"])
async def purge(self, ctx, limit: int, channel: discord.TextChannel = None):
"""Clears a given number of messages, staff only."""
log_channel = self.bot.get_channel(config.modlog_channel)
if not channel:
channel = ctx.channel
await channel.purge(limit=limit)
msg = f"🗑 **Purged**: {ctx.author.mention} purged {limit} "\
f"messages in {channel.mention}."
await log_channel.send(msg)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def warn(self, ctx, target: discord.Member, *, reason: str = ""):
"""Warns a user, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't warn this user as "
"they're a member of staff.")
log_channel = self.bot.get_channel(config.modlog_channel)
warn_count = userlog(target.id, ctx.author, reason,
"warns", target.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
chan_msg = f"⚠️ **Warned**: {ctx.author.mention} warned "\
f"{target.mention} (warn #{warn_count}) "\
f"| {safe_name}\n"
msg = f"You were warned on {ctx.guild.name}."
if reason:
msg += " The given reason is: " + reason
msg += f"\n\nPlease read the rules in {config.rules_url}. "\
f"This is warn #{warn_count}."
if warn_count == 2:
msg += " __The next warn will automatically kick.__"
if warn_count == 3:
msg += "\n\nYou were kicked because of this warning. "\
"You can join again right away. "\
"Two more warnings will result in an automatic ban."
if warn_count == 4:
msg += "\n\nYou were kicked because of this warning. "\
"This is your final warning. "\
"You can join again, but "\
"**one more warn will result in a ban**."
chan_msg += "**This resulted in an auto-kick.**\n"
if warn_count == 5:
msg += "\n\nYou were automatically banned due to five warnings."
chan_msg += "**This resulted in an auto-ban.**\n"
try:
await target.send(msg)
except discord.errors.Forbidden:
# Prevents log issues in cases where user blocked bot
# or has DMs disabled
pass
if warn_count == 3 or warn_count == 4:
await target.kick()
if warn_count >= 5: # just in case
await target.ban(reason="exceeded warn limit",
delete_message_days=0)
await ctx.send(f"{target.mention} warned. "
f"User has {warn_count} warning(s).")
if reason:
chan_msg += f"✏️ __Reason__: \"{reason}\""
else:
chan_msg += "Please add an explanation below. In the future"\
", it is recommended to use `.ban <user> [reason]`"\
" as the reason is automatically sent to the user."
await log_channel.send(chan_msg)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["setnick", "nick"])
async def nickname(self, ctx, target: discord.Member, *, nick: str = ""):
"""Sets a user's nickname, staff only.
Just send .nickname <user> to wipe the nickname."""
if nick:
await target.edit(nick=nick, reason=str(ctx.author))
else:
await target.edit(nick=None, reason=str(ctx.author))
await ctx.send("Successfully set nickname.")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=['echo'])
async def say(self, ctx, *, the_text: str):
"""Repeats a given text, staff only."""
await ctx.send(the_text)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def speak(self, ctx, channel: discord.TextChannel, *, the_text: str):
"""Repeats a given text in a given channel, staff only."""
await channel.send(the_text)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["setplaying", "setgame"])
async def playing(self, ctx, *, game: str = ""):
"""Sets the bot's currently played game name, staff only.
Just send .playing to wipe the playing state."""
if game:
await self.bot.change_presence(activity=discord.Game(name=game))
else:
await self.bot.change_presence(activity=None)
await ctx.send("Successfully set game.")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["setbotnick", "botnick", "robotnick"])
async def botnickname(self, ctx, *, nick: str = ""):
"""Sets the bot's nickname, staff only.
Just send .botnickname to wipe the nickname."""
if nick:
await ctx.guild.me.edit(nick=nick, reason=str(ctx.author))
else:
await ctx.guild.me.edit(nick=None, reason=str(ctx.author))
await ctx.send("Successfully set bot nickname.")
def setup(bot):
bot.add_cog(Mod(bot))

View file

@ -1,9 +1,8 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
from robocop_ng.helpers.userlogs import userlog
from helpers.checks import check_if_staff
from helpers.userlogs import userlog
class ModNote(Cog):
@ -15,7 +14,8 @@ class ModNote(Cog):
@commands.command(aliases=["addnote"])
async def note(self, ctx, target: discord.Member, *, note: str = ""):
"""Adds a note to a user, staff only."""
userlog(self.bot, target.id, ctx.author, note, "notes", target.name)
userlog(target.id, ctx.author, note,
"notes", target.name)
await ctx.send(f"{ctx.author.mention}: noted!")
@commands.guild_only()
@ -23,9 +23,10 @@ class ModNote(Cog):
@commands.command(aliases=["addnoteid"])
async def noteid(self, ctx, target: int, *, note: str = ""):
"""Adds a note to a user by userid, staff only."""
userlog(self.bot, target, ctx.author, note, "notes")
await ctx.send(f"{ctx.author.mention}: noted!")
userlog(target, ctx.author, note,
"notes")
await ctx.send(f"{target.mention}: noted!")
async def setup(bot):
await bot.add_cog(ModNote(bot))
def setup(bot):
bot.add_cog(ModNote(bot))

View file

@ -1,10 +1,9 @@
import asyncio
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
import config
from helpers.checks import check_if_staff
class ModReact(Cog):
@ -14,41 +13,34 @@ class ModReact(Cog):
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def clearreactsbyuser(
self,
ctx,
user: discord.Member,
*,
channel: discord.TextChannel = None,
limit: int = 50,
):
async def clearreactsbyuser(self, ctx, user: discord.Member, *,
channel: discord.TextChannel = None,
limit: int = 50):
"""Clears reacts from a given user in the given channel, staff only."""
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
if not channel:
channel = ctx.channel
count = 0
async for msg in channel.history(limit=limit):
for react in msg.reactions:
async for react_user in react.users():
if react_user == user:
count += 1
await react.remove(user)
msg = (
f"✏️ **Cleared reacts**: {ctx.author.mention} cleared "
f"{user.mention}'s reacts from the last {limit} messages "
f"in {channel.mention}."
)
if await react.users().find(lambda u: u == user):
count += 1
async for u in react.users():
await msg.remove_reaction(react, u)
msg = f"✏️ **Cleared reacts**: {ctx.author.mention} cleared "\
f"{user.mention}'s reacts from the last {limit} messages "\
f"in {channel.mention}."
await ctx.channel.send(f"Cleared {count} unique reactions")
await log_channel.send(msg)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def clearallreacts(
self, ctx, *, limit: int = 50, channel: discord.TextChannel = None
):
async def clearallreacts(self, ctx, *,
limit: int = 50,
channel: discord.TextChannel = None):
"""Clears all reacts in a given channel, staff only. Use with care."""
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
if not channel:
channel = ctx.channel
count = 0
@ -56,10 +48,8 @@ class ModReact(Cog):
if msg.reactions:
count += 1
await msg.clear_reactions()
msg = (
f"✏️ **Cleared reacts**: {ctx.author.mention} cleared all "
f"reacts from the last {limit} messages in {channel.mention}."
)
msg = f"✏️ **Cleared reacts**: {ctx.author.mention} cleared all "\
f"reacts from the last {limit} messages in {channel.mention}."
await ctx.channel.send(f"Cleared reacts from {count} messages!")
await log_channel.send(msg)
@ -68,10 +58,8 @@ class ModReact(Cog):
@commands.command()
async def clearreactsinteractive(self, ctx):
"""Clears reacts interactively, staff only. Use with care."""
msg_text = (
f"{ctx.author.mention}, react to the reactions you want "
f"to remove. React to this message when you're done."
)
msg_text = f"{ctx.author.mention}, react to the reactions you want "\
f"to remove. React to this message when you're done."
msg = await ctx.channel.send(msg_text)
tasks = []
@ -86,11 +74,10 @@ class ModReact(Cog):
else:
# remove a reaction
async def impl():
msg = (
await self.bot.get_guild(event.guild_id)
.get_channel(event.channel_id)
.get_message(event.message_id)
)
msg = await self.bot \
.get_guild(event.guild_id) \
.get_channel(event.channel_id) \
.get_message(event.message_id)
def check_emoji(r):
if event.emoji.is_custom_emoji() == r.custom_emoji:
@ -101,17 +88,17 @@ class ModReact(Cog):
return event.emoji.name == r.emoji
else:
return False
for reaction in filter(check_emoji, msg.reactions):
async for u in reaction.users():
await reaction.message.remove_reaction(reaction, u)
# schedule immediately
tasks.append(asyncio.create_task(impl()))
return False
try:
await self.bot.wait_for("raw_reaction_add", timeout=120.0, check=check)
await self.bot.wait_for("raw_reaction_add",
timeout=120.0,
check=check)
except asyncio.TimeoutError:
await msg.edit(content=f"{msg_text} Timed out.")
else:
@ -119,5 +106,5 @@ class ModReact(Cog):
await msg.edit(content=f"{msg_text} Done!")
async def setup(bot):
await bot.add_cog(ModReact(bot))
def setup(bot):
bot.add_cog(ModReact(bot))

137
cogs/mod_timed.py Normal file
View file

@ -0,0 +1,137 @@
import discord
import config
from datetime import datetime
from discord.ext import commands
from discord.ext.commands import Cog
from helpers.checks import check_if_staff
from helpers.robocronp import add_job
from helpers.userlogs import userlog
from helpers.restrictions import add_restriction
class ModTimed(Cog):
def __init__(self, bot):
self.bot = bot
def check_if_target_is_staff(self, target):
return any(r.id in config.staff_role_ids for r in target.roles)
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def timeban(self, ctx, target: discord.Member,
duration: str, *, reason: str = ""):
"""Bans a user for a specified amount of time, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't ban this user as "
"they're a member of staff.")
expiry_timestamp = self.bot.parse_time(duration)
expiry_datetime = datetime.utcfromtimestamp(expiry_timestamp)
duration_text = self.bot.get_relative_timestamp(time_to=expiry_datetime,
include_to=True,
humanized=True)
userlog(target.id, ctx.author, f"{reason} (Timed, until "
f"{duration_text})",
"bans", target.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
dm_message = f"You were banned from {ctx.guild.name}."
if reason:
dm_message += f" The given reason is: \"{reason}\"."
dm_message += f"\n\nThis ban will expire {duration_text}."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents ban issues in cases where user blocked bot
# or has DMs disabled
pass
await target.ban(reason=f"{ctx.author}, reason: {reason}",
delete_message_days=0)
chan_message = f"⛔ **Timed Ban**: {ctx.author.mention} banned "\
f"{target.mention} for {duration_text} | {safe_name}\n"\
f"🏷 __User ID__: {target.id}\n"
if reason:
chan_message += f"✏️ __Reason__: \"{reason}\""
else:
chan_message += "Please add an explanation below. In the future"\
", it is recommended to use `.ban <user> [reason]`"\
" as the reason is automatically sent to the user."
add_job("unban", target.id, {"guild": ctx.guild.id}, expiry_timestamp)
log_channel = self.bot.get_channel(config.log_channel)
await log_channel.send(chan_message)
await ctx.send(f"{safe_name} is now b&. "
f"It will expire {duration_text}. 👍")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def timemute(self, ctx, target: discord.Member,
duration: str, *, reason: str = ""):
"""Mutes a user for a specified amount of time, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't mute this user as "
"they're a member of staff.")
expiry_timestamp = self.bot.parse_time(duration)
expiry_datetime = datetime.utcfromtimestamp(expiry_timestamp)
duration_text = self.bot.get_relative_timestamp(time_to=expiry_datetime,
include_to=True,
humanized=True)
userlog(target.id, ctx.author, f"{reason} (Timed, until "
f"{duration_text})",
"mutes", target.name)
safe_name = await commands.clean_content().convert(ctx, str(target))
dm_message = f"You were muted!"
if reason:
dm_message += f" The given reason is: \"{reason}\"."
dm_message += f"\n\nThis mute will expire {duration_text}."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents kick issues in cases where user blocked bot
# or has DMs disabled
pass
mute_role = ctx.guild.get_role(config.mute_role)
await target.add_roles(mute_role, reason=str(ctx.author))
chan_message = f"🔇 **Timed Mute**: {ctx.author.mention} muted "\
f"{target.mention} for {duration_text} | {safe_name}\n"\
f"🏷 __User ID__: {target.id}\n"
if reason:
chan_message += f"✏️ __Reason__: \"{reason}\""
else:
chan_message += "Please add an explanation below. In the future, "\
"it is recommended to use `.mute <user> [reason]`"\
" as the reason is automatically sent to the user."
add_job("unmute", target.id, {"guild": ctx.guild.id}, expiry_timestamp)
log_channel = self.bot.get_channel(config.log_channel)
await log_channel.send(chan_message)
await ctx.send(f"{target.mention} can no longer speak. "
f"It will expire {duration_text}.")
add_restriction(target.id, config.mute_role)
def setup(bot):
bot.add_cog(ModTimed(bot))

View file

@ -1,27 +1,25 @@
import json
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
from robocop_ng.helpers.userlogs import get_userlog, set_userlog, userlog_event_types
import config
import json
from helpers.checks import check_if_staff
from helpers.userlogs import get_userlog, set_userlog, userlog_event_types
class ModUserlog(Cog):
def __init__(self, bot):
self.bot = bot
def get_userlog_embed_for_id(
self, uid: str, name: str, own: bool = False, event=""
):
def get_userlog_embed_for_id(self, uid: str, name: str, own: bool = False,
event=""):
own_note = " Good for you!" if own else ""
wanted_events = ["warns", "bans", "kicks", "mutes"]
if event and not isinstance(event, list):
wanted_events = [event]
embed = discord.Embed(color=discord.Color.dark_red())
embed.set_author(name=f"Userlog for {name}")
userlog = get_userlog(self.bot)
userlog = get_userlog()
if uid not in userlog:
embed.description = f"There are none!{own_note} (no entry)"
@ -32,17 +30,12 @@ class ModUserlog(Cog):
if event_type in userlog[uid] and userlog[uid][event_type]:
event_name = userlog_event_types[event_type]
for idx, event in enumerate(userlog[uid][event_type]):
issuer = (
""
if own
else f"Issuer: {event['issuer_name']} "
f"({event['issuer_id']})\n"
)
embed.add_field(
name=f"{event_name} {idx + 1}: " f"{event['timestamp']}",
value=issuer + f"Reason: {event['reason']}",
inline=False,
)
issuer = "" if own else f"Issuer: {event['issuer_name']} "\
f"({event['issuer_id']})\n"
embed.add_field(name=f"{event_name} {idx + 1}: "
f"{event['timestamp']}",
value=issuer + f"Reason: {event['reason']}",
inline=False)
if not own and "watch" in userlog[uid]:
watch_state = "" if userlog[uid]["watch"] else "NOT "
@ -54,37 +47,37 @@ class ModUserlog(Cog):
return embed
def clear_event_from_id(self, uid: str, event_type):
userlog = get_userlog(self.bot)
userlog = get_userlog()
if uid not in userlog:
return f"<@{uid}> has no {event_type}!"
event_count = len(userlog[uid][event_type])
if not event_count:
return f"<@{uid}> has no {event_type}!"
userlog[uid][event_type] = []
set_userlog(self.bot, json.dumps(userlog))
set_userlog(json.dumps(userlog))
return f"<@{uid}> no longer has any {event_type}!"
def delete_event_from_id(self, uid: str, idx: int, event_type):
userlog = get_userlog(self.bot)
userlog = get_userlog()
if uid not in userlog:
return f"<@{uid}> has no {event_type}!"
event_count = len(userlog[uid][event_type])
if not event_count:
return f"<@{uid}> has no {event_type}!"
if idx > event_count:
return "Index is higher than " f"count ({event_count})!"
return "Index is higher than "\
f"count ({event_count})!"
if idx < 1:
return "Index is below 1!"
event = userlog[uid][event_type][idx - 1]
event_name = userlog_event_types[event_type]
embed = discord.Embed(
color=discord.Color.dark_red(),
title=f"{event_name} {idx} on " f"{event['timestamp']}",
description=f"Issuer: {event['issuer_name']}\n"
f"Reason: {event['reason']}",
)
embed = discord.Embed(color=discord.Color.dark_red(),
title=f"{event_name} {idx} on "
f"{event['timestamp']}",
description=f"Issuer: {event['issuer_name']}\n"
f"Reason: {event['reason']}")
del userlog[uid][event_type][idx - 1]
set_userlog(self.bot, json.dumps(userlog))
set_userlog(json.dumps(userlog))
return embed
@commands.guild_only()
@ -92,18 +85,21 @@ class ModUserlog(Cog):
@commands.command(aliases=["events"])
async def eventtypes(self, ctx):
"""Lists the available event types, staff only."""
event_list = [f"{et} ({userlog_event_types[et]})" for et in userlog_event_types]
event_text = "Available events:\n``` - " + "\n - ".join(event_list) + "```"
event_list = [f"{et} ({userlog_event_types[et]})" for et in
userlog_event_types]
event_text = ("Available events:\n``` - " +
"\n - ".join(event_list) +
"```")
await ctx.send(event_text)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(
name="userlog", aliases=["listwarns", "getuserlog", "listuserlog"]
)
@commands.command(name="userlog",
aliases=["listwarns", "getuserlog", "listuserlog"])
async def userlog_cmd(self, ctx, target: discord.Member, event=""):
"""Lists the userlog events for a user, staff only."""
embed = self.get_userlog_embed_for_id(str(target.id), str(target), event=event)
embed = self.get_userlog_embed_for_id(str(target.id), str(target),
event=event)
await ctx.send(embed=embed)
@commands.guild_only()
@ -111,16 +107,16 @@ class ModUserlog(Cog):
@commands.command(aliases=["listnotes", "usernotes"])
async def notes(self, ctx, target: discord.Member):
"""Lists the notes for a user, staff only."""
embed = self.get_userlog_embed_for_id(
str(target.id), str(target), event="notes"
)
embed = self.get_userlog_embed_for_id(str(target.id), str(target),
event="notes")
await ctx.send(embed=embed)
@commands.guild_only()
@commands.command(aliases=["mywarns"])
async def myuserlog(self, ctx):
"""Lists your userlog events (warns etc)."""
embed = self.get_userlog_embed_for_id(str(ctx.author.id), str(ctx.author), True)
embed = self.get_userlog_embed_for_id(str(ctx.author.id),
str(ctx.author), True)
await ctx.send(embed=embed)
@commands.guild_only()
@ -134,20 +130,16 @@ class ModUserlog(Cog):
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["clearwarns"])
async def clearevent(self, ctx, target: discord.Member, event="warns"):
async def clearevent(self, ctx, target: discord.Member,
event="warns"):
"""Clears all events of given type for a user, staff only."""
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
msg = self.clear_event_from_id(str(target.id), event)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
safe_name = await commands.clean_content().convert(ctx, str(target))
await ctx.send(msg)
msg = (
f"🗑 **Cleared {event}**: {ctx.author.mention} cleared"
f" all {event} events of {target.mention} | "
f"{safe_name}"
f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
)
msg = f"🗑 **Cleared {event}**: {ctx.author.mention} cleared"\
f" all {event} events of {target.mention} | "\
f"{safe_name}"
await log_channel.send(msg)
@commands.guild_only()
@ -155,36 +147,29 @@ class ModUserlog(Cog):
@commands.command(aliases=["clearwarnsid"])
async def cleareventid(self, ctx, target: int, event="warns"):
"""Clears all events of given type for a userid, staff only."""
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
msg = self.clear_event_from_id(str(target), event)
await ctx.send(msg)
msg = (
f"🗑 **Cleared {event}**: {ctx.author.mention} cleared"
f" all {event} events of <@{target}> "
f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
)
msg = f"🗑 **Cleared {event}**: {ctx.author.mention} cleared"\
f" all {event} events of <@{target}> "
await log_channel.send(msg)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["delwarn"])
async def delevent(self, ctx, target: discord.Member, idx: int, event="warns"):
async def delevent(self, ctx, target: discord.Member, idx: int,
event="warns"):
"""Removes a specific event from a user, staff only."""
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
del_event = self.delete_event_from_id(str(target.id), idx, event)
event_name = userlog_event_types[event].lower()
# This is hell.
if isinstance(del_event, discord.Embed):
await ctx.send(f"{target.mention} has a {event_name} removed!")
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
msg = (
f"🗑 **Deleted {event_name}**: "
f"{ctx.author.mention} removed "
f"{event_name} {idx} from {target.mention} | {safe_name}"
f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
)
safe_name = await commands.clean_content().convert(ctx, str(target))
msg = f"🗑 **Deleted {event_name}**: "\
f"{ctx.author.mention} removed "\
f"{event_name} {idx} from {target.mention} | {safe_name}"
await log_channel.send(msg, embed=del_event)
else:
await ctx.send(del_event)
@ -194,18 +179,15 @@ class ModUserlog(Cog):
@commands.command(aliases=["delwarnid"])
async def deleventid(self, ctx, target: int, idx: int, event="warns"):
"""Removes a specific event from a userid, staff only."""
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(config.modlog_channel)
del_event = self.delete_event_from_id(str(target), idx, event)
event_name = userlog_event_types[event].lower()
# This is hell.
if isinstance(del_event, discord.Embed):
await ctx.send(f"<@{target}> has a {event_name} removed!")
msg = (
f"🗑 **Deleted {event_name}**: "
f"{ctx.author.mention} removed "
f"{event_name} {idx} from <@{target}> "
f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
)
msg = f"🗑 **Deleted {event_name}**: "\
f"{ctx.author.mention} removed "\
f"{event_name} {idx} from <@{target}> "
await log_channel.send(msg, embed=del_event)
else:
await ctx.send(del_event)
@ -220,30 +202,21 @@ class ModUserlog(Cog):
role = "@ everyone"
event_types = ["warns", "bans", "kicks", "mutes", "notes"]
embed = self.get_userlog_embed_for_id(
str(user.id), str(user), event=event_types
)
embed = self.get_userlog_embed_for_id(str(user.id), str(user),
event=event_types)
user_name = await commands.clean_content(escape_markdown=True).convert(
ctx, user.name
)
display_name = await commands.clean_content(escape_markdown=True).convert(
ctx, user.display_name
)
await ctx.send(
f"user = {user_name}\n"
f"id = {user.id}\n"
f"avatar = {user.display_avatar}\n"
f"bot = {user.bot}\n"
f"created_at = {user.created_at}\n"
f"display_name = {display_name}\n"
f"joined_at = {user.joined_at}\n"
f"color = {user.colour}\n"
f"top_role = {role}\n",
embed=embed,
)
await ctx.send(f"user = {user}\n"
f"id = {user.id}\n"
f"avatar = {user.avatar_url}\n"
f"bot = {user.bot}\n"
f"created_at = {user.created_at}\n"
f"display_name = {user.display_name}\n"
f"joined_at = {user.joined_at}\n"
f"activities = `{user.activities}`\n"
f"color = {user.colour}\n"
f"top_role = {role}\n",
embed=embed)
async def setup(bot):
await bot.add_cog(ModUserlog(bot))
def setup(bot):
bot.add_cog(ModUserlog(bot))

View file

@ -1,9 +1,8 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
from robocop_ng.helpers.userlogs import setwatch
from helpers.checks import check_if_staff
from helpers.userlogs import setwatch
class ModWatch(Cog):
@ -15,7 +14,7 @@ class ModWatch(Cog):
@commands.command()
async def watch(self, ctx, target: discord.Member, *, note: str = ""):
"""Puts a user under watch, staff only."""
setwatch(self.bot, target.id, ctx.author, True, target.name)
setwatch(target.id, ctx.author, True, target.name)
await ctx.send(f"{ctx.author.mention}: user is now on watch.")
@commands.guild_only()
@ -23,7 +22,7 @@ class ModWatch(Cog):
@commands.command()
async def watchid(self, ctx, target: int, *, note: str = ""):
"""Puts a user under watch by userid, staff only."""
setwatch(self.bot, target, ctx.author, True, target.name)
setwatch(target, ctx.author, True, target.name)
await ctx.send(f"{target.mention}: user is now on watch.")
@commands.guild_only()
@ -31,7 +30,7 @@ class ModWatch(Cog):
@commands.command()
async def unwatch(self, ctx, target: discord.Member, *, note: str = ""):
"""Removes a user from watch, staff only."""
setwatch(self.bot, target.id, ctx.author, False, target.name)
setwatch(target.id, ctx.author, False, target.name)
await ctx.send(f"{ctx.author.mention}: user is now not on watch.")
@commands.guild_only()
@ -39,9 +38,9 @@ class ModWatch(Cog):
@commands.command()
async def unwatchid(self, ctx, target: int, *, note: str = ""):
"""Removes a user from watch by userid, staff only."""
setwatch(self.bot, target, ctx.author, False, target.name)
setwatch(target, ctx.author, False, target.name)
await ctx.send(f"{target.mention}: user is now not on watch.")
async def setup(bot):
await bot.add_cog(ModWatch(bot))
def setup(bot):
bot.add_cog(ModWatch(bot))

View file

@ -1,13 +1,12 @@
import aiohttp
import gidgethub.aiohttp
from discord import Embed
from discord.enums import MessageType
import config
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_collaborator
from robocop_ng.helpers.checks import check_if_pin_channel
from discord.enums import MessageType
from discord import Embed
import aiohttp
import gidgethub.aiohttp
from helpers.checks import check_if_collaborator
from helpers.checks import check_if_pin_channel
class Pin(Cog):
"""
@ -18,11 +17,9 @@ class Pin(Cog):
self.bot = bot
def is_pinboard(self, msg):
return (
msg.author == self.bot.user
and len(msg.embeds) > 0
and msg.embeds[0].title == "Pinboard"
)
return msg.author == self.bot.user and \
len(msg.embeds) > 0 and \
msg.embeds[0].title == "Pinboard"
async def get_pinboard(self, gh, channel):
# Find pinboard pin
@ -35,44 +32,43 @@ class Pin(Cog):
return (id, data["files"]["pinboard.md"]["content"])
# Create pinboard pin if it does not exist
data = await gh.post(
"/gists",
data={
"files": {
"pinboard.md": {"content": "Old pins are available here:\n\n"}
},
"description": f"Pinboard for SwitchRoot #{channel.name}",
"public": True,
data = await gh.post("/gists", data={
"files": {
"pinboard.md": {
"content": "Old pins are available here:\n\n"
}
},
)
"description": f"Pinboard for SwitchRoot #{channel.name}",
"public": True
})
msg = await channel.send(
embed=Embed(
title="Pinboard",
description="Old pins are moved to the pinboard to make space for \
msg = await channel.send(embed=Embed(
title="Pinboard",
description="Old pins are moved to the pinboard to make space for \
new ones. Check it out!",
url=data["html_url"],
)
)
url=data["html_url"]))
await msg.pin()
return (data["id"], data["files"]["pinboard.md"]["content"])
async def add_pin_to_pinboard(self, channel, data):
if self.bot.config.github_oauth_token == "":
if config.github_oauth_token == "":
# Don't add to gist pinboard if we don't have an oauth token
return
async with aiohttp.ClientSession() as session:
gh = gidgethub.aiohttp.GitHubAPI(
session, "RoboCop-NG", oauth_token=self.bot.config.github_oauth_token
)
gh = gidgethub.aiohttp.GitHubAPI(session, "RoboCop-NG",
oauth_token=config.github_oauth_token)
(id, content) = await self.get_pinboard(gh, channel)
content += "- " + data + "\n"
await gh.patch(
f"/gists/{id}", data={"files": {"pinboard.md": {"content": content}}}
)
await gh.patch(f"/gists/{id}", data={
"files": {
"pinboard.md": {
"content": content
}
}
})
@commands.command()
@commands.guild_only()
@ -102,7 +98,7 @@ class Pin(Cog):
return
# Check that reaction pinning is allowd in this channel
if payload.channel_id not in self.bot.config.allowed_pin_channels:
if payload.channel_id not in config.allowed_pin_channels:
return
target_guild = self.bot.get_guild(payload.guild_id)
@ -111,7 +107,7 @@ class Pin(Cog):
# Check that the user is allowed to reaction-pin
target_user = target_guild.get_member(payload.user_id)
for role in self.bot.config.staff_role_ids + self.bot.config.allowed_pin_roles:
for role in config.staff_role_ids + config.allowed_pin_roles:
if role in [role.id for role in target_user.roles]:
target_chan = self.bot.get_channel(payload.channel_id)
target_msg = await target_chan.get_message(payload.message_id)
@ -140,7 +136,7 @@ class Pin(Cog):
break
# Wait for the automated "Pinned" message so we can delete it
waitable = self.bot.wait_for("message", check=check)
waitable = self.bot.wait_for('message', check=check)
# Pin the message
await target_msg.pin()
@ -157,5 +153,5 @@ def check(msg):
return msg.type is MessageType.pins_add
async def setup(bot):
await bot.add_cog(Pin(bot))
def setup(bot):
bot.add_cog(Pin(bot))

View file

@ -1,12 +1,10 @@
import discord
import asyncio
import time
from datetime import datetime
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.robocronp import add_job, get_crontab
from helpers.robocronp import add_job, get_crontab
class Remind(Cog):
@ -17,72 +15,55 @@ class Remind(Cog):
@commands.command()
async def remindlist(self, ctx):
"""Lists your reminders."""
ctab = get_crontab(self.bot)
ctab = get_crontab()
uid = str(ctx.author.id)
embed = discord.Embed(title=f"Active robocronp jobs")
for jobtimestamp in ctab["remind"]:
if uid not in ctab["remind"][jobtimestamp]:
continue
job_details = ctab["remind"][jobtimestamp][uid]
expiry_timestr = datetime.utcfromtimestamp(int(jobtimestamp)).strftime(
"%Y-%m-%d %H:%M:%S (UTC)"
)
embed.add_field(
name=f"Reminder for {expiry_timestr}",
value=f"Added on: {job_details['added']}, "
f"Text: {job_details['text']}",
inline=False,
)
expiry_timestr = datetime.utcfromtimestamp(int(jobtimestamp))\
.strftime('%Y-%m-%d %H:%M:%S (UTC)')
embed.add_field(name=f"Reminder for {expiry_timestr}",
value=f"Added on: {job_details['added']}, "
f"Text: {job_details['text']}",
inline=False)
await ctx.send(embed=embed)
@commands.cooldown(1, 60, type=commands.BucketType.user)
@commands.command(aliases=["remindme"])
async def remind(self, ctx, when: str, *, text: str = "something"):
"""Reminds you about something."""
ref_message = None
if ctx.message.reference is not None:
ref_message = await ctx.channel.fetch_message(
ctx.message.reference.message_id
)
if ctx.guild:
await ctx.message.delete()
current_timestamp = time.time()
expiry_timestamp = self.bot.parse_time(when)
if current_timestamp + 5 > expiry_timestamp:
msg = await ctx.send(
f"{ctx.author.mention}: Minimum remind interval is 5 seconds."
)
msg = await ctx.send(f"{ctx.author.mention}: Minimum "
"remind interval is 5 seconds.")
await asyncio.sleep(5)
await msg.delete()
return
expiry_datetime = datetime.utcfromtimestamp(expiry_timestamp)
duration_text = self.bot.get_relative_timestamp(
time_to=expiry_datetime, include_to=True, humanized=True
)
duration_text = self.bot.get_relative_timestamp(time_to=expiry_datetime,
include_to=True,
humanized=True)
safe_text = await commands.clean_content().convert(ctx, str(text))
if ref_message is not None:
safe_text += f"\nMessage reference: {ref_message.jump_url}"
added_on = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S (UTC)")
add_job(
self.bot,
"remind",
ctx.author.id,
{"text": safe_text, "added": added_on},
expiry_timestamp,
)
add_job("remind",
ctx.author.id,
{"text": safe_text, "added": added_on},
expiry_timestamp)
await ctx.send(
f"{ctx.author.mention}: I'll remind you in "
f"DMs about `{safe_text}` in {duration_text}."
)
msg = await ctx.send(f"{ctx.author.mention}: I'll remind you in "
f"DMs about `{safe_text}` in {duration_text}.")
await asyncio.sleep(5)
await msg.delete()
async def setup(bot):
await bot.add_cog(Remind(bot))
def setup(bot):
bot.add_cog(Remind(bot))

153
cogs/robocronp.py Normal file
View file

@ -0,0 +1,153 @@
import asyncio
import config
import time
import discord
import traceback
from discord.ext import commands
from discord.ext.commands import Cog
from helpers.robocronp import get_crontab, delete_job
from helpers.restrictions import remove_restriction
from helpers.checks import check_if_staff
class Robocronp(Cog):
def __init__(self, bot):
self.bot = bot
bot.loop.create_task(self.minutely())
bot.loop.create_task(self.hourly())
async def send_data(self):
data_files = [discord.File(fpath) for fpath in self.bot.wanted_jsons]
log_channel = self.bot.get_channel(config.botlog_channel)
await log_channel.send("Hourly data backups:", files=data_files)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def listjobs(self, ctx):
"""Lists timed robocronp jobs, staff only."""
ctab = get_crontab()
embed = discord.Embed(title=f"Active robocronp jobs")
for jobtype in ctab:
for jobtimestamp in ctab[jobtype]:
for job_name in ctab[jobtype][jobtimestamp]:
job_details = repr(ctab[jobtype][jobtimestamp][job_name])
embed.add_field(name=f"{jobtype} for {job_name}",
value=f"Timestamp: {jobtimestamp}, "
f"Details: {job_details}",
inline=False)
await ctx.send(embed=embed)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["removejob"])
async def deletejob(self, ctx, timestamp: str,
job_type: str, job_name: str):
"""Removes a timed robocronp job, staff only.
You'll need to supply:
- timestamp (like 1545981602)
- job type (like "unban")
- job name (userid, like 420332322307571713)
You can get all 3 from listjobs command."""
delete_job(timestamp, job_type, job_name)
await ctx.send(f"{ctx.author.mention}: Deleted!")
async def do_jobs(self, ctab, jobtype, timestamp):
log_channel = self.bot.get_channel(config.botlog_channel)
for job_name in ctab[jobtype][timestamp]:
try:
job_details = ctab[jobtype][timestamp][job_name]
if jobtype == "unban":
target_user = await self.bot.get_user_info(job_name)
target_guild = self.bot.get_guild(job_details["guild"])
delete_job(timestamp, jobtype, job_name)
await target_guild.unban(target_user,
reason="Robocronp: Timed "
"ban expired.")
elif jobtype == "unmute":
remove_restriction(job_name, config.mute_role)
target_guild = self.bot.get_guild(job_details["guild"])
target_member = target_guild.get_member(int(job_name))
target_role = target_guild.get_role(config.mute_role)
await target_member.remove_roles(target_role,
reason="Robocronp: Timed "
"mute expired.")
delete_job(timestamp, jobtype, job_name)
elif jobtype == "remind":
text = job_details["text"]
added_on = job_details["added"]
target = await self.bot.get_user_info(int(job_name))
if target:
await target.send("You asked to be reminded about"
f" `{text}` on {added_on}.")
delete_job(timestamp, jobtype, job_name)
except:
# Don't kill cronjobs if something goes wrong.
delete_job(timestamp, jobtype, job_name)
await log_channel.send("Crondo has errored, job deleted: ```"
f"{traceback.format_exc()}```")
async def clean_channel(self, channel_id):
log_channel = self.bot.get_channel(config.botlog_channel)
channel = self.bot.get_channel(channel_id)
try:
done_cleaning = False
count = 0
while not done_cleaning:
purge_res = await channel.purge(limit=100)
count += len(purge_res)
if len(purge_res) != 100:
done_cleaning = True
await log_channel.send(f"Wiped {count} messages from "
f"<#{channel.id}> automatically.")
except:
# Don't kill cronjobs if something goes wrong.
await log_channel.send("Cronclean has errored: ```"
f"{traceback.format_exc()}```")
async def minutely(self):
await self.bot.wait_until_ready()
log_channel = self.bot.get_channel(config.botlog_channel)
while not self.bot.is_closed():
try:
ctab = get_crontab()
timestamp = time.time()
for jobtype in ctab:
for jobtimestamp in ctab[jobtype]:
if timestamp > int(jobtimestamp):
await self.do_jobs(ctab, jobtype, jobtimestamp)
# Handle clean channels
for clean_channel in config.minutely_clean_channels:
await self.clean_channel(clean_channel)
except:
# Don't kill cronjobs if something goes wrong.
await log_channel.send("Cron-minutely has errored: ```"
f"{traceback.format_exc()}```")
await asyncio.sleep(60)
async def hourly(self):
await self.bot.wait_until_ready()
log_channel = self.bot.get_channel(config.botlog_channel)
while not self.bot.is_closed():
# Your stuff that should run at boot
# and after that every hour goes here
await asyncio.sleep(3600)
try:
await self.send_data()
# Handle clean channels
for clean_channel in config.hourly_clean_channels:
await self.clean_channel(clean_channel)
except:
# Don't kill cronjobs if something goes wrong.
await log_channel.send("Cron-hourly has errored: ```"
f"{traceback.format_exc()}```")
# Your stuff that should run an hour after boot
# and after that every hour goes here
def setup(bot):
bot.add_cog(Robocronp(bot))

294
cogs/verification.py Normal file
View file

@ -0,0 +1,294 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
import asyncio
import config
import random
from inspect import cleandoc
import hashlib
from helpers.checks import check_if_staff
welcome_header = """
<:ReSwitched:326421448543567872> __**Welcome to ReSwitched!**__
__**Be sure you read the following rules and information before participating. If you came here to ask about "backups", this is NOT the place.**__
__**Got questions about Nintendo Switch hacking? Before asking in the server, please see our FAQ at <https://reswitched.team/faq/> to see if your question has already been answered.**__
:bookmark_tabs:__Rules:__
"""
welcome_rules = (
# 1
"""
Read all the rules before participating in chat. Not reading the rules is *not* an excuse for breaking them.
It's suggested that you read channel topics and pins before asking questions as well, as some questions may have already been answered in those.
""",
# 2
"""
Be nice to each other. It's fine to disagree, it's not fine to insult or attack other people.
You may disagree with anyone or anything you like, but you should try to keep it to opinions, and not people. Avoid vitriol.
Constant antagonistic behavior is considered uncivil and appropriate action will be taken.
The use of derogatory slurs -- sexist, racist, homophobic, transphobic, or otherwise -- is unacceptable and may be grounds for an immediate ban.
""",
# 3
'If you have concerns about another user, please take up your concerns with a staff member (someone with the "mod" role in the sidebar) in private. Don\'t publicly call other users out.',
# 4
"""
From time to time, we may mention everyone in the server. We do this when we feel something important is going on that requires attention. Complaining about these pings may result in a ban.
To disable notifications for these pings, suppress them in "ReSwitched → Notification Settings".
""",
# 5
"""
Don't spam.
For excessively long text, use a service like <https://0bin.net/>.
""",
# 6
"Don't brigade, raid, or otherwise attack other people or communities. Don't discuss participation in these attacks. This may warrant an immediate permanent ban.",
# 7
'Off-topic content goes to #off-topic. Keep low-quality content like memes out.',
# 8
'Trying to evade, look for loopholes, or stay borderline within the rules will be treated as breaking them.',
# 9
"""
Absolutely no piracy or related discussion. This includes:
"Backups", even if you legally own a copy of the game.
"Installable" NSPs, XCIs, and NCAs; this **includes** installable homebrew (i.e. on the Home Menu instead of within nx-hbmenu).
Signature and ES patches, also known as "sigpatches"
Usage of piracy-focused groups' (Team Xecuter, etc.) hardware and software, such as SX OS.
This is a zero-tolerance, non-negotiable policy that is enforced strictly and swiftly, up to and including instant bans without warning.
""",
# 10
'The first character of your server nickname should be alphanumeric if you wish to talk in chat.'
)
welcome_footer = (
"""
:hash: __Channel Breakdown:__
#news - Used exclusively for updates on ReSwitched progress and community information. Most major announcements are passed through this channel and whenever something is posted there it's usually something you'll want to look at.
#switch-hacking-meta - For "meta-discussion" related to hacking the switch. This is where we talk *about* the switch hacking that's going on, and where you can get clarification about the hacks that exist and the work that's being done.
#user-support - End-user focused support, mainly between users. Ask your questions about using switch homebrew here.
#tool-support - Developer focused support. Ask your questions about using PegaSwitch, libtransistor, Mephisto, and other tools here.
#hack-n-all - General hacking, hardware and software development channel for hacking on things *other* than the switch. This is a great place to ask about hacking other systems-- and for the community to have technical discussions.
""",
"""
#switch-hacking-general - Channel for everyone working on hacking the switch-- both in an exploit and a low-level hardware sense. This is where a lot of our in-the-open development goes on. Note that this isn't the place for developing homebrew-- we have #homebrew-development for that!
#homebrew-development - Discussion about the development of homebrew goes there. Feel free to show off your latest creation here.
#off-topic - Channel for discussion of anything that doesn't belong in #general. Anything goes, so long as you make sure to follow the rules and be on your best behavior.
#toolchain-development - Discussion about the development of libtransistor itself goes there.
#cfw-development - Development discussion regarding custom firmware (CFW) projects, such as Atmosphère. This channel is meant for the discussion accompanying active development.
#bot-cmds - Channel for excessive/random use of Robocop's various commands.
**If you are still not sure how to get access to the other channels, please read the rules again.**
**If you have questions about the rules, feel free to ask here!**
**Note: This channel is completely automated (aside from responding to questions about the rules). If your message didn't give you access to the other channels, you failed the test. Feel free to try again.**
""",
)
hidden_term_line = ' • When you have finished reading all of the rules, send a message in this channel that includes the {0} hash of your discord "name#discriminator" (for example, {0}(User#1234)), and we\'ll grant you access to the other channels. You can find your "name#discriminator" (your username followed by a # and four numbers) under the discord channel list.'
class Verification(Cog):
def __init__(self, bot, hash_choice):
self.bot = bot
self.hash_choice = random.choice(tuple(hashlib.algorithms_guaranteed))
@commands.check(check_if_staff)
@commands.command()
async def reset(self, ctx, limit: int = 100, force: bool = False):
"""Wipes messages and pastes the welcome message again. Staff only."""
if ctx.message.channel.id != config.welcome_channel and not force:
await ctx.send(f"This command is limited to"
f" <#{config.welcome_channel}>, unless forced.")
return
await ctx.channel.purge(limit=limit)
await ctx.send(welcome_header)
rules = ['**{}**. {}'.format(i, cleandoc(r)) for i, r in
enumerate(welcome_rules, 1)]
rule_choice = random.randint(2, len(rules))
rules[rule_choice - 1] += '\n' + hidden_term_line.format(self.hash_choice.upper())
msg = f"🗑 **Reset**: {ctx.author.mention} cleared {limit} messages "\
f" in {ctx.channel.mention}"
msg += f"\n💬 __Current challenge location__: under rule {rule_choice}"
log_channel = self.bot.get_channel(config.log_channel)
await log_channel.send(msg)
# find rule that puts us over 2,000 characters, if any
total = 0
messages = []
current_message = ""
for item in rules:
total += len(item) + 2 # \n\n
if total < 2000:
current_message += item + "\n\n"
else:
# we've hit the limit; split!
messages += [current_message]
current_message = "\n\u200B\n" + item + "\n\u200B\n"
total = 0
messages += [current_message]
for item in messages:
await ctx.send(item)
await asyncio.sleep(1)
for x in welcome_footer:
await ctx.send(cleandoc(x))
await asyncio.sleep(1)
async def process_message(self, message):
"""Big code that makes me want to shoot myself
Not really a rewrite but more of a port
Git blame tells me that I should blame/credit Robin Lambertz"""
if message.channel.id == config.welcome_channel:
# Assign common stuff into variables to make stuff less of a mess
member = message.author
full_name = str(member)
discrim = str(member.discriminator)
guild = message.guild
chan = message.channel
mcl = message.content.lower()
# Reply to users that insult the bot
oof = ["bad", "broken", "buggy", "bugged",
"stupid", "dumb", "silly", "fuck", "heck", "h*ck"]
if "bot" in mcl and any(insult in mcl for insult in oof):
snark = random.choice(["bad human",
"no u",
"no u, rtfm",
"pebkac"])
return await chan.send(snark)
# Get the role we will give in case of success
success_role = guild.get_role(config.participant_role)
# Get a list of stuff we'll allow and will consider close
allowed_names = [f"@{full_name}", full_name, str(member.id)]
close_names = [f"@{member.name}", member.name, discrim,
f"#{discrim}"]
# Now add the same things but with newlines at the end of them
allowed_names += [(an + '\n') for an in allowed_names]
close_names += [(cn + '\n') for cn in close_names]
allowed_names += [(an + '\r\n') for an in allowed_names]
close_names += [(cn + '\r\n') for cn in close_names]
# [ ͡° ͜ᔦ ͡°] 𝐖𝐞𝐥𝐜𝐨𝐦𝐞 𝐭𝐨 𝐌𝐚𝐜 𝐎𝐒 𝟗.
allowed_names += [(an + '\r') for an in allowed_names]
close_names += [(cn + '\r') for cn in close_names]
# Finally, hash the stuff so that we can access them later :)
hash_allow = [hashlib.new(self.hash_choice, name.encode('utf-8')).hexdigest()
for name in allowed_names]
# I'm not even going to attempt to break those into lines jfc
if any(allow in mcl for allow in hash_allow):
await member.add_roles(success_role)
await chan.purge(limit=100, check=lambda m: m.author == message.author or (m.author == self.bot.user and message.author.mention in m.content))
elif full_name in message.content or str(member.id) in message.content or member.name in message.content or discrim in message.content:
no_text = ":no_entry: Incorrect. You need to do something *specific* with your name and discriminator instead of just posting it. Please re-read the rules carefully and look up any terms you are not familiar with."
rand_num = random.randint(1, 100)
if rand_num == 42:
no_text = "you're doing it wrong"
elif rand_num == 43:
no_text = "ugh, wrong, read the rules."
elif rand_num == 44:
no_text = "\"The definition of insanity is doing the same thing over and over again, but expecting different results.\"\n-Albert Einstein"
await chan.send(f"{message.author.mention} {no_text}")
@Cog.listener()
async def on_message(self, message):
if message.author.bot:
return
try:
await self.process_message(message)
except discord.errors.Forbidden:
chan = self.bot.get_channel(message.channel)
await chan.send("💢 I don't have permission to do this.")
@Cog.listener()
async def on_message_edit(self, before, after):
if after.author.bot:
return
try:
await self.process_message(after)
except discord.errors.Forbidden:
chan = self.bot.get_channel(after.channel)
await chan.send("💢 I don't have permission to do this.")
# @commands.guild_only()
# @commands.command()
# async def verify(self, ctx, *, verification_string: str):
# """Does verification.
# See text on top of #verification for more info."""
# await ctx.message.delete()
# veriflogs_channel = ctx.guild.get_channel(config.veriflogs_chanid)
# verification_role = ctx.guild.get_role(config.read_rules_roleid)
# verification_wanted = config.verification_code\
# .replace("[discrim]", ctx.author.discriminator)
# # Do checks on if the user can even attempt to verify
# if ctx.channel.id != config.verification_chanid:
# resp = await ctx.send("This command can only be used "
# f"on <#{config.verification_chanid}>.")
# await asyncio.sleep(config.sleep_secs)
# return await resp.delete()
# if verification_role in ctx.author.roles:
# resp = await ctx.send("This command can only by those without "
# f"<@&{config.read_rules_roleid}> role.")
# await asyncio.sleep(config.sleep_secs)
# return await resp.delete()
# # Log verification attempt
# await self.bot.update_logs("Verification Attempt",
# ctx.author.id,
# veriflogs_channel,
# log_text=verification_string,
# digdepth=50, result=-1)
# # Check verification code
# if verification_string.lower().strip() == verification_wanted:
# resp = await ctx.send("Success! Welcome to the "
# f"club, {str(ctx.author)}.")
# await self.bot.update_logs("Verification Attempt",
# ctx.author.id,
# veriflogs_channel,
# digdepth=50, result=0)
# await asyncio.sleep(config.sleep_secs)
# await ctx.author.add_roles(verification_role)
# await resp.delete()
# else:
# resp = await ctx.send(f"Incorrect password, {str(ctx.author)}.")
# await asyncio.sleep(config.sleep_secs)
# await resp.delete()
def setup(bot):
bot.add_cog(Verification(bot))

99
config_template.py Normal file
View file

@ -0,0 +1,99 @@
import datetime
# Basic bot config, insert your token here, update description if you want
prefixes = [".", "!"]
token = "token-goes-here"
bot_description = "Robocop-NG, the moderation bot of ReSwitched."
# If you forked robocop-ng, put your repo here
source_url = "https://github.com/reswitched/robocop-ng"
rules_url = "https://reswitched.team/discord/#rules"
# The bot description to be used in .robocop embed
embed_desc = "Robocop-NG is developed by [Ave](https://github.com/aveao)"\
" and [tomGER](https://github.com/tumGER), and is a rewrite "\
"of Robocop.\nRobocop is based on Kurisu by 916253 and ihaveamac."
# Minimum account age required to join the guild
# If user's account creation is shorter than the time delta given here
# then user will be kicked and informed
min_age = datetime.timedelta(minutes=15)
# The bot will only work in these guilds
guild_whitelist = [
269333940928512010 # ReSwitched discord
]
# Named roles to be used with .approve and .revoke
# Example: .approve User hacker
named_roles = {
"community": 420010997877833731,
"hacker": 364508795038072833,
"participant": 434353085926866946
}
# The bot manager and staff roles
# Bot manager can run eval, exit and other destructive commands
# Staff can run administrative commands
bot_manager_role_id = 466447265863696394 # Bot management role in ReSwitched
staff_role_ids = [364647829248933888, # Team role in ReSwitched
360138431524765707, # Mod role in ReSwitched
466447265863696394, # Bot management role in ReSwitched
360138163156549632, # Admin role in ReSwitched
287289529986187266] # Wizard role in ReSwitched
# Various log channels used to log bot and guild's activity
# You can use same channel for multiple log types
# Spylog channel logs suspicious messages or messages by members under watch
# Invites created with .invite will direct to the welcome channel.
log_channel = 290958160414375946 # server-logs in ReSwitched
botlog_channel = 529070282409771048 # bot-logs channel in ReSwitched
modlog_channel = 542114169244221452 # mod-logs channel in ReSwitched
spylog_channel = 548304839294189579 # spy channel in ReSwitched
welcome_channel = 326416669058662401 # newcomers channel in ReSwitched
# These channel entries are used to determine which roles will be given
# access when we unmute on them
general_channels = [420029476634886144,
414949821003202562,
383368936466546698,
343244421044633602,
491316901692178432,
539212260350885908] # Channels everyone can access
community_channels = [269333940928512010,
438839875970662400,
404722395845361668,
435687501068501002,
286612533757083648] # Channels requiring community role
# Controls which roles are blocked during lockdown
lockdown_configs = {
# Used as a default value for channels without a config
"default": {
"channels": general_channels,
"roles": [named_roles["participant"]]
},
"community": {
"channels": community_channels,
"roles": [named_roles["community"], named_roles["hacker"]]
}
}
# Mute role is applied to users when they're muted
# As we no longer have mute role on ReSwitched, I set it to 0 here
mute_role = 0 # Mute role in ReSwitched
# Channels that will be cleaned every minute/hour
minutely_clean_channels = []
hourly_clean_channels = []
# Edited and deletes messages in these channels will be logged
spy_channels = general_channels
# Channels and roles where users can pin messages
allowed_pin_channels = []
allowed_pin_roles = []
# Used for the pinboard. Leave empty if you don't wish for a gist pinboard.
github_oauth_token = ""

View file

@ -1,4 +0,0 @@
(import (fetchTarball
"https://github.com/edolstra/flake-compat/archive/master.tar.gz") {
src = builtins.fetchGit ./.;
}).defaultNix

View file

@ -1,175 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703863825,
"narHash": "sha256-rXwqjtwiGKJheXB43ybM8NwWB8rO2dSRrEqes0S7F5Y=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "5163432afc817cf8bd1f031418d1869e4c9d5547",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1720535198,
"narHash": "sha256-zwVvxrdIzralnSbcpghA92tWu2DV2lwv89xZc8MTrbg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "205fd4226592cc83fd4c0885a3e4c9c400efabb5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils_2",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems_3",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1724417163,
"narHash": "sha256-gD0N0pnKxWJcKtbetlkKOIumS0Zovgxx/nMfOIJIzoI=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "7619e43c2b48c29e24b88a415256f09df96ec276",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "poetry2nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"poetry2nix": "poetry2nix"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"id": "systems",
"type": "indirect"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1719749022,
"narHash": "sha256-ddPKHcqaKCIFSFc/cvxS14goUhCOAwsM1PbMr0ZtHMg=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "8df5ff62195d4e67e2264df0b7f5e8c9995fd0bd",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

133
flake.nix
View file

@ -1,133 +0,0 @@
{
description = "Application packaged using poetry2nix";
inputs = {
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";
poetry2nix = {
url = "github:nix-community/poetry2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, poetry2nix }@inputs:
let
ryuko_overlay = final: prev:
let
pkgs = import nixpkgs { system = prev.system; };
poetry2nix = inputs.poetry2nix.lib.mkPoetry2Nix { inherit pkgs; };
in {
ryuko-ng = with final;
poetry2nix.mkPoetryApplication rec {
projectDir = self;
src = projectDir;
overrides = [ poetry2nix.defaultPoetryOverrides (self: super: {
cryptography = super.cryptography.overridePythonAttrs (old: {
cargoDeps = pkgs.rustPlatform.fetchCargoTarball {
src = old.src;
sourceRoot = "${old.pname}-${old.version}/src/rust";
name = "${old.pname}-${old.version}";
# cryptography-42.0.7
sha256 = "sha256-wAup/0sI8gYVsxr/vtcA+tNkBT8wxmp68FPbOuro1E4=";
};
});
}) ];
};
};
in flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlays."${system}" ];
};
in {
packages = {
default = self.packages.${system}.ryuko-ng;
ryuko-ng = pkgs.ryuko-ng;
};
overlays = ryuko_overlay;
nixosModules.ryuko-ng = { pkgs, lib, config, ... }: {
options = let inherit (lib) mkEnableOption mkOption types;
in {
services.ryuko-ng = {
enable = mkEnableOption (lib.mdDoc "ryuko-ng discord bot");
};
};
config = let
inherit (lib) mkIf;
cfg = config.services.ryuko-ng;
in mkIf cfg.enable {
nixpkgs.overlays = [ self.overlays."${system}" ];
systemd.services.ryuko-ng = {
description = "ryuko-ng bot";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
script = ''
${pkgs.ryuko-ng.dependencyEnv}/bin/python3 -m robocop_ng /var/lib/ryuko-ng
'';
serviceConfig = rec {
Type = "simple";
User = "ryuko-ng";
Group = "ryuko-ng";
StateDirectory = "ryuko-ng";
StateDirectoryMode = "0700";
CacheDirectory = "ryuko-ng";
CacheDirectoryMode = "0700";
UMask = "0077";
WorkingDirectory = "/var/lib/ryuko-ng";
Restart = "on-failure";
};
};
users = {
users.ryuko-ng = {
group = "ryuko-ng";
isSystemUser = true;
};
extraUsers.ryuko-ng.uid = 989;
groups.ryuko-ng = { };
extraGroups.ryuko-ng = {
name = "ryuko-ng";
gid = 987;
};
};
};
};
devShells.default = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.ryuko-ng ];
packages = [ pkgs.poetry ];
};
checks = {
vmTest = with import (nixpkgs + "/nixos/lib/testing-python.nix") {
inherit system;
};
makeTest {
name = "ryuko-ng nixos module testing ${system}";
nodes = {
client = { ... }: {
imports = [ self.nixosModules.${system}.ryuko-ng ];
services.ryuko-ng.enable = true;
};
};
testScript = ''
client.wait_for_unit("ryuko-ng.service")
'';
};
};
formatter = pkgs.nixfmt;
});
}

View file

@ -16,25 +16,17 @@ def check_if_bot_manager(ctx):
def check_if_staff_or_ot(ctx):
if not ctx.guild:
return True
is_ot = ctx.channel.name == "off-topic"
is_bot_cmds = ctx.channel.name == "bot-cmds"
is_ot = (ctx.channel.name == "off-topic")
is_bot_cmds = (ctx.channel.name == "bot-cmds")
is_staff = any(r.id in config.staff_role_ids for r in ctx.author.roles)
return is_ot or is_staff or is_bot_cmds
def check_if_staff_or_dm(ctx):
if not ctx.guild:
return True
return any(r.id in config.staff_role_ids for r in ctx.author.roles)
return (is_ot or is_staff or is_bot_cmds)
def check_if_collaborator(ctx):
if not ctx.guild:
return False
return any(
r.id in config.staff_role_ids + config.allowed_pin_roles
for r in ctx.author.roles
)
return any(r.id in config.staff_role_ids + config.allowed_pin_roles
for r in ctx.author.roles)
def check_if_pin_channel(ctx):

File diff suppressed because it is too large Load diff

42
helpers/restrictions.py Normal file
View file

@ -0,0 +1,42 @@
import json
def get_restrictions():
with open("data/restrictions.json", "r") as f:
return json.load(f)
def set_restrictions(contents):
with open("data/restrictions.json", "w") as f:
f.write(contents)
def get_user_restrictions(uid):
uid = str(uid)
with open("data/restrictions.json", "r") as f:
rsts = json.load(f)
if uid in rsts:
return rsts[uid]
return []
def add_restriction(uid, rst):
# mostly from kurisu source, credits go to ihaveamac
uid = str(uid)
rsts = get_restrictions()
if uid not in rsts:
rsts[uid] = []
if rst not in rsts[uid]:
rsts[uid].append(rst)
set_restrictions(json.dumps(rsts))
def remove_restriction(uid, rst):
# mostly from kurisu source, credits go to ihaveamac
uid = str(uid)
rsts = get_restrictions()
if uid not in rsts:
rsts[uid] = []
if rst in rsts[uid]:
rsts[uid].remove(rst)
set_restrictions(json.dumps(rsts))

37
helpers/robocronp.py Normal file
View file

@ -0,0 +1,37 @@
import json
import math
def get_crontab():
with open("data/robocronptab.json", "r") as f:
return json.load(f)
def set_crontab(contents):
with open("data/robocronptab.json", "w") as f:
f.write(contents)
def add_job(job_type, job_name, job_details, timestamp):
timestamp = str(math.floor(timestamp))
job_name = str(job_name)
ctab = get_crontab()
if job_type not in ctab:
ctab[job_type] = {}
if timestamp not in ctab[job_type]:
ctab[job_type][timestamp] = {}
ctab[job_type][timestamp][job_name] = job_details
set_crontab(json.dumps(ctab))
def delete_job(timestamp, job_type, job_name):
timestamp = str(timestamp)
job_name = str(job_name)
ctab = get_crontab()
del ctab[job_type][timestamp][job_name]
set_crontab(json.dumps(ctab))

63
helpers/userlogs.py Normal file
View file

@ -0,0 +1,63 @@
import json
import time
userlog_event_types = {"warns": "Warn",
"bans": "Ban",
"kicks": "Kick",
"mutes": "Mute",
"notes": "Note"}
def get_userlog():
with open("data/userlog.json", "r") as f:
return json.load(f)
def set_userlog(contents):
with open("data/userlog.json", "w") as f:
f.write(contents)
def userlog(uid, issuer, reason, event_type, uname: str = ""):
userlogs = get_userlog()
uid = str(uid)
if uid not in userlogs:
userlogs[uid] = {"warns": [],
"mutes": [],
"kicks": [],
"bans": [],
"notes": [],
"watch": False,
"name": "n/a"}
if uname:
userlogs[uid]["name"] = uname
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
log_data = {"issuer_id": issuer.id,
"issuer_name": f"{issuer}",
"reason": reason,
"timestamp": timestamp}
if event_type not in userlogs[uid]:
userlogs[uid][event_type] = []
userlogs[uid][event_type].append(log_data)
set_userlog(json.dumps(userlogs))
return len(userlogs[uid][event_type])
def setwatch(uid, issuer, watch_state, uname: str = ""):
userlogs = get_userlog()
uid = str(uid)
# Can we reduce code repetition here?
if uid not in userlogs:
userlogs[uid] = {"warns": [],
"mutes": [],
"kicks": [],
"bans": [],
"notes": [],
"watch": False,
"name": "n/a"}
if uname:
userlogs[uid]["name"] = uname
userlogs[uid]["watch"] = watch_state
set_userlog(json.dumps(userlogs))
return

686
poetry.lock generated
View file

@ -1,686 +0,0 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "aiohttp"
version = "3.9.5"
description = "Async http client/server framework (asyncio)"
optional = false
python-versions = ">=3.8"
files = [
{file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"},
{file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"},
{file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"},
{file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"},
{file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"},
{file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"},
{file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"},
{file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"},
{file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"},
{file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"},
{file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"},
{file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"},
{file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"},
{file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"},
{file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"},
{file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"},
{file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"},
{file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"},
{file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"},
{file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"},
{file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"},
{file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"},
{file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"},
{file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"},
{file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"},
{file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"},
{file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"},
{file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"},
{file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"},
{file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"},
{file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"},
{file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"},
{file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"},
{file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"},
{file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"},
{file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"},
{file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"},
{file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"},
{file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"},
{file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"},
{file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"},
{file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"},
{file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"},
{file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"},
{file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"},
{file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"},
{file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"},
{file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"},
{file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"},
{file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"},
{file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"},
{file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"},
{file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"},
{file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"},
{file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"},
{file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"},
{file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"},
{file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"},
{file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"},
{file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"},
{file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"},
{file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"},
{file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"},
{file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"},
{file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"},
{file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"},
{file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"},
{file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"},
{file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"},
{file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"},
{file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"},
{file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"},
{file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"},
{file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"},
{file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"},
{file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"},
]
[package.dependencies]
aiosignal = ">=1.1.2"
attrs = ">=17.3.0"
frozenlist = ">=1.1.1"
multidict = ">=4.5,<7.0"
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["Brotli", "aiodns", "brotlicffi"]
[[package]]
name = "aiosignal"
version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks"
optional = false
python-versions = ">=3.7"
files = [
{file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
{file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
]
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "attrs"
version = "23.2.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.7"
files = [
{file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
{file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
]
[package.extras]
cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
dev = ["attrs[tests]", "pre-commit"]
docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
tests = ["attrs[tests-no-zope]", "zope-interface"]
tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
[[package]]
name = "cffi"
version = "1.16.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
{file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
{file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
{file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
{file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
{file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
{file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
{file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
{file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
{file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
{file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
{file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
{file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
{file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
{file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
{file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
{file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
{file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
{file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
{file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
{file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
{file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
{file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
{file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
{file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
{file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
{file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
{file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
{file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
{file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
{file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
{file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
{file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
]
[package.dependencies]
pycparser = "*"
[[package]]
name = "cryptography"
version = "42.0.7"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = ">=3.7"
files = [
{file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a987f840718078212fdf4504d0fd4c6effe34a7e4740378e59d47696e8dfb477"},
{file = "cryptography-42.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd13b5e9b543532453de08bcdc3cc7cebec6f9883e886fd20a92f26940fd3e7a"},
{file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a79165431551042cc9d1d90e6145d5d0d3ab0f2d66326c201d9b0e7f5bf43604"},
{file = "cryptography-42.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47787a5e3649008a1102d3df55424e86606c9bae6fb77ac59afe06d234605f8"},
{file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:02c0eee2d7133bdbbc5e24441258d5d2244beb31da5ed19fbb80315f4bbbff55"},
{file = "cryptography-42.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5e44507bf8d14b36b8389b226665d597bc0f18ea035d75b4e53c7b1ea84583cc"},
{file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f8b25fa616d8b846aef64b15c606bb0828dbc35faf90566eb139aa9cff67af2"},
{file = "cryptography-42.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:93a3209f6bb2b33e725ed08ee0991b92976dfdcf4e8b38646540674fc7508e13"},
{file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6b8f1881dac458c34778d0a424ae5769de30544fc678eac51c1c8bb2183e9da"},
{file = "cryptography-42.0.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3de9a45d3b2b7d8088c3fbf1ed4395dfeff79d07842217b38df14ef09ce1d8d7"},
{file = "cryptography-42.0.7-cp37-abi3-win32.whl", hash = "sha256:789caea816c6704f63f6241a519bfa347f72fbd67ba28d04636b7c6b7da94b0b"},
{file = "cryptography-42.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:8cb8ce7c3347fcf9446f201dc30e2d5a3c898d009126010cbd1f443f28b52678"},
{file = "cryptography-42.0.7-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:a3a5ac8b56fe37f3125e5b72b61dcde43283e5370827f5233893d461b7360cd4"},
{file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:779245e13b9a6638df14641d029add5dc17edbef6ec915688f3acb9e720a5858"},
{file = "cryptography-42.0.7-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d563795db98b4cd57742a78a288cdbdc9daedac29f2239793071fe114f13785"},
{file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:31adb7d06fe4383226c3e963471f6837742889b3c4caa55aac20ad951bc8ffda"},
{file = "cryptography-42.0.7-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efd0bf5205240182e0f13bcaea41be4fdf5c22c5129fc7ced4a0282ac86998c9"},
{file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a9bc127cdc4ecf87a5ea22a2556cab6c7eda2923f84e4f3cc588e8470ce4e42e"},
{file = "cryptography-42.0.7-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:3577d029bc3f4827dd5bf8bf7710cac13527b470bbf1820a3f394adb38ed7d5f"},
{file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2e47577f9b18723fa294b0ea9a17d5e53a227867a0a4904a1a076d1646d45ca1"},
{file = "cryptography-42.0.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1a58839984d9cb34c855197043eaae2c187d930ca6d644612843b4fe8513c886"},
{file = "cryptography-42.0.7-cp39-abi3-win32.whl", hash = "sha256:e6b79d0adb01aae87e8a44c2b64bc3f3fe59515280e00fb6d57a7267a2583cda"},
{file = "cryptography-42.0.7-cp39-abi3-win_amd64.whl", hash = "sha256:16268d46086bb8ad5bf0a2b5544d8a9ed87a0e33f5e77dd3c3301e63d941a83b"},
{file = "cryptography-42.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2954fccea107026512b15afb4aa664a5640cd0af630e2ee3962f2602693f0c82"},
{file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:362e7197754c231797ec45ee081f3088a27a47c6c01eff2ac83f60f85a50fe60"},
{file = "cryptography-42.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4f698edacf9c9e0371112792558d2f705b5645076cc0aaae02f816a0171770fd"},
{file = "cryptography-42.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5482e789294854c28237bba77c4c83be698be740e31a3ae5e879ee5444166582"},
{file = "cryptography-42.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e9b2a6309f14c0497f348d08a065d52f3020656f675819fc405fb63bbcd26562"},
{file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d8e3098721b84392ee45af2dd554c947c32cc52f862b6a3ae982dbb90f577f14"},
{file = "cryptography-42.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c65f96dad14f8528a447414125e1fc8feb2ad5a272b8f68477abbcc1ea7d94b9"},
{file = "cryptography-42.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36017400817987670037fbb0324d71489b6ead6231c9604f8fc1f7d008087c68"},
{file = "cryptography-42.0.7.tar.gz", hash = "sha256:ecbfbc00bf55888edda9868a4cf927205de8499e7fabe6c050322298382953f2"},
]
[package.dependencies]
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"]
nox = ["nox"]
pep8test = ["check-sdist", "click", "mypy", "ruff"]
sdist = ["build"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
test-randomorder = ["pytest-randomly"]
[[package]]
name = "discord-py"
version = "2.3.2"
description = "A Python wrapper for the Discord API"
optional = false
python-versions = ">=3.8.0"
files = [
{file = "discord.py-2.3.2-py3-none-any.whl", hash = "sha256:9da4679fc3cb10c64b388284700dc998663e0e57328283bbfcfc2525ec5960a6"},
{file = "discord.py-2.3.2.tar.gz", hash = "sha256:4560f70f2eddba7e83370ecebd237ac09fbb4980dc66507482b0c0e5b8f76b9c"},
]
[package.dependencies]
aiohttp = ">=3.7.4,<4"
[package.extras]
docs = ["sphinx (==4.4.0)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport", "typing-extensions (>=4.3,<5)"]
speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7)", "orjson (>=3.5.4)"]
test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)"]
voice = ["PyNaCl (>=1.3.0,<1.6)"]
[[package]]
name = "frozenlist"
version = "1.4.1"
description = "A list-like structure which implements collections.abc.MutableSequence"
optional = false
python-versions = ">=3.8"
files = [
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"},
{file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"},
{file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"},
{file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"},
{file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"},
{file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"},
{file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"},
{file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"},
{file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"},
{file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"},
{file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"},
{file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"},
{file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"},
{file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"},
{file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"},
{file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"},
{file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"},
{file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"},
{file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"},
{file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"},
{file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"},
{file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"},
{file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"},
{file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"},
{file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"},
{file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"},
{file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"},
{file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"},
{file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
]
[[package]]
name = "gidgethub"
version = "5.3.0"
description = "An async GitHub API library"
optional = false
python-versions = ">=3.7"
files = [
{file = "gidgethub-5.3.0-py3-none-any.whl", hash = "sha256:4dd92f2252d12756b13f9dd15cde322bfb0d625b6fb5d680da1567ec74b462c0"},
{file = "gidgethub-5.3.0.tar.gz", hash = "sha256:9ece7d37fbceb819b80560e7ed58f936e48a65d37ec5f56db79145156b426a25"},
]
[package.dependencies]
PyJWT = {version = ">=2.4.0", extras = ["crypto"]}
uritemplate = ">=3.0.1"
[package.extras]
aiohttp = ["aiohttp"]
dev = ["aiohttp", "black", "coverage[toml] (>=5.0.3)", "httpx", "mypy", "pytest-cov", "pytest-xdist", "tornado"]
doc = ["sphinx (>=4.0.0)", "sphinx-rtd-theme (>=0.5.2)"]
httpx = ["httpx (>=0.16.1)"]
test = ["importlib-resources", "pytest (>=5.4.1)", "pytest-asyncio", "pytest-tornasync"]
tornado = ["tornado"]
[[package]]
name = "humanize"
version = "4.9.0"
description = "Python humanize utilities"
optional = false
python-versions = ">=3.8"
files = [
{file = "humanize-4.9.0-py3-none-any.whl", hash = "sha256:ce284a76d5b1377fd8836733b983bfb0b76f1aa1c090de2566fcf008d7f6ab16"},
{file = "humanize-4.9.0.tar.gz", hash = "sha256:582a265c931c683a7e9b8ed9559089dea7edcf6cc95be39a3cbc2c5d5ac2bcfa"},
]
[package.extras]
tests = ["freezegun", "pytest", "pytest-cov"]
[[package]]
name = "idna"
version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
]
[[package]]
name = "multidict"
version = "6.0.5"
description = "multidict implementation"
optional = false
python-versions = ">=3.7"
files = [
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"},
{file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"},
{file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"},
{file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"},
{file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"},
{file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"},
{file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"},
{file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"},
{file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"},
{file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"},
{file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"},
{file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"},
{file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"},
{file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"},
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"},
{file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"},
{file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"},
{file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"},
{file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"},
{file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"},
{file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"},
{file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"},
{file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"},
{file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"},
{file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"},
{file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"},
{file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"},
{file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"},
{file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"},
{file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"},
{file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"},
{file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"},
{file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"},
{file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"},
{file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"},
{file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"},
{file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"},
{file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"},
{file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"},
{file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"},
{file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"},
{file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"},
]
[[package]]
name = "parsedatetime"
version = "2.6"
description = "Parse human-readable date/time text."
optional = false
python-versions = "*"
files = [
{file = "parsedatetime-2.6-py3-none-any.whl", hash = "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b"},
{file = "parsedatetime-2.6.tar.gz", hash = "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455"},
]
[[package]]
name = "pycparser"
version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
[[package]]
name = "pyjwt"
version = "2.8.0"
description = "JSON Web Token implementation in Python"
optional = false
python-versions = ">=3.7"
files = [
{file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"},
{file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"},
]
[package.dependencies]
cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
[package.extras]
crypto = ["cryptography (>=3.4.0)"]
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]]
name = "uritemplate"
version = "4.1.1"
description = "Implementation of RFC 6570 URI Templates"
optional = false
python-versions = ">=3.6"
files = [
{file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"},
{file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"},
]
[[package]]
name = "yarl"
version = "1.9.4"
description = "Yet another URL library"
optional = false
python-versions = ">=3.7"
files = [
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"},
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"},
{file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"},
{file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"},
{file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"},
{file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"},
{file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"},
{file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"},
{file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"},
{file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"},
{file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"},
{file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"},
{file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"},
{file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"},
{file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"},
{file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"},
{file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"},
{file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"},
{file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"},
{file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"},
{file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"},
{file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"},
]
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "1453fcd147cbf31437ecaa4b37deb0f5d880579a2b93b5f668c28c154ed299d8"

View file

@ -1,23 +0,0 @@
[tool.poetry]
name = "robocop_ng"
version = "1.0.1"
description = "Discord bot for handling ReSwitched moderation tasks and such, (n)ext-(g)en rewrite of Robocop"
authors = ["ReSwitched Team"]
license = "MIT"
readme = "README.md"
repository = "https://github.com/reswitched/robocop-ng"
[tool.poetry.dependencies]
python = "^3.11"
python-dateutil = "^2.8.2"
humanize = "^4.8.0"
parsedatetime = "^2.6"
aiohttp = "^3.9.3"
gidgethub = "^5.3.0"
"discord.py" = "^2.3.2"
[tool.poetry.dev-dependencies]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
git+https://github.com/Rapptz/discord.py@rewrite
asyncio
python-dateutil
humanize
parsedatetime
aiohttp
gidgethub

View file

@ -1,296 +0,0 @@
import asyncio
import logging.handlers
import os
import sys
import aiohttp
import discord
from discord.ext import commands
from discord.ext.commands import CommandError, Context
from robocop_ng.helpers.notifications import report_critical_error
if len(sys.argv[1:]) != 1:
sys.stderr.write("usage: <state_dir>")
sys.exit(1)
state_dir = os.path.abspath(sys.argv[1])
sys.path.append(state_dir)
import config
script_name = os.path.basename(__file__).split(".")[0]
log_file_name = f"{script_name}.log"
# Limit of discord (non-nitro) is 8MB (not MiB)
max_file_size = 1000 * 1000 * 8
backup_count = 3
file_handler = logging.handlers.RotatingFileHandler(
filename=log_file_name, maxBytes=max_file_size, backupCount=backup_count
)
stdout_handler = logging.StreamHandler(sys.stdout)
log_format = logging.Formatter(
"[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s"
)
file_handler.setFormatter(log_format)
stdout_handler.setFormatter(log_format)
log = logging.getLogger("discord")
log.setLevel(logging.INFO)
log.addHandler(file_handler)
log.addHandler(stdout_handler)
def get_prefix(bot, message):
prefixes = config.prefixes
return commands.when_mentioned_or(*prefixes)(bot, message)
wanted_jsons = [
"data/restrictions.json",
"data/robocronptab.json",
"data/userlog.json",
"data/invites.json",
"data/macros.json",
"data/persistent_roles.json",
"data/disabled_ids.json",
]
if not os.path.exists(os.path.join(state_dir, "data")):
os.makedirs(os.path.join(state_dir, "data"))
for wanted_json_idx in range(len(wanted_jsons)):
wanted_jsons[wanted_json_idx] = os.path.join(
state_dir, wanted_jsons[wanted_json_idx]
)
if not os.path.isfile(wanted_jsons[wanted_json_idx]):
with open(wanted_jsons[wanted_json_idx], "w") as file:
file.write("{}")
intents = discord.Intents.all()
intents.typing = False
bot = commands.Bot(
command_prefix=get_prefix, description=config.bot_description, intents=intents
)
bot.help_command = commands.DefaultHelpCommand(dm_help=True)
bot.log = log
bot.config = config
bot.script_name = script_name
bot.state_dir = state_dir
bot.wanted_jsons = wanted_jsons
async def get_channel_safe(self, channel_id: int):
res = self.get_channel(channel_id)
if res is None:
res = await self.fetch_channel(channel_id)
return res
commands.Bot.get_channel_safe = get_channel_safe
@bot.event
async def on_ready():
aioh = {"User-Agent": f"{script_name}/1.0'"}
bot.aiosession = aiohttp.ClientSession(headers=aioh)
bot.app_info = await bot.application_info()
bot.botlog_channel = await bot.get_channel_safe(config.botlog_channel)
log.info(
f"\nLogged in as: {bot.user.name} - "
f"{bot.user.id}\ndpy version: {discord.__version__}\n"
)
game_name = f"{config.prefixes[0]}help"
# Send "Robocop has started! x has y members!"
guild = bot.botlog_channel.guild
msg = (
f"{bot.user.name} has started! "
f"{guild.name} has {guild.member_count} members!"
)
data_files = [discord.File(fpath) for fpath in wanted_jsons]
await bot.botlog_channel.send(msg, files=data_files)
activity = discord.Activity(name=game_name, type=discord.ActivityType.listening)
await bot.change_presence(activity=activity)
@bot.event
async def on_command(ctx):
log_text = (
f"{ctx.message.author} ({ctx.message.author.id}): " f'"{ctx.message.content}" '
)
if ctx.guild: # was too long for tertiary if
log_text += (
f'on "{ctx.channel.name}" ({ctx.channel.id}) '
f'at "{ctx.guild.name}" ({ctx.guild.id})'
)
else:
log_text += f"on DMs ({ctx.channel.id})"
log.info(log_text)
@bot.event
async def on_error(event: str, *args, **kwargs):
log.exception(f"Error on {event}:")
exception = sys.exc_info()[1]
is_report_allowed = any(
[
not isinstance(exception, x)
for x in [
discord.RateLimited,
discord.GatewayNotFound,
discord.InteractionResponded,
discord.LoginFailure,
]
]
)
if exception is not None and is_report_allowed:
await report_critical_error(
bot,
exception,
additional_info={"Event": event, "args": args, "kwargs": kwargs},
)
@bot.event
async def on_command_error(ctx: Context, error: CommandError):
error_text = str(error)
err_msg = (
f'Error with "{ctx.message.content}" from '
f'"{ctx.message.author} ({ctx.message.author.id}) '
f"of type {type(error)}: {error_text}"
)
log.exception(err_msg)
if not isinstance(error, commands.CommandNotFound):
err_msg = bot.escape_message(err_msg)
await bot.botlog_channel.send(err_msg)
if isinstance(error, commands.NoPrivateMessage):
return await ctx.send("This command doesn't work on DMs.")
elif isinstance(error, commands.MissingPermissions):
roles_needed = "\n- ".join(error.missing_perms)
return await ctx.send(
f"{ctx.author.mention}: You don't have the right"
" permissions to run this command. You need: "
f"```- {roles_needed}```"
)
elif isinstance(error, commands.BotMissingPermissions):
roles_needed = "\n-".join(error.missing_perms)
return await ctx.send(
f"{ctx.author.mention}: Bot doesn't have "
"the right permissions to run this command. "
"Please add the following roles: "
f"```- {roles_needed}```"
)
elif isinstance(error, commands.CommandOnCooldown):
return await ctx.send(
f"{ctx.author.mention}: You're being "
"ratelimited. Try in "
f"{error.retry_after:.1f} seconds."
)
elif isinstance(error, commands.CheckFailure):
return await ctx.send(
f"{ctx.author.mention}: Check failed. "
"You might not have the right permissions "
"to run this command, or you may not be able "
"to run this command in the current channel."
)
elif isinstance(error, commands.CommandInvokeError) and (
"Cannot send messages to this user" in error_text
):
return await ctx.send(
f"{ctx.author.mention}: I can't DM you.\n"
"You might have me blocked or have DMs "
f"blocked globally or for {ctx.guild.name}.\n"
"Please resolve that, then "
"run the command again."
)
elif isinstance(error, commands.CommandNotFound):
# Nothing to do when command is not found.
return
help_text = (
f"Usage of this command is: ```{ctx.prefix}{ctx.command.name} "
f"{ctx.command.signature}```\nPlease see `{ctx.prefix}help "
f"{ctx.command.name}` for more info about this command."
)
# Keep a list of commands that involve mentioning users
# and can involve users leaving/getting banned
# noinspection NonAsciiCharacters,PyPep8Naming
ಠ_ಠ = ["warn", "kick", "ban"]
if isinstance(error, commands.BadArgument):
# and if said commands get used, add a specific notice.
if ctx.command.name in ಠ_ಠ:
help_text = (
"This probably means that user left (or already got kicked/banned).\n"
+ help_text
)
return await ctx.send(
f"{ctx.author.mention}: You gave incorrect arguments. {help_text}"
)
elif isinstance(error, commands.MissingRequiredArgument):
return await ctx.send(
f"{ctx.author.mention}: You gave incomplete arguments. {help_text}"
)
@bot.event
async def on_message(message):
if message.author.bot:
return
if (message.guild) and (message.guild.id not in config.guild_whitelist):
return
# Ignore messages in newcomers channel, unless it's potentially
# an allowed command
welcome_allowed = ["reset", "kick", "ban", "warn"]
if message.channel.id == config.welcome_channel and not any(
cmd in message.content for cmd in welcome_allowed
):
return
ctx = await bot.get_context(message)
await bot.invoke(ctx)
async def main():
async with bot:
if len(config.guild_whitelist) == 1:
invite_url = discord.utils.oauth_url(
config.client_id,
guild=discord.Object(config.guild_whitelist[0]),
disable_guild_select=True,
)
else:
invite_url = discord.utils.oauth_url(config.client_id)
log.info(f"\nInvite URL: {invite_url}\n")
for cog in config.initial_cogs:
try:
await bot.load_extension(f"robocop_ng.{cog}")
except Exception as e:
log.exception(f"Failed to load cog {cog}:", e)
await bot.start(config.token)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,2 +0,0 @@
*.otf
*.ttf

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -1,177 +0,0 @@
import inspect
import re
import traceback
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_bot_manager
class Admin(Cog):
def __init__(self, bot):
self.bot = bot
self.last_eval_result = None
self.previous_eval_code = None
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command(name="exit", aliases=["quit", "bye"])
async def _exit(self, ctx):
"""Shuts down the bot, bot manager only."""
await ctx.send(":wave: Goodbye!")
await self.bot.close()
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def fetchlog(self, ctx):
"""Returns log"""
await ctx.send(
"Here's the current log file:",
file=discord.File(f"{self.bot.script_name}.log"),
)
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def fetchdata(self, ctx):
"""Returns data files"""
data_files = [discord.File(fpath) for fpath in self.bot.wanted_jsons]
await ctx.send("Here you go:", files=data_files)
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command(name="eval")
async def _eval(self, ctx, *, code: str):
"""Evaluates some code, bot manager only."""
try:
code = code.strip("` ")
env = {
"bot": self.bot,
"ctx": ctx,
"message": ctx.message,
"server": ctx.guild,
"guild": ctx.guild,
"channel": ctx.message.channel,
"author": ctx.message.author,
# modules
"discord": discord,
"commands": commands,
# utilities
"_get": discord.utils.get,
"_find": discord.utils.find,
# last result
"_": self.last_eval_result,
"_p": self.previous_eval_code,
}
env.update(globals())
self.bot.log.info(f"Evaling {repr(code)}:")
result = eval(code, env)
if inspect.isawaitable(result):
result = await result
if result is not None:
self.last_eval_result = result
self.previous_eval_code = code
sliced_message = await self.bot.slice_message(
repr(result), prefix="```", suffix="```"
)
for msg in sliced_message:
await ctx.send(msg)
except:
sliced_message = await self.bot.slice_message(
traceback.format_exc(), prefix="```", suffix="```"
)
for msg in sliced_message:
await ctx.send(msg)
async def cog_load_actions(self, cog_name):
if cog_name == "verification":
verif_channel = self.bot.get_channel(self.bot.config.welcome_channel)
await self.bot.do_resetalgo(verif_channel, "cog load")
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def pull(self, ctx, auto=False):
"""Does a git pull, bot manager only."""
tmp = await ctx.send("Pulling...")
git_output = await self.bot.async_call_shell("git pull")
await tmp.edit(content=f"Pull complete. Output: ```{git_output}```")
if auto:
cogs_to_reload = re.findall(r"cogs/([a-z_]*).py[ ]*\|", git_output)
for cog in cogs_to_reload:
cog_name = "robocop_ng.cogs." + cog
if cog_name not in self.bot.config.initial_cogs:
continue
try:
await self.bot.unload_extension(cog_name)
await self.bot.load_extension(cog_name)
self.bot.log.info(f"Reloaded ext {cog}")
await ctx.send(f":white_check_mark: `{cog}` successfully reloaded.")
await self.cog_load_actions(cog)
except:
await ctx.send(
f":x: Cog reloading failed, traceback: "
f"```\n{traceback.format_exc()}\n```"
)
return
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def load(self, ctx, ext: str):
"""Loads a cog, bot manager only."""
try:
await self.bot.load_extension("robocop_ng.cogs." + ext)
await self.cog_load_actions(ext)
except:
await ctx.send(
f":x: Cog loading failed, traceback: "
f"```\n{traceback.format_exc()}\n```"
)
return
self.bot.log.info(f"Loaded ext {ext}")
await ctx.send(f":white_check_mark: `{ext}` successfully loaded.")
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def unload(self, ctx, ext: str):
"""Unloads a cog, bot manager only."""
await self.bot.unload_extension("robocop_ng.cogs." + ext)
self.bot.log.info(f"Unloaded ext {ext}")
await ctx.send(f":white_check_mark: `{ext}` successfully unloaded.")
@commands.check(check_if_bot_manager)
@commands.command()
async def reload(self, ctx, ext="_"):
"""Reloads a cog, bot manager only."""
if ext == "_":
ext = self.lastreload
else:
self.lastreload = ext
try:
await self.bot.unload_extension("robocop_ng.cogs." + ext)
await self.bot.load_extension("robocop_ng.cogs." + ext)
await self.cog_load_actions(ext)
except:
await ctx.send(
f":x: Cog reloading failed, traceback: "
f"```\n{traceback.format_exc()}\n```"
)
return
self.bot.log.info(f"Reloaded ext {ext}")
await ctx.send(f":white_check_mark: `{ext}` successfully reloaded.")
async def setup(bot):
await bot.add_cog(Admin(bot))

View file

@ -1,67 +0,0 @@
import time
import discord
from discord.ext import commands
from discord.ext.commands import Cog
class Basic(Cog):
def __init__(self, bot):
self.bot = bot
@commands.command()
async def hello(self, ctx):
"""Says hello. Duh."""
await ctx.send(f"Hello {ctx.author.mention}!")
@commands.cooldown(1, 10, type=commands.BucketType.user)
@commands.command(name="hex")
async def _hex(self, ctx, num: int):
"""Converts base 10 to 16 (for emummc sector calculation)"""
hex_val = hex(num).upper().replace("0X", "0x")
await ctx.send(f"{ctx.author.mention}: {hex_val}")
@commands.cooldown(1, 10, type=commands.BucketType.user)
@commands.command(name="dec")
async def _dec(self, ctx, num):
"""Converts base 16 to 10"""
await ctx.send(f"{ctx.author.mention}: {int(num, 16)}")
@commands.guild_only()
@commands.command()
async def membercount(self, ctx):
"""Prints the member count of the server."""
await ctx.send(f"{ctx.guild.name} has {ctx.guild.member_count} members!")
@commands.command(aliases=["robocopng", "robocop-ng"])
async def robocop(self, ctx):
"""Shows a quick embed with bot info."""
embed = discord.Embed(
title="Robocop-NG",
url=self.bot.config.source_url,
description=self.bot.config.embed_desc,
)
embed.set_thumbnail(url=str(self.bot.user.display_avatar))
await ctx.send(embed=embed)
@commands.command(aliases=["p"])
async def ping(self, ctx):
"""Shows ping values to discord.
RTT = Round-trip time, time taken to send a message to discord
GW = Gateway Ping"""
before = time.monotonic()
tmp = await ctx.send("Calculating ping...")
after = time.monotonic()
rtt_ms = (after - before) * 1000
gw_ms = self.bot.latency * 1000
message_text = f":ping_pong:\nrtt: `{rtt_ms:.1f}ms`\ngw: `{gw_ms:.1f}ms`"
self.bot.log.info(message_text)
await tmp.edit(content=message_text)
async def setup(bot):
await bot.add_cog(Basic(bot))

View file

@ -1,29 +0,0 @@
from discord.ext import commands
from discord.ext.commands import Cog
class BasicReswitched(Cog):
def __init__(self, bot):
self.bot = bot
@commands.guild_only()
@commands.command()
async def communitycount(self, ctx):
"""Prints the community member count of the server."""
community = ctx.guild.get_role(self.bot.config.named_roles["community"])
await ctx.send(
f"{ctx.guild.name} has {len(community.members)} community members!"
)
@commands.guild_only()
@commands.command()
async def hackercount(self, ctx):
"""Prints the hacker member count of the server."""
h4x0r = ctx.guild.get_role(self.bot.config.named_roles["hacker"])
await ctx.send(
f"{ctx.guild.name} has {len(h4x0r.members)} people with hacker role!"
)
async def setup(bot):
await bot.add_cog(BasicReswitched(bot))

View file

@ -1,40 +0,0 @@
import json
import os
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_collaborator
from robocop_ng.helpers.invites import add_invite
class Invites(Cog):
def __init__(self, bot):
self.bot = bot
@commands.command()
@commands.guild_only()
@commands.check(check_if_collaborator)
async def invite(self, ctx):
welcome_channel = self.bot.get_channel(self.bot.config.welcome_channel)
author = ctx.message.author
reason = f"Created by {str(author)} ({author.id})"
invite = await welcome_channel.create_invite(
max_age=0, max_uses=1, temporary=True, unique=True, reason=reason
)
add_invite(self.bot, invite.id, invite.url, 1, invite.code)
await ctx.message.add_reaction("🆗")
try:
await ctx.author.send(f"Created single-use invite {invite.url}")
except discord.errors.Forbidden:
await ctx.send(
f"{ctx.author.mention} I could not send you the \
invite. Send me a DM so I can reply to you."
)
async def setup(bot):
await bot.add_cog(Invites(bot))

View file

@ -1,37 +0,0 @@
from discord.ext import commands
from discord.ext.commands import Cog
class Legacy(Cog):
def __init__(self, bot):
self.bot = bot
@commands.command(hidden=True, aliases=["removehacker"])
async def probate(self, ctx):
"""Use .revoke <user> <role>"""
await ctx.send(
"This command was replaced with `.revoke <user> <role>`"
" on Robocop-NG, please use that instead."
)
@commands.command(hidden=True)
async def softlock(self, ctx):
"""Use .lock True"""
await ctx.send(
"This command was replaced with `.lock True`"
" on Robocop-NG, please use that instead.\n"
"Also... good luck, and sorry for taking your time. "
"Lockdown rarely means anything good."
)
@commands.command(hidden=True, aliases=["addhacker"])
async def unprobate(self, ctx):
"""Use .approve <user> <role>"""
await ctx.send(
"This command was replaced with `.approve <user> <role>`"
" on Robocop-NG, please use that instead."
)
async def setup(bot):
await bot.add_cog(Legacy(bot))

View file

@ -1,79 +0,0 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
class Links(Cog):
"""
Commands for easily linking to projects.
"""
def __init__(self, bot):
self.bot = bot
@commands.command(hidden=True)
async def pegaswitch(self, ctx):
"""Link to the Pegaswitch repo"""
await ctx.send("https://github.com/reswitched/pegaswitch")
@commands.command(hidden=True, aliases=["atmos"])
async def atmosphere(self, ctx):
"""Link to the Atmosphere repo"""
await ctx.send("https://github.com/atmosphere-nx/atmosphere")
@commands.command(hidden=True, aliases=["xyproblem"])
async def xy(self, ctx):
"""Link to the "What is the XY problem?" post from SE"""
await ctx.send(
"<https://meta.stackexchange.com/q/66377/285481>\n\n"
"TL;DR: It's asking about your attempted solution "
"rather than your actual problem.\n"
"It's perfectly okay to want to learn about a "
"solution, but please be clear about your intentions "
"if you're not actually trying to solve a problem."
)
@commands.command(hidden=True, aliases=["guides", "link"])
async def guide(self, ctx):
"""Link to the guides"""
await ctx.send(self.bot.config.links_guide_text)
@commands.command()
async def source(self, ctx):
"""Gives link to source code."""
await ctx.send(
f"You can find my source at {self.bot.config.source_url}. "
"Serious PRs and issues welcome!"
)
@commands.command()
async def rules(self, ctx, *, targetuser: discord.Member = None):
"""Post a link to the Rules"""
if not targetuser:
targetuser = ctx.author
await ctx.send(
f"{targetuser.mention}: A link to the rules "
f"can be found here: {self.bot.config.rules_url}"
)
@commands.command()
async def community(self, ctx, *, targetuser: discord.Member = None):
"""Post a link to the community section of the rules"""
if not targetuser:
targetuser = ctx.author
await ctx.send(
f"{targetuser.mention}: "
"https://reswitched.github.io/discord/#member-roles-breakdown"
"\n\n"
"Community role allows access to the set of channels "
"on the community category (#off-topic, "
"#homebrew-development, #switch-hacking-general etc)."
"\n\n"
"What you need to get the role is to be around, "
"be helpful and nice to people and "
"show an understanding of rules."
)
async def setup(bot):
await bot.add_cog(Links(bot))

View file

@ -1,392 +0,0 @@
import io
import os.path
import discord
from discord.ext import commands
from discord.ext.commands import Cog
class Lists(Cog):
"""
Manages channels that are dedicated to lists.
"""
def __init__(self, bot):
self.bot = bot
# Helpers
def check_if_target_is_staff(self, target):
return any(r.id in self.bot.config.staff_role_ids for r in target.roles)
def is_edit(self, emoji):
return str(emoji)[0] == "" or str(emoji)[0] == "📝"
def is_delete(self, emoji):
return str(emoji)[0] == "" or str(emoji)[0] == ""
def is_recycle(self, emoji):
return str(emoji)[0] == ""
def is_insert_above(self, emoji):
return str(emoji)[0] == "⤴️" or str(emoji)[0] == ""
def is_insert_below(self, emoji):
return str(emoji)[0] == "⤵️" or str(emoji)[0] == ""
def is_reaction_valid(self, reaction):
allowed_reactions = [
"",
"📝",
"",
"",
"",
"⤴️",
"",
"",
"⤵️",
]
return str(reaction.emoji)[0] in allowed_reactions
async def find_reactions(self, user_id, channel_id, limit=None):
reactions = []
channel = self.bot.get_channel(channel_id)
async for message in channel.history(limit=limit):
if len(message.reactions) == 0:
continue
for reaction in message.reactions:
users = await reaction.users().flatten()
user_ids = map(lambda user: user.id, users)
if user_id in user_ids:
reactions.append(reaction)
return reactions
def create_log_message(self, emoji, action, user, channel, reason=""):
msg = (
f"{emoji} **{action}** \n"
f"from {self.bot.escape_message(user.name)} ({user.id}), in {channel.mention}"
)
if reason != "":
msg += f":\n`{reason}`"
return msg
async def clean_up_raw_text_file_message(self, message):
embeds = message.embeds
if len(embeds) == 0:
return
fields = embeds[0].fields
for field in fields:
if field.name == "Message ID":
files_channel = self.bot.get_channel(self.bot.config.list_files_channel)
file_message = await files_channel.fetch_message(int(field.value))
await file_message.delete()
await message.edit(embed=None)
async def cache_message(self, message):
msg = {
"has_attachment": False,
"attachment_filename": "",
"attachment_data": b"",
"content": message.content,
}
if len(message.attachments) != 0:
attachment = next(
(
a
for a in message.attachments
if os.path.splitext(a.filename)[1] in [".png", ".jpg", ".jpeg"]
),
None,
)
if attachment is not None:
msg["has_attachment"] = True
msg["attachment_filename"] = attachment.filename
msg["attachment_data"] = await attachment.read()
return msg
async def send_cached_message(self, channel, message):
if message["has_attachment"] == True:
file = discord.File(
io.BytesIO(message["attachment_data"]),
filename=message["attachment_filename"],
)
await channel.send(content=message["content"], file=file)
else:
await channel.send(content=message["content"])
# Commands
@commands.command(aliases=["list"])
async def listitem(self, ctx, channel: discord.TextChannel, number: int):
"""Link to a specific list item."""
if number <= 0:
await ctx.send(f"Number must be greater than 0.")
return
if channel.id not in self.bot.config.list_channels:
await ctx.send(f"{channel.mention} is not a list channel.")
return
counter = 0
async for message in channel.history(limit=None, oldest_first=True):
if message.content.strip():
counter += 1
if counter == number:
embed = discord.Embed(
title=f"Item #{number} in #{channel.name}",
description=message.content,
url=message.jump_url,
)
await ctx.send(content="", embed=embed)
return
await ctx.send(f"Unable to find item #{number} in {channel.mention}.")
# Listeners
@Cog.listener()
async def on_raw_reaction_add(self, payload):
await self.bot.wait_until_ready()
# We only care about reactions in Rules, and Support FAQ
if payload.channel_id not in self.bot.config.list_channels:
return
channel = self.bot.get_channel(payload.channel_id)
message = await channel.fetch_message(payload.message_id)
member = channel.guild.get_member(payload.user_id)
user = self.bot.get_user(payload.user_id)
reaction = next(
(
reaction
for reaction in message.reactions
if str(reaction.emoji) == str(payload.emoji)
),
None,
)
if reaction is None:
return
# Only staff can add reactions in these channels.
if not self.check_if_target_is_staff(member):
await reaction.remove(user)
return
# Reactions are only allowed on messages from the bot.
if not message.author.bot:
await reaction.remove(user)
return
# Only certain reactions are allowed.
if not self.is_reaction_valid(reaction):
await reaction.remove(user)
return
# Remove all other reactions from user in this channel.
for r in await self.find_reactions(payload.user_id, payload.channel_id):
if r.message.id != message.id or (
r.message.id == message.id and str(r.emoji) != str(reaction.emoji)
):
await r.remove(user)
# When editing we want to provide the user a copy of the raw text.
if self.is_edit(reaction.emoji) and self.bot.config.list_files_channel != 0:
files_channel = self.bot.get_channel(self.bot.config.list_files_channel)
file = discord.File(
io.BytesIO(message.content.encode("utf-8")),
filename=f"{message.id}.txt",
)
file_message = await files_channel.send(file=file)
embed = discord.Embed(
title="Click here to get the raw text to modify.",
url=f"{file_message.attachments[0].url}?",
)
embed.add_field(name="Message ID", value=file_message.id, inline=False)
await message.edit(embed=embed)
@Cog.listener()
async def on_raw_reaction_remove(self, payload):
await self.bot.wait_until_ready()
# We only care about reactions in Rules, and Support FAQ
if payload.channel_id not in self.bot.config.list_channels:
return
channel = self.bot.get_channel(payload.channel_id)
message = await channel.fetch_message(payload.message_id)
# Reaction was removed from a message we don"t care about.
if not message.author.bot:
return
# We want to remove the embed we added.
if self.is_edit(payload.emoji) and self.bot.config.list_files_channel != 0:
await self.clean_up_raw_text_file_message(message)
@Cog.listener()
async def on_message(self, message):
await self.bot.wait_until_ready()
# We only care about messages in Rules, and Support FAQ
if message.channel.id not in self.bot.config.list_channels:
return
# We don"t care about messages from bots.
if message.author.bot:
return
# Only staff can modify lists.
if not self.check_if_target_is_staff(message.author):
await message.delete()
return
log_channel = self.bot.get_channel(self.bot.config.log_channel)
channel = message.channel
content = message.content
user = message.author
attachment_filename = None
attachment_data = None
if len(message.attachments) != 0:
# Lists will only reupload the first image.
attachment = next(
(
a
for a in message.attachments
if os.path.splitext(a.filename)[1] in [".png", ".jpg", ".jpeg"]
),
None,
)
if attachment is not None:
attachment_filename = attachment.filename
attachment_data = await attachment.read()
await message.delete()
reactions = await self.find_reactions(user.id, channel.id)
# Add to the end of the list if there is no reactions or somehow more
# than one.
if len(reactions) != 1:
if attachment_filename is not None and attachment_data is not None:
file = discord.File(
io.BytesIO(attachment_data), filename=attachment_filename
)
await channel.send(content=content, file=file)
else:
await channel.send(content)
for reaction in reactions:
await reaction.remove(user)
await log_channel.send(
self.create_log_message("💬", "List item added:", user, channel)
)
return
targeted_reaction = reactions[0]
targeted_message = targeted_reaction.message
if self.is_edit(targeted_reaction):
if self.bot.config.list_files_channel != 0:
await self.clean_up_raw_text_file_message(targeted_message)
await targeted_message.edit(content=content)
await targeted_reaction.remove(user)
await log_channel.send(
self.create_log_message("📝", "List item edited:", user, channel)
)
elif self.is_delete(targeted_reaction):
await targeted_message.delete()
await log_channel.send(
self.create_log_message(
"", "List item deleted:", user, channel, content
)
)
elif self.is_recycle(targeted_reaction):
messages = [await self.cache_message(targeted_message)]
for message in await channel.history(
limit=None, after=targeted_message, oldest_first=True
).flatten():
messages.append(await self.cache_message(message))
await channel.purge(limit=len(messages) + 1, bulk=True)
for message in messages:
await self.send_cached_message(channel, message)
await log_channel.send(
self.create_log_message(
"", "List item recycled:", user, channel, content
)
)
elif self.is_insert_above(targeted_reaction):
messages = [await self.cache_message(targeted_message)]
for message in await channel.history(
limit=None, after=targeted_message, oldest_first=True
).flatten():
messages.append(await self.cache_message(message))
await channel.purge(limit=len(messages) + 1, bulk=True)
if attachment_filename is not None and attachment_data is not None:
file = discord.File(
io.BytesIO(attachment_data), filename=attachment_filename
)
await channel.send(content=content, file=file)
else:
await channel.send(content)
for message in messages:
await self.send_cached_message(channel, message)
await log_channel.send(
self.create_log_message("💬", "List item added:", user, channel)
)
elif self.is_insert_below(targeted_reaction):
await targeted_reaction.remove(user)
messages = []
for message in await channel.history(
limit=None, after=targeted_message, oldest_first=True
).flatten():
messages.append(await self.cache_message(message))
await channel.purge(limit=len(messages), bulk=True)
if attachment_filename is not None and attachment_data is not None:
file = discord.File(
io.BytesIO(attachment_data), filename=attachment_filename
)
await channel.send(content=content, file=file)
else:
await channel.send(content)
for message in messages:
await self.send_cached_message(channel, message)
await log_channel.send(
self.create_log_message("💬", "List item added:", user, channel)
)
async def setup(bot):
await bot.add_cog(Lists(bot))

View file

@ -1,710 +0,0 @@
import logging
import re
from typing import Optional
import aiohttp
from discord import Colour, Embed, Message, Attachment
from discord.ext import commands
from discord.ext.commands import Cog, Context, BucketType
from robocop_ng.helpers.checks import check_if_staff
from robocop_ng.helpers.disabled_ids import (
add_disabled_app_id,
is_app_id_valid,
remove_disabled_app_id,
get_disabled_ids,
is_app_id_disabled,
is_build_id_valid,
add_disabled_build_id,
remove_disabled_build_id,
is_build_id_disabled,
is_ro_section_disabled,
is_ro_section_valid,
add_disabled_ro_section,
remove_disabled_ro_section,
remove_disable_id,
)
from robocop_ng.helpers.disabled_paths import (
is_path_disabled,
get_disabled_paths,
add_disabled_path,
remove_disabled_path,
)
from robocop_ng.helpers.ryujinx_log_analyser import LogAnalyser
logging.basicConfig(
format="%(asctime)s (%(levelname)s) %(message)s (Line %(lineno)d)",
level=logging.INFO,
)
class LogFileReader(Cog):
@staticmethod
def is_valid_log_name(attachment: Attachment) -> tuple[bool, bool]:
filename = attachment.filename
ryujinx_log_file_regex = re.compile(r"^Ryujinx_.*\.log$")
log_file = re.compile(r"^.*\.log|.*\.txt$")
is_ryujinx_log_file = re.match(ryujinx_log_file_regex, filename) is not None
is_log_file = re.match(log_file, filename) is not None
return is_log_file, is_ryujinx_log_file
def __init__(self, bot):
self.bot = bot
self.bot_log_allowed_channels = self.bot.config.bot_log_allowed_channels
self.disallowed_named_roles = ["pirate"]
self.ryujinx_blue = Colour(0x4A90E2)
self.uploaded_log_info = []
self.disallowed_roles = [
self.bot.config.named_roles[x] for x in self.disallowed_named_roles
]
@staticmethod
async def download_file(log_url):
async with aiohttp.ClientSession() as session:
# Grabs first and last few bytes of log file to prevent abuse from large files
headers = {"Range": "bytes=0-60000, -6000"}
async with session.get(log_url, headers=headers) as response:
return await response.text("UTF-8")
@staticmethod
def is_log_valid(log_file: str) -> bool:
app_info = LogAnalyser.get_app_info(log_file)
is_homebrew = LogAnalyser.is_homebrew(log_file)
if app_info is None or is_homebrew:
return True
game_name, app_id, another_app_id, build_ids, main_ro_section = app_info
if (
game_name is None
or app_id is None
or another_app_id is None
or build_ids is None
or main_ro_section is None
):
return False
return app_id == another_app_id
def is_game_blocked(self, log_file: str) -> bool:
app_info = LogAnalyser.get_app_info(log_file)
if app_info is None:
return False
game_name, app_id, another_app_id, build_ids, main_ro_section = app_info
if is_app_id_disabled(self.bot, app_id) or is_app_id_disabled(
self.bot, another_app_id
):
return True
for bid in build_ids:
if is_build_id_disabled(self.bot, bid):
return True
return is_ro_section_disabled(self.bot, main_ro_section)
def contains_blocked_paths(self, log_file: str) -> Optional[str]:
filepaths = LogAnalyser.get_filepaths(log_file)
if filepaths is None:
return None
for filepath in filepaths:
if is_path_disabled(self.bot, filepath):
return filepath
return None
async def blocked_game_action(self, message: Message) -> Embed:
warn_command = self.bot.get_command("warn")
if warn_command is not None:
warn_message = await message.reply(
".warn This log contains a blocked game."
)
warn_context = await self.bot.get_context(warn_message)
await warn_context.invoke(
warn_command,
target=None,
reason="This log contains a blocked game.",
)
else:
logging.error(
f"Couldn't find 'warn' command. Unable to warn {message.author} for uploading a log of a blocked game."
)
pirate_role = message.guild.get_role(self.bot.config.named_roles["pirate"])
await message.author.add_roles(pirate_role)
embed = Embed(
title="⛔ Blocked game detected ⛔",
colour=Colour(0xFF0000),
description="This log contains a blocked game and has been removed.\n"
"The user has been warned and the pirate role has been applied.",
)
embed.set_footer(text=f"Log uploaded by @{message.author.name}")
await message.delete()
return embed
async def blocked_path_action(self, message: Message, blocked_path: str) -> Embed:
warn_command = self.bot.get_command("warn")
if warn_command is not None:
warn_message = await message.reply(
".warn This log contains blocked content in paths."
)
warn_context = await self.bot.get_context(warn_message)
await warn_context.invoke(
warn_command,
target=None,
reason=f"This log contains blocked content in paths: '{blocked_path}'",
)
else:
logging.error(
f"Couldn't find 'warn' command. Unable to warn {message.author} for uploading a log "
f"containing a blocked content in paths."
)
pirate_role = message.guild.get_role(self.bot.config.named_roles["pirate"])
await message.author.add_roles(pirate_role)
embed = Embed(
title="⛔ Blocked content in path detected ⛔",
colour=Colour(0xFF0000),
description="This log contains paths containing blocked content and has been removed.\n"
"The user has been warned and the pirate role has been applied.",
)
embed.set_footer(text=f"Log uploaded by @{message.author.name}")
await message.delete()
return embed
def format_analysed_log(self, author_name: str, analysed_log):
cleaned_game_name = re.sub(
r"\s\[(64|32)-bit\]$", "", analysed_log["game_info"]["game_name"]
)
analysed_log["game_info"]["game_name"] = cleaned_game_name
hardware_info = " | ".join(
(
f"**CPU:** {analysed_log['hardware_info']['cpu']}",
f"**GPU:** {analysed_log['hardware_info']['gpu']}",
f"**RAM:** {analysed_log['hardware_info']['ram']}",
f"**OS:** {analysed_log['hardware_info']['os']}",
)
)
system_settings_info = "\n".join(
(
f"**Audio Backend:** `{analysed_log['settings']['audio_backend']}`",
f"**Console Mode:** `{analysed_log['settings']['docked']}`",
f"**PPTC Cache:** `{analysed_log['settings']['pptc']}`",
f"**Shader Cache:** `{analysed_log['settings']['shader_cache']}`",
f"**V-Sync:** `{analysed_log['settings']['vsync']}`",
f"**Hypervisor:** `{analysed_log['settings']['hypervisor']}`",
)
)
graphics_settings_info = "\n".join(
(
f"**Graphics Backend:** `{analysed_log['settings']['graphics_backend']}`",
f"**Resolution:** `{analysed_log['settings']['resolution_scale']}`",
f"**Anisotropic Filtering:** `{analysed_log['settings']['anisotropic_filtering']}`",
f"**Aspect Ratio:** `{analysed_log['settings']['aspect_ratio']}`",
f"**Texture Recompression:** `{analysed_log['settings']['texture_recompression']}`",
)
)
ryujinx_info = " | ".join(
(
f"**Version:** {analysed_log['emu_info']['ryu_version']}",
f"**Firmware:** {analysed_log['emu_info']['ryu_firmware']}",
)
)
log_embed = Embed(title=f"{cleaned_game_name}", colour=self.ryujinx_blue)
log_embed.set_footer(text=f"Log uploaded by {author_name}")
log_embed.add_field(
name="General Info",
value=" | ".join((ryujinx_info, hardware_info)),
inline=False,
)
log_embed.add_field(
name="System Settings",
value=system_settings_info,
inline=True,
)
log_embed.add_field(
name="Graphics Settings",
value=graphics_settings_info,
inline=True,
)
if (
cleaned_game_name == "Unknown"
and analysed_log["game_info"]["errors"] == "No errors found in log"
):
log_embed.add_field(
name="Empty Log",
value=f"""The log file appears to be empty. To get a proper log, follow these steps:
1) In Logging settings, ensure `Enable Logging to File` is checked.
2) Ensure the following default logs are enabled: `Info`, `Warning`, `Error`, `Guest` and `Stub`.
3) Start a game up.
4) Play until your issue occurs.
5) Upload the latest log file which is larger than 3KB.""",
inline=False,
)
if (
cleaned_game_name == "Unknown"
and analysed_log["game_info"]["errors"] != "No errors found in log"
):
log_embed.add_field(
name="Latest Error Snippet",
value=analysed_log["game_info"]["errors"],
inline=False,
)
log_embed.add_field(
name="No Game Boot Detected",
value=f"""No game boot has been detected in log file. To get a proper log, follow these steps:
1) In Logging settings, ensure `Enable Logging to File` is checked.
2) Ensure the following default logs are enabled: `Info`, `Warning`, `Error`, `Guest` and `Stub`.
3) Start a game up.
4) Play until your issue occurs.
5) Upload the latest log file which is larger than 3KB.""",
inline=False,
)
else:
log_embed.add_field(
name="Latest Error Snippet",
value=analysed_log["game_info"]["errors"],
inline=False,
)
log_embed.add_field(
name="Mods", value=analysed_log["game_info"]["mods"], inline=False
)
log_embed.add_field(
name="Cheats", value=analysed_log["game_info"]["cheats"], inline=False
)
log_embed.add_field(
name="Notes",
value=analysed_log["game_info"]["notes"],
inline=False,
)
return log_embed
async def log_file_read(self, message):
attached_log = message.attachments[0]
author_name = f"@{message.author.name}"
log_file = await self.download_file(attached_log.url)
if self.is_game_blocked(log_file):
return await self.blocked_game_action(message)
blocked_path = self.contains_blocked_paths(log_file)
if blocked_path:
return await self.blocked_path_action(message, blocked_path)
for role in message.author.roles:
if role.id in self.disallowed_roles:
embed = Embed(
colour=Colour(0xFF0000),
description="I'm not allowed to analyse this log.",
)
embed.set_footer(text=f"Log uploaded by {author_name}")
return embed
if not self.is_log_valid(log_file):
embed = Embed(
title="⚠️ Modified log detected ⚠️",
colour=Colour(0xFCFC00),
description="This log contains manually modified information and won't be analysed.",
)
embed.set_footer(text=f"Log uploaded by {author_name}")
return embed
try:
analyser = LogAnalyser(log_file)
except ValueError:
return Embed(
colour=self.ryujinx_blue,
description="This log file appears to be invalid. Please make sure to upload a Ryujinx log file.",
)
is_channel_allowed = False
for allowed_channel_id in self.bot.config.bot_log_allowed_channels.values():
if message.channel.id == allowed_channel_id:
is_channel_allowed = True
break
return self.format_analysed_log(
author_name,
analyser.analyse_discord(
is_channel_allowed,
self.bot.config.bot_log_allowed_channels["pr-testing"],
),
)
@commands.check(check_if_staff)
@commands.command(
aliases=["disallow_log_id", "forbid_log_id", "block_id", "blockid"]
)
async def disable_log_id(
self, ctx: Context, disable_id: str, block_id_type: str, *, block_id: str
):
match block_id_type.lower():
case "app" | "app_id" | "appid" | "tid" | "title_id":
if not is_app_id_valid(block_id):
return await ctx.send("The specified app id is invalid.")
if add_disabled_app_id(self.bot, disable_id, block_id):
return await ctx.send(
f"Application id '{block_id}' is now blocked!"
)
else:
return await ctx.send(
f"Application id '{block_id}' is already blocked."
)
case "build" | "build_id" | "bid":
if not is_build_id_valid(block_id):
return await ctx.send("The specified build id is invalid.")
if add_disabled_build_id(self.bot, disable_id, block_id):
return await ctx.send(f"Build id '{block_id}' is now blocked!")
else:
return await ctx.send(f"Build id '{block_id}' is already blocked.")
case "ro_section" | "rosection":
ro_section_snippet = block_id.strip("`").splitlines()
ro_section_snippet = [
line for line in ro_section_snippet if len(line.strip()) > 0
]
ro_section_info_regex = re.search(
r"PrintRoSectionInfo: main:", ro_section_snippet[0]
)
if ro_section_info_regex is None:
ro_section_snippet.insert(0, "PrintRoSectionInfo: main:")
ro_section = LogAnalyser.get_main_ro_section(
"\n".join(ro_section_snippet)
)
if ro_section is not None and is_ro_section_valid(ro_section):
if add_disabled_ro_section(self.bot, disable_id, ro_section):
return await ctx.send(
f"The specified read-only section for '{disable_id}' is now blocked."
)
else:
return await ctx.send(
f"The specified read-only section for '{disable_id}' is already blocked."
)
case _:
return await ctx.send(
"The specified id type is invalid. Valid id types are: ['app_id', 'build_id', 'ro_section']"
)
@commands.check(check_if_staff)
@commands.command(
aliases=[
"allow_log_id",
"unblock_log_id",
"unblock_id",
"allow_id",
"unblockid",
]
)
async def enable_log_id(self, ctx: Context, disable_id: str, block_id_type="all"):
match block_id_type.lower():
case "all":
if remove_disable_id(self.bot, disable_id):
return await ctx.send(
f"All ids for '{disable_id}' are now unblocked!"
)
else:
return await ctx.send(f"No blocked ids for '{disable_id}' found.")
case "app" | "app_id" | "appid" | "tid" | "title_id":
if remove_disabled_app_id(self.bot, disable_id):
return await ctx.send(
f"Application id for '{disable_id}' is now unblocked!"
)
else:
return await ctx.send(
f"No blocked application id for '{disable_id}' found."
)
case "build" | "build_id" | "bid":
if remove_disabled_build_id(self.bot, disable_id):
return await ctx.send(
f"Build id for '{disable_id}' is now unblocked!"
)
else:
return await ctx.send(f"No blocked build id '{disable_id}' found.")
case "ro_section" | "rosection":
if remove_disabled_ro_section(self.bot, disable_id):
return await ctx.send(
f"Read-only section for '{disable_id}' is now unblocked!"
)
else:
return await ctx.send(
f"No blocked read-only section for '{disable_id}' found."
)
case _:
return await ctx.send(
"The specified id type is invalid. Valid id types are: ['all', 'app_id', 'build_id', 'ro_section']"
)
@commands.check(check_if_staff)
@commands.command(
aliases=[
"disabled_ids",
"blocked_ids",
"listblockedids",
"list_blocked_log_ids",
"list_blocked_ids",
]
)
async def list_disabled_ids(self, ctx: Context):
disabled_ids = get_disabled_ids(self.bot)
id_types = {"app_id": "AppID", "build_id": "BID", "ro_section": "RoSection"}
message = "**Blocking analysis of the following IDs:**\n"
for name, entry in disabled_ids.items():
message += f"- {name}:\n"
for id_type, title in id_types.items():
if len(entry[id_type]) > 0:
if id_type != "ro_section":
message += f" - __{title}__: {entry[id_type]}\n"
else:
message += f" - __{title}__\n"
message += "\n"
return await ctx.send(message)
@commands.check(check_if_staff)
@commands.command(
aliases=[
"get_blocked_ro_section",
"disabled_ro_section",
"blocked_ro_section",
"list_disabled_ro_section",
"list_blocked_ro_section",
]
)
async def get_disabled_ro_section(self, ctx: Context, disable_id: str):
disabled_ids = get_disabled_ids(self.bot)
disable_id = disable_id.lower()
if (
disable_id in disabled_ids.keys()
and len(disabled_ids[disable_id]["ro_section"]) > 0
):
message = f"**Blocked read-only section for '{disable_id}'**:\n"
message += "```\n"
for key, content in disabled_ids[disable_id]["ro_section"].items():
match key:
case "module":
message += f"Module: {content}\n"
case "sdk_libraries":
message += f"SDK Libraries: \n"
for entry in content:
message += f" SDK {entry}\n"
message += "\n"
message += "```"
return await ctx.send(message)
else:
return await ctx.send(f"No read-only section blocked for '{disable_id}'.")
@commands.check(check_if_staff)
@commands.command(
aliases=["disallow_path", "forbid_path", "block_path", "blockpath"]
)
async def disable_path(self, ctx: Context, block_path: str):
if add_disabled_path(self.bot, block_path):
return await ctx.send(f"Path content `{block_path}` is now blocked!")
else:
return await ctx.send(f"Path content `{block_path}` is already blocked.")
@commands.check(check_if_staff)
@commands.command(
aliases=[
"allow_path",
"unblock_path",
"unblockpath",
]
)
async def enable_path(self, ctx: Context, block_path: str):
if remove_disabled_path(self.bot, block_path):
return await ctx.send(f"Path content `{block_path}` is now unblocked!")
else:
return await ctx.send(f"No blocked path content '{block_path}' found.")
@commands.check(check_if_staff)
@commands.command(
aliases=[
"disabled_paths",
"blocked_paths",
"listdisabledpaths",
"listblockedpaths",
"list_blocked_paths",
]
)
async def list_disabled_paths(self, ctx: Context):
messages = []
disabled_paths = get_disabled_paths(self.bot)
message = (
"**Blocking analysis of logs containing the following content in paths:**\n"
)
for entry in disabled_paths:
if len(message) >= 1500:
messages.append(message)
message = f"- `{entry}`\n"
else:
message += f"- `{entry}`\n"
if message not in messages:
# Add the last message as well
messages.append(message)
for msg in messages:
await ctx.send(msg)
async def analyse_log_message(self, message: Message, attachment_index=0):
author_id = message.author.id
author_mention = message.author.mention
filename = message.attachments[attachment_index].filename
filesize = message.attachments[attachment_index].size
# Any message over 2000 chars is uploaded as message.txt, so this is accounted for
log_file_link = message.jump_url
uploaded_logs_exist = [
True for elem in self.uploaded_log_info if filename in elem.values()
]
if not any(uploaded_logs_exist):
reply_message = await message.channel.send(
"Log detected, parsing...", reference=message
)
try:
embed = await self.log_file_read(message)
if "Ryujinx_" in filename:
self.uploaded_log_info.append(
{
"filename": filename,
"file_size": filesize,
"link": log_file_link,
"author": author_id,
}
)
# Avoid duplicate log file analysis, at least temporarily; keep track of the last few filenames of uploaded logs
# this should help support channels not be flooded with too many log files
# fmt: off
self.uploaded_log_info = self.uploaded_log_info[-5:]
# fmt: on
return await reply_message.edit(content=None, embed=embed)
except UnicodeDecodeError as error:
await reply_message.edit(
content=author_mention,
embed=Embed(
description="This log file appears to be invalid. Please re-check and re-upload your log file.",
colour=self.ryujinx_blue,
),
)
logging.warning(error)
except Exception as error:
await reply_message.edit(
content=f"Error: Couldn't parse log; parser threw `{type(error).__name__}` exception."
)
logging.warning(error)
else:
duplicate_log_file = next(
(
elem
for elem in self.uploaded_log_info
if elem["filename"] == filename
and elem["file_size"] == filesize
and elem["author"] == author_id
),
None,
)
await message.channel.send(
content=author_mention,
embed=Embed(
description=f"The log file `{filename}` appears to be a duplicate [already uploaded here]({duplicate_log_file['link']}). Please upload a more recent file.",
colour=self.ryujinx_blue,
),
)
@commands.cooldown(3, 30, BucketType.channel)
@commands.command(
aliases=["analyselog", "analyse_log", "analyze", "analyzelog", "analyze_log"]
)
async def analyse(self, ctx: Context, attachment_number=1):
await ctx.message.delete()
if ctx.message.reference is not None:
message = await ctx.fetch_message(ctx.message.reference.message_id)
if len(message.attachments) >= attachment_number:
attachment = message.attachments[attachment_number - 1]
is_log_file, _ = self.is_valid_log_name(attachment)
if is_log_file:
return await self.analyse_log_message(
message, attachment_number - 1
)
else:
return await ctx.send(
f"The attached log file '{attachment.filename}' is not valid.",
reference=ctx.message.reference,
)
return await ctx.send(
"Please use `.analyse` as a reply to a message with an attached log file."
)
@Cog.listener()
async def on_message(self, message: Message):
await self.bot.wait_until_ready()
if message.author.bot:
return
for attachment in message.attachments:
is_log_file, is_ryujinx_log_file = self.is_valid_log_name(attachment)
if is_log_file and not is_ryujinx_log_file:
attached_log = message.attachments[0]
log_file = await self.download_file(attached_log.url)
# Large files show a header value when not downloaded completely
# this regex makes sure that the log text to read starts from the first timestamp, ignoring headers
log_file_header_regex = re.compile(
r"\d{2}:\d{2}:\d{2}\.\d{3}.*", re.DOTALL
)
log_file_match = re.search(log_file_header_regex, log_file)
if log_file_match:
log_file = log_file_match.group(0)
if self.is_game_blocked(log_file):
return await message.channel.send(
content=None, embed=await self.blocked_game_action(message)
)
blocked_path = self.contains_blocked_paths(log_file)
if blocked_path:
return await message.channel.send(
content=None,
embed=await self.blocked_path_action(message, blocked_path),
)
elif (
is_log_file
and is_ryujinx_log_file
and message.channel.id in self.bot_log_allowed_channels.values()
):
return await self.analyse_log_message(
message, message.attachments.index(attachment)
)
elif (
is_log_file
and is_ryujinx_log_file
and message.channel.id not in self.bot_log_allowed_channels.values()
):
return await message.author.send(
content=message.author.mention,
embed=Embed(
description="\n".join(
(
f"Please upload Ryujinx log files to the correct location:\n",
f'<#{self.bot.config.bot_log_allowed_channels["windows-support"]}>: Windows help and troubleshooting',
f'<#{self.bot.config.bot_log_allowed_channels["linux-support"]}>: Linux help and troubleshooting',
f'<#{self.bot.config.bot_log_allowed_channels["macos-support"]}>: macOS help and troubleshooting',
f'<#{self.bot.config.bot_log_allowed_channels["patreon-support"]}>: Help and troubleshooting for Patreon subscribers',
f'<#{self.bot.config.bot_log_allowed_channels["development"]}>: Ryujinx development discussion',
f'<#{self.bot.config.bot_log_allowed_channels["pr-testing"]}>: Discussion of in-progress pull request builds',
)
),
colour=self.ryujinx_blue,
),
)
async def setup(bot):
await bot.add_cog(LogFileReader(bot))

View file

@ -1,179 +0,0 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog, Context, BucketType, Greedy
from robocop_ng.helpers.checks import check_if_staff, check_if_staff_or_dm
from robocop_ng.helpers.macros import (
get_macro,
add_macro,
edit_macro,
remove_macro,
get_macros_dict,
add_aliases,
remove_aliases,
clear_aliases,
)
class Macro(Cog):
def __init__(self, bot):
self.bot = bot
@commands.cooldown(3, 30, BucketType.user)
@commands.command(aliases=["m"])
async def macro(self, ctx: Context, key: str, targets: Greedy[discord.User] = None):
if ctx.guild:
await ctx.message.delete()
if len(key) > 0:
text = get_macro(self.bot, key)
if text is not None:
if targets is not None:
await ctx.send(
f"{', '.join(target.mention for target in targets)}:\n{text}"
)
else:
if ctx.message.reference is not None:
await ctx.send(
text, reference=ctx.message.reference, mention_author=True
)
else:
await ctx.send(text)
else:
await ctx.send(
f"{ctx.author.mention}: The macro '{key}' doesn't exist."
)
@commands.check(check_if_staff)
@commands.command(name="macroadd", aliases=["ma", "addmacro", "add_macro"])
async def add_macro(self, ctx: Context, key: str, *, text: str):
if add_macro(self.bot, key, text):
await ctx.send(f"Macro '{key}' added!")
else:
await ctx.send(f"Error: Macro '{key}' already exists.")
@commands.check(check_if_staff)
@commands.command(name="aliasadd", aliases=["addalias", "add_alias"])
async def add_alias_macro(self, ctx: Context, existing_key: str, *new_keys: str):
if len(new_keys) == 0:
await ctx.send("Error: You need to add at least one alias.")
else:
if add_aliases(self.bot, existing_key, list(new_keys)):
await ctx.send(
f"Added {len(new_keys)} aliases to macro '{existing_key}'!"
)
else:
await ctx.send(f"Error: No new and unique aliases found.")
@commands.check(check_if_staff)
@commands.command(name="macroedit", aliases=["me", "editmacro", "edit_macro"])
async def edit_macro(self, ctx: Context, key: str, *, text: str):
if edit_macro(self.bot, key, text):
await ctx.send(f"Macro '{key}' edited!")
else:
await ctx.send(f"Error: Macro '{key}' not found.")
@commands.check(check_if_staff)
@commands.command(
name="aliasremove",
aliases=[
"aliasdelete",
"delalias",
"aliasdel",
"removealias",
"remove_alias",
"delete_alias",
],
)
async def remove_alias_macro(
self, ctx: Context, existing_key: str, *remove_keys: str
):
if len(remove_keys) == 0:
await ctx.send("Error: You need to remove at least one alias.")
else:
if remove_aliases(self.bot, existing_key, list(remove_keys)):
await ctx.send(
f"Removed {len(remove_keys)} aliases from macro '{existing_key}'!"
)
else:
await ctx.send(
f"Error: None of the specified aliases were found for macro '{existing_key}'."
)
@commands.check(check_if_staff)
@commands.command(
name="macroremove",
aliases=[
"mr",
"md",
"removemacro",
"remove_macro",
"macrodel",
"delmacro",
"delete_macro",
],
)
async def remove_macro(self, ctx: Context, key: str):
if remove_macro(self.bot, key):
await ctx.send(f"Macro '{key}' removed!")
else:
await ctx.send(f"Error: Macro '{key}' not found.")
@commands.check(check_if_staff)
@commands.command(name="aliasclear", aliases=["clearalias", "clear_alias"])
async def clear_alias_macro(self, ctx: Context, existing_key: str):
if clear_aliases(self.bot, existing_key):
await ctx.send(f"Removed all aliases of macro '{existing_key}'!")
else:
await ctx.send(f"Error: No aliases found for macro '{existing_key}'.")
@commands.check(check_if_staff_or_dm)
@commands.cooldown(3, 30, BucketType.channel)
@commands.command(name="macros", aliases=["ml", "listmacros", "list_macros"])
async def list_macros(self, ctx: Context, macros_only=False):
macros = get_macros_dict(self.bot)
if len(macros["macros"]) > 0:
messages = []
macros_formatted = []
for key in sorted(macros["macros"].keys()):
message = f"- {key}"
if not macros_only and key in macros["aliases"]:
for alias in macros["aliases"][key]:
message += f", {alias}"
macros_formatted.append(message)
message = f"📝 **Macros**:\n"
for macro in macros_formatted:
if len(message) >= 1500:
messages.append(message)
message = f"{macro}\n"
else:
message += f"{macro}\n"
if message not in messages:
# Add the last message as well
messages.append(message)
for msg in messages:
await ctx.send(msg)
else:
await ctx.send("Couldn't find any macros.")
@commands.check(check_if_staff_or_dm)
@commands.cooldown(3, 30, BucketType.channel)
@commands.command(name="aliases", aliases=["listaliases", "list_aliases"])
async def list_aliases(self, ctx: Context, existing_key: str):
macros = get_macros_dict(self.bot)
existing_key = existing_key.lower()
if existing_key in macros["aliases"].keys():
message = f"📝 **Aliases for '{existing_key}'**:\n"
for alias in sorted(macros["aliases"][existing_key]):
message += f"- {alias}\n"
await ctx.send(message)
else:
await ctx.send(f"Couldn't find any aliases for macro '{existing_key}'.")
async def setup(bot):
await bot.add_cog(Macro(bot))

View file

@ -1,246 +0,0 @@
import datetime
import math
import platform
import random
from typing import Optional
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff_or_ot
class Meme(Cog):
"""
Meme commands.
"""
def __init__(self, bot):
self.bot = bot
def c_to_f(self, c):
"""this is where we take memes too far"""
return math.floor(9.0 / 5.0 * c + 32)
def c_to_k(self, c):
"""this is where we take memes REALLY far"""
return math.floor(c + 273.15)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, name="warm")
async def warm_member(self, ctx, user: Optional[discord.Member]):
"""Warms a user :3"""
if user is None and ctx.message.reference is None:
celsius = random.randint(15, 20)
fahrenheit = self.c_to_f(celsius)
kelvin = self.c_to_k(celsius)
await ctx.send(
f"{ctx.author.mention} tries to warm themself."
f" User is now {celsius}°C "
f"({fahrenheit}°F, {kelvin}K).\n"
"You might have more success warming someone else :3"
)
else:
if user is None:
user = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
celsius = random.randint(15, 100)
fahrenheit = self.c_to_f(celsius)
kelvin = self.c_to_k(celsius)
await ctx.send(
f"{user.mention} warmed."
f" User is now {celsius}°C "
f"({fahrenheit}°F, {kelvin}K)."
)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def lick(self, ctx, user: Optional[discord.Member]):
"""licks a user :?"""
if user is None and ctx.message.reference is None:
await ctx.send(f"{ctx.author.mention} licks their lips! 👅")
else:
if user is None:
user = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
await ctx.send(f"{user.mention} has been licked! 👅")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, name="chill", aliases=["cold"])
async def chill_member(self, ctx, user: Optional[discord.Member]):
"""Chills a user >:3"""
if user is None and ctx.message.reference is None:
celsius = random.randint(-75, 10)
fahrenheit = self.c_to_f(celsius)
kelvin = self.c_to_k(celsius)
await ctx.send(
f"{ctx.author.mention} chills themself."
f" User is now {celsius}°C "
f"({fahrenheit}°F, {kelvin}K).\n"
"🧊 Don't be so hard on yourself. 😔"
)
else:
if user is None:
user = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
celsius = random.randint(-50, 15)
fahrenheit = self.c_to_f(celsius)
kelvin = self.c_to_k(celsius)
await ctx.send(
f"{user.mention} chilled."
f" User is now {celsius}°C "
f"({fahrenheit}°F, {kelvin}K)."
)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, aliases=["thank", "reswitchedgold"])
async def gild(self, ctx, user: Optional[discord.Member]):
"""Gives a star to a user"""
if user is None and ctx.message.reference is None:
await ctx.send(f"No stars for you, {ctx.author.mention}!")
else:
if user is None:
user = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
await ctx.send(f"{user.mention} gets a :star:, yay!")
@commands.check(check_if_staff_or_ot)
@commands.command(
hidden=True, aliases=["reswitchedsilver", "silv3r", "reswitchedsilv3r"]
)
async def silver(self, ctx, user: Optional[discord.Member]):
"""Gives a user ReSwitched Silver™"""
if user is None and ctx.message.reference is None:
await ctx.send(f"{ctx.author.mention}, you can't reward yourself.")
else:
if user is None:
user = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
embed = discord.Embed(
title="ReSwitched Silver™!",
description=f"Here's your ReSwitched Silver™," f"{user.mention}!",
)
embed.set_image(
url="https://cdn.discordapp.com/emojis/548623626916724747.png?v=1"
)
await ctx.send(embed=embed)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def btwiuse(self, ctx):
"""btw i use arch"""
uname = platform.uname()
await ctx.send(
f"BTW I use {platform.python_implementation()} "
f"{platform.python_version()} on {uname.system} "
f"{uname.release}"
)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def yahaha(self, ctx):
"""secret command"""
await ctx.send(f"🍂 you found me 🍂")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def blackalabi(self, ctx):
"""secret command"""
await ctx.send("https://elixi.re/i/discord.png")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def peng(self, ctx):
"""heck tomger"""
await ctx.send(f"🐧")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, aliases=["outstanding"])
async def outstandingmove(self, ctx):
"""Posts the outstanding move meme"""
await ctx.send(
"https://cdn.discordapp.com/attachments"
"/371047036348268545/528413677007929344"
"/image0-5.jpg"
)
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def bones(self, ctx):
await ctx.send("https://cdn.discordapp.com/emojis/443501365843591169.png?v=1")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True)
async def headpat(self, ctx):
await ctx.send("https://cdn.discordapp.com/emojis/465650811909701642.png?v=1")
@commands.check(check_if_staff_or_ot)
@commands.command(
hidden=True, aliases=["when", "etawhen", "emunand", "emummc", "thermosphere"]
)
async def eta(self, ctx):
await ctx.send("June 15.")
@commands.check(check_if_staff_or_ot)
@commands.command(hidden=True, name="bam")
async def bam_member(self, ctx, target: Optional[discord.Member]):
"""Bams a user owo"""
if target is None and ctx.message.reference is None:
await ctx.reply("https://tenor.com/view/bonk-gif-26414884")
else:
if ctx.message.reference is not None:
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
if target == ctx.author:
if target.id == 181627658520625152:
return await ctx.send(
"https://cdn.discordapp.com/attachments/286612533757083648/403080855402315796/rehedge.PNG"
)
return await ctx.send("hedgeberg#7337 is ̶n͢ow b̕&̡.̷ 👍̡")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
await ctx.send(f"{safe_name} is ̶n͢ow b̕&̡.̷ 👍̡")
@commands.command(hidden=True)
async def memebercount(self, ctx):
"""Checks memeber count, as requested by dvdfreitag"""
await ctx.send("There's like, uhhhhh a bunch")
@commands.command(hidden=True)
async def frolics(self, ctx):
"""test"""
await ctx.send("https://www.youtube.com/watch?v=VmarNEsjpDI")
@commands.command(
hidden=True,
aliases=[
"yotld",
"yold",
"yoltd",
"yearoflinuxondesktop",
"yearoflinuxonthedesktop",
],
)
async def yearoflinux(self, ctx):
"""Shows the year of Linux on the desktop"""
await ctx.send(
f"{datetime.datetime.now().year} is the year of Linux on the Desktop"
)
async def setup(bot):
await bot.add_cog(Meme(bot))

View file

@ -1,933 +0,0 @@
import io
from typing import Optional
import discord
from discord.ext import commands
from discord.ext.commands import Cog, Context
from robocop_ng.helpers.checks import check_if_staff, check_if_bot_manager
from robocop_ng.helpers.restrictions import add_restriction, remove_restriction
from robocop_ng.helpers.userlogs import userlog
class Mod(Cog):
def __init__(self, bot):
self.bot = bot
def check_if_target_is_staff(self, target):
return any(r.id in self.bot.config.staff_role_ids for r in target.roles)
@commands.guild_only()
@commands.check(check_if_bot_manager)
@commands.command()
async def setguildicon(self, ctx, url):
"""Changes guild icon, bot manager only."""
img_bytes = await self.bot.aiogetbytes(url)
await ctx.guild.edit(icon=img_bytes, reason=str(ctx.author))
await ctx.send(f"Done!")
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_msg = (
f"✏️ **Guild Icon Update**: {ctx.author} changed the guild icon."
f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
)
img_filename = url.split("/")[-1].split("#")[0] # hacky
img_file = discord.File(io.BytesIO(img_bytes), filename=img_filename)
await log_channel.send(log_msg, file=img_file)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def mute(self, ctx, target: Optional[discord.Member], *, reason: str = ""):
"""Mutes a user, staff only."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
if target is not None:
reason = str(target) + reason
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif self.check_if_target_is_staff(target):
return await ctx.send(
"I can't mute this user as they're a member of staff."
)
userlog(self.bot, target.id, ctx.author, reason, "mutes", target.name)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
dm_message = f"You were muted!"
if reason:
dm_message += f' The given reason is: "{reason}".'
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents kick issues in cases where user blocked bot
# or has DMs disabled
pass
mute_role = ctx.guild.get_role(self.bot.config.mute_role)
await target.add_roles(mute_role, reason=str(ctx.author))
chan_message = (
f"🔇 **Muted**: {str(ctx.author)} muted "
f"{target.mention} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future, "
"it is recommended to use `.mute <user> [reason]`"
" as the reason is automatically sent to the user."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{target.mention} can no longer speak.")
add_restriction(self.bot, target.id, self.bot.config.mute_role)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def unmute(self, ctx, target: discord.Member):
"""Unmutes a user, staff only."""
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
mute_role = ctx.guild.get_role(self.bot.config.mute_role)
await target.remove_roles(mute_role, reason=str(ctx.author))
chan_message = (
f"🔈 **Unmuted**: {str(ctx.author)} unmuted "
f"{target.mention} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{target.mention} can now speak again.")
remove_restriction(self.bot, target.id, self.bot.config.mute_role)
@commands.guild_only()
@commands.bot_has_permissions(kick_members=True)
@commands.check(check_if_staff)
@commands.command()
async def kick(self, ctx, target: Optional[discord.Member], *, reason: str = ""):
"""Kicks a user, staff only."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
if target is not None:
reason = str(target) + reason
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif self.check_if_target_is_staff(target):
return await ctx.send(
"I can't kick this user as they're a member of staff."
)
userlog(self.bot, target.id, ctx.author, reason, "kicks", target.name)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
dm_message = f"You were kicked from {ctx.guild.name}."
if reason:
dm_message += f' The given reason is: "{reason}".'
dm_message += (
"\n\nYou are able to rejoin the server,"
" but please be sure to behave when participating again."
)
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents kick issues in cases where user blocked bot
# or has DMs disabled
pass
await target.kick(reason=f"{ctx.author}, reason: {reason}")
chan_message = (
f"👢 **Kick**: {str(ctx.author)} kicked "
f"{target.mention} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future"
", it is recommended to use "
"`.kick <user> [reason]`"
" as the reason is automatically sent to the user."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"👢 {safe_name}, 👍.")
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command(aliases=["yeet"])
async def ban(self, ctx, target: Optional[discord.Member], *, reason: str = ""):
"""Bans a user, staff only."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
if target is not None:
reason = str(target) + reason
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
# Hedge-proofing the code
if target == ctx.author:
if target.id == 181627658520625152:
return await ctx.send(
"https://cdn.discordapp.com/attachments/286612533757083648/403080855402315796/rehedge.PNG"
)
return await ctx.send("hedgeberg#7337 is now b&. 👍")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't ban this user as they're a member of staff.")
userlog(self.bot, target.id, ctx.author, reason, "bans", target.name)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
dm_message = f"You were banned from {ctx.guild.name}."
if reason:
dm_message += f' The given reason is: "{reason}".'
dm_message += "\n\nThis ban does not expire."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents ban issues in cases where user blocked bot
# or has DMs disabled
pass
await target.ban(
reason=f"{ctx.author}, reason: {reason}", delete_message_days=0
)
chan_message = (
f"⛔ **Ban**: {str(ctx.author)} banned "
f"{target.mention} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future"
", it is recommended to use `.ban <user> [reason]`"
" as the reason is automatically sent to the user."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{safe_name} is now b&. 👍")
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def bandel(
self, ctx, day_count: int, target: Optional[discord.Member], *, reason: str = ""
):
"""Bans a user for a given number of days, staff only."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
if target is not None:
reason = str(target) + reason
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
# Hedge-proofing the code
if target == ctx.author:
if target.id == 181627658520625152:
return await ctx.send(
"https://cdn.discordapp.com/attachments/286612533757083648/403080855402315796/rehedge.PNG"
)
return await ctx.send("hedgeberg#7337 is now b&. 👍")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't ban this user as they're a member of staff.")
if day_count < 0 or day_count > 7:
return await ctx.send(
"Message delete day count needs to be between 0 and 7 days."
)
userlog(self.bot, target.id, ctx.author, reason, "bans", target.name)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
dm_message = f"You were banned from {ctx.guild.name}."
if reason:
dm_message += f' The given reason is: "{reason}".'
dm_message += "\n\nThis ban does not expire."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents ban issues in cases where user blocked bot
# or has DMs disabled
pass
await target.ban(
reason=f"{ctx.author}, days of message deletions: {day_count}, reason: {reason}",
delete_message_days=day_count,
)
chan_message = (
f"⛔ **Ban**: {str(ctx.author)} banned with {day_count} of messages deleted "
f"{target.mention} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future"
", it is recommended to use `.bandel <daycount> <user> [reason]`"
" as the reason is automatically sent to the user."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(
f"{safe_name} is now b&, with {day_count} days of messages deleted. 👍"
)
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command(aliases=["softban"])
async def hackban(self, ctx, target: int, *, reason: str = ""):
"""Bans a user with their ID, doesn't message them, staff only."""
target_user = await self.bot.fetch_user(target)
target_member = ctx.guild.get_member(target)
# Hedge-proofing the code
if target == ctx.author.id:
return await ctx.send("You can't do mod actions on yourself.")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif target_member and self.check_if_target_is_staff(target_member):
return await ctx.send("I can't ban this user as they're a member of staff.")
userlog(self.bot, target, ctx.author, reason, "bans", target_user.name)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
await ctx.guild.ban(
target_user, reason=f"{ctx.author}, reason: {reason}", delete_message_days=0
)
chan_message = (
f"⛔ **Hackban**: {str(ctx.author)} banned "
f"{target_user.mention} | {safe_name}\n"
f"🏷 __User ID__: {target}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future"
", it is recommended to use "
"`.hackban <user> [reason]`."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{safe_name} is now b&. 👍")
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def massban(self, ctx, *, targets: str):
"""Bans users with their IDs, doesn't message them, staff only."""
targets_int = [int(target) for target in targets.strip().split(" ")]
for target in targets_int:
target_user = await self.bot.fetch_user(target)
target_member = ctx.guild.get_member(target)
# Hedge-proofing the code
if target == ctx.author.id:
await ctx.send(f"(re: {target}) You can't do mod actions on yourself.")
continue
elif target == self.bot.user:
await ctx.send(
f"(re: {target}) I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
continue
elif target_member and self.check_if_target_is_staff(target_member):
await ctx.send(
f"(re: {target}) I can't ban this user as they're a member of staff."
)
continue
userlog(self.bot, target, ctx.author, f"massban", "bans", target_user.name)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
await ctx.guild.ban(
target_user,
reason=f"{ctx.author}, reason: massban",
delete_message_days=0,
)
chan_message = (
f"⛔ **Massban**: {str(ctx.author)} banned "
f"{target_user.mention} | {safe_name}\n"
f"🏷 __User ID__: {target}\n"
"Please add an explanation below."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"All {len(targets_int)} users are now b&. 👍")
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def unban(self, ctx, target: int, *, reason: str = ""):
"""Unbans a user with their ID, doesn't message them, staff only."""
target_user = await self.bot.fetch_user(target)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
await ctx.guild.unban(target_user, reason=f"{ctx.author}, reason: {reason}")
chan_message = (
f"⚠️ **Unban**: {str(ctx.author)} unbanned "
f"{target_user.mention} | {safe_name}\n"
f"🏷 __User ID__: {target}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future"
", it is recommended to use "
"`.unban <user id> [reason]`."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
await ctx.send(f"{safe_name} is now unb&.")
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def silentban(self, ctx, target: discord.Member, *, reason: str = ""):
"""Bans a user, staff only."""
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't ban this user as they're a member of staff.")
userlog(self.bot, target.id, ctx.author, reason, "bans", target.name)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
await target.ban(
reason=f"{ctx.author}, reason: {reason}", delete_message_days=0
)
chan_message = (
f"⛔ **Silent ban**: {str(ctx.author)} banned "
f"{target.mention} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future"
", it is recommended to use `.ban <user> [reason]`"
" as the reason is automatically sent to the user."
)
chan_message += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_message)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def approve(
self, ctx, target: Optional[discord.Member], role: str = "community"
):
"""Add a role to a user (default: community), staff only."""
if role not in self.bot.config.named_roles:
return await ctx.send(
"No such role! Available roles: "
+ ",".join(self.bot.config.named_roles)
)
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
target_role = ctx.guild.get_role(self.bot.config.named_roles[role])
if target_role in target.roles:
return await ctx.send("Target already has this role.")
await target.add_roles(target_role, reason=str(ctx.author))
await ctx.send(f"Approved {target.mention} to `{role}` role.")
await log_channel.send(
f"✅ Approved: {str(ctx.author)} added"
f" {role} to {target.mention}"
f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["unapprove"])
async def revoke(
self, ctx, target: Optional[discord.Member], role: str = "community"
):
"""Remove a role from a user (default: community), staff only."""
if role not in self.bot.config.named_roles:
return await ctx.send(
"No such role! Available roles: "
+ ",".join(self.bot.config.named_roles)
)
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
target_role = ctx.guild.get_role(self.bot.config.named_roles[role])
if target_role not in target.roles:
return await ctx.send("Target doesn't have this role.")
await target.remove_roles(target_role, reason=str(ctx.author))
await ctx.send(f"Un-approved {target.mention} from `{role}` role.")
await log_channel.send(
f"❌ Un-approved: {str(ctx.author)} removed"
f" {role} from {target.mention}"
f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["clear"])
async def purge(self, ctx, limit: int, channel: discord.TextChannel = None):
"""Clears a given number of messages, staff only."""
modlog_channel = self.bot.get_channel(self.bot.config.modlog_channel)
log_channel = self.bot.get_channel(self.bot.config.log_channel)
if not channel:
channel = ctx.channel
purged_log_jump_url = ""
for deleted_message in await channel.purge(limit=limit):
msg = (
"🗑️ **Message purged**: \n"
f"from {self.bot.escape_message(deleted_message.author.name)} "
f"({deleted_message.author.id}), in {deleted_message.channel.mention}:\n"
f"`{deleted_message.clean_content}`"
)
if len(purged_log_jump_url) == 0:
purged_log_jump_url = (await log_channel.send(msg)).jump_url
else:
await log_channel.send(msg)
msg = (
f"🗑 **Purged**: {str(ctx.author)} purged {limit} "
f"messages in {channel.mention}."
f"\n🔗 __Jump__: <{purged_log_jump_url}>"
)
await modlog_channel.send(msg)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def warn(self, ctx, target: Optional[discord.Member], *, reason: str = ""):
"""Warns a user, staff only."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
if target is not None:
reason = str(target) + reason
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif self.check_if_target_is_staff(target):
return await ctx.send(
"I can't warn this user as they're a member of staff."
)
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
warn_count = userlog(
self.bot, target.id, ctx.author, reason, "warns", target.name
)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
chan_msg = (
f"⚠️ **Warned**: {str(ctx.author)} warned "
f"{target.mention} (warn #{warn_count}) "
f"| {safe_name}\n"
)
msg = f"You were warned on {ctx.guild.name}."
if reason:
msg += " The given reason is: " + reason
msg += (
f"\n\nPlease read the rules in {self.bot.config.rules_url}. "
f"This is warn #{warn_count}."
)
if warn_count == 2:
msg += " __The next warn will automatically kick.__"
if warn_count == 3:
msg += (
"\n\nYou were kicked because of this warning. "
"This is your final warning. "
"You can join again, but "
"**one more warn will result in a ban**."
)
chan_msg += "**This resulted in an auto-kick.**\n"
if warn_count == 4:
msg += "\n\nYou were automatically banned due to four warnings."
chan_msg += "**This resulted in an auto-ban.**\n"
try:
await target.send(msg)
except discord.errors.Forbidden:
# Prevents log issues in cases where user blocked bot
# or has DMs disabled
pass
if warn_count == 3:
await target.kick()
if warn_count >= 4: # just in case
await target.ban(reason="exceeded warn limit", delete_message_days=0)
await ctx.send(
f"{target.mention} warned. " f"User has {warn_count} warning(s)."
)
if reason:
chan_msg += f'✏️ __Reason__: "{reason}"'
else:
chan_msg += (
"Please add an explanation below. In the future"
", it is recommended to use `.warn <user> [reason]`"
" as the reason is automatically sent to the user."
)
chan_msg += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
await log_channel.send(chan_msg)
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command(aliases=["softwarn"])
async def hackwarn(self, ctx, target: int, *, reason: str = ""):
"""Warns a user with their ID, doesn't message them, staff only."""
target_user = await self.bot.fetch_user(target)
target_member = ctx.guild.get_member(target)
# Hedge-proofing the code
if target == ctx.author.id:
return await ctx.send("You can't do mod actions on yourself.")
elif target == self.bot.user:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
elif target_member and self.check_if_target_is_staff(target_member):
return await ctx.send(
"I can't warn this user as they're a member of staff."
)
warn_count = userlog(
self.bot, target, ctx.author, reason, "warns", target_user.name
)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
chan_msg = (
f"⚠️ **Hackwarned**: {str(ctx.author)} warned "
f"{target_user.mention} (warn #{warn_count}) | {safe_name}\n"
f"🏷 __User ID__: {target}\n"
)
if warn_count == 4:
userlog(
self.bot,
target,
ctx.author,
"exceeded warn limit",
"bans",
target_user.name,
)
chan_msg += "**This resulted in an auto-hackban.**\n"
await ctx.guild.ban(
target_user,
reason=f"{ctx.author}, reason: exceeded warn limit",
delete_message_days=0,
)
if reason:
chan_msg += f'✏️ __Reason__: "{reason}"'
else:
chan_msg += (
"Please add an explanation below. In the future"
", it is recommended to use "
"`.hackwarn <user> [reason]`."
)
chan_msg += f"\n🔗 __Jump__: <{ctx.message.jump_url}>"
log_channel = self.bot.get_channel(self.bot.config.modlog_channel)
await log_channel.send(chan_msg)
await ctx.send(f"{safe_name} warned. " f"User has {warn_count} warning(s).")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["setnick", "nick"])
async def nickname(self, ctx, target: Optional[discord.Member], *, nick: str = ""):
"""Sets a user's nickname, staff only.
Just send .nickname <user> to wipe the nickname."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
try:
if nick:
await target.edit(nick=nick, reason=str(ctx.author))
else:
await target.edit(nick=None, reason=str(ctx.author))
await ctx.send("Successfully set nickname.")
except discord.errors.Forbidden:
await ctx.send(
"I don't have the permission to set that user's nickname.\n"
"User's top role may be above mine, or I may lack Manage Nicknames permission."
)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["echo"])
async def say(self, ctx, *, the_text: str):
"""Repeats a given text, staff only."""
await ctx.send(the_text)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def speak(self, ctx, channel: discord.TextChannel, *, the_text: str):
"""Repeats a given text in a given channel, staff only."""
await channel.send(the_text)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["setplaying", "setgame"])
async def playing(self, ctx, *, game: str = ""):
"""Sets the bot's currently played game name, staff only.
Just send .playing to wipe the playing state."""
if game:
await self.bot.change_presence(activity=discord.Game(name=game))
else:
await self.bot.change_presence(activity=None)
await ctx.send("Successfully set game.")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["setbotnick", "botnick", "robotnick"])
async def botnickname(self, ctx, *, nick: str = ""):
"""Sets the bot's nickname, staff only.
Just send .botnickname to wipe the nickname."""
if nick:
await ctx.guild.me.edit(nick=nick, reason=str(ctx.author))
else:
await ctx.guild.me.edit(nick=None, reason=str(ctx.author))
await ctx.send("Successfully set bot nickname.")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def move(self, ctx, channelTo: discord.TextChannel, *, limit: int):
"""Move a user to another channel, staff only.
!move {channel to move to} {number of messages}"""
# get a list of the messages
fetchedMessages = []
async for message in ctx.channel.history(limit=limit + 1):
fetchedMessages.append(message)
# delete all of those messages from the channel
for i in fetchedMessages:
await i.delete()
# invert the list and remove the last message (gets rid of the command message)
fetchedMessages = fetchedMessages[::-1]
fetchedMessages = fetchedMessages[:-1]
# Loop over the messages fetched
for message in fetchedMessages:
# if the message is embedded already
if message.embeds:
# set the embed message to the old embed object
embedMessage = message.embeds[0]
# else
else:
# Create embed message object and set content to original
embedMessage = discord.Embed(description=message.content)
avatar_url = None
if message.author.display_avatar is not None:
avatar_url = str(message.author.display_avatar)
# set the embed message author to original author
embedMessage.set_author(name=message.author, icon_url=avatar_url)
# if message has attachments add them
if message.attachments:
for i in message.attachments:
embedMessage.set_image(url=i.proxy_url)
# Send to the desired channel
await channelTo.send(embed=embedMessage)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["slow"])
async def slowmode(
self, ctx: Context, seconds: int, channel: Optional[discord.TextChannel] = None
):
if channel is None:
channel = ctx.channel
if seconds > 21600 or seconds < 0:
return await ctx.send("Seconds can't be above '21600' or less then '0'")
await channel.edit(
slowmode_delay=seconds, reason=f"{str(ctx.author)} set the slowmode"
)
await ctx.send(f"Set the slowmode delay in this channel to {seconds} seconds!")
async def setup(bot):
await bot.add_cog(Mod(bot))

View file

@ -1,45 +0,0 @@
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
class ModReswitched(Cog):
def __init__(self, bot):
self.bot = bot
@commands.guild_only()
@commands.command(aliases=["pingmods", "summonmods"])
async def pingmod(self, ctx):
"""Pings mods, only use when there's an emergency."""
can_ping = any(r.id in self.bot.config.pingmods_allow for r in ctx.author.roles)
if can_ping:
await ctx.send(
f"<@&{self.bot.config.pingmods_role}>: {ctx.author.mention} needs assistance."
)
else:
await ctx.send(
f"{ctx.author.mention}: You need the community role to be able to ping the entire mod team, please pick an online mod (not staff, please!), and ping them instead."
)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["togglemod"])
async def modtoggle(self, ctx):
"""Toggles your mod role, staff only."""
target_role = ctx.guild.get_role(self.bot.config.modtoggle_role)
if target_role in ctx.author.roles:
await ctx.author.remove_roles(
target_role, reason="Staff self-unassigned mod role"
)
await ctx.send(f"{ctx.author.mention}: Removed your mod role.")
else:
await ctx.author.add_roles(
target_role, reason="Staff self-assigned mod role"
)
await ctx.send(f"{ctx.author.mention}: Gave you mod role.")
async def setup(bot):
await bot.add_cog(ModReswitched(bot))

View file

@ -1,188 +0,0 @@
from datetime import datetime
from typing import Optional
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
from robocop_ng.helpers.restrictions import add_restriction
from robocop_ng.helpers.robocronp import add_job
from robocop_ng.helpers.userlogs import userlog
class ModTimed(Cog):
def __init__(self, bot):
self.bot = bot
def check_if_target_is_staff(self, target):
return any(r.id in self.bot.config.staff_role_ids for r in target.roles)
@commands.guild_only()
@commands.bot_has_permissions(ban_members=True)
@commands.check(check_if_staff)
@commands.command()
async def timeban(
self, ctx, target: Optional[discord.Member], duration: str, *, reason: str = ""
):
"""Bans a user for a specified amount of time, staff only."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
if target is not None:
duration = str(target) + duration
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send("I can't ban this user as they're a member of staff.")
expiry_timestamp = self.bot.parse_time(duration)
expiry_datetime = datetime.utcfromtimestamp(expiry_timestamp)
duration_text = self.bot.get_relative_timestamp(
time_to=expiry_datetime, include_to=True, humanized=True
)
userlog(
self.bot,
target.id,
ctx.author,
f"{reason} (Timed, until " f"{duration_text})",
"bans",
target.name,
)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
dm_message = f"You were banned from {ctx.guild.name}."
if reason:
dm_message += f' The given reason is: "{reason}".'
dm_message += f"\n\nThis ban will expire {duration_text}."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents ban issues in cases where user blocked bot
# or has DMs disabled
pass
await target.ban(
reason=f"{ctx.author}, reason: {reason}", delete_message_days=0
)
chan_message = (
f"⛔ **Timed Ban**: {ctx.author.mention} banned "
f"{target.mention} for {duration_text} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future"
", it is recommended to use `.ban <user> [reason]`"
" as the reason is automatically sent to the user."
)
add_job(self.bot, "unban", target.id, {"guild": ctx.guild.id}, expiry_timestamp)
log_channel = self.bot.get_channel(self.bot.config.log_channel)
await log_channel.send(chan_message)
await ctx.send(f"{safe_name} is now b&. " f"It will expire {duration_text}. 👍")
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def timemute(
self, ctx, target: Optional[discord.Member], duration: str, *, reason: str = ""
):
"""Mutes a user for a specified amount of time, staff only."""
if target is None and ctx.message.reference is None:
return await ctx.send(
f"I'm sorry {ctx.author.mention}, I'm afraid I can't do that."
)
else:
if ctx.message.reference is not None:
if target is not None:
duration = str(target) + duration
target = (
await ctx.channel.fetch_message(ctx.message.reference.message_id)
).author
# Hedge-proofing the code
if target == ctx.author:
return await ctx.send("You can't do mod actions on yourself.")
elif self.check_if_target_is_staff(target):
return await ctx.send(
"I can't mute this user as they're a member of staff."
)
expiry_timestamp = self.bot.parse_time(duration)
expiry_datetime = datetime.utcfromtimestamp(expiry_timestamp)
duration_text = self.bot.get_relative_timestamp(
time_to=expiry_datetime, include_to=True, humanized=True
)
userlog(
self.bot,
target.id,
ctx.author,
f"{reason} (Timed, until " f"{duration_text})",
"mutes",
target.name,
)
safe_name = await commands.clean_content(escape_markdown=True).convert(
ctx, str(target)
)
dm_message = f"You were muted!"
if reason:
dm_message += f' The given reason is: "{reason}".'
dm_message += f"\n\nThis mute will expire {duration_text}."
try:
await target.send(dm_message)
except discord.errors.Forbidden:
# Prevents kick issues in cases where user blocked bot
# or has DMs disabled
pass
mute_role = ctx.guild.get_role(self.bot.config.mute_role)
await target.add_roles(mute_role, reason=str(ctx.author))
chan_message = (
f"🔇 **Timed Mute**: {ctx.author.mention} muted "
f"{target.mention} for {duration_text} | {safe_name}\n"
f"🏷 __User ID__: {target.id}\n"
)
if reason:
chan_message += f'✏️ __Reason__: "{reason}"'
else:
chan_message += (
"Please add an explanation below. In the future, "
"it is recommended to use `.mute <user> [reason]`"
" as the reason is automatically sent to the user."
)
add_job(
self.bot, "unmute", target.id, {"guild": ctx.guild.id}, expiry_timestamp
)
log_channel = self.bot.get_channel(self.bot.config.log_channel)
await log_channel.send(chan_message)
await ctx.send(
f"{target.mention} can no longer speak. " f"It will expire {duration_text}."
)
add_restriction(self.bot, target.id, self.bot.config.mute_role)
async def setup(bot):
await bot.add_cog(ModTimed(bot))

View file

@ -1,179 +0,0 @@
import time
import traceback
import discord
from discord.ext import commands, tasks
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
from robocop_ng.helpers.restrictions import remove_restriction
from robocop_ng.helpers.robocronp import get_crontab, delete_job
class Robocronp(Cog):
def __init__(self, bot):
self.bot = bot
self.minutely.start()
self.hourly.start()
self.daily.start()
def cog_unload(self):
self.minutely.cancel()
self.hourly.cancel()
self.daily.cancel()
async def send_data(self):
await self.bot.wait_until_ready()
data_files = [discord.File(fpath) for fpath in self.bot.wanted_jsons]
log_channel = await self.bot.get_channel_safe(self.bot.config.botlog_channel)
await log_channel.send("Hourly data backups:", files=data_files)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def listjobs(self, ctx):
"""Lists timed robocronp jobs, staff only."""
ctab = get_crontab(self.bot)
embed = discord.Embed(title=f"Active robocronp jobs")
for jobtype in ctab:
for jobtimestamp in ctab[jobtype]:
for job_name in ctab[jobtype][jobtimestamp]:
job_details = repr(ctab[jobtype][jobtimestamp][job_name])
embed.add_field(
name=f"{jobtype} for {job_name}",
value=f"Timestamp: {jobtimestamp}, Details: {job_details}",
inline=False,
)
await ctx.send(embed=embed)
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command(aliases=["removejob"])
async def deletejob(self, ctx, timestamp: str, job_type: str, job_name: str):
"""Removes a timed robocronp job, staff only.
You'll need to supply:
- timestamp (like 1545981602)
- job type (like "unban")
- job name (userid, like 420332322307571713)
You can get all 3 from listjobs command."""
delete_job(self.bot, timestamp, job_type, job_name)
await ctx.send(f"{ctx.author.mention}: Deleted!")
async def do_jobs(self, ctab, jobtype, timestamp):
await self.bot.wait_until_ready()
log_channel = await self.bot.get_channel_safe(self.bot.config.botlog_channel)
for job_name in ctab[jobtype][timestamp]:
try:
job_details = ctab[jobtype][timestamp][job_name]
if jobtype == "unban":
target_user = await self.bot.fetch_user(job_name)
target_guild = self.bot.get_guild(job_details["guild"])
delete_job(self.bot, timestamp, jobtype, job_name)
await target_guild.unban(
target_user, reason="Robocronp: Timed ban expired."
)
elif jobtype == "unmute":
remove_restriction(self.bot, job_name, self.bot.config.mute_role)
target_guild = self.bot.get_guild(job_details["guild"])
target_member = target_guild.get_member(int(job_name))
target_role = target_guild.get_role(self.bot.config.mute_role)
await target_member.remove_roles(
target_role, reason="Robocronp: Timed mute expired."
)
delete_job(self.bot, timestamp, jobtype, job_name)
elif jobtype == "remind":
text = job_details["text"]
added_on = job_details["added"]
target = await self.bot.fetch_user(int(job_name))
if target:
await target.send(
f"You asked to be reminded about `{text}` on {added_on}."
)
delete_job(self.bot, timestamp, jobtype, job_name)
except:
# Don't kill cronjobs if something goes wrong.
delete_job(self.bot, timestamp, jobtype, job_name)
await log_channel.send(
"Crondo has errored, job deleted: ```"
f"{traceback.format_exc()}```"
)
async def clean_channel(self, channel_id):
await self.bot.wait_until_ready()
log_channel = await self.bot.get_channel_safe(self.bot.config.botlog_channel)
channel = await self.bot.get_channel_safe(channel_id)
try:
done_cleaning = False
count = 0
while not done_cleaning:
purge_res = await channel.purge(limit=100)
count += len(purge_res)
if len(purge_res) != 100:
done_cleaning = True
await log_channel.send(
f"Wiped {count} messages from <#{channel.id}> automatically."
)
except:
# Don't kill cronjobs if something goes wrong.
await log_channel.send(
f"Cronclean has errored: ```{traceback.format_exc()}```"
)
@tasks.loop(minutes=1)
async def minutely(self):
await self.bot.wait_until_ready()
log_channel = await self.bot.get_channel_safe(self.bot.config.botlog_channel)
try:
ctab = get_crontab(self.bot)
timestamp = time.time()
for jobtype in ctab:
for jobtimestamp in ctab[jobtype]:
if timestamp > int(jobtimestamp):
await self.do_jobs(ctab, jobtype, jobtimestamp)
# Handle clean channels
for clean_channel in self.bot.config.minutely_clean_channels:
await self.clean_channel(clean_channel)
except:
# Don't kill cronjobs if something goes wrong.
await log_channel.send(
f"Cron-minutely has errored: ```{traceback.format_exc()}```"
)
@tasks.loop(hours=1)
async def hourly(self):
await self.bot.wait_until_ready()
log_channel = await self.bot.get_channel_safe(self.bot.config.botlog_channel)
try:
await self.send_data()
# Handle clean channels
for clean_channel in self.bot.config.hourly_clean_channels:
await self.clean_channel(clean_channel)
except:
# Don't kill cronjobs if something goes wrong.
await log_channel.send(
f"Cron-hourly has errored: ```{traceback.format_exc()}```"
)
@tasks.loop(hours=24)
async def daily(self):
await self.bot.wait_until_ready()
log_channel = await self.bot.get_channel_safe(self.bot.config.botlog_channel)
try:
# Reset verification and algorithm
if "cogs.verification" in self.bot.config.initial_cogs:
verif_channel = await self.bot.get_channel_safe(
self.bot.config.welcome_channel
)
await self.bot.do_resetalgo(verif_channel, "daily robocronp")
except:
# Don't kill cronjobs if something goes wrong.
await log_channel.send(
f"Cron-daily has errored: ```{traceback.format_exc()}```"
)
async def setup(bot):
await bot.add_cog(Robocronp(bot))

View file

@ -1,42 +0,0 @@
from discord import RawMemberRemoveEvent, Member
from discord.ext.commands import Cog
from robocop_ng.helpers.roles import add_user_roles, get_user_roles
class RolePersistence(Cog):
def __init__(self, bot):
self.bot = bot
@Cog.listener()
async def on_raw_member_remove(self, payload: RawMemberRemoveEvent):
save_roles = []
for role in payload.user.roles:
if (
role.is_assignable()
and not role.is_default()
and not role.is_premium_subscriber()
and not role.is_bot_managed()
and not role.is_integration()
):
save_roles.append(role.id)
if len(save_roles) > 0:
add_user_roles(self.bot, payload.user.id, save_roles)
@Cog.listener()
async def on_member_join(self, member: Member):
user_roles = get_user_roles(self.bot, member.id)
if len(user_roles) > 0:
user_roles = [
member.guild.get_role(int(role))
for role in user_roles
if member.guild.get_role(int(role)) is not None
]
await member.add_roles(
*user_roles, reason="Restoring old roles from `role_persistence`."
)
async def setup(bot):
await bot.add_cog(RolePersistence(bot))

View file

@ -1,266 +0,0 @@
import collections
import json
import os
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
class RyujinxReactionRoles(Cog):
def __init__(self, bot):
self.bot = bot
self.channel_id = (
self.bot.config.reaction_roles_channel_id
) # The channel to send the reaction role message. (self-roles channel)
self.file = os.path.join(
self.bot.state_dir, "data/reactionroles.json"
) # the file to store the required reaction role data. (message id of the RR message.)
self.msg_id = None
self.m = None # the msg object
@commands.guild_only()
@commands.check(check_if_staff)
@commands.command()
async def register_reaction_role(self, ctx, target_role_id: int, emoji_name: str):
"""Register a reaction role, staff only."""
await self.bot.wait_until_ready()
if emoji_name[0] == "<":
emoji_name = emoji_name[1:-1]
if target_role_id in self.bot.config.staff_role_ids:
return await ctx.send("Error: Dangerous role found!")
target_role = ctx.guild.get_role(target_role_id)
if target_role is None:
return await ctx.send("Error: Role not found!")
target_role_name = target_role.name
for key in self.reaction_config["reaction_roles_emoji_map"]:
value = self.reaction_config["reaction_roles_emoji_map"][key]
if type(value) is str and target_role_name == value:
return await ctx.send(f"Error: {target_role_name}: already registered.")
self.reaction_config["reaction_roles_emoji_map"][emoji_name] = target_role_name
self.save_reaction_config(self.reaction_config)
await self.reload_reaction_message(False)
await ctx.send(f"{target_role_name}: registered.")
def get_emoji_full_name(self, emoji):
emoji_name = emoji.name
if emoji_name is not None and emoji.id is not None:
emoji_name = f":{emoji_name}:{emoji.id}"
return emoji_name
def get_role(self, key):
return discord.utils.get(
self.bot.guilds[0].roles,
name=self.get_role_from_emoji(key),
)
def get_role_from_emoji(self, key):
value = self.emoji_map.get(key)
if value is not None and type(value) is not str:
return value.get("role")
return value
async def generate_embed(self):
last_descrption = []
description = [
"React to this message with the emojis given below to get your 'Looking for LDN game' roles. \n"
]
for x in self.emoji_map:
value = self.emoji_map[x]
emoji = x
if len(emoji.split(":")) == 3:
emoji = f"<{emoji}>"
if type(value) is str:
description.append(
f"{emoji} for __{self.emoji_map.get(x).split('(')[1].split(')')[0]}__"
)
else:
role_name = value["role"]
line_fmt = value["fmt"]
if value.get("should_be_last", False):
last_descrption.append(line_fmt.format(emoji, role_name))
else:
description.append(line_fmt.format(emoji, role_name))
embed = discord.Embed(
title="**Select your roles**",
description="\n".join(description) + "\n" + "\n".join(last_descrption),
color=420420,
)
embed.set_footer(
text="To remove a role, simply remove the corresponding reaction."
)
return embed
async def handle_offline_reaction_add(self):
await self.bot.wait_until_ready()
for reaction in self.m.reactions:
reactions_users = []
async for user in reaction.users():
reactions_users.append(user)
for user in reactions_users:
emoji_name = str(reaction.emoji)
if emoji_name[0] == "<":
emoji_name = emoji_name[1:-1]
if self.get_role_from_emoji(emoji_name) is not None:
role = self.get_role(emoji_name)
if (
not user in role.members
and not user.bot
and type(user) is discord.Member
):
await user.add_roles(role)
else:
await self.m.clear_reaction(reaction.emoji)
async def handle_offline_reaction_remove(self):
await self.bot.wait_until_ready()
for emoji in self.emoji_map:
for reaction in self.m.reactions:
emoji_name = str(reaction.emoji)
if emoji_name[0] == "<":
emoji_name = emoji_name[1:-1]
reactions_users = []
async for user in reaction.users():
reactions_users.append(user)
role = self.get_role(emoji_name)
for user in role.members:
if user not in reactions_users:
member = self.m.guild.get_member(user.id)
if member is not None:
await member.remove_roles(role)
def load_reaction_config(self):
if not os.path.exists(self.file):
with open(self.file, "w") as f:
json.dump({}, f)
with open(self.file, "r") as f:
return json.load(f)
def save_reaction_config(self, value):
with open(self.file, "w") as f:
json.dump(value, f)
async def reload_reaction_message(self, should_handle_offline=True):
await self.bot.wait_until_ready()
self.emoji_map = collections.OrderedDict(
sorted(
self.reaction_config["reaction_roles_emoji_map"].items(),
key=lambda x: str(x[1]),
)
)
guild = self.bot.guilds[0] # The ryu guild in which the bot is.
channel = guild.get_channel(self.channel_id)
if channel is None:
channel = await guild.fetch_channel(self.channel_id)
history = []
async for msg in channel.history():
history.append(msg)
m = discord.utils.get(history, id=self.reaction_config["id"])
if m is None:
self.reaction_config["id"] = None
embed = await self.generate_embed()
self.m = await channel.send(embed=embed)
self.msg_id = self.m.id
for x in self.emoji_map:
await self.m.add_reaction(x)
self.reaction_config["id"] = self.m.id
self.save_reaction_config(self.reaction_config)
await self.handle_offline_reaction_remove()
else:
self.m = m
self.msg_id = self.m.id
await self.m.edit(embed=await self.generate_embed())
for x in self.emoji_map:
if not x in self.m.reactions:
await self.m.add_reaction(x)
if should_handle_offline:
await self.handle_offline_reaction_add()
await self.handle_offline_reaction_remove()
@Cog.listener()
async def on_ready(self):
await self.bot.wait_until_ready()
self.reaction_config = self.load_reaction_config()
await self.reload_reaction_message()
@Cog.listener()
async def on_raw_reaction_add(self, payload):
await self.bot.wait_until_ready()
if payload.member.bot:
pass
else:
if payload.message_id == self.msg_id:
emoji_name = self.get_emoji_full_name(payload.emoji)
if self.get_role_from_emoji(emoji_name) is not None:
target_role = self.get_role(emoji_name)
if target_role is not None:
await payload.member.add_roles(target_role)
else:
self.bot.log.error(
f"Role {self.emoji_map[emoji_name]} not found."
)
await self.m.clear_reaction(payload.emoji)
else:
await self.m.clear_reaction(payload.emoji)
@Cog.listener()
async def on_raw_reaction_remove(self, payload):
await self.bot.wait_until_ready()
if payload.message_id == self.msg_id:
emoji_name = self.get_emoji_full_name(payload.emoji)
if self.get_role_from_emoji(emoji_name) is not None:
guild = discord.utils.find(
lambda guild: guild.id == payload.guild_id, self.bot.guilds
)
target_role = self.get_role(emoji_name)
if target_role is not None:
await guild.get_member(payload.user_id).remove_roles(
self.get_role(emoji_name)
) # payload.member.remove_roles will throw error
async def setup(bot):
await bot.add_cog(RyujinxReactionRoles(bot))

View file

@ -1,100 +0,0 @@
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
class RyujinxVerification(Cog):
def __init__(self, bot):
self.bot = bot
# Export reset channel functions
self.bot.do_reset = self.do_reset
self.bot.do_resetalgo = self.do_resetalgo
@Cog.listener()
async def on_member_join(self, member):
await self.bot.wait_until_ready()
if member.guild.id not in self.bot.config.guild_whitelist:
return
join_channel = self.bot.get_channel(self.bot.config.welcome_channel)
if join_channel is not None:
await join_channel.send(
"Hello {0.mention}! Welcome to Ryujinx! Please read the <#411271165429022730>, and then type the verifying command here to gain access to the rest of the channels.\n\nIf you need help with basic common questions, visit the <#585288848704143371> channel after joining.\n\nIf you need help with Animal Crossing visit the <#692104087889641472> channel for common issues and solutions. If you need help that is not Animal Crossing related, please visit the <#410208610455519243> channel after verifying.".format(
member
)
)
async def process_message(self, message):
"""Process the verification process"""
if message.channel.id == self.bot.config.welcome_channel:
# Assign common stuff into variables to make stuff less of a mess
mcl = message.content.lower()
# Get the role we will give in case of success
success_role = message.guild.get_role(self.bot.config.participant_role)
if self.bot.config.verification_string == mcl:
await message.author.add_roles(success_role)
await message.delete()
@Cog.listener()
async def on_message(self, message):
if message.author.bot:
return
try:
await self.process_message(message)
except discord.errors.Forbidden:
chan = self.bot.get_channel(message.channel)
await chan.send("💢 I don't have permission to do this.")
@Cog.listener()
async def on_message_edit(self, before, after):
if after.author.bot:
return
try:
await self.process_message(after)
except discord.errors.Forbidden:
chan = self.bot.get_channel(after.channel)
await chan.send("💢 I don't have permission to do this.")
@Cog.listener()
async def on_member_join(self, member):
await self.bot.wait_until_ready()
if member.guild.id not in self.bot.config.guild_whitelist:
return
join_channel = self.bot.get_channel(self.bot.config.welcome_channel)
if join_channel is not None:
await join_channel.send(self.bot.config.join_message.format(member))
@commands.check(check_if_staff)
@commands.command()
async def reset(self, ctx, limit: int = 100, force: bool = False):
"""Wipes messages and pastes the welcome message again. Staff only."""
if ctx.message.channel.id != self.bot.config.welcome_channel and not force:
await ctx.send(
f"This command is limited to"
f" <#{self.bot.config.welcome_channel}>, unless forced."
)
return
await self.do_reset(ctx.channel, ctx.author.mention, limit)
async def do_reset(self, channel, author, limit: int = 100):
await channel.purge(limit=limit)
async def do_resetalgo(self, channel, author, limit: int = 100):
# We only auto clear the channel daily
await self.do_reset(channel, author)
async def setup(bot):
await bot.add_cog(RyujinxVerification(bot))

View file

@ -1,48 +0,0 @@
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff_or_ot
class SAR(Cog):
def __init__(self, bot):
self.bot = bot
@commands.guild_only()
@commands.command()
@commands.check(check_if_staff_or_ot)
async def sar(self, ctx):
"""Lists self assignable roles."""
return await ctx.send(
"Self assignable roles in this guild: "
+ ",".join(self.bot.config.self_assignable_roles)
+ f"\n\nRun `{self.bot.config.prefixes[0]}iam role_name_goes_here` to get or remove one."
)
@commands.cooldown(1, 30, type=commands.BucketType.user)
@commands.guild_only()
@commands.command(aliases=["iamnot"])
@commands.check(check_if_staff_or_ot)
async def iam(self, ctx, role: str):
"""Gets you a self assignable role."""
if role not in self.bot.config.self_assignable_roles:
return await ctx.send(
"There's no self assignable role with that name. Run .sar to see what you can self assign."
)
target_role = ctx.guild.get_role(self.bot.config.self_assignable_roles[role])
if target_role in ctx.author.roles:
await ctx.author.remove_roles(target_role, reason=str(ctx.author))
await ctx.send(
f"{ctx.author.mention}: Successfully removed your `{role}` role. Run the command again if you want to add it again."
)
else:
await ctx.author.add_roles(target_role, reason=str(ctx.author))
await ctx.send(
f"{ctx.author.mention}: Successfully gave you the `{role}` role. Run the command again if you want to remove it."
)
async def setup(bot):
await bot.add_cog(SAR(bot))

View file

@ -1,40 +0,0 @@
from discord import Guild
from discord.errors import Forbidden
from discord.ext import tasks
from discord.ext.commands import Cog
class VanityUrl(Cog):
def __init__(self, bot):
self.bot = bot
self.vanity_codes: dict[int, str] = self.bot.config.vanity_codes
self.check_changed_vanity_codes.start()
def cog_unload(self):
self.check_changed_vanity_codes.cancel()
async def update_vanity_code(self, guild: Guild, code: str):
if "VANITY_URL" in guild.features and guild.vanity_url_code != code:
try:
await guild.edit(
reason="Configured vanity code was different", vanity_code=code
)
except Forbidden:
self.bot.log.exception(f"Not allowed to edit vanity url for: {guild}")
self.cog_unload()
await self.bot.unload_extension("robocop_ng.cogs.vanity_url")
@Cog.listener()
async def on_guild_update(self, before: Guild, after: Guild):
if after.id in self.vanity_codes:
await self.update_vanity_code(after, self.vanity_codes[after.id])
@tasks.loop(hours=12)
async def check_changed_vanity_codes(self):
await self.bot.wait_until_ready()
for guild, vanity_code in self.vanity_codes.items():
await self.update_vanity_code(self.bot.get_guild(guild), vanity_code)
async def setup(bot):
await bot.add_cog(VanityUrl(bot))

View file

@ -1,227 +0,0 @@
import asyncio
import hashlib
import itertools
import random
from inspect import cleandoc
import discord
from discord.ext import commands
from discord.ext.commands import Cog
from robocop_ng.helpers.checks import check_if_staff
class Verification(Cog):
def __init__(self, bot):
self.bot = bot
self.hash_choice = random.choice(self.bot.config.welcome_hashes)
# Export reset channel functions
self.bot.do_reset = self.do_reset
self.bot.do_resetalgo = self.do_resetalgo
async def do_reset(self, channel, author, limit: int = 100):
await channel.purge(limit=limit)
await channel.send(self.bot.config.welcome_header)
rules = [
"**{}**. {}".format(i, cleandoc(r))
for i, r in enumerate(self.bot.config.welcome_rules, 1)
]
rule_choice = random.randint(2, len(rules))
hash_choice_str = self.hash_choice.upper()
if hash_choice_str == "BLAKE2B":
hash_choice_str += "-512"
elif hash_choice_str == "BLAKE2S":
hash_choice_str += "-256"
rules[rule_choice - 1] += "\n" + self.bot.config.hidden_term_line.format(
hash_choice_str
)
msg = (
f"🗑 **Reset**: {author} cleared {limit} messages " f" in {channel.mention}"
)
msg += f"\n💬 __Current challenge location__: under rule {rule_choice}"
log_channel = self.bot.get_channel(self.bot.config.log_channel)
await log_channel.send(msg)
# find rule that puts us over 2,000 characters, if any
total = 0
messages = []
current_message = ""
for item in rules:
total += len(item) + 2 # \n\n
if total < 2000:
current_message += item + "\n\n"
else:
# we've hit the limit; split!
messages += [current_message]
current_message = "\n\u200B\n" + item + "\n\u200B\n"
total = 0
messages += [current_message]
for item in messages:
await channel.send(item)
await asyncio.sleep(1)
for x in self.bot.config.welcome_footer:
await channel.send(cleandoc(x))
await asyncio.sleep(1)
async def do_resetalgo(self, channel, author, limit: int = 100):
# randomize hash_choice on reset
self.hash_choice = random.choice(tuple(self.bot.config.welcome_hashes))
msg = (
f"📘 **Reset Algorithm**: {author} reset " f"algorithm in {channel.mention}"
)
msg += f"\n💬 __Current algorithm__: {self.hash_choice.upper()}"
log_channel = self.bot.get_channel(self.bot.config.log_channel)
await log_channel.send(msg)
await self.do_reset(channel, author)
@commands.check(check_if_staff)
@commands.command()
async def reset(self, ctx, limit: int = 100, force: bool = False):
"""Wipes messages and pastes the welcome message again. Staff only."""
if ctx.message.channel.id != self.bot.config.welcome_channel and not force:
await ctx.send(
f"This command is limited to"
f" <#{self.bot.config.welcome_channel}>, unless forced."
)
return
await self.do_reset(ctx.channel, ctx.author.mention, limit)
@commands.check(check_if_staff)
@commands.command()
async def resetalgo(self, ctx, limit: int = 100, force: bool = False):
"""Resets the verification algorithm and does what reset does. Staff only."""
if ctx.message.channel.id != self.bot.config.welcome_channel and not force:
await ctx.send(
f"This command is limited to"
f" <#{self.bot.config.welcome_channel}>, unless forced."
)
return
await self.do_resetalgo(ctx.channel, ctx.author.mention, limit)
async def process_message(self, message):
"""Big code that makes me want to shoot myself
Not really a rewrite but more of a port
Git blame tells me that I should blame/credit Robin Lambertz"""
if message.channel.id == self.bot.config.welcome_channel:
# Assign common stuff into variables to make stuff less of a mess
member = message.author
full_name = str(member)
discrim = str(member.discriminator)
guild = message.guild
chan = message.channel
mcl = message.content.lower()
# Reply to users that insult the bot
oof = [
"bad",
"broken",
"buggy",
"bugged",
"stupid",
"dumb",
"silly",
"fuck",
"heck",
"h*ck",
]
if "bot" in mcl and any(insult in mcl for insult in oof):
snark = random.choice(["bad human", "no u", "no u, rtfm", "pebkac"])
return await chan.send(snark)
# Get the role we will give in case of success
success_role = guild.get_role(self.bot.config.named_roles["participant"])
# Get a list of stuff we'll allow and will consider close
allowed_names = [f"@{full_name}", full_name, str(member.id)]
close_names = [f"@{member.name}", member.name, discrim, f"#{discrim}"]
# Now add the same things but with newlines at the end of them
allowed_names += [(an + "\n") for an in allowed_names]
close_names += [(cn + "\n") for cn in close_names]
allowed_names += [(an + "\r\n") for an in allowed_names]
close_names += [(cn + "\r\n") for cn in close_names]
# [ ͡° ͜ᔦ ͡°] 𝐖𝐞𝐥𝐜𝐨𝐦𝐞 𝐭𝐨 𝐌𝐚𝐜 𝐎𝐒 𝟗.
allowed_names += [(an + "\r") for an in allowed_names]
close_names += [(cn + "\r") for cn in close_names]
# Finally, hash the stuff so that we can access them later :)
hash_allow = [
hashlib.new(self.hash_choice, name.encode("utf-8")).hexdigest()
for name in allowed_names
]
# I'm not even going to attempt to break those into lines jfc
if any(allow in mcl for allow in hash_allow):
await member.add_roles(success_role)
return await chan.purge(
limit=100,
check=lambda m: m.author == message.author
or (
m.author == self.bot.user
and message.author.mention in m.content
),
)
# Detect if the user uses the wrong hash algorithm
wrong_hash_algos = list(
set(self.bot.config.welcome_hashes) - {self.hash_choice}
)
for algo in wrong_hash_algos:
for name in itertools.chain(allowed_names, close_names):
if hashlib.new(algo, name.encode("utf-8")).hexdigest() in mcl:
log_channel = self.bot.get_channel(self.bot.config.log_channel)
await log_channel.send(
f"User {message.author.mention} tried verification with algo {algo} instead of {self.hash_choice}."
)
return await chan.send(
f"{message.author.mention} :no_entry: Close, but not quite. Go back and re-read!"
)
if (
full_name in message.content
or str(member.id) in message.content
or member.name in message.content
or discrim in message.content
):
no_text = ":no_entry: Incorrect. You need to do something *specific* with your name and discriminator instead of just posting it. Please re-read the rules carefully and look up any terms you are not familiar with."
rand_num = random.randint(1, 100)
if rand_num == 42:
no_text = "you're doing it wrong"
elif rand_num == 43:
no_text = "ugh, wrong, read the rules."
elif rand_num == 44:
no_text = '"The definition of insanity is doing the same thing over and over again, but expecting different results."\n-Albert Einstein'
await chan.send(f"{message.author.mention} {no_text}")
@Cog.listener()
async def on_message(self, message):
if message.author.bot:
return
try:
await self.process_message(message)
except discord.errors.Forbidden:
chan = self.bot.get_channel(message.channel)
await chan.send("💢 I don't have permission to do this.")
@Cog.listener()
async def on_message_edit(self, before, after):
if after.author.bot:
return
try:
await self.process_message(after)
except discord.errors.Forbidden:
chan = self.bot.get_channel(after.channel)
await chan.send("💢 I don't have permission to do this.")
async def setup(bot):
await bot.add_cog(Verification(bot))

View file

@ -1,153 +0,0 @@
import asyncio
import base64
import hmac
import re
import secrets
from discord.ext.commands import Cog
class YubicoOTP(Cog):
def __init__(self, bot):
self.bot = bot
self.otp_re = re.compile("((cc|vv)[cbdefghijklnrtuv]{42})$")
self.api_servers = [
"https://api.yubico.com",
"https://api2.yubico.com",
"https://api3.yubico.com",
"https://api4.yubico.com",
"https://api5.yubico.com",
]
self.reuse_responses = ["BAD_OTP", "REPLAYED_OTP"]
self.bad_responses = [
"MISSING_PARAMETER",
"NO_SUCH_CLIENT",
"OPERATION_NOT_ALLOWED",
]
self.modhex_to_hex_conversion_map = {
"c": "0",
"b": "1",
"d": "2",
"e": "3",
"f": "4",
"g": "5",
"h": "6",
"i": "7",
"j": "8",
"k": "9",
"l": "a",
"n": "b",
"r": "c",
"t": "d",
"u": "e",
"v": "f",
}
def get_serial(self, otp):
"""Get OTP from serial, based on code by linuxgemini"""
if otp[:2] != "cc":
return False
hexconv = []
for modhexletter in otp[0:12]:
hexconv.append(self.modhex_to_hex_conversion_map[modhexletter])
return int("".join(hexconv), 16)
def calc_signature(self, text):
key = base64.b64decode(self.bot.config.yubico_otp_secret)
signature_bytes = hmac.digest(key, text.encode(), "SHA1")
return base64.b64encode(signature_bytes).decode()
def validate_response_signature(self, response_dict):
yubico_signature = response_dict["h"]
to_sign = ""
for key in sorted(response_dict.keys()):
if key == "h":
continue
to_sign += f"{key}={response_dict[key]}&"
our_signature = self.calc_signature(to_sign.strip("&"))
return our_signature == yubico_signature
async def validate_yubico_otp(self, otp):
nonce = secrets.token_hex(15) # Random number in the valid range
params = f"id={self.bot.config.yubico_otp_client_id}&nonce={nonce}&otp={otp}"
# If secret is supplied, sign our request
if self.bot.config.yubico_otp_secret:
params += "&h=" + self.calc_signature(params)
for api_server in self.api_servers:
url = f"{api_server}/wsapi/2.0/verify?{params}"
try:
resp = await self.bot.aiosession.get(url)
assert resp.status == 200
except Exception as ex:
self.bot.log.warning(f"Got {repr(ex)} on {api_server} with otp {otp}.")
continue
resptext = await resp.text()
# Turn the fields to a python dict for easier parsing
datafields = resptext.strip().split("\r\n")
datafields = {
line[: line.index("=")]: line[line.index("=") + 1 :]
for line in datafields
}
# Verify nonce
assert datafields["nonce"] == nonce
# Verify signature if secret is present
if self.bot.config.yubico_otp_secret:
assert self.validate_response_signature(datafields)
# If we got a success, then return True
if datafields["status"] == "OK":
return True
elif datafields["status"] in self.reuse_responses:
return False
# If status isn't an expected one, log it
self.bot.log.warning(
f"Got {repr(datafields)} on {api_server} with otp {otp} and nonce {nonce}"
)
# If we fucked up in a way we can't recover from, just return None
if datafields["status"] in self.bad_responses:
return None
# Return None if we fail to get responses from any server
return None
@Cog.listener()
async def on_message(self, message):
await self.bot.wait_until_ready()
otps = self.otp_re.findall(message.content.strip())
if otps:
otp = otps[0][0]
# Validate OTP
validation_result = await self.validate_yubico_otp(otp)
if validation_result is not True:
return
# Derive serial and a string to use it
serial = self.get_serial(otp)
serial_str = f" (serial: `{serial}`)" if serial else ""
# If the message content is _just_ the OTP code, delete it toos
if message.content.strip() == otp:
await message.delete()
# If OTP is valid, tell user that it was revoked
msg = await message.channel.send(
f"{message.author.mention}: Ate Yubico OTP `{otp}`{serial_str}"
". This message will self destruct in 5 seconds."
)
# and delete message after 5s to help SNR
await asyncio.sleep(5)
await msg.delete()
async def setup(bot):
await bot.add_cog(YubicoOTP(bot))

View file

@ -1,343 +0,0 @@
import datetime
import hashlib
# Basic bot config, insert your token here, update description if you want
prefixes = [".", "!"]
client_id = 0
token = "token-goes-here"
bot_description = "Robocop-NG, the moderation bot of ReSwitched."
# If you forked robocop-ng, put your repo here
source_url = "https://github.com/reswitched/robocop-ng"
rules_url = "https://reswitched.github.io/discord/#rules"
# The bot description to be used in .robocop embed
embed_desc = (
"Robocop-NG is developed by [Ave](https://github.com/aveao)"
" and [tomGER](https://github.com/tumGER), and is a rewrite "
"of Robocop.\nRobocop is based on Kurisu by 916253 and ihaveamac."
)
# The cogs the bot will load on startup.
initial_cogs = [
"cogs.common",
"cogs.admin",
"cogs.verification",
"cogs.mod",
"cogs.mod_note",
"cogs.mod_reacts",
"cogs.mod_userlog",
"cogs.mod_timed",
"cogs.mod_watch",
"cogs.basic",
"cogs.logs",
"cogs.err",
"cogs.lockdown",
"cogs.legacy",
"cogs.links",
"cogs.remind",
"cogs.robocronp",
"cogs.meme",
"cogs.invites",
"cogs.yubicootp",
]
# The following cogs are also available but aren't loaded by default:
# cogs.imagemanip - Adds a meme command called .cox.
# Requires Pillow to be installed with pip.
# cogs.lists - Allows managing list channels (rules, FAQ) easily through the bot
# PR'd in at: https://github.com/reswitched/robocop-ng/pull/65
# cogs.pin - Lets users pin important messages
# and sends pins above limit to a github gist
# The string that users need to say to get past verification
verification_string = "go read the rules, not the code"
# Minimum account age required to join the guild
# If user's account creation is shorter than the time delta given here
# then user will be kicked and informed
min_age = datetime.timedelta(minutes=15)
# The bot will only work in these guilds
guild_whitelist = [269333940928512010] # ReSwitched discord
# Custom invite URL codes
vanity_codes = {269333940928512010: "reswitched"}
# Named roles to be used with .approve and .revoke
# Example: .approve User hacker
named_roles = {
"community": 420010997877833731,
"hacker": 364508795038072833,
"participant": 434353085926866946,
"pirate": 0,
}
# The bot manager and staff roles
# Bot manager can run eval, exit and other destructive commands
# Staff can run administrative commands
bot_manager_role_id = 466447265863696394 # Bot management role in ReSwitched
staff_role_ids = [
364647829248933888, # Team role in ReSwitched
360138431524765707, # Mod role in ReSwitched
466447265863696394, # Bot management role in ReSwitched
360138163156549632, # Admin role in ReSwitched
287289529986187266, # Wizard role in ReSwitched
]
# Various log channels used to log bot and guild's activity
# You can use same channel for multiple log types
# Spylog channel logs suspicious messages or messages by members under watch
# Invites created with .invite will direct to the welcome channel.
log_channel = 290958160414375946 # server-logs in ReSwitched
botlog_channel = 529070282409771048 # bot-logs channel in ReSwitched
modlog_channel = 542114169244221452 # mod-logs channel in ReSwitched
spylog_channel = 548304839294189579 # spy channel in ReSwitched
welcome_channel = 326416669058662401 # newcomers channel in ReSwitched
# These channel entries are used to determine which roles will be given
# access when we unmute on them
general_channels = [
420029476634886144,
414949821003202562,
383368936466546698,
343244421044633602,
491316901692178432,
539212260350885908,
] # Channels everyone can access
community_channels = [
269333940928512010,
438839875970662400,
404722395845361668,
435687501068501002,
286612533757083648,
] # Channels requiring community role
# Controls which roles are blocked during lockdown
lockdown_configs = {
# Used as a default value for channels without a config
"default": {"channels": general_channels, "roles": [named_roles["participant"]]},
"community": {
"channels": community_channels,
"roles": [named_roles["community"], named_roles["hacker"]],
},
}
# Mute role is applied to users when they're muted
# As we no longer have mute role on ReSwitched, I set it to 0 here
mute_role = 0 # Mute role in ReSwitched
# Channels that will be cleaned every minute/hour.
# This feature isn't very good rn.
# See https://github.com/reswitched/robocop-ng/issues/23
minutely_clean_channels = []
hourly_clean_channels = []
# Edited and deletes messages in these channels will be logged
spy_channels = general_channels
# All lower case, no spaces, nothing non-alphanumeric
suspect_words = [
"deepsea", # piracy-enabling cfw
"sx", # piracy-enabling cfw
"tx", # piracy-enabling cfw
"reinx", # piracy-enabling cfw
"gomanx", # piracy-enabling cfw
"neutos", # piracy-enabling cfw
"underpack", # piracy-enabling cfw
"underos", # piracy-enabling cfw
"tinfoil", # title manager
"dz", # title manager
"goldleaf", # potential title manager
"lithium", # title manager
"cracked", # older term for pirated games
"xci", # "backup" format
"xcz", # "backup" format
"nsz", # "backup" format
"hbg", # piracy source
"jits", # piracy source
]
# List of words that will be ignored if they match one of the
# suspect_words (This is used to remove false positives)
suspect_ignored_words = [
"excit",
"s/x",
"3dsx",
"psx",
"txt",
"s(x",
"txd",
"t=x",
"osx",
"rtx",
"shift-x",
"users/x",
"tx1",
"tx2",
"tcptx",
"udptx",
"ctx",
"jit's",
]
# == For cogs.links ==
links_guide_text = """**Generic starter guides:**
Nintendo Homebrew's Guide: <https://nh-server.github.io/switch-guide/>
**Specific guides:**
Manually Updating/Downgrading (with HOS): <https://switch.homebrew.guide/usingcfw/manualupgrade>
Manually Repairing/Downgrading (without HOS): <https://switch.homebrew.guide/usingcfw/manualchoiupgrade>
How to set up a Homebrew development environment: <https://devkitpro.org/wiki/Getting_Started>
Getting full RAM in homebrew without NSPs: As of Atmosphere 0.8.6, hold R while opening any game.
Check if a switch is vulnerable to RCM through serial: <https://akdm.github.io/ssnc/checker/>
"""
# == For cogs.verification ==
# ReSwitched verification system is rather unique.
# You might want to reimplement it.
# If you do, use a different name for easier upstream merge.
# https://docs.python.org/3.7/library/hashlib.html#shake-variable-length-digests
_welcome_blacklisted_hashes = {"shake_128", "shake_256"}
# List of hashes that are to be used during verification
welcome_hashes = tuple(hashlib.algorithms_guaranteed - _welcome_blacklisted_hashes)
# Header before rules in #newcomers - https://elixi.re/i/opviq90y.png
welcome_header = """
<:ReSwitched:326421448543567872> __**Welcome to ReSwitched!**__
__**Be sure you read the following rules and information before participating. If you came here to ask about "backups", this is NOT the place.**__
__**Got questions about Nintendo Switch hacking? Before asking in the server, please see our FAQ at <https://reswitched.github.io/faq/> to see if your question has already been answered.**__
__**This is a server for technical discussion and development support. If you are looking for end-user support, the Nintendo Homebrew discord server may be a better fit: <https://discord.gg/C29hYvh>.**__
:bookmark_tabs:__Rules:__
"""
# Rules in #newcomers - https://elixi.re/i/dp3enq5i.png
welcome_rules = (
# 1
"""
Read all the rules before participating in chat. Not reading the rules is *not* an excuse for breaking them.
It's suggested that you read channel topics and pins before asking questions as well, as some questions may have already been answered in those.
""",
# 2
"""
Be nice to each other. It's fine to disagree, it's not fine to insult or attack other people.
You may disagree with anyone or anything you like, but you should try to keep it to opinions, and not people. Avoid vitriol.
Constant antagonistic behavior is considered uncivil and appropriate action will be taken.
The use of derogatory slurs -- sexist, racist, homophobic, transphobic, or otherwise -- is unacceptable and may be grounds for an immediate ban.
""",
# 3
'If you have concerns about another user, please take up your concerns with a staff member (someone with the "mod" role in the sidebar) in private. Don\'t publicly call other users out.',
# 4
"""
From time to time, we may mention everyone in the server. We do this when we feel something important is going on that requires attention. Complaining about these pings may result in a ban.
To disable notifications for these pings, suppress them in "ReSwitched → Notification Settings".
""",
# 5
"""
Don't spam.
For excessively long text, use a service like <https://0bin.net/>.
""",
# 6
"Don't brigade, raid, or otherwise attack other people or communities. Don't discuss participation in these attacks. This may warrant an immediate permanent ban.",
# 7
"Off-topic content goes to #off-topic. Keep low-quality content like memes out.",
# 8
"Trying to evade, look for loopholes, or stay borderline within the rules will be treated as breaking them.",
# 9
"""
Absolutely no piracy or related discussion. This includes:
"Backups", even if you legally own a copy of the game.
"Installable" NSPs, XCIs, and NCAs; this **includes** installable homebrew (i.e. on the Home Menu instead of within nx-hbmenu).
Signature and ES patches, also known as "sigpatches"
Usage of piracy-focused groups' (Team Xecuter, etc.) hardware and software, such as SX OS.
This is a zero-tolerance, non-negotiable policy that is enforced strictly and swiftly, up to and including instant bans without warning.
""",
# 10
"The first character of your server nickname should be alphanumeric if you wish to talk in chat.",
# 11
"""
Do not boost the server.
ReSwitched neither wants nor needs your server boosts, and your money is better off elsewhere. Consider the EFF (or a charity of your choice).
Boosting the server is liable to get you kicked (to remove the nitro boost role), and/or warned. Roles you possessed prior to the kick may not be restored in a timely fashion.
""",
)
# Footer after rules in #newcomers - https://elixi.re/i/uhfiecib.png
welcome_footer = (
"""
:hash: __Channel Breakdown:__
#news - Used exclusively for updates on ReSwitched progress and community information. Most major announcements are passed through this channel and whenever something is posted there it's usually something you'll want to look at.
#switch-hacking-meta - For "meta-discussion" related to hacking the switch. This is where we talk *about* the switch hacking that's going on, and where you can get clarification about the hacks that exist and the work that's being done.
#user-support - End-user focused support, mainly between users. Ask your questions about using switch homebrew here.
#tool-support - Developer focused support. Ask your questions about using PegaSwitch, libtransistor, Mephisto, and other tools here.
#hack-n-all - General hacking, hardware and software development channel for hacking on things *other* than the switch. This is a great place to ask about hacking other systems-- and for the community to have technical discussions.
""",
"""
#switch-hacking-general - Channel for everyone working on hacking the switch-- both in an exploit and a low-level hardware sense. This is where a lot of our in-the-open development goes on. Note that this isn't the place for developing homebrew-- we have #homebrew-development for that!
#homebrew-development - Discussion about the development of homebrew goes there. Feel free to show off your latest creation here.
#off-topic - Channel for discussion of anything that doesn't belong in #general. Anything goes, so long as you make sure to follow the rules and be on your best behavior.
#toolchain-development - Discussion about the development of libtransistor itself goes there.
#cfw-development - Development discussion regarding custom firmware (CFW) projects, such as Atmosphère. This channel is meant for the discussion accompanying active development.
#bot-cmds - Channel for excessive/random use of Robocop's various commands.
**If you are still not sure how to get access to the other channels, please read the rules again.**
**If you have questions about the rules, feel free to ask here!**
**Note: This channel is completely automated (aside from responding to questions about the rules). If your message didn't give you access to the other channels, you failed the test. Feel free to try again.**
""",
)
# Line to be hidden in rules
hidden_term_line = ' • When you have finished reading all of the rules, send a message in this channel that includes the {0} hex digest of your discord "name#discriminator", and bot will automatically grant you access to the other channels. You can find your "name#discriminator" (your username followed by a # and four numbers) under the discord channel list.'
# == Only if you want to use cogs.pin ==
# Used for the pinboard. Leave empty if you don't wish for a gist pinboard.
github_oauth_token = ""
# Channels and roles where users can pin messages
allowed_pin_channels = []
allowed_pin_roles = []
# Channel to upload text files while editing list items. (They are cleaned up.)
list_files_channel = 0
# == Only if you want to use cogs.lists ==
# Channels that are lists that are controlled by the lists cog.
list_channels = []
# == Only if you want to use cogs.sar ==
self_assignable_roles = {
"streamnotifs": 715158689060880384,
}
# == Only if you want to use cogs.mod_reswitched ==
pingmods_allow = [named_roles["community"]] + staff_role_ids
pingmods_role = 360138431524765707
modtoggle_role = 360138431524765707
# == Only if you want to use cogs.yubicootp ==
# Optiona: Get your own from https://upgrade.yubico.com/getapikey/
yubico_otp_client_id = 1
# Note: You can keep client ID on 1, it will function.
yubico_otp_secret = ""
# Optional: If you provide a secret, requests will be signed
# and responses will be verified.

View file

@ -1,21 +0,0 @@
import json
import os
from robocop_ng.helpers.notifications import report_critical_error
def read_json(bot, filepath: str) -> dict:
if os.path.isfile(filepath) and os.path.getsize(filepath) > 0:
with open(filepath, "r") as f:
try:
return json.load(f)
except json.JSONDecodeError as e:
content = f.read()
report_critical_error(
bot,
e,
additional_info={
"file": {"length": len(content), "content": content}
},
)
return {}

View file

@ -1,189 +0,0 @@
import json
import os
from typing import Union
from robocop_ng.helpers.data_loader import read_json
def get_disabled_ids_path(bot) -> str:
return os.path.join(bot.state_dir, "data/disabled_ids.json")
def is_app_id_valid(app_id: str) -> bool:
return len(app_id) == 16 and app_id.isalnum()
def is_build_id_valid(build_id: str) -> bool:
return 32 <= len(build_id) <= 64 and build_id.isalnum()
def is_ro_section_valid(ro_section: dict[str, str]) -> bool:
return "module" in ro_section.keys() and "sdk_libraries" in ro_section.keys()
def get_disabled_ids(bot) -> dict[str, dict[str, Union[str, dict[str, str]]]]:
disabled_ids = read_json(bot, get_disabled_ids_path(bot))
if len(disabled_ids) > 0:
# Migration code
if "app_id" in disabled_ids.keys():
old_disabled_ids = disabled_ids.copy()
disabled_ids = {}
for key in old_disabled_ids["app_id"].values():
disabled_ids[key.lower()] = {
"app_id": "",
"build_id": "",
"ro_section": {},
}
for id_type in ["app_id", "build_id"]:
for value, key in old_disabled_ids[id_type].items():
disabled_ids[key.lower()][id_type] = value
for key, value in old_disabled_ids["ro_section"].items():
disabled_ids[key.lower()]["ro_section"] = value
set_disabled_ids(bot, disabled_ids)
return disabled_ids
def set_disabled_ids(bot, contents: dict[str, dict[str, Union[str, dict[str, str]]]]):
with open(get_disabled_ids_path(bot), "w") as f:
json.dump(contents, f)
def add_disable_id_if_necessary(
disable_id: str, disabled_ids: dict[str, dict[str, Union[str, dict[str, str]]]]
):
if disable_id not in disabled_ids.keys():
disabled_ids[disable_id] = {"app_id": "", "build_id": "", "ro_section": {}}
def is_app_id_disabled(bot, app_id: str) -> bool:
disabled_app_ids = [
entry["app_id"]
for entry in get_disabled_ids(bot).values()
if len(entry["app_id"]) > 0
]
app_id = app_id.lower()
return app_id in disabled_app_ids
def is_build_id_disabled(bot, build_id: str) -> bool:
disabled_build_ids = [
entry["build_id"]
for entry in get_disabled_ids(bot).values()
if len(entry["build_id"]) > 0
]
build_id = build_id.lower()
if len(build_id) < 64:
build_id += "0" * (64 - len(build_id))
return build_id in disabled_build_ids
def is_ro_section_disabled(bot, ro_section: dict[str, Union[str, list[str]]]) -> bool:
disabled_ro_sections = [
entry["ro_section"]
for entry in get_disabled_ids(bot).values()
if len(entry["ro_section"]) > 0
]
matches = []
for disabled_ro_section in disabled_ro_sections:
for key, content in disabled_ro_section.items():
if key == "module":
matches.append(ro_section[key].lower() == content.lower())
else:
matches.append(ro_section[key] == content)
if all(matches) and len(matches) > 0:
return True
else:
matches = []
return False
def remove_disable_id(bot, disable_id: str) -> bool:
disabled_ids = get_disabled_ids(bot)
if disable_id in disabled_ids.keys():
del disabled_ids[disable_id]
set_disabled_ids(bot, disabled_ids)
return True
return False
def add_disabled_app_id(bot, disable_id: str, app_id: str) -> bool:
disabled_ids = get_disabled_ids(bot)
disable_id = disable_id.lower()
app_id = app_id.lower()
if not is_app_id_disabled(bot, app_id):
add_disable_id_if_necessary(disable_id, disabled_ids)
disabled_ids[disable_id]["app_id"] = app_id
set_disabled_ids(bot, disabled_ids)
return True
return False
def add_disabled_build_id(bot, disable_id: str, build_id: str) -> bool:
disabled_ids = get_disabled_ids(bot)
disable_id = disable_id.lower()
build_id = build_id.lower()
if len(build_id) < 64:
build_id += "0" * (64 - len(build_id))
if not is_build_id_disabled(bot, build_id):
add_disable_id_if_necessary(disable_id, disabled_ids)
disabled_ids[disable_id]["build_id"] = build_id
set_disabled_ids(bot, disabled_ids)
return True
return False
def remove_disabled_app_id(bot, disable_id: str) -> bool:
disabled_ids = get_disabled_ids(bot)
disable_id = disable_id.lower()
if (
disable_id in disabled_ids.keys()
and len(disabled_ids[disable_id]["app_id"]) > 0
):
disabled_ids[disable_id]["app_id"] = ""
set_disabled_ids(bot, disabled_ids)
return True
return False
def remove_disabled_build_id(bot, disable_id: str) -> bool:
disabled_ids = get_disabled_ids(bot)
disable_id = disable_id.lower()
if (
disable_id in disabled_ids.keys()
and len(disabled_ids[disable_id]["build_id"]) > 0
):
disabled_ids[disable_id]["build_id"] = ""
set_disabled_ids(bot, disabled_ids)
return True
return False
def add_disabled_ro_section(
bot, disable_id: str, ro_section: dict[str, Union[str, list[str]]]
) -> bool:
disabled_ids = get_disabled_ids(bot)
disable_id = disable_id.lower()
add_disable_id_if_necessary(disable_id, disabled_ids)
if len(ro_section) > len(disabled_ids[disable_id]["ro_section"]):
for key, content in ro_section.items():
if key == "module":
disabled_ids[disable_id]["ro_section"][key] = content.lower()
else:
disabled_ids[disable_id]["ro_section"][key] = content
set_disabled_ids(bot, disabled_ids)
return True
return False
def remove_disabled_ro_section(bot, disable_id: str) -> bool:
disabled_ids = get_disabled_ids(bot)
disable_id = disable_id.lower()
if (
disable_id in disabled_ids.keys()
and len(disabled_ids[disable_id]["ro_section"]) > 0
):
disabled_ids[disable_id]["ro_section"] = {}
set_disabled_ids(bot, disabled_ids)
return True
return False

View file

@ -1,47 +0,0 @@
import json
import os
from robocop_ng.helpers.data_loader import read_json
def get_disabled_paths_path(bot) -> str:
return os.path.join(bot.state_dir, "data/disabled_paths.json")
def get_disabled_paths(bot) -> list[str]:
disabled_paths = read_json(bot, get_disabled_paths_path(bot))
if "paths" not in disabled_paths.keys():
return []
return disabled_paths["paths"]
def set_disabled_paths(bot, contents: list[str]):
with open(get_disabled_paths_path(bot), "w") as f:
json.dump({"paths": contents}, f)
def is_path_disabled(bot, path: str) -> bool:
for disabled_path in get_disabled_paths(bot):
if disabled_path in path.strip().lower():
return True
return False
def add_disabled_path(bot, disabled_path: str) -> bool:
disabled_path = disabled_path.strip().lower()
disabled_paths = get_disabled_paths(bot)
if disabled_path not in disabled_paths:
disabled_paths.append(disabled_path)
set_disabled_paths(bot, disabled_paths)
return True
return False
def remove_disabled_path(bot, disabled_path: str) -> bool:
disabled_path = disabled_path.strip().lower()
disabled_paths = get_disabled_paths(bot)
if disabled_path in disabled_paths:
disabled_paths.remove(disabled_path)
set_disabled_paths(bot, disabled_paths)
return True
return False

View file

@ -1,29 +0,0 @@
import json
import os
from typing import Union
from robocop_ng.helpers.data_loader import read_json
def get_invites_path(bot):
return os.path.join(bot.state_dir, "data/invites.json")
def get_invites(bot) -> dict[str, dict[str, Union[str, int]]]:
return read_json(bot, get_invites_path(bot))
def add_invite(bot, invite_id: str, url: str, max_uses: int, code: str):
invites = get_invites(bot)
invites[invite_id] = {
"uses": 0,
"url": url,
"max_uses": max_uses,
code: code,
}
set_invites(bot, invites)
def set_invites(bot, contents: dict[str, dict[str, Union[str, int]]]):
with open(get_invites_path(bot), "w") as f:
json.dump(contents, f)

View file

@ -1,142 +0,0 @@
import json
import os
from typing import Optional, Union
from robocop_ng.helpers.data_loader import read_json
def get_macros_path(bot):
return os.path.join(bot.state_dir, "data/macros.json")
def get_macros_dict(bot) -> dict[str, dict[str, Union[list[str], str]]]:
macros = read_json(bot, get_macros_path(bot))
if len(macros) > 0:
# Migration code
if "aliases" not in macros.keys():
new_macros = {"macros": macros, "aliases": {}}
unique_macros = set(new_macros["macros"].values())
for macro_text in unique_macros:
first_macro_key = ""
duplicate_num = 0
for key, macro in new_macros["macros"].copy().items():
if macro == macro_text and duplicate_num == 0:
first_macro_key = key
duplicate_num += 1
continue
elif macro == macro_text:
if first_macro_key not in new_macros["aliases"].keys():
new_macros["aliases"][first_macro_key] = []
new_macros["aliases"][first_macro_key].append(key)
del new_macros["macros"][key]
duplicate_num += 1
set_macros(bot, new_macros)
return new_macros
return macros
return {"macros": {}, "aliases": {}}
def is_macro_key_available(
bot, key: str, macros: dict[str, dict[str, Union[list[str], str]]] = None
) -> bool:
if macros is None:
macros = get_macros_dict(bot)
if key in macros["macros"].keys():
return False
for aliases in macros["aliases"].values():
if key in aliases:
return False
return True
def set_macros(bot, contents: dict[str, dict[str, Union[list[str], str]]]):
with open(get_macros_path(bot), "w") as f:
json.dump(contents, f)
def get_macro(bot, key: str) -> Optional[str]:
macros = get_macros_dict(bot)
key = key.lower()
if key in macros["macros"].keys():
return macros["macros"][key]
for main_key, aliases in macros["aliases"].items():
if key in aliases:
return macros["macros"][main_key]
return None
def add_macro(bot, key: str, message: str) -> bool:
macros = get_macros_dict(bot)
key = key.lower()
if is_macro_key_available(bot, key, macros):
macros["macros"][key] = message
set_macros(bot, macros)
return True
return False
def add_aliases(bot, key: str, aliases: list[str]) -> bool:
macros = get_macros_dict(bot)
key = key.lower()
success = False
if key in macros["macros"].keys():
for alias in aliases:
alias = alias.lower()
if is_macro_key_available(bot, alias, macros):
if key not in macros["aliases"].keys():
macros["aliases"][key] = []
macros["aliases"][key].append(alias)
success = True
if success:
set_macros(bot, macros)
return success
def edit_macro(bot, key: str, message: str) -> bool:
macros = get_macros_dict(bot)
key = key.lower()
if key in macros["macros"].keys():
macros["macros"][key] = message
set_macros(bot, macros)
return True
return False
def remove_aliases(bot, key: str, aliases: list[str]) -> bool:
macros = get_macros_dict(bot)
key = key.lower()
success = False
if key not in macros["aliases"].keys():
return False
for alias in aliases:
alias = alias.lower()
if alias in macros["aliases"][key]:
macros["aliases"][key].remove(alias)
if len(macros["aliases"][key]) == 0:
del macros["aliases"][key]
success = True
if success:
set_macros(bot, macros)
return success
def remove_macro(bot, key: str) -> bool:
macros = get_macros_dict(bot)
key = key.lower()
if key in macros["macros"].keys():
del macros["macros"][key]
set_macros(bot, macros)
return True
return False
def clear_aliases(bot, key: str) -> bool:
macros = get_macros_dict(bot)
key = key.lower()
if key in macros["macros"].keys() and key in macros["aliases"].keys():
del macros["aliases"][key]
set_macros(bot, macros)
return True
return False

View file

@ -1,53 +0,0 @@
import json
from typing import Optional, Union
from discord import Message, MessageReference, PartialMessage
MessageReferenceTypes = Union[Message, MessageReference, PartialMessage]
async def notify_management(
bot, message: str, reference_message: Optional[MessageReferenceTypes] = None
):
log_channel = await bot.get_channel_safe(bot.config.botlog_channel)
bot_manager_role = log_channel.guild.get_role(bot.config.bot_manager_role_id)
notification_message = f"{bot_manager_role.mention}:\n"
if reference_message is not None and reference_message.channel != log_channel:
notification_message += f"Message reference: {reference_message.jump_url}\n"
notification_message += message
return await log_channel.send(notification_message)
else:
notification_message += message
return await log_channel.send(
notification_message,
reference=reference_message,
mention_author=False,
)
async def report_critical_error(
bot,
error: BaseException,
reference_message: Optional[MessageReferenceTypes] = None,
additional_info: Optional[dict] = None,
):
message = "⛔ A critical error occurred!"
if additional_info is not None:
message += f"""
```json
{json.dumps(additional_info)}
```"""
message += f"""
Exception:
```
{error}
```"""
return await notify_management(bot, message, reference_message)

View file

@ -1,47 +0,0 @@
import json
import os
from robocop_ng.helpers.data_loader import read_json
def get_restrictions_path(bot):
return os.path.join(bot.state_dir, "data/restrictions.json")
def get_restrictions(bot):
return read_json(bot, get_restrictions_path(bot))
def set_restrictions(bot, contents):
with open(get_restrictions_path(bot), "w") as f:
f.write(contents)
def get_user_restrictions(bot, uid):
uid = str(uid)
rsts = get_restrictions(bot)
if uid in rsts:
return rsts[uid]
return []
def add_restriction(bot, uid, rst):
# mostly from kurisu source, credits go to ihaveamac
uid = str(uid)
rsts = get_restrictions(bot)
if uid not in rsts:
rsts[uid] = []
if rst not in rsts[uid]:
rsts[uid].append(rst)
set_restrictions(bot, json.dumps(rsts))
def remove_restriction(bot, uid, rst):
# mostly from kurisu source, credits go to ihaveamac
uid = str(uid)
rsts = get_restrictions(bot)
if uid not in rsts:
rsts[uid] = []
if rst in rsts[uid]:
rsts[uid].remove(rst)
set_restrictions(bot, json.dumps(rsts))

View file

@ -1,43 +0,0 @@
import json
import math
import os
from robocop_ng.helpers.data_loader import read_json
def get_crontab_path(bot):
return os.path.join(bot.state_dir, "data/robocronptab.json")
def get_crontab(bot):
return read_json(bot, get_crontab_path(bot))
def set_crontab(bot, contents):
with open(get_crontab_path(bot), "w") as f:
f.write(contents)
def add_job(bot, job_type, job_name, job_details, timestamp):
timestamp = str(math.floor(timestamp))
job_name = str(job_name)
ctab = get_crontab(bot)
if job_type not in ctab:
ctab[job_type] = {}
if timestamp not in ctab[job_type]:
ctab[job_type][timestamp] = {}
ctab[job_type][timestamp][job_name] = job_details
set_crontab(bot, json.dumps(ctab))
def delete_job(bot, timestamp, job_type, job_name):
timestamp = str(timestamp)
job_name = str(job_name)
ctab = get_crontab(bot)
del ctab[job_type][timestamp][job_name]
set_crontab(bot, json.dumps(ctab))

View file

@ -1,33 +0,0 @@
import json
import os.path
import os
from robocop_ng.helpers.data_loader import read_json
def get_persistent_roles_path(bot):
return os.path.join(bot.state_dir, "data/persistent_roles.json")
def get_persistent_roles(bot) -> dict[str, list[str]]:
return read_json(bot, get_persistent_roles_path(bot))
def set_persistent_roles(bot, contents: dict[str, list[str]]):
with open(get_persistent_roles_path(bot), "w") as f:
json.dump(contents, f)
def add_user_roles(bot, uid: int, roles: list[int]):
uid = str(uid)
roles = [str(x) for x in roles]
persistent_roles = get_persistent_roles(bot)
persistent_roles[uid] = roles
set_persistent_roles(bot, persistent_roles)
def get_user_roles(bot, uid: int) -> list[str]:
uid = str(uid)
persistent_roles = get_persistent_roles(bot)
return persistent_roles[uid] if uid in persistent_roles else []

View file

@ -1,745 +0,0 @@
import re
from enum import IntEnum, auto
from typing import Optional, Union
from robocop_ng.helpers.disabled_ids import is_build_id_valid
from robocop_ng.helpers.size import Size
class CommonError(IntEnum):
SHADER_CACHE_COLLISION = auto()
DUMP_HASH = auto()
SHADER_CACHE_CORRUPTION = auto()
UPDATE_KEYS = auto()
FILE_PERMISSIONS = auto()
FILE_NOT_FOUND = auto()
MISSING_SERVICES = auto()
VULKAN_OUT_OF_MEMORY = auto()
class RyujinxVersion(IntEnum):
MASTER = auto()
OLD_MASTER = auto()
LDN = auto()
MAC = auto()
PR = auto()
CUSTOM = auto()
class LogAnalyser:
_log_text: str
_log_errors: list[list[str]]
_hardware_info: dict[str, Optional[str]]
_emu_info: dict[str, Optional[str]]
_game_info: dict[str, Optional[str]]
_settings: dict[str, Optional[str]]
_notes: Union[set[str], list[str]]
@staticmethod
def is_homebrew(log_file: str) -> bool:
return (
re.search("Load.*Application: Loading as [Hh]omebrew", log_file) is not None
)
@staticmethod
def get_filepaths(log_file: str) -> set[str]:
return set(
x.rstrip("\u0000")
for x in re.findall(r"(?:[A-Za-z]:)?(?:[\\/]+[^\\/:\"\r\n]+)+", log_file)
)
@staticmethod
def get_main_ro_section(log_file: str) -> Optional[dict[str, str]]:
ro_section_matches = re.findall(
r"PrintRoSectionInfo: main:[\r\n]((?:\s+.*[\r\n])*)", log_file
)
if ro_section_matches and len(ro_section_matches) > 0:
ro_section_match: str = ro_section_matches[-1]
ro_section = {"module": "", "sdk_libraries": []}
if ro_section_match is None or len(ro_section_match) == 0:
return None
for line in ro_section_match.splitlines():
line = line.strip()
if line.startswith("Module:"):
ro_section["module"] = line[8:]
elif line.startswith("SDK Libraries:"):
ro_section["sdk_libraries"].append(line[19:])
elif line.startswith("SDK "):
ro_section["sdk_libraries"].append(line[4:])
else:
break
return ro_section
return None
@staticmethod
def get_app_info(
log_file: str,
) -> Optional[tuple[str, str, str, list[str], dict[str, str]]]:
game_name_match = re.findall(
r"Loader [A-Za-z]*: Application Loaded:\s([^;\n\r]*)",
log_file,
re.MULTILINE,
)
if game_name_match:
game_name = game_name_match[-1].rstrip()
app_id_match = re.match(r".* \[([a-zA-Z0-9]*)\]", game_name)
if app_id_match:
app_id = app_id_match.group(1).strip().upper()
else:
app_id = ""
bids_match_all = re.findall(
r"Build ids found for (?:title|application) ([a-zA-Z0-9]*):[\n\r]*((?:\s+.*[\n\r]+)+)",
log_file,
)
if bids_match_all and len(bids_match_all) > 0:
bids_match: tuple[str] = bids_match_all[-1]
app_id_from_bids = None
build_ids = None
if bids_match[0] is not None:
app_id_from_bids = bids_match[0].strip().upper()
if bids_match[1] is not None:
build_ids = [
bid.strip().upper()
for bid in bids_match[1].splitlines()
if is_build_id_valid(bid.strip())
]
return (
game_name,
app_id,
app_id_from_bids,
build_ids,
LogAnalyser.get_main_ro_section(log_file),
)
return None
@staticmethod
def contains_errors(search_terms, errors):
for term in search_terms:
for error_lines in errors:
line = "\n".join(error_lines)
if term in line:
return True
return False
def __init__(self, log_text: Union[str, list[str]]):
self.__init_members()
if isinstance(log_text, str):
self._log_text = log_text.replace("\r\n", "\n")
elif isinstance(log_text, list):
self._log_text = "\n".join(log_text)
else:
raise TypeError(log_text)
# Large files show a header value when not downloaded completely
# this regex makes sure that the log text to read starts from the first timestamp, ignoring headers
log_file_header_regex = re.compile(r"\d{2}:\d{2}:\d{2}\.\d{3}.*", re.DOTALL)
log_file_match = re.search(log_file_header_regex, self._log_text)
if log_file_match and log_file_match.group(0) is not None:
self._log_text = log_file_match.group(0)
else:
raise ValueError("No log entries found.")
self.__get_errors()
self.__get_hardware_info()
self.__get_settings_info()
self.__get_ryujinx_info()
self.__get_app_name()
self.__get_mods()
self.__get_cheats()
self.__get_notes()
def __init_members(self):
self._hardware_info = {
"cpu": "Unknown",
"gpu": "Unknown",
"ram": "Unknown",
"os": "Unknown",
}
self._emu_info = {
"ryu_version": "Unknown",
"ryu_firmware": "Unknown",
"logs_enabled": None,
}
self._game_info = {
"game_name": "Unknown",
"errors": "No errors found in log",
"mods": "No mods found",
"cheats": "No cheats found",
}
self._settings = {
"audio_backend": "Unknown",
"backend_threading": "Unknown",
"docked": "Unknown",
"expand_ram": "Unknown",
"fs_integrity": "Unknown",
"graphics_backend": "Unknown",
"ignore_missing_services": "Unknown",
"memory_manager": "Unknown",
"pptc": "Unknown",
"shader_cache": "Unknown",
"vsync": "Unknown",
"hypervisor": "Unknown",
"resolution_scale": "Unknown",
"anisotropic_filtering": "Unknown",
"aspect_ratio": "Unknown",
"texture_recompression": "Unknown",
}
self._notes = set()
self._log_errors = []
def __get_errors(self):
errors = []
curr_error_lines = []
error_line = False
for line in self._log_text.splitlines():
if len(line.strip()) == 0:
continue
if "|E|" in line:
curr_error_lines = [line]
errors.append(curr_error_lines)
error_line = True
elif error_line and line[0] == " ":
curr_error_lines.append(line)
if len(curr_error_lines) > 0:
errors.append(curr_error_lines)
self._log_errors = errors
def __get_hardware_info(self):
for setting in self._hardware_info.keys():
match setting:
case "cpu":
cpu_match = re.search(
r"CPU:\s([^;\n\r]*)", self._log_text, re.MULTILINE
)
if cpu_match is not None and cpu_match.group(1) is not None:
self._hardware_info[setting] = cpu_match.group(1).rstrip()
case "ram":
sizes = "|".join(Size.names())
ram_match = re.search(
rf"RAM: Total ([\d.]+) ({sizes}) ; Available ([\d.]+) ({sizes})",
self._log_text,
re.MULTILINE,
)
if ram_match is not None:
try:
dest_unit = Size.MiB
ram_available = float(ram_match.group(3))
ram_available = Size.from_name(ram_match.group(4)).convert(
ram_available, dest_unit
)
ram_total = float(ram_match.group(1))
ram_total = Size.from_name(ram_match.group(2)).convert(
ram_total, dest_unit
)
self._hardware_info[setting] = (
f"{ram_available:.0f}/{ram_total:.0f} {dest_unit.name}"
)
except ValueError:
# ram_match.group(1) or ram_match.group(3) couldn't be parsed as a float.
self._hardware_info[setting] = "Error"
case "os":
os_match = re.search(
r"Operating System:\s([^;\n\r]*)",
self._log_text,
re.MULTILINE,
)
if os_match is not None and os_match.group(1) is not None:
self._hardware_info[setting] = os_match.group(1).rstrip()
case "gpu":
gpu_match = re.search(
r"PrintGpuInformation:\s([^;\n\r]*)",
self._log_text,
re.MULTILINE,
)
if gpu_match is not None and gpu_match.group(1) is not None:
self._hardware_info[setting] = gpu_match.group(1).rstrip()
case _:
raise NotImplementedError(setting)
def __get_ryujinx_info(self):
for setting in self._emu_info.keys():
match setting:
case "ryu_version":
for line in self._log_text.splitlines():
if "Ryujinx Version:" in line:
self._emu_info[setting] = line.split()[-1].strip()
break
case "logs_enabled":
logs_match = re.search(
r"Logs Enabled:\s([^;\n\r]*)", self._log_text, re.MULTILINE
)
if logs_match is not None and logs_match.group(1) is not None:
self._emu_info[setting] = logs_match.group(1).rstrip()
case "ryu_firmware":
for line in self._log_text.splitlines():
if "Firmware Version:" in line:
self._emu_info[setting] = line.split()[-1].strip()
break
case _:
raise NotImplementedError(setting)
def __get_setting_value(self, name, key):
values = [
line.split()[-1]
for line in self._log_text.splitlines()
if re.search(rf"LogValueChange: ({key})\s", line)
]
if len(values) > 0:
value = values[-1]
else:
return None
match name:
case "docked":
return "Docked" if value == "True" else "Handheld"
case "resolution_scale":
resolution_map = {
"-1": "Custom",
"1": "Native (720p/1080p)",
"2": "2x (1440p/2160p)",
"3": "3x (2160p/3240p)",
"4": "4x (2880p/4320p)",
}
if value in resolution_map.keys():
return resolution_map[value]
else:
return "Custom"
case "anisotropic_filtering":
anisotropic_map = {
"-1": "Auto",
"2": "2x",
"4": "4x",
"8": "8x",
"16": "16x",
}
if value in anisotropic_map.keys():
return anisotropic_map[value]
else:
return "Auto"
case "aspect_ratio":
aspect_map = {
"Fixed4x3": "4:3",
"Fixed16x9": "16:9",
"Fixed16x10": "16:10",
"Fixed21x9": "21:9",
"Fixed32x9": "32:9",
"Stretched": "Stretch to Fit Window",
}
if value in aspect_map.keys():
return aspect_map[value]
else:
return "Unknown"
case "pptc" | "shader_cache" | "texture_recompression" | "vsync":
return "Enabled" if value == "True" else "Disabled"
case "hypervisor":
if "mac" in self._hardware_info["os"]:
return "Enabled" if value == "True" else "Disabled"
else:
return "N/A"
case _:
return value
def __get_settings_info(self):
settings_map = {
"anisotropic_filtering": "MaxAnisotropy",
"aspect_ratio": "AspectRatio",
"audio_backend": "AudioBackend",
"backend_threading": "BackendThreading",
"docked": "EnableDockedMode",
"expand_ram": "ExpandRam",
"fs_integrity": "EnableFsIntegrityChecks",
"graphics_backend": "GraphicsBackend",
"ignore_missing_services": "IgnoreMissingServices",
"memory_manager": "MemoryManagerMode",
"pptc": "EnablePtc",
"resolution_scale": "ResScale",
"shader_cache": "EnableShaderCache",
"texture_recompression": "EnableTextureRecompression",
"vsync": "EnableVsync",
"hypervisor": "UseHypervisor",
}
for key in self._settings.keys():
if key in settings_map:
self._settings[key] = self.__get_setting_value(key, settings_map[key])
else:
raise NotImplementedError(key)
def __get_mods(self):
mods_regex = re.compile(
r"Found\s(enabled|disabled)?\s?mod\s\'(.+?)\'\s(\[.+?\])"
)
matches = re.findall(mods_regex, self._log_text)
if matches:
mods = [
{"mod": match[1], "status": match[0], "type": match[2]}
for match in matches
]
mods_status = [
f" {i['mod']} ({'ExeFS' if i['type'] == '[E]' else 'RomFS'})"
for i in mods
if i["status"] == "" or i["status"] == "enabled"
]
# Remove duplicated mods from output
mods_status = list(dict.fromkeys(mods_status))
self._game_info["mods"] = "\n".join(mods_status)
def __get_cheats(self):
# Make sure to skip cheats which fail to compile
cheat_regex = re.compile(
r"Installing cheat\s'(.+)'(?!\s\d{2}:\d{2}:\d{2}\.\d{3}\s\|E\|\sTamperMachine\sCompile)"
)
matches = re.findall(cheat_regex, self._log_text)
if matches:
cheats = [f" {match}" for match in matches]
self._game_info["cheats"] = "\n".join(cheats)
def __get_app_name(self):
app_match = re.findall(
r"Loader [A-Za-z]*: Application Loaded:\s([^;\n\r]*)",
self._log_text,
re.MULTILINE,
)
if app_match:
self._game_info["game_name"] = app_match[-1].rstrip()
def __get_controller_notes(self):
controllers_regex = re.compile(r"Hid Configure: ([^\r\n]+)")
controllers = re.findall(controllers_regex, self._log_text)
if controllers:
input_status = [f" {match}" for match in controllers]
# Hid Configure lines can appear multiple times, so converting to dict keys removes duplicate entries,
# also maintains the list order
input_status = list(dict.fromkeys(input_status))
self._notes.add("\n".join(input_status))
# If emulator crashes on startup without game load, there is no need to show controller notification at all
elif self._game_info["game_name"] != "Unknown":
self._notes.add("⚠️ No controller information found")
def __get_os_notes(self):
if (
"Windows" in self._hardware_info["os"]
and self._settings["graphics_backend"] != "Vulkan"
):
if "Intel" in self._hardware_info["gpu"]:
self._notes.add(
"**⚠️ Intel iGPU users should consider using Vulkan graphics backend**"
)
if "AMD" in self._hardware_info["gpu"]:
self._notes.add(
"**⚠️ AMD GPU users should consider using Vulkan graphics backend**"
)
def __get_cpu_notes(self):
if "VirtualApple" in self._hardware_info["cpu"]:
self._notes.add("🔴 **Rosetta should be disabled**")
def __get_log_notes(self):
default_logs = ["Info", "Warning", "Error", "Guest"]
user_logs = []
if self._emu_info["logs_enabled"] is not None:
user_logs = (
self._emu_info["logs_enabled"].rstrip().replace(" ", "").split(",")
)
if "Debug" in user_logs:
self._notes.add(
"⚠️ **Debug logs enabled will have a negative impact on performance**"
)
disabled_logs = set(default_logs).difference(set(user_logs))
if disabled_logs:
logs_status = [f"⚠️ {log} log is not enabled" for log in disabled_logs]
log_string = "\n".join(logs_status)
else:
log_string = "✅ Default logs enabled"
self._notes.add(log_string)
def __get_settings_notes(self):
if self._settings["audio_backend"] == "Dummy":
self._notes.add(
"⚠️ Dummy audio backend, consider changing to SDL2 or OpenAL"
)
if self._settings["pptc"] == "Disabled":
self._notes.add("🔴 **PPTC cache should be enabled**")
if self._settings["shader_cache"] == "Disabled":
self._notes.add("🔴 **Shader cache should be enabled**")
if self._settings["expand_ram"] == "True":
self._notes.add(
"⚠️ `Use alternative memory layout` should only be enabled for 4K mods"
)
if self._settings["memory_manager"] == "SoftwarePageTable":
self._notes.add(
"🔴 **`Software` setting in Memory Manager Mode will give slower performance than the default setting of `Host unchecked`**"
)
if self._settings["ignore_missing_services"] == "True":
self._notes.add(
"⚠️ `Ignore Missing Services` being enabled can cause instability"
)
if self._settings["vsync"] == "Disabled":
self._notes.add(
"⚠️ V-Sync disabled can cause instability like games running faster than intended or longer load times"
)
if self._settings["fs_integrity"] == "Disabled":
self._notes.add(
"⚠️ Disabling file integrity checks may cause corrupted dumps to not be detected"
)
if self._settings["backend_threading"] == "Off":
self._notes.add(
"🔴 **Graphics Backend Multithreading should be set to `Auto`**"
)
def __sort_notes(self):
def severity(log_note_string):
symbols = ["", "🔴", "⚠️", "", ""]
return next(
i for i, symbol in enumerate(symbols) if symbol in log_note_string
)
game_notes = [note for note in self._notes]
# Warnings split on the string after the warning symbol for alphabetical ordering
# Severity key then orders alphabetically sorted warnings to show most severe first
return sorted(sorted(game_notes, key=lambda x: x.split()[1]), key=severity)
def __get_notes(self):
for common_error in self.get_common_errors():
match common_error:
case CommonError.SHADER_CACHE_COLLISION:
self._notes.add(
"⚠️ Cache collision detected. Investigate possible shader cache issues"
)
case CommonError.SHADER_CACHE_CORRUPTION:
self._notes.add(
"⚠️ Cache corruption detected. Investigate possible shader cache issues"
)
case CommonError.DUMP_HASH:
self._notes.add(
"⚠️ Dump error detected. Investigate possible bad game/firmware dump issues"
)
case CommonError.UPDATE_KEYS:
self._notes.add(
"⚠️ Keys or firmware out of date, consider updating them"
)
case CommonError.FILE_PERMISSIONS:
self._notes.add(
"⚠️ File permission error. Consider deleting save directory and allowing Ryujinx to make a new one"
)
case CommonError.FILE_NOT_FOUND:
self._notes.add(
"⚠️ Save not found error. Consider starting game without a save file or using a new save file"
)
case CommonError.MISSING_SERVICES:
if self._settings["ignore_missing_services"] == "False":
self._notes.add(
"⚠️ Consider enabling `Ignore Missing Services` in Ryujinx settings"
)
case CommonError.VULKAN_OUT_OF_MEMORY:
if self._settings["texture_recompression"] == "Disabled":
self._notes.add(
"⚠️ Consider enabling `Texture Recompression` in Ryujinx settings"
)
case _:
raise NotImplementedError(common_error)
timestamp_regex = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s+?\|")
latest_timestamp = re.findall(timestamp_regex, self._log_text)[-1]
if latest_timestamp:
timestamp_message = f" Time elapsed: `{latest_timestamp}`"
self._notes.add(timestamp_message)
if self.is_default_user_profile():
self._notes.add(
"⚠️ Default user profile in use, consider creating a custom one."
)
self.__get_controller_notes()
self.__get_os_notes()
self.__get_cpu_notes()
if (
self._emu_info["ryu_firmware"] == "Unknown"
and self._game_info["game_name"] != "Unknown"
):
firmware_warning = f"**❌ Nintendo Switch firmware not found**"
self._notes.add(firmware_warning)
self.__get_settings_notes()
if self.get_ryujinx_version() == RyujinxVersion.CUSTOM:
self._notes.add("**⚠️ Custom builds are not officially supported**")
def get_ryujinx_version(self):
mainline_version = re.compile(r"^\d\.\d\.\d+$")
old_mainline_version = re.compile(r"^\d\.\d\.(\d){4}$")
pr_version = re.compile(r"^\d\.\d\.\d\+([a-f]|\d){7}$")
ldn_version = re.compile(r"^\d\.\d\.\d-ldn\d+\.\d+(?:\.\d+|$)")
mac_version = re.compile(r"^\d\.\d\.\d-macos\d+(?:\.\d+(?:\.\d+|$)|$)")
if re.match(mainline_version, self._emu_info["ryu_version"]):
return RyujinxVersion.MASTER
elif re.match(old_mainline_version, self._emu_info["ryu_version"]):
return RyujinxVersion.OLD_MASTER
elif re.match(mac_version, self._emu_info["ryu_version"]):
return RyujinxVersion.MAC
elif re.match(ldn_version, self._emu_info["ryu_version"]):
return RyujinxVersion.LDN
elif re.match(pr_version, self._emu_info["ryu_version"]):
return RyujinxVersion.PR
else:
return RyujinxVersion.CUSTOM
def is_default_user_profile(self) -> bool:
return (
re.search(r"UserId: 00000000000000010000000000000000", self._log_text)
is not None
)
def get_last_error(self) -> Optional[list[str]]:
return self._log_errors[-1] if len(self._log_errors) > 0 else None
def get_common_errors(self) -> list[CommonError]:
errors = []
if self.contains_errors(["Cache collision found"], self._log_errors):
errors.append(CommonError.SHADER_CACHE_COLLISION)
if self.contains_errors(
[
"ResultFsInvalidIvfcHash",
"ResultFsNonRealDataVerificationFailed",
],
self._log_errors,
):
errors.append(CommonError.DUMP_HASH)
if self.contains_errors(
[
"Ryujinx.Graphics.Gpu.Shader.ShaderCache.Initialize()",
"System.IO.InvalidDataException: End of Central Directory record could not be found",
"ICSharpCode.SharpZipLib.Zip.ZipException: Cannot find central directory",
],
self._log_errors,
):
errors.append(CommonError.SHADER_CACHE_CORRUPTION)
if self.contains_errors(["MissingKeyException"], self._log_errors):
errors.append(CommonError.UPDATE_KEYS)
if self.contains_errors(["ResultFsPermissionDenied"], self._log_errors):
errors.append(CommonError.FILE_PERMISSIONS)
if self.contains_errors(["ResultFsTargetNotFound"], self._log_errors):
errors.append(CommonError.FILE_NOT_FOUND)
if self.contains_errors(["ServiceNotImplementedException"], self._log_errors):
errors.append(CommonError.MISSING_SERVICES)
if self.contains_errors(["ErrorOutOfDeviceMemory"], self._log_errors):
errors.append(CommonError.VULKAN_OUT_OF_MEMORY)
return errors
def analyse_discord(
self, is_channel_allowed: bool, pr_channel: int
) -> dict[str, dict[str, str]]:
last_error = self.get_last_error()
if last_error is not None:
last_error = "\n".join(last_error[:2])
self._game_info["errors"] = f"```\n{last_error}\n```"
else:
self._game_info["errors"] = "No errors found in log"
# Limit mods and cheats to 5 entries
mods = self._game_info["mods"].splitlines()
cheats = self._game_info["cheats"].splitlines()
if len(mods) > 5:
limit_mods = mods[:5]
limit_mods.append(f"✂️ {len(mods) - 5} other mods")
self._game_info["mods"] = "\n".join(limit_mods)
if len(cheats) > 5:
limit_cheats = cheats[:5]
limit_cheats.append(f"✂️ {len(cheats) - 5} other cheats")
self._game_info["cheats"] = "\n".join(limit_cheats)
if is_channel_allowed and self.get_ryujinx_version() == RyujinxVersion.PR:
self._notes.add(
f"**⚠️ PR build logs should be posted in <#{pr_channel}> if reporting bugs or tests**"
)
self._notes = self.__sort_notes()
full_game_info = self._game_info
full_game_info["notes"] = (
"\n".join(self._notes) if len(self._notes) > 0 else "Nothing to note"
)
return {
"hardware_info": self._hardware_info,
"emu_info": self._emu_info,
"game_info": full_game_info,
"settings": self._settings,
}
def analyse(self) -> dict[str, Union[dict[str, str], list[str], list[list[str]]]]:
self._notes = list(self.__sort_notes())
last_error = self.get_last_error()
if last_error is not None:
last_error = "\n".join(last_error[:2])
self._game_info["errors"] = f"```\n{last_error}\n```"
else:
self._game_info["errors"] = "No errors found in log"
return {
"hardware_info": self._hardware_info,
"emu_info": self._emu_info,
"game_info": self._game_info,
"notes": self._notes,
"errors": self._log_errors,
"settings": self._settings,
"app_info": self.get_app_info(self._log_text),
"paths": list(self.get_filepaths(self._log_text)),
}
if __name__ == "__main__":
import argparse
import json
import os
parser = argparse.ArgumentParser()
parser.add_argument("log_file", type=str)
args = parser.parse_args()
if not os.path.isfile(args.log_file):
print(f"Couldn't find log file: {args.log_file}")
exit(1)
with open(args.log_file, "r") as file:
text = file.read()
analyser = LogAnalyser(text)
result = analyser.analyse()
print(json.dumps(result, indent=2))

View file

@ -1,53 +0,0 @@
from enum import IntEnum, auto
from typing import Self
class Size(IntEnum):
KB = auto()
KiB = auto()
MB = auto()
MiB = auto()
GB = auto()
GiB = auto()
@classmethod
def names(cls) -> list[str]:
return [size.name for size in cls]
@classmethod
def from_name(cls, name: str) -> Self:
for size in cls:
if size.name.lower() == name.lower():
return size
raise ValueError(f"No matching member found for: {name}")
@property
def _is_si_unit(self) -> bool:
return self.value % 2 != 0
@property
def _unit_value(self) -> int:
return self.value // 2 + (1 if self._is_si_unit else 0)
@property
def _base_factor(self) -> int:
return 10**3 if self._is_si_unit else 2**10
@property
def _byte_factor(self) -> int:
return (
10 ** (3 * self._unit_value)
if self._is_si_unit
else 2 ** (10 * self._unit_value)
)
def convert(self, value: float, fmt: Self) -> float:
if self == fmt:
return value
if self._is_si_unit == fmt._is_si_unit:
if self < fmt:
return value / self._base_factor ** (fmt._unit_value - self._unit_value)
else:
return value * self._base_factor ** (self._unit_value - fmt._unit_value)
else:
return value * (self._byte_factor / fmt._byte_factor)

View file

@ -1,70 +0,0 @@
import json
import os
import time
from robocop_ng.helpers.data_loader import read_json
userlog_event_types = {
"warns": "Warn",
"bans": "Ban",
"kicks": "Kick",
"mutes": "Mute",
"notes": "Note",
}
def get_userlog_path(bot):
return os.path.join(bot.state_dir, "data/userlog.json")
def get_userlog(bot):
return read_json(bot, get_userlog_path(bot))
def set_userlog(bot, contents):
with open(get_userlog_path(bot), "w") as f:
f.write(contents)
def fill_userlog(bot, userid, uname):
userlogs = get_userlog(bot)
uid = str(userid)
if uid not in userlogs:
userlogs[uid] = {
"warns": [],
"mutes": [],
"kicks": [],
"bans": [],
"notes": [],
"watch": False,
"name": "n/a",
}
if uname:
userlogs[uid]["name"] = uname
return userlogs, uid
def userlog(bot, uid, issuer, reason, event_type, uname: str = ""):
userlogs, uid = fill_userlog(bot, uid, uname)
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
log_data = {
"issuer_id": issuer.id,
"issuer_name": f"{issuer}",
"reason": reason,
"timestamp": timestamp,
}
if event_type not in userlogs[uid]:
userlogs[uid][event_type] = []
userlogs[uid][event_type].append(log_data)
set_userlog(bot, json.dumps(userlogs))
return len(userlogs[uid][event_type])
def setwatch(bot, uid, issuer, watch_state, uname: str = ""):
userlogs, uid = fill_userlog(bot, uid, uname)
userlogs[uid]["watch"] = watch_state
set_userlog(bot, json.dumps(userlogs))
return

View file

@ -1,4 +0,0 @@
(import (fetchTarball
"https://github.com/edolstra/flake-compat/archive/master.tar.gz") {
src = builtins.fetchGit ./.;
}).shellNix