diff --git a/discord/__init__.py b/discord/__init__.py index d1bf89db6..069abf179 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -39,6 +39,7 @@ from .emoji import * from .entitlements import * from .enums import * from .errors import * +from .experiment import * from .file import * from .flags import * from .guild import * diff --git a/discord/client.py b/discord/client.py index 462eb5536..21ece250f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -36,6 +36,7 @@ from typing import ( Dict, Generator, List, + Literal, Optional, overload, Sequence, @@ -90,6 +91,7 @@ from .relationship import FriendSuggestion, Relationship from .settings import UserSettings, LegacyUserSettings, TrackingSettings, EmailSettings from .affinity import * from .oauth2 import OAuth2Authorization, OAuth2Token +from .experiment import UserExperiment, GuildExperiment if TYPE_CHECKING: from typing_extensions import Self @@ -527,6 +529,48 @@ class Client: """ return self._connection.tutorial + @property + def experiments(self) -> Sequence[UserExperiment]: + """Sequence[:class:`.UserExperiment`]: The experiments assignments for the connected client. + + .. versionadded:: 2.1 + """ + return utils.SequenceProxy(self._connection.experiments.values()) + + @property + def guild_experiments(self) -> Sequence[GuildExperiment]: + """Sequence[:class:`.GuildExperiment`]: The guild experiments assignments for the connected client. + + .. versionadded:: 2.1 + """ + return utils.SequenceProxy(self._connection.guild_experiments.values()) + + def get_experiment(self, experiment: Union[str, int], /) -> Optional[Union[UserExperiment, GuildExperiment]]: + """Returns a user or guild experiment from the given experiment identifier. + + Parameters + ----------- + experiment: Union[:class:`str`, :class:`int`] + The experiment name or hash to search for. + + Returns + -------- + Optional[Union[:class:`.UserExperiment`, :class:`.GuildExperiment`]] + The experiment, if found. + """ + name = None + if not isinstance(experiment, int) and not experiment.isdigit(): + name = experiment + experiment_hash = utils.murmurhash32(experiment, signed=False) + else: + experiment_hash = int(experiment) + + exp = self._connection.experiments.get(experiment_hash, self._connection.guild_experiments.get(experiment_hash)) + if exp and not exp.name and name: + # Backfill the name + exp.name = name + return exp + def is_ready(self) -> bool: """:class:`bool`: Specifies if the client's internal cache is ready for use.""" return self._ready is not MISSING and self._ready.is_set() @@ -4951,3 +4995,59 @@ class Client: icon_data = utils._bytes_to_base64_data(icon.fp.read()) await state.http.upload_unverified_application_icon(app.name, app.hash, icon_data) return app + + @overload + async def fetch_experiments( + self, with_guild_experiments: Literal[True] = ... + ) -> List[Union[UserExperiment, GuildExperiment]]: + ... + + @overload + async def fetch_experiments(self, with_guild_experiments: Literal[False] = ...) -> List[UserExperiment]: + ... + + @overload + async def fetch_experiments( + self, with_guild_experiments: bool = True + ) -> Union[List[UserExperiment], List[Union[UserExperiment, GuildExperiment]]]: + ... + + async def fetch_experiments( + self, with_guild_experiments: bool = True + ) -> Union[List[UserExperiment], List[Union[UserExperiment, GuildExperiment]]]: + """|coro| + + Retrieves the experiment rollouts available in relation to the user. + + .. versionadded:: 2.1 + + .. note:: + + Certain guild experiments are only available via the gateway. + See :attr:`guild_experiments` for these. + + Parameters + ----------- + with_guild_experiments: :class:`bool` + Whether to include guild experiment rollouts in the response. + + Raises + ------- + HTTPException + Retrieving the experiment assignments failed. + + Returns + ------- + List[Union[:class:`.UserExperiment`, :class:`.GuildExperiment`]] + The experiment rollouts. + """ + state = self._connection + data = await state.http.get_experiments(with_guild_experiments=with_guild_experiments) + + experiments: List[Union[UserExperiment, GuildExperiment]] = [ + UserExperiment(state=state, data=exp) for exp in data['assignments'] + ] + for exp in data.get('guild_experiments', []): + experiments.append(GuildExperiment(state=state, data=exp)) + + return experiments diff --git a/discord/enums.py b/discord/enums.py index 647297d12..febee89de 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1538,6 +1538,16 @@ class ReadStateType(Enum): onboarding = 4 +class ExperimentFilterType(Enum): + feature = 1604612045 + id_range = 2404720969 + member_count_range = 2918402255 + ids = 3013771838 + hub_type = 4148745523 + vanity_url = 188952590 + hash_range = 2294888943 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/experiment.py b/discord/experiment.py new file mode 100644 index 000000000..2bf4b619d --- /dev/null +++ b/discord/experiment.py @@ -0,0 +1,730 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator, List, Optional, Sequence, Tuple, Union + +from .enums import ExperimentFilterType, try_enum +from .metadata import Metadata +from .utils import SequenceProxy, SnowflakeList, murmurhash32 + +if TYPE_CHECKING: + from .abc import Snowflake + from .guild import Guild + from .state import ConnectionState + from .types.experiment import ( + Filters as FiltersPayload, + GuildExperiment as GuildExperimentPayload, + Override as OverridePayload, + Population as PopulationPayload, + Rollout as RolloutPayload, + UserExperiment as AssignmentPayload, + ) + +__all__ = ( + 'ExperimentRollout', + 'ExperimentFilter', + 'ExperimentPopulation', + 'ExperimentOverride', + 'HoldoutExperiment', + 'GuildExperiment', + 'UserExperiment', +) + + +class ExperimentRollout: + """Represents a rollout for an experiment population. + + .. container:: operations + + .. describe:: x in y + + Checks if a position is eligible for the rollout. + + .. versionadded:: 2.1 + + Attributes + ----------- + population: :class:`ExperimentPopulation` + The population this rollout belongs to. + bucket: :class:`int` + The bucket the rollout grants. + ranges: List[Tuple[:class:`int`, :class:`int`]] + The position ranges of the rollout. + """ + + __slots__ = ('population', 'bucket', 'ranges') + + def __init__(self, population: ExperimentPopulation, data: RolloutPayload): + bucket, ranges = data + + self.population = population + self.bucket: int = bucket + self.ranges: List[Tuple[int, int]] = [(range['s'], range['e']) for range in ranges] + + def __repr__(self) -> str: + return f'' + + def __contains__(self, item: int, /) -> bool: + for start, end in self.ranges: + if start <= item <= end: + return True + return False + + +class ExperimentFilter: + """Represents a filter for an experiment population. + + This is a purposefuly very low-level object. + + .. container:: operations + + .. describe:: x in y + + Checks if a guild fulfills the filter requirements. + + .. versionadded:: 2.1 + + Attributes + ----------- + population: :class:`ExperimentPopulation` + The population this filter belongs to. + type: :class:`ExperimentFilterType` + The type of filter. + options: :class:`Metadata` + The parameters for the filter. + If known, murmur3-hashed keys are unhashed to their original names. + """ + + __slots__ = ('population', 'type', 'options') + + # Most of these are taken from the client + FILTER_KEYS = { + 1604612045: 'guild_has_feature', + 2404720969: 'guild_id_range', + 2918402255: 'guild_member_count_range', + 3013771838: 'guild_ids', + 4148745523: 'guild_hub_types', + 188952590: 'guild_has_vanity_url', + 2294888943: 'guild_in_range_by_hash', + 3399957344: 'min_id', + 1238858341: 'max_id', + 2690752156: 'hash_key', + 1982804121: 'target', + 1183251248: 'guild_features', + } + + def __init__(self, population: ExperimentPopulation, data: FiltersPayload): + type, options = data + + self.population = population + self.type: ExperimentFilterType = try_enum(ExperimentFilterType, type) + + self.options = metadata = Metadata() + for key, value in options: + try: + key = self.FILTER_KEYS[int(key)] + except (KeyError, ValueError): + pass + if isinstance(value, str) and value.isdigit(): + value = int(value) + + metadata[str(key)] = value + + def __repr__(self) -> str: + return f'' + + def __contains__(self, guild: Guild, /) -> bool: + return self.is_eligible(guild) + + @staticmethod + def in_range(num: int, start: Optional[int], end: Optional[int], /) -> bool: + if start is not None and num < start: + return False + if end is not None and num > end: + return False + return True + + def is_eligible(self, guild: Guild, /) -> bool: + """Checks whether the guild fulfills the filter requirements. + + .. note:: + + This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`. + + Parameters + ----------- + guild: :class:`Guild` + The guild to check. + + Returns + -------- + :class:`bool` + Whether the guild fulfills the filter requirements. + """ + type = self.type + options = self.options + + if type == ExperimentFilterType.feature: + # One feature must be present + return options.guild_features and any(feature in guild.features for feature in options.guild_features) + elif type == ExperimentFilterType.id_range: + # Guild must be within the range of snowflakes + return self.in_range(guild.id, options.min_id, options.max_id) + elif type == ExperimentFilterType.member_count_range: + # Guild must be within the range of member counts + return guild.member_count is not None and self.in_range(guild.member_count, options.min_id, options.max_id) + elif type == ExperimentFilterType.ids: + # Guild must be in the list of snowflakes, similar to ExperimentOverride + return options.guild_ids is not None and guild.id in options.guild_ids + elif type == ExperimentFilterType.hub_type: + # TODO: Pending hub implementation + # return guild.hub_type and options.guild_hub_types and guild.hub_type.value in options.guild_hub_types + return False + elif type == ExperimentFilterType.hash_range: + # Guild must... no idea tbh + # Probably for cleanly splitting populations + result = murmurhash32(f'{options.hash_key}:{guild.id}', signed=False) + if result > 0: + result += result + else: + result = (result % 0x100000000) >> 0 + return options.target and result % 10000 < options.target + elif type == ExperimentFilterType.vanity_url: + # Guild must or must not have a vanity URL + return bool(guild.vanity_url_code) == options.guild_has_vanity_url + else: + # TODO: Maybe just return False? + raise NotImplementedError(f'Unknown filter type: {type}') + + +class ExperimentPopulation: + """Represents a population of an experiment. + + .. container:: operations + + .. describe:: x in y + + Checks if a guild is present in the population. + + .. versionadded:: 2.1 + + Attributes + ----------- + experiment: :class:`GuildExperiment` + The experiment this population belongs to. + filters: List[:class:`ExperimentFilter`] + The filters that apply to the population. + rollouts: List[Tuple[:class:`int`, :class:`int`]] + The position-based rollouts of the population. + """ + + __slots__ = ('experiment', 'filters', 'rollouts') + + def __init__(self, experiment: GuildExperiment, data: PopulationPayload): + rollouts, filters = data + + self.experiment = experiment + self.filters: List[ExperimentFilter] = [ExperimentFilter(self, x) for x in filters] + self.rollouts: List[ExperimentRollout] = [ExperimentRollout(self, x) for x in rollouts] + + def __repr__(self) -> str: + return f'' + + def __contains__(self, item: Guild, /) -> bool: + return self.bucket_for(item) != -1 + + def bucket_for(self, guild: Guild, _result: Optional[int] = None, /) -> int: + """Returns the assigned experiment bucket within a population for a guild. + Defaults to none (-1) if the guild is not in the population. + + .. note:: + + This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`. + + Parameters + ----------- + guild: :class:`Guild` + The guild to compute experiment eligibility for. + + Raises + ------ + :exc:`ValueError` + The experiment name is unset. + + Returns + ------- + :class:`int` + The experiment bucket. + """ + if _result is None: + _result = self.experiment.result_for(guild) + + for filter in self.filters: + if not filter.is_eligible(guild): + return -1 + + for rollout in self.rollouts: + for start, end in rollout.ranges: + if start <= _result <= end: + return rollout.bucket + + return -1 + + +class ExperimentOverride: + """Represents an experiment override. + + .. container:: operations + + .. describe:: len(x) + + Returns the number of resources eligible for the override. + + .. describe:: x in y + + Checks if a resource is eligible for the override. + + .. describe:: iter(x) + + Returns an iterator of the resources eligible for the override. + + .. versionadded:: 2.1 + + Attributes + ----------- + experiment: :class:`GuildExperiment` + The experiment this override belongs to. + bucket: :class:`int` + The bucket the override applies. + """ + + __slots__ = ('experiment', 'bucket', '_ids') + + def __init__(self, experiment: GuildExperiment, data: OverridePayload): + self.experiment = experiment + self.bucket: int = data['b'] + self._ids: SnowflakeList = SnowflakeList(map(int, data['k'])) + + def __repr__(self) -> str: + return f'' + + def __len__(self) -> int: + return len(self._ids) + + def __contains__(self, item: Union[int, Snowflake], /) -> bool: + return getattr(item, 'id', item) in self._ids + + def __iter__(self) -> Iterator[int]: + return iter(self._ids) + + @property + def ids(self) -> Sequence[int]: + """Sequence[:class:`int`]: The eligible guild/user IDs for the override.""" + return SequenceProxy(self._ids) + + +class HoldoutExperiment: + """Represents an experiment dependency. + + .. container:: operations + + .. describe:: x in y + + Checks if a guild fulfills the dependency. + + .. versionadded:: 2.1 + + Attributes + ----------- + dependent: :class:`GuildExperiment` + The experiment that depends on this experiment. + name: :class:`str` + The name of the dependency. + bucket: :class:`int` + The required bucket of the dependency. + """ + + __slots__ = ('dependent', 'name', 'bucket') + + def __init__(self, dependent: GuildExperiment, name: str, bucket: int): + self.dependent = dependent + self.name: str = name + self.bucket: int = bucket + + def __repr__(self) -> str: + return f'' + + def __contains__(self, item: Guild) -> bool: + return self.is_eligible(item) + + @property + def experiment(self) -> Optional[GuildExperiment]: + """Optional[:class:`GuildExperiment`]: The experiment dependency, if found.""" + experiment_hash = murmurhash32(self.name, signed=False) + experiment = self.dependent._state.guild_experiments.get(experiment_hash) + if experiment and not experiment.name: + # Backfill the name + experiment._name = self.name + return experiment + + def is_eligible(self, guild: Guild, /) -> bool: + """Checks whether the guild fulfills the dependency. + + .. note:: + + This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`. + + Parameters + ----------- + guild: :class:`Guild` + The guild to check. + + Returns + -------- + :class:`bool` + Whether the guild fulfills the dependency. + """ + experiment = self.experiment + if experiment is None: + # We don't have the experiment, so we can't check + return True + + return experiment.bucket_for(guild) == self.bucket + + +class GuildExperiment: + """Represents a guild experiment rollout. + + .. container:: operations + + .. describe:: x == y + + Checks if two experiments are equal. + + .. describe:: x != y + + Checks if two experiments are not equal. + + .. describe:: hash(x) + + Returns the experiment's hash. + + .. versionadded:: 2.1 + + Attributes + ----------- + hash: :class:`int` + The 32-bit unsigned Murmur3 hash of the experiment's name. + revision: :class:`int` + The current revision of the experiment rollout. + populations: List[:class:`ExperimentPopulation`] + The rollout populations of the experiment. + overrides: List[:class:`ExperimentOverride`] + The explicit bucket overrides of the experiment. + overrides_formatted: List[List[:class:`ExperimentPopulation`]] + Additional rollout populations for the experiment. + holdout: Optional[:class:`HoldoutExperiment`] + The experiment this experiment depends on, if any. + aa_mode: :class:`bool` + Whether the experiment is in A/A mode. + """ + + __slots__ = ( + '_state', + 'hash', + '_name', + 'revision', + 'populations', + 'overrides', + 'overrides_formatted', + 'holdout', + 'aa_mode', + ) + + def __init__(self, *, state: ConnectionState, data: GuildExperimentPayload): + ( + hash, + hash_key, + revision, + populations, + overrides, + overrides_formatted, + holdout_name, + holdout_bucket, + aa_mode, + ) = data + + self._state = state + self.hash: int = hash + self._name: Optional[str] = hash_key + self.revision: int = revision + self.populations: List[ExperimentPopulation] = [ExperimentPopulation(self, x) for x in populations] + self.overrides: List[ExperimentOverride] = [ExperimentOverride(self, x) for x in overrides] + self.overrides_formatted: List[List[ExperimentPopulation]] = [ + [ExperimentPopulation(self, y) for y in x] for x in overrides_formatted + ] + self.holdout: Optional[HoldoutExperiment] = ( + HoldoutExperiment(self, holdout_name, holdout_bucket) + if holdout_name is not None and holdout_bucket is not None + else None + ) + self.aa_mode: bool = aa_mode == 1 + + def __repr__(self) -> str: + return f'' + + def __hash__(self) -> int: + return self.hash + + def __eq__(self, other: object, /) -> bool: + if isinstance(other, GuildExperiment): + return self.hash == other.hash + return NotImplemented + + @property + def name(self) -> Optional[str]: + """Optional[:class:`str`]: The unique name of the experiment. + + This data is not always available via the API, and must be set manually for using related functions. + """ + return self._name + + @name.setter + def name(self, value: Optional[str], /) -> None: + if not value: + self._name = None + elif murmurhash32(value, signed=False) != self.hash: + raise ValueError('The name provided does not match the experiment hash') + else: + self._name = value + + def result_for(self, guild: Snowflake, /) -> int: + """Returns the calulated position of the guild within the experiment (0-9999). + + Parameters + ----------- + guild: :class:`abc.Snowflake` + The guild to compute the position for. + + Raises + ------ + :exc:`ValueError` + The experiment name is unset. + + Returns + ------- + :class:`int` + The position of the guild within the experiment. + """ + if not self.name: + raise ValueError('The experiment name must be set to compute the result') + + return murmurhash32(f'{self.name}:{guild.id}', signed=False) % 10000 + + def bucket_for(self, guild: Guild, /) -> int: + """Returns the assigned experiment bucket for a guild. + Defaults to none (-1) if the guild is not in the experiment. + + Parameters + ----------- + guild: :class:`Guild` + The guild to compute experiment eligibility for. + + Raises + ------ + :exc:`ValueError` + The experiment name is unset. + + Returns + ------- + :class:`int` + The experiment bucket. + """ + # a/a mode is always -1 + if self.aa_mode: + return -1 + + # Holdout must be fulfilled + if self.holdout and not self.holdout.is_eligible(guild): + return -1 + + hash_result = self.result_for(guild) + + # Overrides take precedence + # And yes, they can be assigned to a user ID + for override in self.overrides: + if guild.id in override.ids or guild.owner_id in override.ids: + return override.bucket + + for overrides in self.overrides_formatted: + for override in overrides: + pop_bucket = override.bucket_for(guild, hash_result) + if pop_bucket != -1: + return pop_bucket + + for population in self.populations: + pop_bucket = population.bucket_for(guild, hash_result) + if pop_bucket != -1: + return pop_bucket + + return -1 + + def guilds_for(self, bucket: int, /) -> List[Guild]: + """Returns a list of guilds assigned to a specific bucket. + + Parameters + ----------- + bucket: :class:`int` + The bucket to get guilds for. + + Raises + ------ + :exc:`ValueError` + The experiment name is unset. + + Returns + ------- + List[:class:`Guild`] + The guilds assigned to the bucket. + """ + return [x for x in self._state.guilds if self.bucket_for(x) == bucket] + + +class UserExperiment: + """Represents a user's experiment assignment. + + .. container:: operations + + .. describe:: x == y + + Checks if two experiments are equal. + + .. describe:: x != y + + Checks if two experiments are not equal. + + .. describe:: hash(x) + + Returns the experiment's hash. + + .. versionadded:: 2.1 + + .. note:: + + In contrast to the wide range of data provided for guild experiments, + user experiments do not reveal detailed rollout information, providing only the assigned bucket. + + Attributes + ---------- + hash: :class:`int` + The 32-bit unsigned Murmur3 hash of the experiment's name. + revision: :class:`int` + The current revision of the experiment rollout. + assignment: :class:`int` + The assigned bucket for the user. + override: :class:`int` + The overriden bucket for the user, takes precedence over :attr:`assignment`. + population: :class:`int` + The internal population group for the user. + aa_mode: :class:`bool` + Whether the experiment is in A/A mode. + """ + + __slots__ = ( + '_state', + '_name', + 'hash', + 'revision', + 'assignment', + 'override', + 'population', + '_result', + 'aa_mode', + ) + + def __init__(self, *, state: ConnectionState, data: AssignmentPayload): + (hash, revision, bucket, override, population, hash_result, aa_mode) = data + + self._state = state + self._name: Optional[str] = None + self.hash: int = hash + self.revision: int = revision + self.assignment: int = bucket + self.override: int = override + self.population: int = population + self._result: int = hash_result + self.aa_mode: bool = True if aa_mode == 1 else False + + def __repr__(self) -> str: + return f'' + + def __hash__(self) -> int: + return self.hash + + def __eq__(self, other: object, /) -> bool: + if isinstance(other, UserExperiment): + return self.hash == other.hash + return NotImplemented + + @property + def name(self) -> Optional[str]: + """Optional[:class:`str`]: The unique name of the experiment. + + This data is not always available via the API, and must be set manually for using related functions. + """ + return self._name + + @name.setter + def name(self, value: Optional[str], /) -> None: + if not value: + self._name = None + elif murmurhash32(value, signed=False) != self.hash: + raise ValueError('The name provided does not match the experiment hash') + else: + self._name = value + + @property + def bucket(self) -> int: + """:class:`int`: The assigned bucket for the user.""" + if self.aa_mode: + return -1 + return self.override if self.override != -1 else self.population + + @property + def result(self) -> int: + """:class:`int`: The calulated position of the user within the experiment (0-9999). + + Raises + ------ + :exc:`ValueError` + The experiment name is unset without a precomputed result. + """ + if self._result: + return self._result + elif not self.name: + raise ValueError('The experiment name must be set to compute the result') + else: + return murmurhash32(f'{self.name}:{self._state.self_id}', signed=False) % 10000 diff --git a/discord/gateway.py b/discord/gateway.py index 92158f0a4..5822b4a90 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -571,6 +571,7 @@ class DiscordWebSocket: self.sequence = None self.session_id = None self.gateway = self.DEFAULT_GATEWAY + _log.info('Gateway session has been invalidated.') await self.close(code=1000) raise ReconnectWebSocket(resume=False) @@ -583,6 +584,7 @@ class DiscordWebSocket: self.sequence = msg['s'] self.session_id = data['session_id'] self.gateway = yarl.URL(data['resume_gateway_url']) + _log.info('Connected to Gateway (Session ID: %s).', self.session_id) await self.voice_state() # Initial OP 4 diff --git a/discord/http.py b/discord/http.py index 200cc29ad..7f18785e1 100644 --- a/discord/http.py +++ b/discord/http.py @@ -91,6 +91,7 @@ if TYPE_CHECKING: channel, emoji, entitlements, + experiment, guild, integration, invite, @@ -4655,3 +4656,27 @@ class HTTPClient: raise Forbidden(resp, 'cannot retrieve rtc regions') else: raise HTTPException(resp, 'failed to get rtc regions') + + # Experiments + + @overload + def get_experiments( + self, with_guild_experiments: Literal[True] = ... + ) -> Response[experiment.ExperimentResponseWithGuild]: + ... + + @overload + def get_experiments(self, with_guild_experiments: Literal[False] = ...) -> Response[experiment.ExperimentResponse]: + ... + + @overload + def get_experiments( + self, with_guild_experiments: bool = True + ) -> Response[Union[experiment.ExperimentResponse, experiment.ExperimentResponseWithGuild]]: + ... + + def get_experiments( + self, with_guild_experiments: bool = True + ) -> Response[Union[experiment.ExperimentResponse, experiment.ExperimentResponseWithGuild]]: + params = {'with_guild_experiments': str(with_guild_experiments).lower()} + return self.request(Route('GET', '/experiments'), params=params, context_properties=ContextProperties.empty()) diff --git a/discord/metadata.py b/discord/metadata.py index bf5fd4468..2a026d92d 100644 --- a/discord/metadata.py +++ b/discord/metadata.py @@ -70,19 +70,29 @@ class Metadata: return for key, value in data.items(): - if isinstance(value, dict): - value = Metadata(value) - elif key.endswith('_id') and isinstance(value, str) and value.isdigit(): - value = int(value) - elif (key.endswith('_at') or key.endswith('_date')) and isinstance(value, str): + key, value = self.__parse(key, value) + self.__dict__[key] = value + + @staticmethod + def __parse(key: str, value: Any) -> Tuple[str, Any]: + if isinstance(value, dict): + value = Metadata(value) + elif isinstance(value, list): + if key.endswith('_ids'): try: - value = parse_time(value) + value = [int(x) for x in value] except ValueError: pass - elif isinstance(value, list): - value = [Metadata(x) if isinstance(x, dict) else x for x in value] + value = [Metadata(x) if isinstance(x, dict) else x for x in value] + elif key.endswith('_id') and isinstance(value, str) and value.isdigit(): + value = int(value) + elif (key.endswith('_at') or key.endswith('_date')) and isinstance(value, str): + try: + value = parse_time(value) + except ValueError: + pass - self.__dict__[key] = value + return key, value def __repr__(self) -> str: if not self.__dict__: @@ -106,11 +116,16 @@ class Metadata: return self.__dict__[key] def __setitem__(self, key: str, value: Any) -> None: + key, value = self.__parse(key, value) self.__dict__[key] = value def __getattr__(self, _) -> Any: return None + def __setattr__(self, key: str, value: Any) -> None: + key, value = self.__parse(key, value) + self.__dict__[key] = value + def __contains__(self, key: str) -> bool: return key in self.__dict__ diff --git a/discord/state.py b/discord/state.py index d3714e916..cd7cff0a5 100644 --- a/discord/state.py +++ b/discord/state.py @@ -98,6 +98,7 @@ from .automod import AutoModRule, AutoModAction from .audit_logs import AuditLogEntry from .read_state import ReadState from .tutorial import Tutorial +from .experiment import UserExperiment, GuildExperiment if TYPE_CHECKING: from typing_extensions import Self @@ -641,6 +642,9 @@ class ConnectionState: else: self._messages: Optional[Deque[Message]] = None + self.experiments: Dict[int, UserExperiment] = {} + self.guild_experiments: Dict[int, GuildExperiment] = {} + def process_chunk_requests(self, guild_id: int, nonce: Optional[str], members: List[Member], complete: bool) -> None: removed = [] for key, request in self._chunk_requests.items(): @@ -1062,6 +1066,10 @@ class ConnectionState: self.required_action = try_enum(RequiredActionType, data['required_action']) if 'required_action' in data else None self.friend_suggestion_count = data.get('friend_suggestion_count', 0) + # Experiments + self.experiments = {exp[0]: UserExperiment(state=self, data=exp) for exp in data.get('experiments', [])} + self.guild_experiments = {exp[0]: GuildExperiment(state=self, data=exp) for exp in data.get('guild_experiments', [])} + if 'sessions' in data: self.parse_sessions_replace(data['sessions'], from_ready=True) diff --git a/discord/types/experiment.py b/discord/types/experiment.py new file mode 100644 index 000000000..8c725698b --- /dev/null +++ b/discord/types/experiment.py @@ -0,0 +1,102 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +from typing import List, Literal, Optional, Tuple, TypedDict, Union + +from typing_extensions import NotRequired + + +class ExperimentResponse(TypedDict): + fingerprint: NotRequired[str] + assignments: List[UserExperiment] + + +class ExperimentResponseWithGuild(ExperimentResponse): + guild_experiments: NotRequired[List[GuildExperiment]] + + +class RolloutData(TypedDict): + s: int + e: int + + +Rollout = Tuple[int, List[RolloutData]] + + +Filters = Union[ + Tuple[Literal[1604612045], Tuple[Tuple[Literal[1183251248], List[str]]]], # FEATURE + Tuple[ + Literal[2404720969], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]] + ], # ID_RANGE + Tuple[ + Literal[2918402255], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]] + ], # MEMBER_COUNT_RANGE + Tuple[Literal[3013771838], Tuple[Tuple[Literal[3013771838], List[int]]]], # IDs + Tuple[Literal[4148745523], Tuple[Tuple[Literal[4148745523], List[int]]]], # HUB_TYPE + Tuple[Literal[188952590], Tuple[Tuple[Literal[188952590], bool]]], # VANITY_URL + Tuple[Literal[2294888943], Tuple[Tuple[Literal[2690752156], int], Tuple[Literal[1982804121], int]]], # RANGE_BY_HASH +] + + +Population = Tuple[ + List[Rollout], # rollouts + List[Filters], # filters +] + + +class Override(TypedDict): + b: int + k: List[int] + + +Holdout = Tuple[ + int, # bucket + str, # experiment_name +] + + +UserExperiment = Tuple[ + int, # hash + int, # revision + int, # bucket + int, # override + int, # population + int, # hash_result + Literal[0, 1], # aa_mode +] + + +GuildExperiment = Tuple[ + int, # hash + Optional[str], # hash_key + int, # revision + List[Population], # populations + List[Override], # overrides + List[List[Population]], # overrides_formatted + Optional[str], # holdout_name + Optional[int], # holdout_bucket + Literal[0, 1], # aa_mode +] diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 9c7b363c1..1f1a44ff0 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -25,33 +25,34 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations from typing import List, Literal, Optional, TypedDict, Union + from typing_extensions import NotRequired, Required from .activity import Activity, ClientStatus, PartialPresenceUpdate, StatusType +from .application import BaseAchievement, PartialApplication +from .audit_log import AuditLogEntry from .automod import AutoModerationAction, AutoModerationRuleTriggerType -from .voice import GuildVoiceState +from .channel import ChannelType, DMChannel, GroupDMChannel, StageInstance +from .emoji import Emoji, PartialEmoji +from .entitlements import Entitlement, GatewayGift +from .experiment import GuildExperiment, UserExperiment +from .guild import ApplicationCommandCounts, Guild, SupplementalGuild, UnavailableGuild from .integration import BaseIntegration, IntegrationApplication -from .role import Role -from .channel import ChannelType, StageInstance from .interactions import Interaction from .invite import InviteTargetType -from .emoji import Emoji, PartialEmoji +from .library import LibraryApplication from .member import MemberWithUser -from .snowflake import Snowflake from .message import Message -from .sticker import GuildSticker -from .application import BaseAchievement, PartialApplication -from .guild import ApplicationCommandCounts, Guild, UnavailableGuild, SupplementalGuild -from .user import Connection, FriendSuggestion, User, PartialUser, ProtoSettingsType, Relationship, RelationshipType -from .threads import Thread, ThreadMember -from .scheduled_event import GuildScheduledEvent -from .channel import DMChannel, GroupDMChannel -from .subscriptions import PremiumGuildSubscriptionSlot from .payments import Payment -from .entitlements import Entitlement, GatewayGift -from .library import LibraryApplication -from .audit_log import AuditLogEntry from .read_state import ReadState, ReadStateType +from .role import Role +from .scheduled_event import GuildScheduledEvent +from .snowflake import Snowflake +from .sticker import GuildSticker +from .subscriptions import PremiumGuildSubscriptionSlot +from .threads import Thread, ThreadMember +from .user import Connection, FriendSuggestion, PartialUser, ProtoSettingsType, Relationship, RelationshipType, User +from .voice import GuildVoiceState class UserPresenceUpdateEvent(TypedDict): @@ -86,8 +87,10 @@ class ReadyEvent(ResumedEvent): auth_token: NotRequired[str] connected_accounts: List[Connection] country_code: str + experiments: List[UserExperiment] friend_suggestion_count: int geo_ordered_rtc_regions: List[str] + guild_experiments: List[GuildExperiment] guilds: List[Guild] merged_members: List[List[MemberWithUser]] pending_payments: NotRequired[List[Payment]] diff --git a/discord/utils.py b/discord/utils.py index b5670bbda..3782dce68 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -1520,7 +1520,6 @@ def stream_supports_colour(stream: Any) -> bool: class _ColourFormatter(logging.Formatter): - # ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher # It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands # The important ones here relate to colour. @@ -1620,3 +1619,67 @@ def setup_logging( handler.setFormatter(formatter) logger.setLevel(level) logger.addHandler(handler) + + +if TYPE_CHECKING: + + def murmurhash32(key: Union[bytes, bytearray, memoryview, str], seed: int = 0, *, signed: bool = True) -> int: # type: ignore + pass + +else: + try: + from mmh3 import hash as murmurhash32 # Prefer the mmh3 package if available + + except ImportError: + # Modified murmurhash3 function from https://github.com/wc-duck/pymmh3/blob/master/pymmh3.py + def murmurhash32(key: Union[bytes, bytearray, memoryview, str], seed: int = 0, *, signed: bool = True) -> int: + key = bytearray(key.encode() if isinstance(key, str) else key) + length = len(key) + nblocks = int(length / 4) + + h1 = seed + c1 = 0xCC9E2D51 + c2 = 0x1B873593 + + for block_start in range(0, nblocks * 4, 4): + k1 = ( + key[block_start + 3] << 24 + | key[block_start + 2] << 16 + | key[block_start + 1] << 8 + | key[block_start + 0] + ) + + k1 = (c1 * k1) & 0xFFFFFFFF + k1 = (k1 << 15 | k1 >> 17) & 0xFFFFFFFF + k1 = (c2 * k1) & 0xFFFFFFFF + + h1 ^= k1 + h1 = (h1 << 13 | h1 >> 19) & 0xFFFFFFFF + h1 = (h1 * 5 + 0xE6546B64) & 0xFFFFFFFF + + tail_index = nblocks * 4 + k1 = 0 + tail_size = length & 3 + + if tail_size >= 3: + k1 ^= key[tail_index + 2] << 16 + if tail_size >= 2: + k1 ^= key[tail_index + 1] << 8 + if tail_size >= 1: + k1 ^= key[tail_index + 0] + if tail_size > 0: + k1 = (k1 * c1) & 0xFFFFFFFF + k1 = (k1 << 15 | k1 >> 17) & 0xFFFFFFFF + k1 = (k1 * c2) & 0xFFFFFFFF + h1 ^= k1 + + unsigned_val = h1 ^ length + unsigned_val ^= unsigned_val >> 16 + unsigned_val = (unsigned_val * 0x85EBCA6B) & 0xFFFFFFFF + unsigned_val ^= unsigned_val >> 13 + unsigned_val = (unsigned_val * 0xC2B2AE35) & 0xFFFFFFFF + unsigned_val ^= unsigned_val >> 16 + if not signed or (unsigned_val & 0x80000000 == 0): + return unsigned_val + else: + return -((unsigned_val ^ 0xFFFFFFFF) + 1) diff --git a/docs/api.rst b/docs/api.rst index 1556bdb3b..5bad797e0 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5674,6 +5674,40 @@ of :class:`enum.Enum`. Represents a guild-bound read state for guild onboarding. Only one exists per guild. +.. class:: ExperimentFilterType + + Represents the type of an experiment population filter. + + .. versionadded:: 2.1 + + .. attribute:: feature + + The guild must have one of the given features. + + .. attribute:: id_range + + The guild's ID must be within the given range. + + .. attribute:: member_count_range + + The guild's member count must be within the given range. + + .. attribute:: ids + + The guild must be in the given list of IDs. + + .. attribute:: hub_type + + The guild must be one of the given hub types. + + .. attribute:: vanity_url + + The guild must or must not have a vanity URL. + + .. attribute:: hash_range + + The guild's calculated hash must be less than the given value. + .. _discord-api-audit-logs: @@ -7741,6 +7775,44 @@ ForumTag .. autoclass:: ForumTag :members: +Experiment +~~~~~~~~~~ + +.. attributetable:: UserExperiment + +.. autoclass:: UserExperiment() + :members: + +.. attributetable:: GuildExperiment + +.. autoclass:: GuildExperiment() + :members: + +.. attributetable:: HoldoutExperiment + +.. autoclass:: HoldoutExperiment() + :members: + +.. attributetable:: ExperimentOverride + +.. autoclass:: ExperimentOverride() + :members: + +.. attributetable:: ExperimentPopulation + +.. autoclass:: ExperimentPopulation() + :members: + +.. attributetable:: ExperimentFilter + +.. autoclass:: ExperimentFilter() + :members: + +.. attributetable:: ExperimentRollout + +.. autoclass:: ExperimentRollout() + :members: + Flags ~~~~~~ diff --git a/setup.py b/setup.py index 6c190caba..3d5314ab0 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ extras_require = { 'aiodns>=1.1', 'Brotli', 'cchardet==2.1.7; python_version < "3.10"', + 'mmh3>=2.5', ], 'test': [ 'coverage[toml]',