mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-08-11 05:52:12 +00:00
Add support for guild onboarding
Co-authored-by: Josh <8677174+bijij@users.noreply.github.com> Co-authored-by: Josh <josh.ja.butt@gmail.com> Co-authored-by: numbermaniac <5206120+numbermaniac@users.noreply.github.com> Co-authored-by: Andrin <65789180+Puncher1@users.noreply.github.com> Co-authored-by: Andrin Schaller <65789180+codeofandrin@users.noreply.github.com> Co-authored-by: DA344 <108473820+DA-344@users.noreply.github.com>
This commit is contained in:
parent
21fed315c7
commit
7b3f798044
@ -74,6 +74,7 @@ from .soundboard import *
|
||||
from .subscription import *
|
||||
from .presences import *
|
||||
from .primary_guild import *
|
||||
from .onboarding import *
|
||||
|
||||
|
||||
class VersionInfo(NamedTuple):
|
||||
|
@ -44,6 +44,7 @@ from .sticker import GuildSticker
|
||||
from .threads import Thread
|
||||
from .integrations import PartialIntegration
|
||||
from .channel import ForumChannel, StageChannel, ForumTag
|
||||
from .onboarding import OnboardingPrompt, OnboardingPromptOption
|
||||
|
||||
__all__ = (
|
||||
'AuditLogDiff',
|
||||
@ -73,6 +74,7 @@ if TYPE_CHECKING:
|
||||
from .types.snowflake import Snowflake
|
||||
from .types.command import ApplicationCommandPermissions
|
||||
from .types.automod import AutoModerationAction
|
||||
from .types.onboarding import Prompt as PromptPayload, PromptOption as PromptOptionPayload
|
||||
from .user import User
|
||||
from .app_commands import AppCommand
|
||||
from .webhook import Webhook
|
||||
@ -246,6 +248,16 @@ def _transform_default_emoji(entry: AuditLogEntry, data: str) -> PartialEmoji:
|
||||
return PartialEmoji(name=data)
|
||||
|
||||
|
||||
def _transform_onboarding_prompts(entry: AuditLogEntry, data: List[PromptPayload]) -> List[OnboardingPrompt]:
|
||||
return [OnboardingPrompt.from_dict(data=prompt, state=entry._state, guild=entry.guild) for prompt in data]
|
||||
|
||||
|
||||
def _transform_onboarding_prompt_options(
|
||||
entry: AuditLogEntry, data: List[PromptOptionPayload]
|
||||
) -> List[OnboardingPromptOption]:
|
||||
return [OnboardingPromptOption.from_dict(data=option, state=entry._state, guild=entry.guild) for option in data]
|
||||
|
||||
|
||||
E = TypeVar('E', bound=enums.Enum)
|
||||
|
||||
|
||||
@ -268,13 +280,15 @@ def _flag_transformer(cls: Type[F]) -> Callable[[AuditLogEntry, Union[int, str]]
|
||||
|
||||
def _transform_type(
|
||||
entry: AuditLogEntry, data: Union[int, str]
|
||||
) -> Union[enums.ChannelType, enums.StickerType, enums.WebhookType, str]:
|
||||
) -> Union[enums.ChannelType, enums.StickerType, enums.WebhookType, str, enums.OnboardingPromptType]:
|
||||
if entry.action.name.startswith('sticker_'):
|
||||
return enums.try_enum(enums.StickerType, data)
|
||||
elif entry.action.name.startswith('integration_'):
|
||||
return data # type: ignore # integration type is str
|
||||
elif entry.action.name.startswith('webhook_'):
|
||||
return enums.try_enum(enums.WebhookType, data)
|
||||
elif entry.action.name.startswith('onboarding_prompt_'):
|
||||
return enums.try_enum(enums.OnboardingPromptType, data)
|
||||
else:
|
||||
return enums.try_enum(enums.ChannelType, data)
|
||||
|
||||
@ -353,7 +367,11 @@ class AuditLogChanges:
|
||||
'flags': (None, _transform_overloaded_flags),
|
||||
'default_reaction_emoji': (None, _transform_default_reaction),
|
||||
'emoji_name': ('emoji', _transform_default_emoji),
|
||||
'user_id': ('user', _transform_member_id)
|
||||
'user_id': ('user', _transform_member_id),
|
||||
'options': (None, _transform_onboarding_prompt_options),
|
||||
'prompts': (None, _transform_onboarding_prompts),
|
||||
'default_channel_ids': ('default_channels', _transform_channels_or_threads),
|
||||
'mode': (None, _enum_transformer(enums.OnboardingMode)),
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
@ -977,3 +995,6 @@ class AuditLogEntry(Hashable):
|
||||
from .webhook import Webhook
|
||||
|
||||
return self._webhooks.get(target_id) or Object(target_id, type=Webhook)
|
||||
|
||||
def _convert_target_onboarding_prompt(self, target_id: int) -> Object:
|
||||
return Object(target_id, type=OnboardingPrompt)
|
||||
|
@ -79,6 +79,8 @@ __all__ = (
|
||||
'SubscriptionStatus',
|
||||
'MessageReferenceType',
|
||||
'StatusDisplayType',
|
||||
'OnboardingPromptType',
|
||||
'OnboardingMode',
|
||||
)
|
||||
|
||||
|
||||
@ -402,6 +404,13 @@ class AuditLogAction(Enum):
|
||||
automod_quarantine_user = 146
|
||||
creator_monetization_request_created = 150
|
||||
creator_monetization_terms_accepted = 151
|
||||
onboarding_prompt_create = 163
|
||||
onboarding_prompt_update = 164
|
||||
onboarding_prompt_delete = 165
|
||||
onboarding_create = 166
|
||||
onboarding_update = 167
|
||||
home_settings_create = 190
|
||||
home_settings_update = 191
|
||||
# fmt: on
|
||||
|
||||
@property
|
||||
@ -468,6 +477,13 @@ class AuditLogAction(Enum):
|
||||
AuditLogAction.soundboard_sound_create: AuditLogActionCategory.create,
|
||||
AuditLogAction.soundboard_sound_update: AuditLogActionCategory.update,
|
||||
AuditLogAction.soundboard_sound_delete: AuditLogActionCategory.delete,
|
||||
AuditLogAction.onboarding_prompt_create: AuditLogActionCategory.create,
|
||||
AuditLogAction.onboarding_prompt_update: AuditLogActionCategory.update,
|
||||
AuditLogAction.onboarding_prompt_delete: AuditLogActionCategory.delete,
|
||||
AuditLogAction.onboarding_create: AuditLogActionCategory.create,
|
||||
AuditLogAction.onboarding_update: AuditLogActionCategory.update,
|
||||
AuditLogAction.home_settings_create: AuditLogActionCategory.create,
|
||||
AuditLogAction.home_settings_update: AuditLogActionCategory.update,
|
||||
}
|
||||
# fmt: on
|
||||
return lookup[self]
|
||||
@ -513,6 +529,12 @@ class AuditLogAction(Enum):
|
||||
return 'user'
|
||||
elif v < 152:
|
||||
return 'creator_monetization'
|
||||
elif v < 166:
|
||||
return 'onboarding_prompt'
|
||||
elif v < 168:
|
||||
return 'onboarding'
|
||||
elif v < 192:
|
||||
return 'home_settings'
|
||||
|
||||
|
||||
class UserFlags(Enum):
|
||||
@ -921,6 +943,16 @@ class StatusDisplayType(Enum):
|
||||
details = 2
|
||||
|
||||
|
||||
class OnboardingPromptType(Enum):
|
||||
multiple_choice = 0
|
||||
dropdown = 1
|
||||
|
||||
|
||||
class OnboardingMode(Enum):
|
||||
default = 0
|
||||
advanced = 1
|
||||
|
||||
|
||||
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}'
|
||||
|
@ -76,6 +76,7 @@ from .enums import (
|
||||
AutoModRuleEventType,
|
||||
ForumOrderType,
|
||||
ForumLayoutType,
|
||||
OnboardingMode,
|
||||
)
|
||||
from .mixins import Hashable
|
||||
from .user import User
|
||||
@ -91,6 +92,7 @@ from .sticker import GuildSticker
|
||||
from .file import File
|
||||
from .audit_logs import AuditLogEntry
|
||||
from .object import OLDEST_OBJECT, Object
|
||||
from .onboarding import Onboarding
|
||||
from .welcome_screen import WelcomeScreen, WelcomeChannel
|
||||
from .automod import AutoModRule, AutoModTrigger, AutoModRuleAction
|
||||
from .partial_emoji import _EmojiTag, PartialEmoji
|
||||
@ -139,6 +141,7 @@ if TYPE_CHECKING:
|
||||
from .types.widget import EditWidgetSettings
|
||||
from .types.audit_log import AuditLogEvent
|
||||
from .message import EmojiInputType
|
||||
from .onboarding import OnboardingPrompt
|
||||
|
||||
VocalGuildChannel = Union[VoiceChannel, StageChannel]
|
||||
GuildChannel = Union[VocalGuildChannel, ForumChannel, TextChannel, CategoryChannel]
|
||||
@ -4879,3 +4882,74 @@ class Guild(Hashable):
|
||||
|
||||
data = await self._state.http.create_soundboard_sound(self.id, reason=reason, **payload)
|
||||
return SoundboardSound(guild=self, state=self._state, data=data)
|
||||
|
||||
async def onboarding(self) -> Onboarding:
|
||||
"""|coro|
|
||||
|
||||
Fetches the onboarding configuration for this guild.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
Returns
|
||||
--------
|
||||
:class:`Onboarding`
|
||||
The onboarding configuration that was fetched.
|
||||
"""
|
||||
data = await self._state.http.get_guild_onboarding(self.id)
|
||||
return Onboarding(data=data, guild=self, state=self._state)
|
||||
|
||||
async def edit_onboarding(
|
||||
self,
|
||||
*,
|
||||
prompts: List[OnboardingPrompt] = MISSING,
|
||||
default_channels: List[Snowflake] = MISSING,
|
||||
enabled: bool = MISSING,
|
||||
mode: OnboardingMode = MISSING,
|
||||
reason: str = MISSING,
|
||||
) -> Onboarding:
|
||||
"""|coro|
|
||||
|
||||
Edits the onboarding configuration for this guild.
|
||||
|
||||
You must have :attr:`Permissions.manage_guild` and
|
||||
:attr:`Permissions.manage_roles` to do this.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
Parameters
|
||||
-----------
|
||||
prompts: List[:class:`OnboardingPrompt`]
|
||||
The prompts that will be shown to new members.
|
||||
This overrides the existing prompts and its options.
|
||||
default_channels: List[:class:`abc.Snowflake`]
|
||||
The channels that will be used as the default channels for new members.
|
||||
This overrides the existing default channels.
|
||||
enabled: :class:`bool`
|
||||
Whether the onboarding configuration is enabled.
|
||||
This overrides the existing enabled state.
|
||||
mode: :class:`OnboardingMode`
|
||||
The mode that will be used for the onboarding configuration.
|
||||
reason: :class:`str`
|
||||
The reason for editing the onboarding configuration. Shows up on the audit log.
|
||||
|
||||
Raises
|
||||
-------
|
||||
Forbidden
|
||||
You do not have permissions to edit the onboarding configuration.
|
||||
HTTPException
|
||||
Editing the onboarding configuration failed.
|
||||
|
||||
Returns
|
||||
--------
|
||||
:class:`Onboarding`
|
||||
The new onboarding configuration.
|
||||
"""
|
||||
data = await self._state.http.edit_guild_onboarding(
|
||||
self.id,
|
||||
prompts=[p.to_dict(id=i) for i, p in enumerate(prompts)] if prompts is not MISSING else None,
|
||||
default_channel_ids=[c.id for c in default_channels] if default_channels is not MISSING else None,
|
||||
enabled=enabled if enabled is not MISSING else None,
|
||||
mode=mode.value if mode is not MISSING else None,
|
||||
reason=reason if reason is not MISSING else None,
|
||||
)
|
||||
return Onboarding(data=data, guild=self, state=self._state)
|
||||
|
@ -81,6 +81,7 @@ if TYPE_CHECKING:
|
||||
invite,
|
||||
member,
|
||||
message,
|
||||
onboarding,
|
||||
template,
|
||||
role,
|
||||
user,
|
||||
@ -2541,6 +2542,42 @@ class HTTPClient:
|
||||
),
|
||||
)
|
||||
|
||||
# Guild Onboarding
|
||||
|
||||
def get_guild_onboarding(self, guild_id: Snowflake) -> Response[onboarding.Onboarding]:
|
||||
return self.request(Route('GET', '/guilds/{guild_id}/onboarding', guild_id=guild_id))
|
||||
|
||||
def edit_guild_onboarding(
|
||||
self,
|
||||
guild_id: Snowflake,
|
||||
*,
|
||||
prompts: Optional[List[onboarding.Prompt]] = None,
|
||||
default_channel_ids: Optional[List[Snowflake]] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
mode: Optional[onboarding.OnboardingMode] = None,
|
||||
reason: Optional[str],
|
||||
) -> Response[onboarding.Onboarding]:
|
||||
|
||||
payload = {}
|
||||
|
||||
if prompts is not None:
|
||||
payload['prompts'] = prompts
|
||||
|
||||
if default_channel_ids is not None:
|
||||
payload['default_channel_ids'] = default_channel_ids
|
||||
|
||||
if enabled is not None:
|
||||
payload['enabled'] = enabled
|
||||
|
||||
if mode is not None:
|
||||
payload['mode'] = mode
|
||||
|
||||
return self.request(
|
||||
Route('PUT', f'/guilds/{guild_id}/onboarding', guild_id=guild_id),
|
||||
json=payload,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
# Soundboard
|
||||
|
||||
def get_soundboard_default_sounds(self) -> Response[List[soundboard.SoundboardDefaultSound]]:
|
||||
|
369
discord/onboarding.py
Normal file
369
discord/onboarding.py
Normal file
@ -0,0 +1,369 @@
|
||||
"""
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present Rapptz
|
||||
|
||||
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, Iterable, Optional, Set, List, Union
|
||||
|
||||
from .mixins import Hashable
|
||||
from .enums import OnboardingMode, OnboardingPromptType, try_enum
|
||||
from .partial_emoji import PartialEmoji
|
||||
from .utils import cached_slot_property, MISSING
|
||||
from . import utils
|
||||
|
||||
__all__ = (
|
||||
'Onboarding',
|
||||
'OnboardingPrompt',
|
||||
'OnboardingPromptOption',
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Self
|
||||
|
||||
from .abc import GuildChannel, Snowflake
|
||||
from .emoji import Emoji
|
||||
from .guild import Guild
|
||||
from .partial_emoji import PartialEmoji
|
||||
from .role import Role
|
||||
from .threads import Thread
|
||||
from .types.onboarding import (
|
||||
Prompt as PromptPayload,
|
||||
PromptOption as PromptOptionPayload,
|
||||
CreatePromptOption as CreatePromptOptionPayload,
|
||||
Onboarding as OnboardingPayload,
|
||||
)
|
||||
from .state import ConnectionState
|
||||
|
||||
|
||||
class OnboardingPromptOption(Hashable):
|
||||
"""Represents a onboarding prompt option.
|
||||
|
||||
This can be manually created for :meth:`Guild.edit_onboarding`.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
Parameters
|
||||
-----------
|
||||
title: :class:`str`
|
||||
The title of this prompt option.
|
||||
emoji: Union[:class:`Emoji`, :class:`PartialEmoji`, :class:`str`]
|
||||
The emoji tied to this option. May be a custom emoji, or a unicode emoji. I
|
||||
f this is a string, it will be converted to a :class:`PartialEmoji`.
|
||||
description: Optional[:class:`str`]
|
||||
The description of this prompt option.
|
||||
channels: Iterable[Union[:class:`abc.Snowflake`, :class:`int`]]
|
||||
The channels the user will be added to if this option is selected.
|
||||
roles: Iterable[Union[:class:`abc.Snowflake`, :class:`int`]]
|
||||
The roles the user will be given if this option is selected.
|
||||
|
||||
Attributes
|
||||
-----------
|
||||
id: :class:`int`
|
||||
The ID of this prompt option. If this was manually created then the ID will be ``0``.
|
||||
title: :class:`str`
|
||||
The title of this prompt option.
|
||||
description: Optional[:class:`str`]
|
||||
The description of this prompt option.
|
||||
emoji: Optional[Union[:class:`Emoji`, :class:`PartialEmoji`]]
|
||||
The emoji tied to this option. May be a custom emoji, or a unicode emoji.
|
||||
channel_ids: Set[:class:`int`]
|
||||
The IDs of the channels the user will be added to if this option is selected.
|
||||
role_ids: Set[:class:`int`]
|
||||
The IDs of the roles the user will be given if this option is selected.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
'title',
|
||||
'emoji',
|
||||
'description',
|
||||
'id',
|
||||
'channel_ids',
|
||||
'role_ids',
|
||||
'_guild',
|
||||
'_cs_channels',
|
||||
'_cs_roles',
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
emoji: Union[Emoji, PartialEmoji, str] = MISSING,
|
||||
description: Optional[str] = None,
|
||||
channels: Iterable[Union[Snowflake, int]] = MISSING,
|
||||
roles: Iterable[Union[Snowflake, int]] = MISSING,
|
||||
) -> None:
|
||||
self.id: int = 0
|
||||
self.title: str = title
|
||||
self.description: Optional[str] = description
|
||||
self.emoji: Optional[Union[Emoji, PartialEmoji]] = (
|
||||
PartialEmoji.from_str(emoji) if isinstance(emoji, str) else emoji if emoji is not MISSING else None
|
||||
)
|
||||
|
||||
self.channel_ids: Set[int] = (
|
||||
{c.id if not isinstance(c, int) else c for c in channels} if channels is not MISSING else set()
|
||||
)
|
||||
self.role_ids: Set[int] = {c.id if not isinstance(c, int) else c for c in roles} if roles is not MISSING else set()
|
||||
self._guild: Optional[Guild] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<OnboardingPromptOption id={self.id!r} title={self.title!r} emoji={self.emoji!r}>'
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, *, data: PromptOptionPayload, state: ConnectionState, guild: Guild) -> Self:
|
||||
instance = cls(
|
||||
title=data['title'],
|
||||
description=data['description'],
|
||||
emoji=state.get_emoji_from_partial_payload(data['emoji']) if 'emoji' in data else MISSING,
|
||||
channels=[int(id) for id in data['channel_ids']],
|
||||
roles=[int(id) for id in data['role_ids']],
|
||||
)
|
||||
instance._guild = guild
|
||||
instance.id = int(data['id'])
|
||||
return instance
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
) -> CreatePromptOptionPayload:
|
||||
res: CreatePromptOptionPayload = {
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'channel_ids': list(self.channel_ids),
|
||||
'role_ids': list(self.role_ids),
|
||||
}
|
||||
if self.emoji:
|
||||
res.update((self.emoji._to_partial())._to_onboarding_prompt_option_payload()) # type: ignore
|
||||
return res
|
||||
|
||||
@property
|
||||
def guild(self) -> Guild:
|
||||
""":class:`Guild`: The guild this prompt option is related to.
|
||||
|
||||
Raises
|
||||
-------
|
||||
ValueError
|
||||
If the prompt option was created manually.
|
||||
"""
|
||||
if self._guild is None:
|
||||
raise ValueError('This prompt does not have an associated guild because it was created manually.')
|
||||
return self._guild
|
||||
|
||||
@cached_slot_property('_cs_channels')
|
||||
def channels(self) -> List[Union[GuildChannel, Thread]]:
|
||||
"""List[Union[:class:`abc.GuildChannel`, :class:`Thread`]]: The list of channels which will be made visible if this option is selected.
|
||||
|
||||
Raises
|
||||
-------
|
||||
ValueError
|
||||
IF the prompt option is manually created, therefore has no guild.
|
||||
"""
|
||||
it = filter(None, map(self.guild._resolve_channel, self.channel_ids))
|
||||
return utils._unique(it)
|
||||
|
||||
@cached_slot_property('_cs_roles')
|
||||
def roles(self) -> List[Role]:
|
||||
"""List[:class:`Role`]: The list of roles given to the user if this option is selected.
|
||||
|
||||
Raises
|
||||
-------
|
||||
ValueError
|
||||
If the prompt option is manually created, therefore has no guild.
|
||||
"""
|
||||
it = filter(None, map(self.guild.get_role, self.role_ids))
|
||||
return utils._unique(it)
|
||||
|
||||
|
||||
class OnboardingPrompt:
|
||||
"""Represents a onboarding prompt.
|
||||
|
||||
This can be manually created for :meth:`Guild.edit_onboarding`.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
Parameters
|
||||
-----------
|
||||
type: :class:`OnboardingPromptType`
|
||||
The type of this prompt.
|
||||
title: :class:`str`
|
||||
The title of this prompt.
|
||||
options: List[:class:`OnboardingPromptOption`]
|
||||
The options of this prompt.
|
||||
single_select: :class:`bool`
|
||||
Whether this prompt is single select.
|
||||
Defaults to ``True``.
|
||||
required: :class:`bool`
|
||||
Whether this prompt is required.
|
||||
Defaults to ``True``.
|
||||
in_onboarding: :class:`bool`
|
||||
Whether this prompt is in the onboarding flow.
|
||||
Defaults to ``True``.
|
||||
|
||||
Attributes
|
||||
-----------
|
||||
id: :class:`int`
|
||||
The ID of this prompt. If this was manually created then the ID will be ``0``.
|
||||
type: :class:`OnboardingPromptType`
|
||||
The type of this prompt.
|
||||
title: :class:`str`
|
||||
The title of this prompt.
|
||||
options: List[:class:`OnboardingPromptOption`]
|
||||
The options of this prompt.
|
||||
single_select: :class:`bool`
|
||||
Whether this prompt is single select.
|
||||
required: :class:`bool`
|
||||
Whether this prompt is required.
|
||||
in_onboarding: :class:`bool`
|
||||
Whether this prompt is in the onboarding flow.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
'id',
|
||||
'type',
|
||||
'title',
|
||||
'options',
|
||||
'single_select',
|
||||
'required',
|
||||
'in_onboarding',
|
||||
'_guild',
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
type: OnboardingPromptType,
|
||||
title: str,
|
||||
options: List[OnboardingPromptOption],
|
||||
single_select: bool = True,
|
||||
required: bool = True,
|
||||
in_onboarding: bool = True,
|
||||
) -> None:
|
||||
self.id: int = 0
|
||||
self.type: OnboardingPromptType = type
|
||||
self.title: str = title
|
||||
self.options: List[OnboardingPromptOption] = options
|
||||
self.single_select: bool = single_select
|
||||
self.required: bool = required
|
||||
self.in_onboarding: bool = in_onboarding
|
||||
|
||||
self._guild: Optional[Guild] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<OnboardingPrompt id={self.id!r} title={self.title!r}, type={self.type!r}>'
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, *, data: PromptPayload, state: ConnectionState, guild: Guild) -> Self:
|
||||
instance = cls(
|
||||
type=try_enum(OnboardingPromptType, data['type']),
|
||||
title=data['title'],
|
||||
options=[
|
||||
OnboardingPromptOption.from_dict(data=option_data, state=state, guild=guild) # type: ignore
|
||||
for option_data in data['options']
|
||||
],
|
||||
single_select=data['single_select'],
|
||||
required=data['required'],
|
||||
in_onboarding=data['in_onboarding'],
|
||||
)
|
||||
instance.id = int(data['id'])
|
||||
return instance
|
||||
|
||||
def to_dict(self, *, id: int) -> PromptPayload:
|
||||
return {
|
||||
'id': id,
|
||||
'type': self.type.value,
|
||||
'title': self.title,
|
||||
'options': [option.to_dict() for option in self.options],
|
||||
'single_select': self.single_select,
|
||||
'required': self.required,
|
||||
'in_onboarding': self.in_onboarding,
|
||||
}
|
||||
|
||||
@property
|
||||
def guild(self) -> Guild:
|
||||
""":class:`Guild`: The guild this prompt is related to.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the prompt was created manually.
|
||||
"""
|
||||
if self._guild is None:
|
||||
raise ValueError('This prompt does not have an associated guild because it was created manually.')
|
||||
return self._guild
|
||||
|
||||
def get_option(self, option_id: int, /) -> Optional[OnboardingPromptOption]:
|
||||
"""Optional[:class:`OnboardingPromptOption`]: The option with the given ID, if found."""
|
||||
return next((option for option in self.options if option.id == option_id), None)
|
||||
|
||||
|
||||
class Onboarding:
|
||||
"""Represents a guild's onboarding configuration.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
Attributes
|
||||
-----------
|
||||
guild: :class:`Guild`
|
||||
The guild the onboarding configuration is for.
|
||||
prompts: List[:class:`OnboardingPrompt`]
|
||||
The list of prompts shown during the onboarding and customize community flows.
|
||||
default_channel_ids: Set[:class:`int`]
|
||||
The IDs of the channels exposed to a new user by default.
|
||||
enabled: :class:`bool`:
|
||||
Whether onboarding is enabled in this guild.
|
||||
mode: :class:`OnboardingMode`
|
||||
The mode of onboarding for this guild.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
'_state',
|
||||
'_cs_default_channels',
|
||||
'guild',
|
||||
'prompts',
|
||||
'default_channel_ids',
|
||||
'enabled',
|
||||
'mode',
|
||||
)
|
||||
|
||||
def __init__(self, *, data: OnboardingPayload, guild: Guild, state: ConnectionState) -> None:
|
||||
self._state: ConnectionState = state
|
||||
self.guild: Guild = guild
|
||||
self.default_channel_ids: Set[int] = {int(channel_id) for channel_id in data['default_channel_ids']}
|
||||
self.prompts: List[OnboardingPrompt] = [
|
||||
OnboardingPrompt.from_dict(data=prompt_data, state=state, guild=guild) for prompt_data in data['prompts']
|
||||
]
|
||||
self.enabled: bool = data['enabled']
|
||||
self.mode: OnboardingMode = try_enum(OnboardingMode, data.get('mode', 0))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Onboarding guild={self.guild!r} enabled={self.enabled!r} mode={self.mode!r}>'
|
||||
|
||||
@cached_slot_property('_cs_default_channels')
|
||||
def default_channels(self) -> List[Union[GuildChannel, Thread]]:
|
||||
"""List[Union[:class:`abc.GuildChannel`, :class:`Thread`]]: The list of channels exposed to a new user by default."""
|
||||
it = filter(None, map(self.guild._resolve_channel, self.default_channel_ids))
|
||||
return utils._unique(it)
|
||||
|
||||
def get_prompt(self, prompt_id: int, /) -> Optional[OnboardingPrompt]:
|
||||
"""Optional[:class:`OnboardingPrompt`]: The prompt with the given ID, if found."""
|
||||
return next((prompt for prompt in self.prompts if prompt.id == prompt_id), None)
|
@ -167,6 +167,12 @@ class PartialEmoji(_EmojiTag, AssetMixin):
|
||||
return {'emoji_id': self.id, 'emoji_name': None}
|
||||
return {'emoji_id': None, 'emoji_name': self.name}
|
||||
|
||||
def _to_onboarding_prompt_option_payload(self) -> Dict[str, Any]:
|
||||
if self.id is not None:
|
||||
return {'emoji_id': self.id, 'emoji_name': self.name, 'emoji_animated': self.animated}
|
||||
|
||||
return {'emoji_name': self.name}
|
||||
|
||||
@classmethod
|
||||
def with_state(
|
||||
cls,
|
||||
|
@ -102,7 +102,7 @@ class Reaction:
|
||||
|
||||
def __init__(self, *, message: Message, data: ReactionPayload, emoji: Optional[Union[PartialEmoji, Emoji, str]] = None):
|
||||
self.message: Message = message
|
||||
self.emoji: Union[PartialEmoji, Emoji, str] = emoji or message._state.get_reaction_emoji(data['emoji'])
|
||||
self.emoji: Union[PartialEmoji, Emoji, str] = emoji or message._state.get_emoji_from_partial_payload(data['emoji'])
|
||||
self.count: int = data.get('count', 1)
|
||||
self.me: bool = data['me']
|
||||
details = data.get('count_details', {})
|
||||
|
@ -1792,7 +1792,7 @@ class ConnectionState(Generic[ClientT]):
|
||||
return channel.guild.get_member(user_id)
|
||||
return self.get_user(user_id)
|
||||
|
||||
def get_reaction_emoji(self, data: PartialEmojiPayload) -> Union[Emoji, PartialEmoji, str]:
|
||||
def get_emoji_from_partial_payload(self, data: PartialEmojiPayload) -> Union[Emoji, PartialEmoji, str]:
|
||||
emoji_id = utils._get_as_snowflake(data, 'id')
|
||||
|
||||
if not emoji_id:
|
||||
|
@ -38,6 +38,7 @@ from .channel import ChannelType, DefaultReaction, PrivacyLevel, VideoQualityMod
|
||||
from .threads import Thread
|
||||
from .command import ApplicationCommand, ApplicationCommandPermissions
|
||||
from .automod import AutoModerationTriggerMetadata
|
||||
from .onboarding import PromptOption, Prompt
|
||||
|
||||
AuditLogEvent = Literal[
|
||||
1,
|
||||
@ -100,6 +101,13 @@ AuditLogEvent = Literal[
|
||||
146,
|
||||
150,
|
||||
151,
|
||||
163,
|
||||
164,
|
||||
165,
|
||||
166,
|
||||
167,
|
||||
190,
|
||||
191,
|
||||
]
|
||||
|
||||
|
||||
@ -117,6 +125,7 @@ class _AuditLogChange_Str(TypedDict):
|
||||
'tags',
|
||||
'unicode_emoji',
|
||||
'emoji_name',
|
||||
'title',
|
||||
]
|
||||
new_value: str
|
||||
old_value: str
|
||||
@ -164,6 +173,10 @@ class _AuditLogChange_Bool(TypedDict):
|
||||
'available',
|
||||
'archived',
|
||||
'locked',
|
||||
'enabled',
|
||||
'single_select',
|
||||
'required',
|
||||
'in_onboarding',
|
||||
]
|
||||
new_value: bool
|
||||
old_value: bool
|
||||
@ -274,8 +287,8 @@ class _AuditLogChange_AppCommandPermissions(TypedDict):
|
||||
old_value: ApplicationCommandPermissions
|
||||
|
||||
|
||||
class _AuditLogChange_AppliedTags(TypedDict):
|
||||
key: Literal['applied_tags']
|
||||
class _AuditLogChange_SnowflakeList(TypedDict):
|
||||
key: Literal['applied_tags', 'default_channel_ids']
|
||||
new_value: List[Snowflake]
|
||||
old_value: List[Snowflake]
|
||||
|
||||
@ -298,6 +311,18 @@ class _AuditLogChange_TriggerMetadata(TypedDict):
|
||||
old_value: Optional[AutoModerationTriggerMetadata]
|
||||
|
||||
|
||||
class _AuditLogChange_Prompts(TypedDict):
|
||||
key: Literal['prompts']
|
||||
new_value: List[Prompt]
|
||||
old_value: List[Prompt]
|
||||
|
||||
|
||||
class _AuditLogChange_Options(TypedDict):
|
||||
key: Literal['options']
|
||||
new_value: List[PromptOption]
|
||||
old_value: List[PromptOption]
|
||||
|
||||
|
||||
class _AuditLogChange_RoleColours(TypedDict):
|
||||
key: Literal['colors']
|
||||
new_value: RoleColours
|
||||
@ -324,10 +349,12 @@ AuditLogChange = Union[
|
||||
_AuditLogChange_Status,
|
||||
_AuditLogChange_EntityType,
|
||||
_AuditLogChange_AppCommandPermissions,
|
||||
_AuditLogChange_AppliedTags,
|
||||
_AuditLogChange_SnowflakeList,
|
||||
_AuditLogChange_AvailableTags,
|
||||
_AuditLogChange_DefaultReactionEmoji,
|
||||
_AuditLogChange_TriggerMetadata,
|
||||
_AuditLogChange_Prompts,
|
||||
_AuditLogChange_Options,
|
||||
_AuditLogChange_RoleColours,
|
||||
]
|
||||
|
||||
|
72
discord/types/onboarding.py
Normal file
72
discord/types/onboarding.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present Rapptz
|
||||
|
||||
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, Literal, Optional, TypedDict, List, Union
|
||||
|
||||
from .emoji import PartialEmoji
|
||||
from .snowflake import Snowflake
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import NotRequired
|
||||
|
||||
|
||||
PromptType = Literal[0, 1]
|
||||
OnboardingMode = Literal[0, 1]
|
||||
|
||||
|
||||
class _PromptOption(TypedDict):
|
||||
channel_ids: List[Snowflake]
|
||||
role_ids: List[Snowflake]
|
||||
title: str
|
||||
description: Optional[str]
|
||||
|
||||
|
||||
class CreatePromptOption(_PromptOption):
|
||||
emoji_id: NotRequired[Snowflake]
|
||||
emoji_name: NotRequired[str]
|
||||
emoji_animated: NotRequired[bool]
|
||||
|
||||
|
||||
class PromptOption(_PromptOption):
|
||||
id: Snowflake
|
||||
emoji: NotRequired[PartialEmoji]
|
||||
|
||||
|
||||
class Prompt(TypedDict):
|
||||
id: Snowflake
|
||||
options: List[Union[PromptOption, CreatePromptOption]]
|
||||
title: str
|
||||
single_select: bool
|
||||
required: bool
|
||||
in_onboarding: bool
|
||||
type: PromptType
|
||||
|
||||
|
||||
class Onboarding(TypedDict):
|
||||
guild_id: Snowflake
|
||||
prompts: List[Prompt]
|
||||
default_channel_ids: List[Snowflake]
|
||||
enabled: bool
|
||||
mode: OnboardingMode
|
@ -755,7 +755,7 @@ class _WebhookState:
|
||||
|
||||
def get_reaction_emoji(self, data: PartialEmojiPayload) -> Union[PartialEmoji, Emoji, str]:
|
||||
if self._parent is not None:
|
||||
return self._parent.get_reaction_emoji(data)
|
||||
return self._parent.get_emoji_from_partial_payload(data)
|
||||
|
||||
emoji_id = utils._get_as_snowflake(data, 'id')
|
||||
|
||||
|
225
docs/api.rst
225
docs/api.rst
@ -3120,6 +3120,104 @@ of :class:`enum.Enum`.
|
||||
|
||||
.. versionadded:: 2.5
|
||||
|
||||
.. attribute:: onboarding_prompt_create
|
||||
|
||||
A guild onboarding prompt was created.
|
||||
|
||||
When this is the action, the type of :attr:`~AuditLogEntry.target` is
|
||||
a :class:`Object` with the ID of the prompt that the options belong to.
|
||||
|
||||
Possible attributes for :class:`AuditLogDiff`:
|
||||
|
||||
- :attr:`~AuditLogDiff.type`
|
||||
- :attr:`~AuditLogDiff.title`
|
||||
- :attr:`~AuditLogDiff.options`
|
||||
- :attr:`~AuditLogDiff.single_select`
|
||||
- :attr:`~AuditLogDiff.required`
|
||||
- :attr:`~AuditLogDiff.in_onboarding`
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: onboarding_prompt_update
|
||||
|
||||
A guild onboarding prompt was updated.
|
||||
|
||||
When this is the action, the type of :attr:`~AuditLogEntry.target` is
|
||||
a :class:`Object` with the ID of the prompt that the options belong to.
|
||||
|
||||
Possible attributes for :class:`AuditLogDiff`:
|
||||
|
||||
- :attr:`~AuditLogDiff.type`
|
||||
- :attr:`~AuditLogDiff.title`
|
||||
- :attr:`~AuditLogDiff.options`
|
||||
- :attr:`~AuditLogDiff.single_select`
|
||||
- :attr:`~AuditLogDiff.required`
|
||||
- :attr:`~AuditLogDiff.in_onboarding`
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: onboarding_prompt_delete
|
||||
|
||||
A guild onboarding prompt was deleted.
|
||||
|
||||
When this is the action, the type of :attr:`~AuditLogEntry.target` is
|
||||
a :class:`Object` with the ID of the prompt that the options belong to.
|
||||
|
||||
Possible attributes for :class:`AuditLogDiff`:
|
||||
|
||||
- :attr:`~AuditLogDiff.type`
|
||||
- :attr:`~AuditLogDiff.title`
|
||||
- :attr:`~AuditLogDiff.options`
|
||||
- :attr:`~AuditLogDiff.single_select`
|
||||
- :attr:`~AuditLogDiff.required`
|
||||
- :attr:`~AuditLogDiff.in_onboarding`
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: onboarding_create
|
||||
|
||||
The guild's onboarding configuration was created.
|
||||
|
||||
When this is the action, the type of :attr:`~AuditLogEntry.target` is
|
||||
always ``None``. Use :attr:`~AuditLogEntry.guild` to access the guild.
|
||||
|
||||
Possible attributes for :class:`AuditLogDiff`:
|
||||
|
||||
- :attr:`~AuditLogDiff.enabled`
|
||||
- :attr:`~AuditLogDiff.default_channels`
|
||||
- :attr:`~AuditLogDiff.prompts`
|
||||
- :attr:`~AuditLogDiff.mode`
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: onboarding_update
|
||||
|
||||
The guild's onboarding configuration was updated.
|
||||
|
||||
When this is the action, the type of :attr:`~AuditLogEntry.target` is
|
||||
always ``None``. Use :attr:`~AuditLogEntry.guild` to access the guild.
|
||||
|
||||
Possible attributes for :class:`AuditLogDiff`:
|
||||
|
||||
- :attr:`~AuditLogDiff.enabled`
|
||||
- :attr:`~AuditLogDiff.default_channels`
|
||||
- :attr:`~AuditLogDiff.prompts`
|
||||
- :attr:`~AuditLogDiff.mode`
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: home_settings_create
|
||||
|
||||
The guild's server guide was created.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: home_settings_update
|
||||
|
||||
The guild's server guide was updated.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. class:: AuditLogActionCategory
|
||||
|
||||
Represents the category that the :class:`AuditLogAction` belongs to.
|
||||
@ -3917,6 +4015,35 @@ of :class:`enum.Enum`.
|
||||
|
||||
The details of the activity are displayed.
|
||||
|
||||
.. class:: OnboardingPromptType
|
||||
|
||||
Represents the type of onboarding prompt.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: multiple_choice
|
||||
|
||||
Prompt options are multiple choice.
|
||||
|
||||
.. attribute:: dropdown
|
||||
|
||||
Prompt options are displayed as a drop-down.
|
||||
|
||||
.. class:: OnboardingMode
|
||||
|
||||
Represents the onboarding constraint mode.
|
||||
|
||||
.. versionadded:: 2.6
|
||||
|
||||
.. attribute:: default
|
||||
|
||||
Only default channels count towards onboarding constraints.
|
||||
|
||||
.. attribute:: advanced
|
||||
|
||||
Default channels and questions count towards onboarding constraints.
|
||||
|
||||
|
||||
.. _discord-api-audit-logs:
|
||||
|
||||
Audit Log Data
|
||||
@ -4163,9 +4290,9 @@ AuditLogDiff
|
||||
|
||||
.. attribute:: type
|
||||
|
||||
The type of channel, sticker, webhook or integration.
|
||||
The type of channel, sticker, webhook, integration or onboarding prompt.
|
||||
|
||||
:type: Union[:class:`ChannelType`, :class:`StickerType`, :class:`WebhookType`, :class:`str`]
|
||||
:type: Union[:class:`ChannelType`, :class:`StickerType`, :class:`WebhookType`, :class:`str`, :class:`OnboardingPromptType`]
|
||||
|
||||
.. attribute:: topic
|
||||
|
||||
@ -4538,7 +4665,7 @@ AuditLogDiff
|
||||
|
||||
.. attribute:: enabled
|
||||
|
||||
Whether the automod rule is active or not.
|
||||
Whether guild onboarding or the automod rule is active or not.
|
||||
|
||||
:type: :class:`bool`
|
||||
|
||||
@ -4570,7 +4697,7 @@ AuditLogDiff
|
||||
|
||||
The actions to take when an automod rule is triggered.
|
||||
|
||||
:type: List[AutoModRuleAction]
|
||||
:type: List[:class:`AutoModRuleAction`]
|
||||
|
||||
.. attribute:: exempt_roles
|
||||
|
||||
@ -4668,6 +4795,71 @@ AuditLogDiff
|
||||
|
||||
:type: :class:`float`
|
||||
|
||||
.. attribute:: options
|
||||
|
||||
The onboarding prompt options associated with this onboarding prompt.
|
||||
|
||||
See also :attr:`OnboardingPrompt.options`
|
||||
|
||||
:type: List[:class:`OnboardingPromptOption`]
|
||||
|
||||
.. attribute:: default_channels
|
||||
|
||||
The default channels associated with the onboarding in this guild.
|
||||
|
||||
See also :attr:`Onboarding.default_channels`
|
||||
|
||||
:type: List[:class:`abc.GuildChannel`, :class:`Object`]
|
||||
|
||||
.. attribute:: prompts
|
||||
|
||||
The onboarding prompts associated with the onboarding in this guild.
|
||||
|
||||
See also :attr:`Onboarding.prompts`
|
||||
|
||||
:type: List[:class:`OnboardingPrompt`]
|
||||
|
||||
.. attribute:: title
|
||||
|
||||
The title of the onboarding prompt.
|
||||
|
||||
See also :attr:`OnboardingPrompt.title`
|
||||
|
||||
:type: :class:`str`
|
||||
|
||||
.. attribute:: single_select
|
||||
|
||||
Whether only one prompt option can be selected.
|
||||
|
||||
See also :attr:`OnboardingPrompt.single_select`
|
||||
|
||||
:type: :class:`bool`
|
||||
|
||||
.. attribute:: required
|
||||
|
||||
Whether the onboarding prompt is required to complete the onboarding.
|
||||
|
||||
See also :attr:`OnboardingPrompt.required`
|
||||
|
||||
:type: :class:`bool`
|
||||
|
||||
.. attribute:: in_onboarding
|
||||
|
||||
Whether this prompt is currently part of the onboarding flow.
|
||||
|
||||
See also :attr:`OnboardingPrompt.in_onboarding`
|
||||
|
||||
:type: :class:`bool`
|
||||
|
||||
.. attribute:: mode
|
||||
|
||||
The onboarding constraint mode.
|
||||
|
||||
See also :attr:`Onboarding.mode`
|
||||
|
||||
:type: :class:`OnboardingMode`
|
||||
|
||||
|
||||
.. this is currently missing the following keys: reason and application_id
|
||||
I'm not sure how to port these
|
||||
|
||||
@ -5291,6 +5483,31 @@ GuildSticker
|
||||
.. autoclass:: GuildSticker()
|
||||
:members:
|
||||
|
||||
Onboarding
|
||||
~~~~~~~~~~~
|
||||
|
||||
.. attributetable:: Onboarding
|
||||
|
||||
.. autoclass:: Onboarding()
|
||||
:members:
|
||||
|
||||
OnboardingPrompt
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. attributetable:: OnboardingPrompt
|
||||
|
||||
.. autoclass:: OnboardingPrompt()
|
||||
:members:
|
||||
|
||||
|
||||
OnboardingPromptOption
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. attributetable:: OnboardingPromptOption
|
||||
|
||||
.. autoclass:: OnboardingPromptOption()
|
||||
:members:
|
||||
|
||||
BaseSoundboardSound
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user