jack1142 febca8ccbb
Migration to discord.py 2.0 (#5600)
* Temporarily set d.py to use latest git revision

* Remove `bot` param to Client.start

* Switch to aware datetimes

A lot of this is removing `.replace(...)` which while not technically
needed, simplifies the code base. There's only a few changes that are
actually necessary here.

* Update to work with new Asset design

* [threads] Update core ModLog API to support threads

- Added proper support for passing `Thread` to `channel`
  when creating/editing case
- Added `parent_channel_id` attribute to Modlog API's Case
    - Added `parent_channel` property that tries to get parent channel
- Updated case's content to show both thread and parent information

* [threads] Disallow usage of threads in some of the commands

- announceset channel
- filter channel clear
- filter channel add
- filter channel remove
- GlobalUniqueObjectFinder converter
    - permissions addglobalrule
    - permissions removeglobalrule
    - permissions removeserverrule
    - Permissions cog does not perform any validation for IDs
      when setting through YAML so that has not been touched
- streamalert twitch/youtube/picarto
- embedset channel
- set ownernotifications adddestination

* [threads] Handle threads in Red's permissions system (Requires)

- Made permissions system apply rules of (only) parent in threads

* [threads] Update embed_requested to support threads

- Threads don't have their own embed settings and inherit from parent

* [threads] Update Red.message_eligible_as_command to support threads

* [threads] Properly handle invocation of [p](un)mutechannel in threads

Usage of a (un)mutechannel will mute/unmute user in the parent channel
if it's invoked in a thread.

* [threads] Update Filter cog to properly handle threads

- `[p]filter channel list` in a threads sends list for parent channel
- Checking for filter hits for a message in a thread checks its parent
  channel's word list. There's no separate word list for threads.

* [threads] Support threads in Audio cog

- Handle threads being notify channels
- Update type hint for `is_query_allowed()`

* [threads] Update type hints and documentation to reflect thread support

- Documented that `{channel}` in CCs might be a thread
- Allowed (documented) usage of threads with `Config.channel()`
    - Separate thread scope is still in the picture though
      if it were to be done, it's going to be in separate in PR
- GuildContext.channel might be Thread

* Use less costy channel check in customcom's on_message_without_command

This isn't needed for d.py 2.0 but whatever...

* Update for in-place edits

* Embed's bool changed behavior, I'm hoping it doesn't affect us

* Address User.permissions_in() removal

* Swap VerificationLevel.extreme with VerificationLevel.highest

* Change to keyword-only parameters

* Change of `Guild.vanity_invite()` return type

* avatar -> display_avatar

* Fix metaclass shenanigans with Converter

* Update Red.add_cog() to be inline with `dpy_commands.Bot.add_cog()`

This means adding `override` keyword-only parameter and causing
small breakage by swapping RuntimeError with discord.ClientException.

* Address all DEP-WARNs

* Remove Context.clean_prefix and use upstream implementation instead

* Remove commands.Literal and use upstream implementation instead

Honestly, this was a rather bad implementation anyway...

Breaking but actually not really - it was provisional.

* Update Command.callback's setter

Support for functools.partial is now built into d.py

* Add new perms in HUMANIZED_PERM mapping (some from d.py 1.7 it seems)

BTW, that should really be in core instead of what we have now...

* Remove the part of do_conversion that has not worked for a long while

* Stop wrapping BadArgument in ConversionFailure

This is breaking but it's best to resolve it like this.

The functionality of ConversionFailure can be replicated with
Context.current_parameter and Context.current_argument.

* Add custom errors for int and float converters

* Remove Command.__call__ as it's now implemented in d.py

* Get rid of _dpy_reimplements

These were reimplemented for the purpose of typing
so it is no longer needed now that d.py is type hinted.

* Add return to Red.remove_cog

* Ensure we don't delete messages that differ only by used sticker

* discord.InvalidArgument->ValueError

* Move from raw <t:...> syntax to discord.utils.format_dt()

* Address AsyncIter removal

* Swap to pos-only for params that are pos-only in upstream

* Update for changes to Command.params

* [threads] Support threads in ignore checks and allow ignoring them

- Updated `[p](un)ignore channel` to accept threads
- Updated `[p]ignore list` to list ignored threads
- Updated logic in `Red.ignored_channel_or_guild()`

Ignores for guild channels now work as follows (only changes for threads):
- if channel is not a thread:
    - check if user has manage channels perm in channel
      and allow command usage if so
    - check if channel is ignored and disallow command usage if so
    - allow command usage if none of the conditions above happened
- if channel is a thread:
    - check if user has manage channels perm in parent channel
      and allow command usage if so
    - check if parent channel is ignored and disallow command usage
      if so
    - check if user has manage thread perm in parent channel
      and allow command usage if so
    - check if thread is ignored and disallow command usage if so
    - allow command usage if none of the conditions above happened

* [partial] Raise TypeError when channel is of PartialMessageable type

- Red.embed_requested
- Red.ignored_channel_or_guild

* [partial] Discard command messages when channel is PartialMessageable

* [threads] Add utilities for checking appropriate perms in both channels & threads

* [threads] Update code to use can_react_in() and @bot_can_react()

* [threads] Update code to use can_send_messages_in

* [threads] Add send_messages_in_threads perm to mute role and overrides

* [threads] Update code to use (bot/user)_can_manage_channel

* [threads] Update [p]diagnoseissues to work with threads

* Type hint fix

* [threads] Patch vendored discord.ext.menus to check proper perms in threads

I guess we've reached time when we have to patch the lib we vendor...

* Make docs generation work with non-final d.py releases

* Update discord.utils.oauth_url() usage

* Swap usage of discord.Embed.Empty/discord.embeds.EmptyEmbed to None

* Update usage of Guild.member_count to work with `None`

* Switch from Guild.vanity_invite() to Guild.vanity_url

* Update startup process to work with d.py's new asynchronous startup

* Use setup_hook() for pre-connect actions

* Update core's add_cog, remove_cog, and load_extension methods

* Update all setup functions to async and add awaits to bot.add_cog calls

* Modernize cogs by using async cog_load and cog_unload

* Address StoreChannel removal

* [partial] Disallow passing PartialMessageable to Case.channel

* [partial] Update cogs and utils to work better with PartialMessageable

- Ignore messages with PartialMessageable channel in CustomCommands cog
- In Filter cog, don't pass channel to modlog.create_case()
  if it's PartialMessageable
- In Trivia cog, only compare channel IDs
- Make `.utils.menus.menu()` work for messages
  with PartialMessageable channel
- Make checks in `.utils.tunnel.Tunnel.communicate()` more rigid

* Add few missing DEP-WARNs
2022-04-03 03:21:20 +02:00

549 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import datetime
import time
from enum import Enum
from random import randint, choice
from typing import Final
import urllib.parse
import aiohttp
import discord
from redbot.core import commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.menus import menu, DEFAULT_CONTROLS
from redbot.core.utils.chat_formatting import (
bold,
escape,
italics,
humanize_number,
humanize_timedelta,
)
_ = T_ = Translator("General", __file__)
class RPS(Enum):
rock = "\N{MOYAI}"
paper = "\N{PAGE FACING UP}"
scissors = "\N{BLACK SCISSORS}\N{VARIATION SELECTOR-16}"
class RPSParser:
def __init__(self, argument):
argument = argument.lower()
if argument == "rock":
self.choice = RPS.rock
elif argument == "paper":
self.choice = RPS.paper
elif argument == "scissors":
self.choice = RPS.scissors
else:
self.choice = None
MAX_ROLL: Final[int] = 2**64 - 1
@cog_i18n(_)
class General(commands.Cog):
"""General commands."""
global _
_ = lambda s: s
ball = [
_("As I see it, yes"),
_("It is certain"),
_("It is decidedly so"),
_("Most likely"),
_("Outlook good"),
_("Signs point to yes"),
_("Without a doubt"),
_("Yes"),
_("Yes definitely"),
_("You may rely on it"),
_("Reply hazy, try again"),
_("Ask again later"),
_("Better not tell you now"),
_("Cannot predict now"),
_("Concentrate and ask again"),
_("Don't count on it"),
_("My reply is no"),
_("My sources say no"),
_("Outlook not so good"),
_("Very doubtful"),
]
_ = T_
def __init__(self, bot: Red) -> None:
super().__init__()
self.bot = bot
self.stopwatches = {}
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
return
@commands.command(usage="<first> <second> [others...]")
async def choose(self, ctx, *choices):
"""Choose between multiple options.
There must be at least 2 options to pick from.
Options are separated by spaces.
To denote options which include whitespace, you should enclose the options in double quotes.
"""
choices = [escape(c, mass_mentions=True) for c in choices if c]
if len(choices) < 2:
await ctx.send(_("Not enough options to pick from."))
else:
await ctx.send(choice(choices))
@commands.command()
async def roll(self, ctx, number: int = 100):
"""Roll a random number.
The result will be between 1 and `<number>`.
`<number>` defaults to 100.
"""
author = ctx.author
if 1 < number <= MAX_ROLL:
n = randint(1, number)
await ctx.send(
"{author.mention} :game_die: {n} :game_die:".format(
author=author, n=humanize_number(n)
)
)
elif number <= 1:
await ctx.send(_("{author.mention} Maybe higher than 1? ;P").format(author=author))
else:
await ctx.send(
_("{author.mention} Max allowed number is {maxamount}.").format(
author=author, maxamount=humanize_number(MAX_ROLL)
)
)
@commands.command()
async def flip(self, ctx, user: discord.Member = None):
"""Flip a coin... or a user.
Defaults to a coin.
"""
if user is not None:
msg = ""
if user.id == ctx.bot.user.id:
user = ctx.author
msg = _("Nice try. You think this is funny?\n How about *this* instead:\n\n")
char = "abcdefghijklmnopqrstuvwxyz"
tran = "ɐqɔpǝɟƃɥᴉɾʞlɯuodbɹsʇnʌʍxʎz"
table = str.maketrans(char, tran)
name = user.display_name.translate(table)
char = char.upper()
tran = "∀qƆpƎℲפHIſʞ˥WNOԀQᴚS┴∩ΛMX⅄Z"
table = str.maketrans(char, tran)
name = name.translate(table)
await ctx.send(msg + "(╯°□°)╯︵ " + name[::-1])
else:
await ctx.send(_("*flips a coin and... ") + choice([_("HEADS!*"), _("TAILS!*")]))
@commands.command()
async def rps(self, ctx, your_choice: RPSParser):
"""Play Rock Paper Scissors."""
author = ctx.author
player_choice = your_choice.choice
if not player_choice:
return await ctx.send(
_("This isn't a valid option. Try {r}, {p}, or {s}.").format(
r="rock", p="paper", s="scissors"
)
)
red_choice = choice((RPS.rock, RPS.paper, RPS.scissors))
cond = {
(RPS.rock, RPS.paper): False,
(RPS.rock, RPS.scissors): True,
(RPS.paper, RPS.rock): True,
(RPS.paper, RPS.scissors): False,
(RPS.scissors, RPS.rock): False,
(RPS.scissors, RPS.paper): True,
}
if red_choice == player_choice:
outcome = None # Tie
else:
outcome = cond[(player_choice, red_choice)]
if outcome is True:
await ctx.send(
_("{choice} You win {author.mention}!").format(
choice=red_choice.value, author=author
)
)
elif outcome is False:
await ctx.send(
_("{choice} You lose {author.mention}!").format(
choice=red_choice.value, author=author
)
)
else:
await ctx.send(
_("{choice} We're square {author.mention}!").format(
choice=red_choice.value, author=author
)
)
@commands.command(name="8", aliases=["8ball"])
async def _8ball(self, ctx, *, question: str):
"""Ask 8 ball a question.
Question must end with a question mark.
"""
if question.endswith("?") and question != "?":
await ctx.send("`" + T_(choice(self.ball)) + "`")
else:
await ctx.send(_("That doesn't look like a question."))
@commands.command(aliases=["sw"])
async def stopwatch(self, ctx):
"""Start or stop the stopwatch."""
author = ctx.author
if author.id not in self.stopwatches:
self.stopwatches[author.id] = int(time.perf_counter())
await ctx.send(author.mention + _(" Stopwatch started!"))
else:
tmp = abs(self.stopwatches[author.id] - int(time.perf_counter()))
tmp = str(datetime.timedelta(seconds=tmp))
await ctx.send(
author.mention + _(" Stopwatch stopped! Time: **{seconds}**").format(seconds=tmp)
)
self.stopwatches.pop(author.id, None)
@commands.command()
async def lmgtfy(self, ctx, *, search_terms: str):
"""Create a lmgtfy link."""
search_terms = escape(urllib.parse.quote_plus(search_terms), mass_mentions=True)
await ctx.send("https://lmgtfy.app/?q={}".format(search_terms))
@commands.command(hidden=True)
@commands.guild_only()
async def hug(self, ctx, user: discord.Member, intensity: int = 1):
"""Because everyone likes hugs!
Up to 10 intensity levels.
"""
name = italics(user.display_name)
if intensity <= 0:
msg = "(っ˘̩╭╮˘̩)っ" + name
elif intensity <= 3:
msg = "(っ´▽`)っ" + name
elif intensity <= 6:
msg = "╰(*´︶`*)╯" + name
elif intensity <= 9:
msg = "(つ≧▽≦)つ" + name
elif intensity >= 10:
msg = "(づ ̄ ³ ̄)づ{} ⊂(´・ω・`⊂)".format(name)
else:
# For the purposes of "msg might not be defined" linter errors
raise RuntimeError
await ctx.send(msg)
@commands.command()
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def serverinfo(self, ctx, details: bool = False):
"""
Show server information.
`details`: Shows more information when set to `True`.
Default to False.
"""
guild = ctx.guild
created_at = _("Created on {date_and_time}. That's {relative_time}!").format(
date_and_time=discord.utils.format_dt(guild.created_at),
relative_time=discord.utils.format_dt(guild.created_at, "R"),
)
online = humanize_number(
len([m.status for m in guild.members if m.status != discord.Status.offline])
)
total_users = guild.member_count and humanize_number(guild.member_count)
text_channels = humanize_number(len(guild.text_channels))
voice_channels = humanize_number(len(guild.voice_channels))
if not details:
data = discord.Embed(description=created_at, colour=await ctx.embed_colour())
data.add_field(
name=_("Users online"),
value=f"{online}/{total_users}" if total_users else _("Not available"),
)
data.add_field(name=_("Text Channels"), value=text_channels)
data.add_field(name=_("Voice Channels"), value=voice_channels)
data.add_field(name=_("Roles"), value=humanize_number(len(guild.roles)))
data.add_field(name=_("Owner"), value=str(guild.owner))
data.set_footer(
text=_("Server ID: ")
+ str(guild.id)
+ _(" • Use {command} for more info on the server.").format(
command=f"{ctx.clean_prefix}serverinfo 1"
)
)
if guild.icon:
data.set_author(name=guild.name, url=guild.icon)
data.set_thumbnail(url=guild.icon)
else:
data.set_author(name=guild.name)
else:
def _size(num: int):
for unit in ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
if abs(num) < 1024.0:
return "{0:.1f}{1}".format(num, unit)
num /= 1024.0
return "{0:.1f}{1}".format(num, "YB")
def _bitsize(num: int):
for unit in ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
if abs(num) < 1000.0:
return "{0:.1f}{1}".format(num, unit)
num /= 1000.0
return "{0:.1f}{1}".format(num, "YB")
shard_info = (
_("\nShard ID: **{shard_id}/{shard_count}**").format(
shard_id=humanize_number(guild.shard_id + 1),
shard_count=humanize_number(ctx.bot.shard_count),
)
if ctx.bot.shard_count > 1
else ""
)
# Logic from: https://github.com/TrustyJAID/Trusty-cogs/blob/master/serverstats/serverstats.py#L159
online_stats = {
_("Humans: "): lambda x: not x.bot,
_(" • Bots: "): lambda x: x.bot,
"\N{LARGE GREEN CIRCLE}": lambda x: x.status is discord.Status.online,
"\N{LARGE ORANGE CIRCLE}": lambda x: x.status is discord.Status.idle,
"\N{LARGE RED CIRCLE}": lambda x: x.status is discord.Status.do_not_disturb,
"\N{MEDIUM WHITE CIRCLE}\N{VARIATION SELECTOR-16}": lambda x: (
x.status is discord.Status.offline
),
"\N{LARGE PURPLE CIRCLE}": lambda x: any(
a.type is discord.ActivityType.streaming for a in x.activities
),
"\N{MOBILE PHONE}": lambda x: x.is_on_mobile(),
}
member_msg = _("Users online: **{online}/{total_users}**\n").format(
online=online, total_users=total_users
)
count = 1
for emoji, value in online_stats.items():
try:
num = len([m for m in guild.members if value(m)])
except Exception as error:
print(error)
continue
else:
member_msg += f"{emoji} {bold(humanize_number(num))} " + (
"\n" if count % 2 == 0 else ""
)
count += 1
verif = {
"none": _("0 - None"),
"low": _("1 - Low"),
"medium": _("2 - Medium"),
"high": _("3 - High"),
"highest": _("4 - Highest"),
}
features = {
"ANIMATED_ICON": _("Animated Icon"),
"BANNER": _("Banner Image"),
"COMMERCE": _("Commerce"),
"COMMUNITY": _("Community"),
"DISCOVERABLE": _("Server Discovery"),
"FEATURABLE": _("Featurable"),
"INVITE_SPLASH": _("Splash Invite"),
"MEMBER_LIST_DISABLED": _("Member list disabled"),
"MEMBER_VERIFICATION_GATE_ENABLED": _("Membership Screening enabled"),
"MORE_EMOJI": _("More Emojis"),
"NEWS": _("News Channels"),
"PARTNERED": _("Partnered"),
"PREVIEW_ENABLED": _("Preview enabled"),
"PUBLIC_DISABLED": _("Public disabled"),
"VANITY_URL": _("Vanity URL"),
"VERIFIED": _("Verified"),
"VIP_REGIONS": _("VIP Voice Servers"),
"WELCOME_SCREEN_ENABLED": _("Welcome Screen enabled"),
}
guild_features_list = [
f"\N{WHITE HEAVY CHECK MARK} {name}"
for feature, name in features.items()
if feature in guild.features
]
joined_on = _(
"{bot_name} joined this server on {bot_join}. That's over {since_join} days ago!"
).format(
bot_name=ctx.bot.user.name,
bot_join=guild.me.joined_at.strftime("%d %b %Y %H:%M:%S"),
since_join=humanize_number((ctx.message.created_at - guild.me.joined_at).days),
)
data = discord.Embed(
description=(f"{guild.description}\n\n" if guild.description else "") + created_at,
colour=await ctx.embed_colour(),
)
data.set_author(
name=guild.name,
icon_url="https://cdn.discordapp.com/emojis/457879292152381443.png"
if "VERIFIED" in guild.features
else "https://cdn.discordapp.com/emojis/508929941610430464.png"
if "PARTNERED" in guild.features
else None,
)
if guild.icon:
data.set_thumbnail(url=guild.icon)
data.add_field(name=_("Members:"), value=member_msg)
data.add_field(
name=_("Channels:"),
value=_(
"\N{SPEECH BALLOON} Text: {text}\n"
"\N{SPEAKER WITH THREE SOUND WAVES} Voice: {voice}"
).format(text=bold(text_channels), voice=bold(voice_channels)),
)
data.add_field(
name=_("Utility:"),
value=_(
"Owner: {owner}\nVerif. level: {verif}\nServer ID: {id}{shard_info}"
).format(
owner=bold(str(guild.owner)),
verif=bold(verif[str(guild.verification_level)]),
id=bold(str(guild.id)),
shard_info=shard_info,
),
inline=False,
)
data.add_field(
name=_("Misc:"),
value=_(
"AFK channel: {afk_chan}\nAFK timeout: {afk_timeout}\nCustom emojis: {emoji_count}\nRoles: {role_count}"
).format(
afk_chan=bold(str(guild.afk_channel))
if guild.afk_channel
else bold(_("Not set")),
afk_timeout=bold(humanize_timedelta(seconds=guild.afk_timeout)),
emoji_count=bold(humanize_number(len(guild.emojis))),
role_count=bold(humanize_number(len(guild.roles))),
),
inline=False,
)
if guild_features_list:
data.add_field(name=_("Server features:"), value="\n".join(guild_features_list))
if guild.premium_tier != 0:
nitro_boost = _(
"Tier {boostlevel} with {nitroboosters} boosts\n"
"File size limit: {filelimit}\n"
"Emoji limit: {emojis_limit}\n"
"VCs max bitrate: {bitrate}"
).format(
boostlevel=bold(str(guild.premium_tier)),
nitroboosters=bold(humanize_number(guild.premium_subscription_count)),
filelimit=bold(_size(guild.filesize_limit)),
emojis_limit=bold(str(guild.emoji_limit)),
bitrate=bold(_bitsize(guild.bitrate_limit)),
)
data.add_field(name=_("Nitro Boost:"), value=nitro_boost)
if guild.splash:
data.set_image(url=guild.splash.replace(format="png"))
data.set_footer(text=joined_on)
await ctx.send(embed=data)
@commands.command()
async def urban(self, ctx, *, word):
"""Search the Urban Dictionary.
This uses the unofficial Urban Dictionary API.
"""
try:
url = "https://api.urbandictionary.com/v0/define"
params = {"term": str(word).lower()}
headers = {"content-type": "application/json"}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, params=params) as response:
data = await response.json()
except aiohttp.ClientError:
await ctx.send(
_("No Urban Dictionary entries were found, or there was an error in the process.")
)
return
if data.get("error") != 404:
if not data.get("list"):
return await ctx.send(_("No Urban Dictionary entries were found."))
if await ctx.embed_requested():
# a list of embeds
embeds = []
for ud in data["list"]:
embed = discord.Embed(color=await ctx.embed_color())
title = _("{word} by {author}").format(
word=ud["word"].capitalize(), author=ud["author"]
)
if len(title) > 256:
title = "{}...".format(title[:253])
embed.title = title
embed.url = ud["permalink"]
description = _("{definition}\n\n**Example:** {example}").format(**ud)
if len(description) > 2048:
description = "{}...".format(description[:2045])
embed.description = description
embed.set_footer(
text=_(
"{thumbs_down} Down / {thumbs_up} Up, Powered by Urban Dictionary."
).format(**ud)
)
embeds.append(embed)
if embeds is not None and len(embeds) > 0:
await menu(
ctx,
pages=embeds,
controls=DEFAULT_CONTROLS,
message=None,
page=0,
timeout=30,
)
else:
messages = []
for ud in data["list"]:
ud.setdefault("example", "N/A")
message = _(
"<{permalink}>\n {word} by {author}\n\n{description}\n\n"
"{thumbs_down} Down / {thumbs_up} Up, Powered by Urban Dictionary."
).format(word=ud.pop("word").capitalize(), description="{description}", **ud)
max_desc_len = 2000 - len(message)
description = _("{definition}\n\n**Example:** {example}").format(**ud)
if len(description) > max_desc_len:
description = "{}...".format(description[: max_desc_len - 3])
message = message.format(description=description)
messages.append(message)
if messages is not None and len(messages) > 0:
await menu(
ctx,
pages=messages,
controls=DEFAULT_CONTROLS,
message=None,
page=0,
timeout=30,
)
else:
await ctx.send(
_("No Urban Dictionary entries were found, or there was an error in the process.")
)