mirror of
https://github.com/Cog-Creators/Red-DiscordBot.git
synced 2025-11-21 18:27:59 -05:00
Begin work on a data request API (#4045)
[Core] Data Deletion And Disclosure APIs - Adds a Data Deletion API - Deletion comes in a few forms based on who is requesting - Deletion must be handled by 3rd party - Adds a Data Collection Disclosure Command - Provides a dynamically generated statement from 3rd party extensions - Modifies the always available commands to be cog compatible - Also prevents them from being unloaded accidentally
This commit is contained in:
@@ -2,17 +2,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from typing import Union, List, Optional, TYPE_CHECKING
|
||||
import logging
|
||||
from typing import Union, List, Optional, TYPE_CHECKING, Literal
|
||||
from functools import wraps
|
||||
|
||||
import discord
|
||||
|
||||
from redbot.core.utils import AsyncIter
|
||||
from redbot.core.utils.chat_formatting import humanize_number
|
||||
from . import Config, errors, commands
|
||||
from .i18n import Translator
|
||||
|
||||
from .errors import BankPruneError
|
||||
from .utils import AsyncIter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .bot import Red
|
||||
@@ -67,6 +68,10 @@ _DEFAULT_USER = _DEFAULT_MEMBER
|
||||
|
||||
_config: Config = None
|
||||
|
||||
log = logging.getLogger("red.core.bank")
|
||||
|
||||
_data_deletion_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _init():
|
||||
global _config
|
||||
@@ -77,6 +82,28 @@ def _init():
|
||||
_config.register_user(**_DEFAULT_USER)
|
||||
|
||||
|
||||
async def _process_data_deletion(
|
||||
*, requester: Literal["discord_deleted_user", "owner", "user", "user_strict"], user_id: int
|
||||
):
|
||||
"""
|
||||
Bank has no reason to keep any of this data
|
||||
if the user doesn't want it kept,
|
||||
we won't special case any request type
|
||||
"""
|
||||
if requester not in ("discord_deleted_user", "owner", "user", "user_strict"):
|
||||
log.warning(
|
||||
"Got unknown data request type `{req_type}` for user, deleting anyway",
|
||||
req_type=requester,
|
||||
)
|
||||
|
||||
async with _data_deletion_lock:
|
||||
await _config.user_from_id(user_id).clear()
|
||||
all_members = await _config.all_members()
|
||||
async for guild_id, member_dict in AsyncIter(all_members.items(), steps=100):
|
||||
if user_id in member_dict:
|
||||
await _config.member_from_ids(guild_id, user_id).clear()
|
||||
|
||||
|
||||
class Account:
|
||||
"""A single account.
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import platform
|
||||
import shutil
|
||||
import sys
|
||||
import contextlib
|
||||
import weakref
|
||||
import functools
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from enum import IntEnum
|
||||
@@ -22,6 +24,8 @@ from typing import (
|
||||
Callable,
|
||||
Awaitable,
|
||||
Any,
|
||||
Literal,
|
||||
MutableMapping,
|
||||
)
|
||||
from types import MappingProxyType
|
||||
|
||||
@@ -31,7 +35,7 @@ from discord.ext.commands import when_mentioned_or
|
||||
|
||||
from . import Config, i18n, commands, errors, drivers, modlog, bank
|
||||
from .cog_manager import CogManager, CogManagerUI
|
||||
from .core_commands import license_info_command, Core
|
||||
from .core_commands import Core
|
||||
from .data_manager import cog_data_path
|
||||
from .dev_commands import Dev
|
||||
from .events import init_events
|
||||
@@ -45,7 +49,7 @@ from .settings_caches import (
|
||||
)
|
||||
|
||||
from .rpc import RPCMixin
|
||||
from .utils import common_filters
|
||||
from .utils import common_filters, AsyncIter
|
||||
from .utils._internal_utils import send_to_owners_with_prefix_replaced
|
||||
|
||||
CUSTOM_GROUPS = "CUSTOM_GROUPS"
|
||||
@@ -57,6 +61,8 @@ __all__ = ["RedBase", "Red", "ExitCodes"]
|
||||
|
||||
NotMessage = namedtuple("NotMessage", "guild")
|
||||
|
||||
DataDeletionResults = namedtuple("DataDeletionResults", "failed_modules failed_cogs unhandled")
|
||||
|
||||
PreInvokeCoroutine = Callable[[commands.Context], Awaitable[Any]]
|
||||
T_BIC = TypeVar("T_BIC", bound=PreInvokeCoroutine)
|
||||
|
||||
@@ -117,6 +123,8 @@ class RedBase(
|
||||
last_system_info__machine=None,
|
||||
last_system_info__system=None,
|
||||
schema_version=0,
|
||||
datarequests__allow_user_requests=True,
|
||||
datarequests__user_requests_are_strict=True,
|
||||
)
|
||||
|
||||
self._config.register_guild(
|
||||
@@ -198,6 +206,8 @@ class RedBase(
|
||||
self._red_ready = asyncio.Event()
|
||||
self._red_before_invoke_objs: Set[PreInvokeCoroutine] = set()
|
||||
|
||||
self._deletion_requests: MutableMapping[int, asyncio.Lock] = weakref.WeakValueDictionary()
|
||||
|
||||
def get_command(self, name: str) -> Optional[commands.Command]:
|
||||
com = super().get_command(name)
|
||||
assert com is None or isinstance(com, commands.Command)
|
||||
@@ -219,7 +229,7 @@ class RedBase(
|
||||
|
||||
async def _red_before_invoke_method(self, ctx):
|
||||
await self.wait_until_red_ready()
|
||||
return_exceptions = isinstance(ctx.command, commands.commands._AlwaysAvailableCommand)
|
||||
return_exceptions = isinstance(ctx.command, commands.commands._RuleDropper)
|
||||
if self._red_before_invoke_objs:
|
||||
await asyncio.gather(
|
||||
*(coro(ctx) for coro in self._red_before_invoke_objs),
|
||||
@@ -666,7 +676,6 @@ class RedBase(
|
||||
|
||||
self.add_cog(Core(self))
|
||||
self.add_cog(CogManagerUI())
|
||||
self.add_command(license_info_command)
|
||||
if cli_flags.dev:
|
||||
self.add_cog(Dev())
|
||||
|
||||
@@ -1040,7 +1049,7 @@ class RedBase(
|
||||
|
||||
def remove_cog(self, cogname: str):
|
||||
cog = self.get_cog(cogname)
|
||||
if cog is None:
|
||||
if cog is None or isinstance(cog, commands.commands._RuleDropper):
|
||||
return
|
||||
|
||||
for cls in inspect.getmro(cog.__class__):
|
||||
@@ -1197,6 +1206,9 @@ class RedBase(
|
||||
subcommand.requires.ready_event.set()
|
||||
|
||||
def remove_command(self, name: str) -> None:
|
||||
command = self.get_command(name)
|
||||
if isinstance(command, commands.commands._RuleDropper):
|
||||
return
|
||||
command = super().remove_command(name)
|
||||
if not command:
|
||||
return
|
||||
@@ -1395,6 +1407,124 @@ class RedBase(
|
||||
await self.logout()
|
||||
sys.exit(self._shutdown_mode)
|
||||
|
||||
async def _core_data_deletion(
|
||||
self,
|
||||
*,
|
||||
requester: Literal["discord_deleted_user", "owner", "user", "user_strict"],
|
||||
user_id: int,
|
||||
):
|
||||
if requester != "discord_deleted_user":
|
||||
return
|
||||
|
||||
await self._config.user_from_id(user_id).clear()
|
||||
all_guilds = await self._config.all_guilds()
|
||||
|
||||
async for guild_id, guild_data in AsyncIter(all_guilds.items(), steps=100):
|
||||
if user_id in guild_data.get("autoimmune_ids", []):
|
||||
async with self._config.guild_from_id(guild_id).autoimmune_ids() as ids:
|
||||
# prevent a racy crash here without locking
|
||||
# up the vals in all guilds first
|
||||
with contextlib.suppress(ValueError):
|
||||
ids.remove(user_id)
|
||||
|
||||
await self._whiteblacklist_cache.discord_deleted_user(user_id)
|
||||
|
||||
async def handle_data_deletion_request(
|
||||
self,
|
||||
*,
|
||||
requester: Literal["discord_deleted_user", "owner", "user", "user_strict"],
|
||||
user_id: int,
|
||||
) -> DataDeletionResults:
|
||||
"""
|
||||
This tells each cog and extension, as well as any APIs in Red
|
||||
to go delete data
|
||||
|
||||
Calling this should be limited to interfaces designed for it.
|
||||
|
||||
See ``redbot.core.commands.Cog.delete_data_for_user``
|
||||
for details about the parameters and intent.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
requester
|
||||
user_id
|
||||
|
||||
Returns
|
||||
-------
|
||||
DataDeletionResults
|
||||
A named tuple ``(failed_modules, failed_cogs, unhandled)``
|
||||
containing lists with names of failed modules, failed cogs,
|
||||
and cogs that didn't handle data deletion request.
|
||||
"""
|
||||
await self.wait_until_red_ready()
|
||||
lock = self._deletion_requests.setdefault(user_id, asyncio.Lock())
|
||||
async with lock:
|
||||
return await self._handle_data_deletion_request(requester=requester, user_id=user_id)
|
||||
|
||||
async def _handle_data_deletion_request(
|
||||
self,
|
||||
*,
|
||||
requester: Literal["discord_deleted_user", "owner", "user", "user_strict"],
|
||||
user_id: int,
|
||||
) -> DataDeletionResults:
|
||||
"""
|
||||
Actual interface for the above.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
requester
|
||||
user_id
|
||||
|
||||
Returns
|
||||
-------
|
||||
DataDeletionResults
|
||||
"""
|
||||
extension_handlers = {
|
||||
extension_name: handler
|
||||
for extension_name, extension in self.extensions.items()
|
||||
if (handler := getattr(extension, "red_delete_data_for_user", None))
|
||||
}
|
||||
|
||||
cog_handlers = {
|
||||
cog_qualname: cog.red_delete_data_for_user for cog_qualname, cog in self.cogs.items()
|
||||
}
|
||||
|
||||
special_handlers = {
|
||||
"Red Core Modlog API": modlog._process_data_deletion,
|
||||
"Red Core Bank API": bank._process_data_deletion,
|
||||
"Red Core Bot Data": self._core_data_deletion,
|
||||
}
|
||||
|
||||
failures = {
|
||||
"extension": [],
|
||||
"cog": [],
|
||||
"unhandled": [],
|
||||
}
|
||||
|
||||
async def wrapper(func, stype, sname):
|
||||
try:
|
||||
await func(requester=requester, user_id=user_id)
|
||||
except commands.commands.RedUnhandledAPI:
|
||||
log.warning(f"{stype}.{sname} did not handle data deletion ")
|
||||
failures["unhandled"].append(sname)
|
||||
except Exception as exc:
|
||||
log.exception(f"{stype}.{sname} errored when handling data deletion ")
|
||||
failures[stype].append(sname)
|
||||
|
||||
handlers = [
|
||||
*(wrapper(coro, "extension", name) for name, coro in extension_handlers.items()),
|
||||
*(wrapper(coro, "cog", name) for name, coro in cog_handlers.items()),
|
||||
*(wrapper(coro, "extension", name) for name, coro in special_handlers.items()),
|
||||
]
|
||||
|
||||
await asyncio.gather(*handlers)
|
||||
|
||||
return DataDeletionResults(
|
||||
failed_modules=failures["extension"],
|
||||
failed_cogs=failures["cog"],
|
||||
unhandled=failures["unhandled"],
|
||||
)
|
||||
|
||||
|
||||
# This can be removed, and the parent class renamed as a breaking change
|
||||
class Red(RedBase):
|
||||
|
||||
@@ -311,6 +311,10 @@ _ = Translator("CogManagerUI", __file__)
|
||||
class CogManagerUI(commands.Cog):
|
||||
"""Commands to interface with Red's cog manager."""
|
||||
|
||||
async def red_delete_data_for_user(self, **kwargs):
|
||||
""" Nothing to delete (Core Config is handled in a bot method ) """
|
||||
return
|
||||
|
||||
@commands.command()
|
||||
@checks.is_owner()
|
||||
async def paths(self, ctx: commands.Context):
|
||||
|
||||
@@ -15,6 +15,7 @@ from .commands import (
|
||||
GroupMixin as GroupMixin,
|
||||
command as command,
|
||||
group as group,
|
||||
RedUnhandledAPI as RedUnhandledAPI,
|
||||
RESERVED_COMMAND_NAMES as RESERVED_COMMAND_NAMES,
|
||||
)
|
||||
from .context import Context as Context, GuildContext as GuildContext, DMContext as DMContext
|
||||
|
||||
@@ -6,19 +6,23 @@ be used instead of those from the `discord.ext.commands` module.
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import io
|
||||
import re
|
||||
import functools
|
||||
import weakref
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
MutableMapping,
|
||||
TYPE_CHECKING,
|
||||
cast,
|
||||
)
|
||||
|
||||
import discord
|
||||
@@ -55,6 +59,7 @@ __all__ = [
|
||||
"command",
|
||||
"group",
|
||||
"RESERVED_COMMAND_NAMES",
|
||||
"RedUnhandledAPI",
|
||||
]
|
||||
|
||||
#: The following names are reserved for various reasons
|
||||
@@ -66,6 +71,12 @@ _ = Translator("commands.commands", __file__)
|
||||
DisablerDictType = MutableMapping[discord.Guild, Callable[["Context"], Awaitable[bool]]]
|
||||
|
||||
|
||||
class RedUnhandledAPI(Exception):
|
||||
""" An exception which can be raised to signal a lack of handling specific APIs """
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CogCommandMixin:
|
||||
"""A mixin for cogs and commands."""
|
||||
|
||||
@@ -731,6 +742,7 @@ class CogGroupMixin:
|
||||
whether or not the rule was changed as a result of this
|
||||
call.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
|
||||
if cur_rule not in (PermState.NORMAL, PermState.ACTIVE_ALLOW, PermState.ACTIVE_DENY):
|
||||
@@ -809,6 +821,136 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
|
||||
if doc:
|
||||
return inspect.cleandoc(translator(doc))
|
||||
|
||||
async def red_get_data_for_user(self, *, user_id: int) -> MutableMapping[str, io.BytesIO]:
|
||||
"""
|
||||
|
||||
.. note::
|
||||
|
||||
This method is documented provisionally
|
||||
and may have minor changes made to it.
|
||||
It is not expected to undergo major changes,
|
||||
but nothing utilizes this method yet and the inclusion of this method
|
||||
in documentation in advance is solely to allow cog creators time to prepare.
|
||||
|
||||
|
||||
This should be overridden by all cogs.
|
||||
|
||||
Overridden implementations should return a mapping of filenames to io.BytesIO
|
||||
containing a human-readable version of the data
|
||||
the cog has about the specified user_id or an empty mapping
|
||||
if the cog does not have end user data.
|
||||
|
||||
The data should be easily understood for what it represents to
|
||||
most users of age to use Discord.
|
||||
|
||||
You may want to include a readme file
|
||||
which explains specifics about the data.
|
||||
|
||||
This method may also be implemented for an extension.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user_id: int
|
||||
|
||||
Returns
|
||||
-------
|
||||
MutableMapping[str, io.BytesIO]
|
||||
A mapping of filenames to BytesIO objects
|
||||
suitable to send as a files or as part of an archive to a user.
|
||||
|
||||
This may be empty if you don't have data for users.
|
||||
|
||||
Raises
|
||||
------
|
||||
RedUnhandledAPI
|
||||
If the method was not overriden,
|
||||
or an overriden implementation is not handling this
|
||||
|
||||
"""
|
||||
raise RedUnhandledAPI()
|
||||
|
||||
async def red_delete_data_for_user(
|
||||
self,
|
||||
*,
|
||||
requester: Literal["discord_deleted_user", "owner", "user", "user_strict"],
|
||||
user_id: int,
|
||||
):
|
||||
"""
|
||||
This should be overridden by all cogs.
|
||||
|
||||
If your cog does not store data, overriding and doing nothing should still
|
||||
be done to indicate that this has been considered.
|
||||
|
||||
.. note::
|
||||
This may receive other strings in the future without warning
|
||||
you should safely handle
|
||||
any string value (log a warning if needed)
|
||||
as additional requester types may be added
|
||||
in the future without prior warning.
|
||||
(see what this method can raise for details)
|
||||
|
||||
|
||||
This method can currently be passed one of these strings:
|
||||
|
||||
|
||||
- ``"discord_deleted_user"``:
|
||||
|
||||
The request should be processed as if
|
||||
Discord has asked for the data removal
|
||||
This then additionally must treat the
|
||||
user ID itself as something to be deleted.
|
||||
The user ID is no longer operational data
|
||||
as the ID no longer refers to a valid user.
|
||||
|
||||
- ``"owner"``:
|
||||
|
||||
The request was made by the bot owner.
|
||||
If removing the data requested by the owner
|
||||
would be an operational hazard
|
||||
(such as removing a user id from a blocked user list)
|
||||
you may elect to inform the user of an alternative way
|
||||
to remove that ID to ensure the process can not be abused
|
||||
by users to bypass anti-abuse measures,
|
||||
but there must remain a way for them to process this request.
|
||||
|
||||
- ``"user_strict"``:
|
||||
|
||||
The request was made by a user,
|
||||
the bot settings allow a user to request their own data
|
||||
be deleted, and the bot is configured to respect this
|
||||
at the cost of functionality.
|
||||
Cogs may retain data needed for anti abuse measures
|
||||
such as IDs and timestamps of interactions,
|
||||
but should not keep EUD such
|
||||
as user nicknames if receiving a request of this nature.
|
||||
|
||||
- ``"user"``:
|
||||
|
||||
The request was made by a user,
|
||||
the bot settings allow a user to request their own data
|
||||
be deleted, and the bot is configured to let cogs keep
|
||||
data needed for operation.
|
||||
Under this case, you may elect to retain data which is
|
||||
essential to the functionality of the cog. This case will
|
||||
only happen if the bot owner has opted into keeping
|
||||
minimal EUD needed for cog functionality.
|
||||
|
||||
|
||||
Parameters
|
||||
----------
|
||||
requester: Literal["discord_deleted_user", "owner", "user", "user_strict"]
|
||||
See above notes for details about this parameter
|
||||
user_id: int
|
||||
The user ID which needs deletion handling
|
||||
|
||||
Raises
|
||||
------
|
||||
RedUnhandledAPI
|
||||
If the method was not overriden,
|
||||
or an overriden implementation is not handling this
|
||||
"""
|
||||
raise RedUnhandledAPI()
|
||||
|
||||
async def can_run(self, ctx: "Context", **kwargs) -> bool:
|
||||
"""
|
||||
This really just exists to allow easy use with other methods using can_run
|
||||
@@ -826,6 +968,8 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
|
||||
-------
|
||||
bool
|
||||
``True`` if this cog is usable in the given context.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
try:
|
||||
@@ -854,6 +998,7 @@ class CogMixin(CogGroupMixin, CogCommandMixin):
|
||||
bool
|
||||
``True`` if this cog is visible in the given context.
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
|
||||
return await self.can_run(ctx)
|
||||
@@ -873,6 +1018,8 @@ class Cog(CogMixin, DPYCog, metaclass=DPYCogMeta):
|
||||
"""
|
||||
This does not have identical behavior to
|
||||
Group.all_commands but should return what you expect
|
||||
|
||||
:meta private:
|
||||
"""
|
||||
return {cmd.name: cmd for cmd in self.__cog_commands__}
|
||||
|
||||
@@ -917,13 +1064,15 @@ def get_command_disabler(guild: discord.Guild) -> Callable[["Context"], Awaitabl
|
||||
return disabler
|
||||
|
||||
|
||||
# This is intentionally left out of `__all__` as it is not intended for general use
|
||||
class _AlwaysAvailableCommand(Command):
|
||||
# The below are intentionally left out of `__all__`
|
||||
# as they are not intended for general use
|
||||
class _AlwaysAvailableMixin:
|
||||
"""
|
||||
This should be used only for informational commands
|
||||
This should be used for commands
|
||||
which should not be disabled or removed
|
||||
|
||||
These commands cannot belong to a cog.
|
||||
These commands cannot belong to any cog except Core (core_commands.py)
|
||||
to prevent issues with the appearance of certain behavior.
|
||||
|
||||
These commands do not respect most forms of checks, and
|
||||
should only be used with that in mind.
|
||||
@@ -931,10 +1080,56 @@ class _AlwaysAvailableCommand(Command):
|
||||
This particular class is not supported for 3rd party use
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.cog is not None:
|
||||
raise TypeError("This command may not be added to a cog")
|
||||
|
||||
async def can_run(self, ctx, *args, **kwargs) -> bool:
|
||||
return not ctx.author.bot
|
||||
|
||||
can_see = can_run
|
||||
|
||||
|
||||
class _RuleDropper(CogCommandMixin):
|
||||
"""
|
||||
Objects inheriting from this, be they command or cog,
|
||||
should not be interfered with operation except by their own rules,
|
||||
or by global checks which are not tailored for these objects but instead
|
||||
on global abuse prevention
|
||||
(such as a check that disallows blocked users and bots from interacting.)
|
||||
|
||||
This should not be used by 3rd-party extensions directly for their own objects.
|
||||
"""
|
||||
|
||||
def allow_for(self, model_id: Union[int, str], guild_id: int) -> None:
|
||||
""" This will do nothing. """
|
||||
|
||||
def deny_to(self, model_id: Union[int, str], guild_id: int) -> None:
|
||||
""" This will do nothing. """
|
||||
|
||||
def clear_rule_for(
|
||||
self, model_id: Union[int, str], guild_id: int
|
||||
) -> Tuple[PermState, PermState]:
|
||||
"""
|
||||
This will do nothing, except return a compatible rule
|
||||
"""
|
||||
cur_rule = self.requires.get_rule(model_id, guild_id=guild_id)
|
||||
return cur_rule, cur_rule
|
||||
|
||||
def set_default_rule(self, rule: Optional[bool], guild_id: int) -> None:
|
||||
""" This will do nothing. """
|
||||
|
||||
|
||||
class _AlwaysAvailableCommand(_AlwaysAvailableMixin, _RuleDropper, Command):
|
||||
pass
|
||||
|
||||
|
||||
class _AlwaysAvailableGroup(_AlwaysAvailableMixin, _RuleDropper, Group):
|
||||
pass
|
||||
|
||||
|
||||
class _ForgetMeSpecialCommand(_RuleDropper, Command):
|
||||
"""
|
||||
We need special can_run behavior here
|
||||
"""
|
||||
|
||||
async def can_run(self, ctx, *args, **kwargs) -> bool:
|
||||
return await ctx.bot._config.datarequests.allow_user_requests()
|
||||
|
||||
can_see = can_run
|
||||
|
||||
@@ -4,6 +4,9 @@ import datetime
|
||||
import importlib
|
||||
import itertools
|
||||
import logging
|
||||
import io
|
||||
import random
|
||||
import markdown
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -11,15 +14,12 @@ import platform
|
||||
import getpass
|
||||
import pip
|
||||
import traceback
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
from random import SystemRandom
|
||||
from string import ascii_letters, digits
|
||||
from typing import TYPE_CHECKING, Union, Tuple, List, Optional, Iterable, Sequence, Dict, Set
|
||||
|
||||
import aiohttp
|
||||
import discord
|
||||
import pkg_resources
|
||||
from babel import Locale as BabelLocale, UnknownLocaleError
|
||||
from redbot.core.data_manager import storage_type
|
||||
|
||||
@@ -29,10 +29,8 @@ from . import (
|
||||
VersionInfo,
|
||||
checks,
|
||||
commands,
|
||||
drivers,
|
||||
errors,
|
||||
i18n,
|
||||
config,
|
||||
)
|
||||
from .utils import AsyncIter
|
||||
from .utils._internal_utils import fetch_latest_red_version_info
|
||||
@@ -49,6 +47,43 @@ from .utils.chat_formatting import (
|
||||
from .commands.requires import PrivilegeLevel
|
||||
|
||||
|
||||
_entities = {
|
||||
"*": "*",
|
||||
"\\": "\",
|
||||
"`": "`",
|
||||
"!": "!",
|
||||
"{": "{",
|
||||
"[": "[",
|
||||
"_": "_",
|
||||
"(": "(",
|
||||
"#": "#",
|
||||
".": ".",
|
||||
"+": "+",
|
||||
"}": "}",
|
||||
"]": "]",
|
||||
")": ")",
|
||||
}
|
||||
|
||||
PRETTY_HTML_HEAD = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>3rd Party Data Statements</title>
|
||||
<style type="text/css">
|
||||
body{margin:2em auto;max-width:800px;line-height:1.4;font-size:16px;
|
||||
background-color=#EEEEEE;color:#454545;padding:1em;text-align:justify}
|
||||
h1,h2,h3{line-height:1.2}
|
||||
</style></head><body>
|
||||
""" # This ends up being a small bit extra that really makes a difference.
|
||||
|
||||
HTML_CLOSING = "</body></html>"
|
||||
|
||||
|
||||
def entity_transformer(statement: str) -> str:
|
||||
return "".join(_entities.get(c, c) for c in statement)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from redbot.core.bot import Red
|
||||
|
||||
@@ -300,9 +335,13 @@ class CoreLogic:
|
||||
|
||||
|
||||
@i18n.cog_i18n(_)
|
||||
class Core(commands.Cog, CoreLogic):
|
||||
class Core(commands.commands._RuleDropper, commands.Cog, CoreLogic):
|
||||
"""Commands related to core functions."""
|
||||
|
||||
async def red_delete_data_for_user(self, **kwargs):
|
||||
""" Nothing to delete (Core Config is handled in a bot method ) """
|
||||
return
|
||||
|
||||
@commands.command(hidden=True)
|
||||
async def ping(self, ctx: commands.Context):
|
||||
"""Pong."""
|
||||
@@ -443,6 +482,502 @@ class Core(commands.Cog, CoreLogic):
|
||||
)
|
||||
)
|
||||
|
||||
@commands.group(cls=commands.commands._AlwaysAvailableGroup)
|
||||
async def mydata(self, ctx: commands.Context):
|
||||
""" Commands which interact with the data [botname] has about you """
|
||||
|
||||
# 1/10 minutes. It's a static response, but the inability to lock
|
||||
# will annoy people if it's spammable
|
||||
@commands.cooldown(1, 600, commands.BucketType.user)
|
||||
@mydata.command(cls=commands.commands._AlwaysAvailableCommand, name="whatdata")
|
||||
async def mydata_whatdata(self, ctx: commands.Context):
|
||||
""" Find out what type of data [botname] stores and why """
|
||||
|
||||
ver = "latest" if red_version_info.dev_release else "stable"
|
||||
link = f"https://docs.discord.red/en/{ver}/red_core_data_statement.html"
|
||||
await ctx.send(
|
||||
_(
|
||||
"This bot stores some data about users as necessary to function. "
|
||||
"This is mostly the ID your user is assigned by Discord, linked to "
|
||||
"a handful of things depending on what you interact with in the bot. "
|
||||
"There are a few commands which store it to keep track of who created "
|
||||
"something. (such as playlists) "
|
||||
"For full details about this as well as more in depth details of what "
|
||||
"is stored and why, see {link}.\n\n"
|
||||
"Additionally, 3rd party addons loaded by the bot's owner may or "
|
||||
"may not store additional things. "
|
||||
"You can use `{prefix}mydata 3rdparty` "
|
||||
"to view the statements provided by each 3rd-party addition."
|
||||
).format(link=link, prefix=ctx.clean_prefix)
|
||||
)
|
||||
|
||||
# 1/30 minutes. It's not likely to change much and uploads a standalone webpage.
|
||||
@commands.cooldown(1, 1800, commands.BucketType.user)
|
||||
@mydata.command(cls=commands.commands._AlwaysAvailableCommand, name="3rdparty")
|
||||
async def mydata_3rd_party(self, ctx: commands.Context):
|
||||
""" View the End User Data statements of each 3rd-party module. """
|
||||
|
||||
# Can't check this as a command check, and want to prompt DMs as an option.
|
||||
if not ctx.channel.permissions_for(ctx.me).attach_files:
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return await ctx.send(_("I need to be able to attach files (try in DMs?)"))
|
||||
|
||||
statements = {
|
||||
ext_name: getattr(ext, "__red_end_user_data_statement__", None)
|
||||
for ext_name, ext in ctx.bot.extensions.items()
|
||||
if not (ext.__package__ and ext.__package__.startswith("redbot."))
|
||||
}
|
||||
|
||||
if not statements:
|
||||
return await ctx.send(
|
||||
_("This instance does not appear to have any 3rd-party extensions loaded.")
|
||||
)
|
||||
|
||||
parts = []
|
||||
|
||||
formatted_statements = []
|
||||
|
||||
no_statements = []
|
||||
|
||||
for ext_name, statement in sorted(statements.items()):
|
||||
if not statement:
|
||||
no_statements.append(ext_name)
|
||||
else:
|
||||
formatted_statements.append(
|
||||
f"### {entity_transformer(ext_name)}\n\n{entity_transformer(statement)}"
|
||||
)
|
||||
|
||||
if formatted_statements:
|
||||
parts.append(
|
||||
"## "
|
||||
+ _("3rd party End User Data statements")
|
||||
+ "\n\n"
|
||||
+ _("The following are statements provided by 3rd-party extensions.")
|
||||
)
|
||||
parts.extend(formatted_statements)
|
||||
|
||||
if no_statements:
|
||||
parts.append("## " + _("3rd-party extensions without statements\n"))
|
||||
for ext in no_statements:
|
||||
parts.append(f"\n - {entity_transformer(ext)}")
|
||||
|
||||
generated = markdown.markdown("\n".join(parts), output_format="html")
|
||||
|
||||
html = "\n".join((PRETTY_HTML_HEAD, generated, HTML_CLOSING))
|
||||
|
||||
fp = io.BytesIO(html.encode())
|
||||
|
||||
await ctx.send(
|
||||
_("Here's a generated page with the statements provided by 3rd-party extensions"),
|
||||
file=discord.File(fp, filename="3rd-party.html"),
|
||||
)
|
||||
|
||||
async def get_serious_confirmation(self, ctx: commands.Context, prompt: str) -> bool:
|
||||
|
||||
confirm_token = "".join(random.choices((*ascii_letters, *digits), k=8))
|
||||
|
||||
await ctx.send(f"{prompt}\n\n{confirm_token}")
|
||||
try:
|
||||
message = await ctx.bot.wait_for(
|
||||
"message",
|
||||
check=lambda m: m.channel.id == ctx.channel.id and m.author.id == ctx.author.id,
|
||||
timeout=30,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await ctx.send(_("Did not get confirmation, cancelling."))
|
||||
else:
|
||||
if message.content.strip() == confirm_token:
|
||||
return True
|
||||
else:
|
||||
await ctx.send(_("Did not get a matching confirmation, cancelling."))
|
||||
|
||||
return False
|
||||
|
||||
# 1 per day, not stored to config to avoid this being more stored data.
|
||||
# large bots shouldn't be restarting so often that this is an issue,
|
||||
# and small bots that do restart often don't have enough
|
||||
# users for this to be an issue.
|
||||
@commands.cooldown(1, 86400, commands.BucketType.user)
|
||||
@mydata.command(cls=commands.commands._ForgetMeSpecialCommand, name="forgetme")
|
||||
async def mydata_forgetme(self, ctx: commands.Context):
|
||||
"""
|
||||
Have [botname] forget what it knows about you.
|
||||
|
||||
This may not remove all data about you, data needed for operation,
|
||||
such as command cooldowns will be kept until no longer necessary.
|
||||
|
||||
Further interactions with [botname] may cause it to learn about you again.
|
||||
"""
|
||||
if ctx.assume_yes:
|
||||
# lol, no, we're not letting users schedule deletions every day to thrash the bot.
|
||||
ctx.command.reset_cooldown(ctx) # We will however not let that lock them out either.
|
||||
return await ctx.send(
|
||||
_("This command ({command}) does not support non-interactive usage").format(
|
||||
command=ctx.command.qualified_name
|
||||
)
|
||||
)
|
||||
|
||||
if not await self.get_serious_confirmation(
|
||||
ctx,
|
||||
_(
|
||||
"This will cause the bot to get rid of and/or disassociate "
|
||||
"data from you. It will not get rid of operational data such "
|
||||
"as modlog entries, warnings, or mutes. "
|
||||
"If you are sure this is what you want, "
|
||||
"please respond with the following:"
|
||||
),
|
||||
):
|
||||
ctx.command.reset_cooldown(ctx)
|
||||
return
|
||||
await ctx.send(_("This may take some time"))
|
||||
|
||||
if await ctx.bot._config.datarequests.user_requests_are_strict():
|
||||
requester = "user_strict"
|
||||
else:
|
||||
requester = "user"
|
||||
|
||||
results = await self.bot.handle_data_deletion_request(
|
||||
requester=requester, user_id=ctx.author.id
|
||||
)
|
||||
|
||||
if results.failed_cogs and results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all non-operational data about you "
|
||||
"(that I know how to delete) "
|
||||
"{mention}, however the following modules errored: {modules}. "
|
||||
"Additionally, the following cogs errored: {cogs}\n"
|
||||
"Please contact the owner of this bot to address this.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(
|
||||
mention=ctx.author.mention,
|
||||
cogs=humanize_list(results.failed_cogs),
|
||||
modules=humanize_list(results.failed_modules),
|
||||
)
|
||||
)
|
||||
elif results.failed_cogs:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all non-operational data about you "
|
||||
"(that I know how to delete) "
|
||||
"{mention}, however the following cogs errored: {cogs}.\n"
|
||||
"Please contact the owner of this bot to address this.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(mention=ctx.author.mention, cogs=humanize_list(results.failed_cogs))
|
||||
)
|
||||
elif results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all non-operational data about you "
|
||||
"(that I know how to delete) "
|
||||
"{mention}, however the following modules errored: {modules}.\n"
|
||||
"Please contact the owner of this bot to address this.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(mention=ctx.author.mention, modules=humanize_list(results.failed_modules))
|
||||
)
|
||||
else:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I've deleted any non-operational data about you "
|
||||
"(that I know how to delete) {mention}"
|
||||
).format(mention=ctx.author.mention)
|
||||
)
|
||||
|
||||
if results.unhandled:
|
||||
await ctx.send(
|
||||
_("{mention} The following cogs did not handle deletion:\n{cogs}").format(
|
||||
mention=ctx.author.mention, cogs=humanize_list(results.unhandled)
|
||||
)
|
||||
)
|
||||
|
||||
# The cooldown of this should be longer once actually implemented
|
||||
# This is a couple hours, and lets people occasionally check status, I guess.
|
||||
@commands.cooldown(1, 7200, commands.BucketType.user)
|
||||
@mydata.command(cls=commands.commands._AlwaysAvailableCommand, name="getmydata")
|
||||
async def mydata_getdata(self, ctx: commands.Context):
|
||||
""" [Coming Soon] Get what data [botname] has about you. """
|
||||
await ctx.send(
|
||||
_(
|
||||
"This command doesn't do anything yet, "
|
||||
"but we're working on adding support for this."
|
||||
)
|
||||
)
|
||||
|
||||
@checks.is_owner()
|
||||
@mydata.group(name="ownermanagement")
|
||||
async def mydata_owner_management(self, ctx: commands.Context):
|
||||
"""
|
||||
Commands for more complete data handling.
|
||||
"""
|
||||
|
||||
@mydata_owner_management.command(name="allowuserdeletions")
|
||||
async def mydata_owner_allow_user_deletions(self, ctx):
|
||||
"""
|
||||
Set the bot to allow users to request a data deletion.
|
||||
|
||||
This is on by default.
|
||||
"""
|
||||
await ctx.bot._config.datarequests.allow_user_requests.set(True)
|
||||
await ctx.send(
|
||||
_(
|
||||
"User can delete their own data. "
|
||||
"This will not include operational data such as blocked users."
|
||||
)
|
||||
)
|
||||
|
||||
@mydata_owner_management.command(name="disallowuserdeletions")
|
||||
async def mydata_owner_disallow_user_deletions(self, ctx):
|
||||
"""
|
||||
Set the bot to not allow users to request a data deletion.
|
||||
"""
|
||||
await ctx.bot._config.datarequests.allow_user_requests.set(False)
|
||||
await ctx.send(_("User can not delete their own data."))
|
||||
|
||||
@mydata_owner_management.command(name="setuserdeletionlevel")
|
||||
async def mydata_owner_user_deletion_level(self, ctx, level: int):
|
||||
"""
|
||||
Sets how user deletions are treated.
|
||||
|
||||
Level:
|
||||
0: What users can delete is left entirely up to each cog.
|
||||
1: Cogs should delete anything the cog doesn't need about the user.
|
||||
"""
|
||||
|
||||
if level == 1:
|
||||
await ctx.bot._config.datarequests.user_requests_are_strict.set(True)
|
||||
await ctx.send(
|
||||
_(
|
||||
"Cogs will be instructed to remove all non operational "
|
||||
"data upon a user request."
|
||||
)
|
||||
)
|
||||
elif level == 0:
|
||||
await ctx.bot._config.datarequests.user_requests_are_strict.set(False)
|
||||
await ctx.send(
|
||||
_(
|
||||
"Cogs will be informed a user has made a data deletion request, "
|
||||
"and the details of what to delete will be left to the "
|
||||
"discretion of the cog author."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.send_help()
|
||||
|
||||
@mydata_owner_management.command(name="processdiscordrequest")
|
||||
async def mydata_discord_deletion_request(self, ctx, user_id: int):
|
||||
"""
|
||||
Handle a deletion request from discord.
|
||||
"""
|
||||
|
||||
if not await self.get_serious_confirmation(
|
||||
ctx,
|
||||
_(
|
||||
"This will cause the bot to get rid of or disassociate all data "
|
||||
"from the specified user ID. You should not use this unless "
|
||||
"Discord has specifically requested this with regard to a deleted user. "
|
||||
"This will remove the user from various anti-abuse measures. "
|
||||
"If you are processing a manual request from a user, you may want "
|
||||
"`{prefix}{command_name}` instead"
|
||||
"\n\nIf you are sure this is what you intend to do "
|
||||
"please respond with the following:"
|
||||
).format(prefix=ctx.clean_prefix, command_name="mydata ownermanagement deleteforuser"),
|
||||
):
|
||||
return
|
||||
results = await self.bot.handle_data_deletion_request(
|
||||
requester="discord_deleted_user", user_id=user_id
|
||||
)
|
||||
|
||||
if results.failed_cogs and results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following modules errored: {modules}. "
|
||||
"Additionally, the following cogs errored: {cogs}\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(
|
||||
cogs=humanize_list(results.failed_cogs),
|
||||
modules=humanize_list(results.failed_modules),
|
||||
)
|
||||
)
|
||||
elif results.failed_cogs:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following cogs errored: {cogs}.\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(cogs=humanize_list(results.failed_cogs))
|
||||
)
|
||||
elif results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following modules errored: {modules}.\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(modules=humanize_list(results.failed_modules))
|
||||
)
|
||||
else:
|
||||
await ctx.send(_("I've deleted all data about that user that I know how to delete."))
|
||||
|
||||
if results.unhandled:
|
||||
await ctx.send(
|
||||
_("{mention} The following cogs did not handle deletion:\n{cogs}").format(
|
||||
mention=ctx.author.mention, cogs=humanize_list(results.unhandled)
|
||||
)
|
||||
)
|
||||
|
||||
@mydata_owner_management.command(name="deleteforuser")
|
||||
async def mydata_user_deletion_request_by_owner(self, ctx, user_id: int):
|
||||
""" Delete data [botname] has about a user for a user. """
|
||||
if not await self.get_serious_confirmation(
|
||||
ctx,
|
||||
_(
|
||||
"This will cause the bot to get rid of or disassociate "
|
||||
"a lot of non-operational data from the "
|
||||
"specified user. Users have access to "
|
||||
"different command for this unless they can't interact with the bot at all. "
|
||||
"This is a mostly safe operation, but you should not use it "
|
||||
"unless processing a request from this "
|
||||
"user as it may impact their usage of the bot. "
|
||||
"\n\nIf you are sure this is what you intend to do "
|
||||
"please respond with the following:"
|
||||
),
|
||||
):
|
||||
return
|
||||
|
||||
if await ctx.bot._config.datarequests.user_requests_are_strict():
|
||||
requester = "user_strict"
|
||||
else:
|
||||
requester = "user"
|
||||
|
||||
results = await self.bot.handle_data_deletion_request(requester=requester, user_id=user_id)
|
||||
|
||||
if results.failed_cogs and results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all non-operational data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following modules errored: {modules}. "
|
||||
"Additionally, the following cogs errored: {cogs}\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(
|
||||
cogs=humanize_list(results.failed_cogs),
|
||||
modules=humanize_list(results.failed_modules),
|
||||
)
|
||||
)
|
||||
elif results.failed_cogs:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all non-operational data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following cogs errored: {cogs}.\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(cogs=humanize_list(results.failed_cogs))
|
||||
)
|
||||
elif results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all non-operational data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following modules errored: {modules}.\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(modules=humanize_list(results.failed_modules))
|
||||
)
|
||||
else:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I've deleted all non-operational data about that user "
|
||||
"that I know how to delete."
|
||||
)
|
||||
)
|
||||
|
||||
if results.unhandled:
|
||||
await ctx.send(
|
||||
_("{mention} The following cogs did not handle deletion:\n{cogs}").format(
|
||||
mention=ctx.author.mention, cogs=humanize_list(results.unhandled)
|
||||
)
|
||||
)
|
||||
|
||||
@mydata_owner_management.command(name="deleteuserasowner")
|
||||
async def mydata_user_deletion_by_owner(self, ctx, user_id: int):
|
||||
""" Delete data [botname] has about a user. """
|
||||
if not await self.get_serious_confirmation(
|
||||
ctx,
|
||||
_(
|
||||
"This will cause the bot to get rid of or disassociate "
|
||||
"a lot of data about the specified user. "
|
||||
"This may include more than just end user data, including "
|
||||
"anti abuse records."
|
||||
"\n\nIf you are sure this is what you intend to do "
|
||||
"please respond with the following:"
|
||||
),
|
||||
):
|
||||
return
|
||||
results = await self.bot.handle_data_deletion_request(requester="owner", user_id=user_id)
|
||||
|
||||
if results.failed_cogs and results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following modules errored: {modules}. "
|
||||
"Additionally, the following cogs errored: {cogs}\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(
|
||||
cogs=humanize_list(results.failed_cogs),
|
||||
modules=humanize_list(results.failed_modules),
|
||||
)
|
||||
)
|
||||
elif results.failed_cogs:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following cogs errored: {cogs}.\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(cogs=humanize_list(results.failed_cogs))
|
||||
)
|
||||
elif results.failed_modules:
|
||||
await ctx.send(
|
||||
_(
|
||||
"I tried to delete all data about that user, "
|
||||
"(that I know how to delete) "
|
||||
"however the following modules errored: {modules}.\n"
|
||||
"Please check your logs and contact the creators of "
|
||||
"these cogs and modules.\n"
|
||||
"Note: Outside of these failures, data should have been deleted."
|
||||
).format(modules=humanize_list(results.failed_modules))
|
||||
)
|
||||
else:
|
||||
await ctx.send(
|
||||
_("I've deleted all data about that user " "that I know how to delete.")
|
||||
)
|
||||
|
||||
if results.unhandled:
|
||||
await ctx.send(
|
||||
_("{mention} The following cogs did not handle deletion:\n{cogs}").format(
|
||||
mention=ctx.author.mention, cogs=humanize_list(results.unhandled)
|
||||
)
|
||||
)
|
||||
|
||||
@commands.group()
|
||||
async def embedset(self, ctx: commands.Context):
|
||||
"""
|
||||
@@ -2184,7 +2719,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
cog = self.bot.get_cog(cogname)
|
||||
if not cog:
|
||||
return await ctx.send(_("Cog with the given name doesn't exist."))
|
||||
if cog == self:
|
||||
if isinstance(cog, commands.commands._RuleDropper):
|
||||
return await ctx.send(_("You can't disable this cog by default."))
|
||||
await self.bot._disabled_cog_cache.default_disable(cogname)
|
||||
await ctx.send(_("{cogname} has been set as disabled by default.").format(cogname=cogname))
|
||||
@@ -2206,7 +2741,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
cog = self.bot.get_cog(cogname)
|
||||
if not cog:
|
||||
return await ctx.send(_("Cog with the given name doesn't exist."))
|
||||
if cog == self:
|
||||
if isinstance(cog, commands.commands._RuleDropper):
|
||||
return await ctx.send(_("You can't disable this cog as you would lock yourself out."))
|
||||
if await self.bot._disabled_cog_cache.disable_cog_in_guild(cogname, ctx.guild.id):
|
||||
await ctx.send(_("{cogname} has been disabled in this guild.").format(cogname=cogname))
|
||||
@@ -2328,7 +2863,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(command_obj, commands.commands._AlwaysAvailableCommand):
|
||||
if isinstance(command_obj, commands.commands._RuleDropper):
|
||||
await ctx.send(
|
||||
_("This command is designated as being always available and cannot be disabled.")
|
||||
)
|
||||
@@ -2362,7 +2897,7 @@ class Core(commands.Cog, CoreLogic):
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(command_obj, commands.commands._AlwaysAvailableCommand):
|
||||
if isinstance(command_obj, commands.commands._RuleDropper):
|
||||
await ctx.send(
|
||||
_("This command is designated as being always available and cannot be disabled.")
|
||||
)
|
||||
@@ -2748,6 +3283,28 @@ class Core(commands.Cog, CoreLogic):
|
||||
)
|
||||
return msg
|
||||
|
||||
# Removing this command from forks is a violation of the GPLv3 under which it is licensed.
|
||||
# Otherwise interfering with the ability for this command to be accessible is also a violation.
|
||||
@commands.command(
|
||||
cls=commands.commands._AlwaysAvailableCommand,
|
||||
name="licenseinfo",
|
||||
aliases=["licenceinfo"],
|
||||
i18n=_,
|
||||
)
|
||||
async def license_info_command(ctx):
|
||||
"""
|
||||
Get info about Red's licenses.
|
||||
"""
|
||||
|
||||
message = (
|
||||
"This bot is an instance of Red-DiscordBot (hereafter referred to as Red)\n"
|
||||
"Red is a free and open source application made available to the public and "
|
||||
"licensed under the GNU GPLv3. The full text of this license is available to you at "
|
||||
"<https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/LICENSE>"
|
||||
)
|
||||
await ctx.send(message)
|
||||
# We need a link which contains a thank you to other projects which we use at some point.
|
||||
|
||||
|
||||
# DEP-WARN: CooldownMapping should have a method `from_cooldown`
|
||||
# which accepts (number, number, bucket)
|
||||
@@ -2764,30 +3321,7 @@ class LicenseCooldownMapping(commands.CooldownMapping):
|
||||
return (msg.channel.id, msg.author.id)
|
||||
|
||||
|
||||
# Removing this command from forks is a violation of the GPLv3 under which it is licensed.
|
||||
# Otherwise interfering with the ability for this command to be accessible is also a violation.
|
||||
@commands.command(
|
||||
cls=commands.commands._AlwaysAvailableCommand,
|
||||
name="licenseinfo",
|
||||
aliases=["licenceinfo"],
|
||||
i18n=_,
|
||||
)
|
||||
async def license_info_command(ctx):
|
||||
"""
|
||||
Get info about Red's licenses.
|
||||
"""
|
||||
|
||||
message = (
|
||||
"This bot is an instance of Red-DiscordBot (hereafter referred to as Red)\n"
|
||||
"Red is a free and open source application made available to the public and "
|
||||
"licensed under the GNU GPLv3. The full text of this license is available to you at "
|
||||
"<https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/LICENSE>"
|
||||
)
|
||||
await ctx.send(message)
|
||||
# We need a link which contains a thank you to other projects which we use at some point.
|
||||
|
||||
|
||||
# DEP-WARN: command objects should store a single cooldown mapping as `._buckets`
|
||||
license_info_command._buckets = LicenseCooldownMapping.from_cooldown(
|
||||
Core.license_info_command._buckets = LicenseCooldownMapping.from_cooldown(
|
||||
1, 180, commands.BucketType.member # pick a random bucket,it wont get used.
|
||||
)
|
||||
|
||||
@@ -33,6 +33,13 @@ START_CODE_BLOCK_RE = re.compile(r"^((```py)(?=\s)|(```))")
|
||||
class Dev(commands.Cog):
|
||||
"""Various development focused utilities."""
|
||||
|
||||
async def red_delete_data_for_user(self, **kwargs):
|
||||
"""
|
||||
Because despite my best efforts to advise otherwise,
|
||||
people use ``--dev`` in production
|
||||
"""
|
||||
return
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._last_result = None
|
||||
|
||||
@@ -3,12 +3,12 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Union, Optional, cast, TYPE_CHECKING
|
||||
from typing import List, Literal, Union, Optional, cast, TYPE_CHECKING
|
||||
|
||||
import discord
|
||||
|
||||
from redbot.core import Config
|
||||
|
||||
from .utils import AsyncIter
|
||||
from .utils.common_filters import (
|
||||
filter_invites,
|
||||
filter_mass_mentions,
|
||||
@@ -47,10 +47,41 @@ _CASETYPES = "CASETYPES"
|
||||
_CASES = "CASES"
|
||||
_SCHEMA_VERSION = 4
|
||||
|
||||
_data_deletion_lock = asyncio.Lock()
|
||||
|
||||
_ = Translator("ModLog", __file__)
|
||||
|
||||
|
||||
async def _process_data_deletion(
|
||||
*, requester: Literal["discord_deleted_user", "owner", "user", "user_strict"], user_id: int
|
||||
):
|
||||
if requester != "discord_deleted_user":
|
||||
return
|
||||
|
||||
# Oh, how I wish it was as simple as I wanted...
|
||||
|
||||
key_paths = []
|
||||
|
||||
async with _data_deletion_lock:
|
||||
all_cases = await _config.custom(_CASES).all()
|
||||
async for guild_id_str, guild_cases in AsyncIter(all_cases.items(), steps=100):
|
||||
async for case_num_str, case in AsyncIter(guild_cases.items(), steps=100):
|
||||
for keyname in ("user", "moderator", "amended_by"):
|
||||
if (case.get(keyname, 0) or 0) == user_id: # this could be None...
|
||||
key_paths.append((guild_id_str, case_num_str))
|
||||
|
||||
async with _config.custom(_CASES).all() as all_cases:
|
||||
for guild_id_str, case_num_str in key_paths:
|
||||
case = all_cases[guild_id_str][case_num_str]
|
||||
if (case.get("user", 0) or 0) == user_id:
|
||||
case["user"] = 0xDE1
|
||||
case.pop("last_known_username", None)
|
||||
if (case.get("moderator", 0) or 0) == user_id:
|
||||
case["moderator"] = 0xDE1
|
||||
if (case.get("amended_by", 0) or 0) == user_id:
|
||||
case["amended_by"] = 0xDE1
|
||||
|
||||
|
||||
async def _init(bot: Red):
|
||||
global _config
|
||||
global _bot_ref
|
||||
@@ -310,8 +341,11 @@ class Case:
|
||||
moderator = _("Unknown")
|
||||
elif isinstance(self.moderator, int):
|
||||
# can't use _() inside f-string expressions, see bpo-36310 and red#3818
|
||||
translated = _("Unknown or Deleted User")
|
||||
moderator = f"[{translated}] ({self.moderator})"
|
||||
if self.moderator == 0xDE1:
|
||||
moderator = _("Deleted User.")
|
||||
else:
|
||||
translated = _("Unknown or Deleted User")
|
||||
moderator = f"[{translated}] ({self.moderator})"
|
||||
else:
|
||||
moderator = escape_spoilers(f"{self.moderator} ({self.moderator.id})")
|
||||
until = None
|
||||
@@ -329,8 +363,11 @@ class Case:
|
||||
amended_by = None
|
||||
elif isinstance(self.amended_by, int):
|
||||
# can't use _() inside f-string expressions, see bpo-36310 and red#3818
|
||||
translated = _("Unknown or Deleted User")
|
||||
amended_by = f"[{translated}] ({self.amended_by})"
|
||||
if self.amended_by == 0xDE1:
|
||||
amended_by = _("Deleted User.")
|
||||
else:
|
||||
translated = _("Unknown or Deleted User")
|
||||
amended_by = f"[{translated}] ({self.amended_by})"
|
||||
else:
|
||||
amended_by = escape_spoilers(f"{self.amended_by} ({self.amended_by.id})")
|
||||
|
||||
@@ -341,7 +378,9 @@ class Case:
|
||||
)
|
||||
|
||||
if isinstance(self.user, int):
|
||||
if self.last_known_username is None:
|
||||
if self.user == 0xDE1:
|
||||
user = _("Deleted User.")
|
||||
elif self.last_known_username is None:
|
||||
# can't use _() inside f-string expressions, see bpo-36310 and red#3818
|
||||
translated = _("Unknown or Deleted User")
|
||||
user = f"[{translated}] ({self.user})"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional, Union, Set, Iterable, Tuple
|
||||
import asyncio
|
||||
from argparse import Namespace
|
||||
from collections import defaultdict
|
||||
|
||||
import discord
|
||||
|
||||
from .config import Config
|
||||
from .utils import AsyncIter
|
||||
|
||||
|
||||
class PrefixManager:
|
||||
@@ -125,136 +127,185 @@ class WhitelistBlacklistManager:
|
||||
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]:
|
||||
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())
|
||||
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:
|
||||
ret = set(await self._config.whitelist())
|
||||
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()
|
||||
self._cached_whitelist[gid] = ret.copy()
|
||||
|
||||
return ret
|
||||
return ret
|
||||
|
||||
async def add_to_whitelist(self, guild: Optional[discord.Guild], role_or_user: Iterable[int]):
|
||||
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.")
|
||||
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]))
|
||||
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()
|
||||
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])
|
||||
)
|
||||
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):
|
||||
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 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]
|
||||
):
|
||||
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.")
|
||||
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]))
|
||||
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()
|
||||
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])
|
||||
)
|
||||
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]:
|
||||
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())
|
||||
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:
|
||||
ret = set(await self._config.blacklist())
|
||||
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()
|
||||
self._cached_blacklist[gid] = ret.copy()
|
||||
|
||||
return ret
|
||||
return ret
|
||||
|
||||
async def add_to_blacklist(self, guild: Optional[discord.Guild], role_or_user: Iterable[int]):
|
||||
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()
|
||||
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])
|
||||
)
|
||||
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):
|
||||
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 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]
|
||||
):
|
||||
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()
|
||||
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])
|
||||
)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user