Red-DiscordBot/redbot/core/settings_caches.py
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

499 lines
19 KiB
Python

from __future__ import annotations
from typing import Dict, List, Optional, Union, Set, Iterable, Tuple, overload
import asyncio
from argparse import Namespace
from collections import defaultdict
import discord
from .config import Config
from .utils import AsyncIter
class PrefixManager:
def __init__(self, config: Config, cli_flags: Namespace):
self._config: Config = config
self._global_prefix_overide: Optional[List[str]] = (
sorted(cli_flags.prefix, reverse=True) or None
)
self._cached: Dict[Optional[int], List[str]] = {}
async def get_prefixes(self, guild: Optional[discord.Guild] = None) -> List[str]:
ret: List[str]
gid: Optional[int] = guild.id if guild else None
if gid in self._cached:
ret = self._cached[gid].copy()
else:
if gid is not None:
ret = await self._config.guild_from_id(gid).prefix()
if not ret:
ret = await self.get_prefixes(None)
else:
ret = self._global_prefix_overide or (await self._config.prefix())
self._cached[gid] = ret.copy()
return ret
async def set_prefixes(
self, guild: Optional[discord.Guild] = None, prefixes: Optional[List[str]] = None
):
gid: Optional[int] = guild.id if guild else None
prefixes = prefixes or []
if not isinstance(prefixes, list) and not all(isinstance(pfx, str) for pfx in prefixes):
raise TypeError("Prefixes must be a list of strings")
prefixes = sorted(prefixes, reverse=True)
if gid is None:
if not prefixes:
raise ValueError("You must have at least one prefix.")
self._cached.clear()
await self._config.prefix.set(prefixes)
else:
self._cached.pop(gid, None)
await self._config.guild_from_id(gid).prefix.set(prefixes)
class I18nManager:
def __init__(self, config: Config):
self._config: Config = config
self._guild_locale: Dict[Union[int, None], Union[str, None]] = {}
self._guild_regional_format: Dict[Union[int, None], Union[str, None]] = {}
async def get_locale(self, guild: Union[discord.Guild, None]) -> str:
"""Get the guild locale from the cache"""
# Ensure global locale is in the cache
if None not in self._guild_locale:
global_locale = await self._config.locale()
self._guild_locale[None] = global_locale
if guild is None: # Not a guild so cannot support guild locale
# Return the bot's globally set locale if its None on a guild scope.
return self._guild_locale[None]
elif guild.id in self._guild_locale: # Cached guild
if self._guild_locale[guild.id] is None:
return self._guild_locale[None]
else:
return self._guild_locale[guild.id]
else: # Uncached guild
out = await self._config.guild(guild).locale() # No locale set
if out is None:
self._guild_locale[guild.id] = None
return self._guild_locale[None]
else:
self._guild_locale[guild.id] = out
return out
@overload
async def set_locale(self, guild: None, locale: str):
...
@overload
async def set_locale(self, guild: discord.Guild, locale: Union[str, None]):
...
async def set_locale(
self, guild: Union[discord.Guild, None], locale: Union[str, None]
) -> None:
"""Set the locale in the config and cache"""
if guild is None:
if locale is None:
# this method should never be called like this
raise ValueError("Global locale can't be None!")
self._guild_locale[None] = locale
await self._config.locale.set(locale)
return
self._guild_locale[guild.id] = locale
await self._config.guild(guild).locale.set(locale)
async def get_regional_format(self, guild: Union[discord.Guild, None]) -> Optional[str]:
"""Get the regional format from the cache"""
# Ensure global locale is in the cache
if None not in self._guild_regional_format:
global_regional_format = await self._config.regional_format()
self._guild_regional_format[None] = global_regional_format
if guild is None: # Not a guild so cannot support guild locale
return self._guild_regional_format[None]
elif guild.id in self._guild_regional_format: # Cached guild
if self._guild_regional_format[guild.id] is None:
return self._guild_regional_format[None]
else:
return self._guild_regional_format[guild.id]
else: # Uncached guild
out = await self._config.guild(guild).regional_format() # No locale set
if out is None:
self._guild_regional_format[guild.id] = None
return self._guild_regional_format[None]
else: # Not cached, got a custom regional format.
self._guild_regional_format[guild.id] = out
return out
async def set_regional_format(
self, guild: Union[discord.Guild, None], regional_format: Union[str, None]
) -> None:
"""Set the regional format in the config and cache"""
if guild is None:
self._guild_regional_format[None] = regional_format
await self._config.regional_format.set(regional_format)
return
self._guild_regional_format[guild.id] = regional_format
await self._config.guild(guild).regional_format.set(regional_format)
class IgnoreManager:
def __init__(self, config: Config):
self._config: Config = config
self._cached_channels: Dict[int, bool] = {}
self._cached_guilds: Dict[int, bool] = {}
async def get_ignored_channel(
self, channel: Union[discord.TextChannel, discord.Thread], check_category: bool = True
) -> bool:
ret: bool
cid: int = channel.id
cat_id: Optional[int] = (
channel.category.id if check_category and channel.category else None
)
if cid in self._cached_channels:
chan_ret = self._cached_channels[cid]
else:
chan_ret = await self._config.channel_from_id(cid).ignored()
self._cached_channels[cid] = chan_ret
if cat_id and cat_id in self._cached_channels:
cat_ret = self._cached_channels[cat_id]
else:
if cat_id:
cat_ret = await self._config.channel_from_id(cat_id).ignored()
self._cached_channels[cat_id] = cat_ret
else:
cat_ret = False
ret = chan_ret or cat_ret
return ret
async def set_ignored_channel(
self,
channel: Union[discord.TextChannel, discord.Thread, discord.CategoryChannel],
set_to: bool,
):
cid: int = channel.id
self._cached_channels[cid] = set_to
if set_to:
await self._config.channel_from_id(cid).ignored.set(set_to)
else:
await self._config.channel_from_id(cid).ignored.clear()
async def get_ignored_guild(self, guild: discord.Guild) -> bool:
ret: bool
gid: int = guild.id
if gid in self._cached_guilds:
ret = self._cached_guilds[gid]
else:
ret = await self._config.guild_from_id(gid).ignored()
self._cached_guilds[gid] = ret
return ret
async def set_ignored_guild(self, guild: discord.Guild, set_to: bool):
gid: int = guild.id
self._cached_guilds[gid] = set_to
if set_to:
await self._config.guild_from_id(gid).ignored.set(set_to)
else:
await self._config.guild_from_id(gid).ignored.clear()
class WhitelistBlacklistManager:
def __init__(self, config: Config):
self._config: Config = config
self._cached_whitelist: Dict[Optional[int], Set[int]] = {}
self._cached_blacklist: Dict[Optional[int], Set[int]] = {}
# because of discord deletion
# we now have sync and async access that may need to happen at the
# same time.
# blame discord for this.
self._access_lock = asyncio.Lock()
async def discord_deleted_user(self, user_id: int):
async with self._access_lock:
async for guild_id_or_none, ids in AsyncIter(
self._cached_whitelist.items(), steps=100
):
ids.discard(user_id)
async for guild_id_or_none, ids in AsyncIter(
self._cached_blacklist.items(), steps=100
):
ids.discard(user_id)
for grp in (self._config.whitelist, self._config.blacklist):
async with grp() as ul:
try:
ul.remove(user_id)
except ValueError:
pass
# don't use this in extensions, it's optimized and controlled for here,
# but can't be safe in 3rd party use
async with self._config._get_base_group("GUILD").all() as abuse:
for guild_str, guild_data in abuse.items():
for l_name in ("whitelist", "blacklist"):
try:
guild_data[l_name].remove(user_id)
except (ValueError, KeyError):
pass # this is raw access not filled with defaults
async def get_whitelist(self, guild: Optional[discord.Guild] = None) -> Set[int]:
async with self._access_lock:
ret: Set[int]
gid: Optional[int] = guild.id if guild else None
if gid in self._cached_whitelist:
ret = self._cached_whitelist[gid].copy()
else:
if gid is not None:
ret = set(await self._config.guild_from_id(gid).whitelist())
else:
ret = set(await self._config.whitelist())
self._cached_whitelist[gid] = ret.copy()
return ret
async def add_to_whitelist(self, guild: Optional[discord.Guild], role_or_user: Iterable[int]):
async with self._access_lock:
gid: Optional[int] = guild.id if guild else None
role_or_user = role_or_user or []
if not all(isinstance(r_or_u, int) for r_or_u in role_or_user):
raise TypeError("`role_or_user` must be an iterable of `int`s.")
if gid is None:
if gid not in self._cached_whitelist:
self._cached_whitelist[gid] = set(await self._config.whitelist())
self._cached_whitelist[gid].update(role_or_user)
await self._config.whitelist.set(list(self._cached_whitelist[gid]))
else:
if gid not in self._cached_whitelist:
self._cached_whitelist[gid] = set(
await self._config.guild_from_id(gid).whitelist()
)
self._cached_whitelist[gid].update(role_or_user)
await self._config.guild_from_id(gid).whitelist.set(
list(self._cached_whitelist[gid])
)
async def clear_whitelist(self, guild: Optional[discord.Guild] = None):
async with self._access_lock:
gid: Optional[int] = guild.id if guild else None
self._cached_whitelist[gid] = set()
if gid is None:
await self._config.whitelist.clear()
else:
await self._config.guild_from_id(gid).whitelist.clear()
async def remove_from_whitelist(
self, guild: Optional[discord.Guild], role_or_user: Iterable[int]
):
async with self._access_lock:
gid: Optional[int] = guild.id if guild else None
role_or_user = role_or_user or []
if not all(isinstance(r_or_u, int) for r_or_u in role_or_user):
raise TypeError("`role_or_user` must be an iterable of `int`s.")
if gid is None:
if gid not in self._cached_whitelist:
self._cached_whitelist[gid] = set(await self._config.whitelist())
self._cached_whitelist[gid].difference_update(role_or_user)
await self._config.whitelist.set(list(self._cached_whitelist[gid]))
else:
if gid not in self._cached_whitelist:
self._cached_whitelist[gid] = set(
await self._config.guild_from_id(gid).whitelist()
)
self._cached_whitelist[gid].difference_update(role_or_user)
await self._config.guild_from_id(gid).whitelist.set(
list(self._cached_whitelist[gid])
)
async def get_blacklist(self, guild: Optional[discord.Guild] = None) -> Set[int]:
async with self._access_lock:
ret: Set[int]
gid: Optional[int] = guild.id if guild else None
if gid in self._cached_blacklist:
ret = self._cached_blacklist[gid].copy()
else:
if gid is not None:
ret = set(await self._config.guild_from_id(gid).blacklist())
else:
ret = set(await self._config.blacklist())
self._cached_blacklist[gid] = ret.copy()
return ret
async def add_to_blacklist(self, guild: Optional[discord.Guild], role_or_user: Iterable[int]):
async with self._access_lock:
gid: Optional[int] = guild.id if guild else None
role_or_user = role_or_user or []
if not all(isinstance(r_or_u, int) for r_or_u in role_or_user):
raise TypeError("`role_or_user` must be an iterable of `int`s.")
if gid is None:
if gid not in self._cached_blacklist:
self._cached_blacklist[gid] = set(await self._config.blacklist())
self._cached_blacklist[gid].update(role_or_user)
await self._config.blacklist.set(list(self._cached_blacklist[gid]))
else:
if gid not in self._cached_blacklist:
self._cached_blacklist[gid] = set(
await self._config.guild_from_id(gid).blacklist()
)
self._cached_blacklist[gid].update(role_or_user)
await self._config.guild_from_id(gid).blacklist.set(
list(self._cached_blacklist[gid])
)
async def clear_blacklist(self, guild: Optional[discord.Guild] = None):
async with self._access_lock:
gid: Optional[int] = guild.id if guild else None
self._cached_blacklist[gid] = set()
if gid is None:
await self._config.blacklist.clear()
else:
await self._config.guild_from_id(gid).blacklist.clear()
async def remove_from_blacklist(
self, guild: Optional[discord.Guild], role_or_user: Iterable[int]
):
async with self._access_lock:
gid: Optional[int] = guild.id if guild else None
role_or_user = role_or_user or []
if not all(isinstance(r_or_u, int) for r_or_u in role_or_user):
raise TypeError("`role_or_user` must be an iterable of `int`s.")
if gid is None:
if gid not in self._cached_blacklist:
self._cached_blacklist[gid] = set(await self._config.blacklist())
self._cached_blacklist[gid].difference_update(role_or_user)
await self._config.blacklist.set(list(self._cached_blacklist[gid]))
else:
if gid not in self._cached_blacklist:
self._cached_blacklist[gid] = set(
await self._config.guild_from_id(gid).blacklist()
)
self._cached_blacklist[gid].difference_update(role_or_user)
await self._config.guild_from_id(gid).blacklist.set(
list(self._cached_blacklist[gid])
)
class DisabledCogCache:
def __init__(self, config: Config):
self._config = config
self._disable_map: Dict[str, Dict[int, bool]] = defaultdict(dict)
async def cog_disabled_in_guild(self, cog_name: str, guild_id: int) -> bool:
"""
Check if a cog is disabled in a guild
Parameters
----------
cog_name: str
This should be the cog's qualified name, not necessarily the classname
guild_id: int
Returns
-------
bool
"""
if guild_id in self._disable_map[cog_name]:
return self._disable_map[cog_name][guild_id]
gset = await self._config.custom("COG_DISABLE_SETTINGS", cog_name, guild_id).disabled()
if gset is None:
gset = await self._config.custom("COG_DISABLE_SETTINGS", cog_name, 0).disabled()
if gset is None:
gset = False
self._disable_map[cog_name][guild_id] = gset
return gset
async def default_disable(self, cog_name: str):
"""
Sets the default for a cog as disabled.
Parameters
----------
cog_name: str
This should be the cog's qualified name, not necessarily the classname
"""
await self._config.custom("COG_DISABLE_SETTINGS", cog_name, 0).disabled.set(True)
self._disable_map.pop(cog_name, None)
async def default_enable(self, cog_name: str):
"""
Sets the default for a cog as enabled.
Parameters
----------
cog_name: str
This should be the cog's qualified name, not necessarily the classname
"""
await self._config.custom("COG_DISABLE_SETTINGS", cog_name, 0).disabled.clear()
self._disable_map.pop(cog_name, None)
async def disable_cog_in_guild(self, cog_name: str, guild_id: int) -> bool:
"""
Disable a cog in a guild.
Parameters
----------
cog_name: str
This should be the cog's qualified name, not necessarily the classname
guild_id: int
Returns
-------
bool
Whether or not any change was made.
This may be useful for settings commands.
"""
if await self.cog_disabled_in_guild(cog_name, guild_id):
return False
self._disable_map[cog_name][guild_id] = True
await self._config.custom("COG_DISABLE_SETTINGS", cog_name, guild_id).disabled.set(True)
return True
async def enable_cog_in_guild(self, cog_name: str, guild_id: int) -> bool:
"""
Enable a cog in a guild.
Parameters
----------
cog_name: str
This should be the cog's qualified name, not necessarily the classname
guild_id: int
Returns
-------
bool
Whether or not any change was made.
This may be useful for settings commands.
"""
if not await self.cog_disabled_in_guild(cog_name, guild_id):
return False
self._disable_map[cog_name][guild_id] = False
await self._config.custom("COG_DISABLE_SETTINGS", cog_name, guild_id).disabled.set(False)
return True