diff --git a/examples/events_experiment.py b/examples/events_experiment.py new file mode 100644 index 0000000..fbb71dd --- /dev/null +++ b/examples/events_experiment.py @@ -0,0 +1,74 @@ +import asyncio +import guilded + +# This is a showcase of the event style experiment. +# Read more here: +# https://www.guilded.gg/guilded-api/blog/updates/Event-style-experiment +# https://github.com/shayypy/guilded.py/issues/39 + +client = guilded.Client(experimental_event_style=True) + +@client.event +async def on_ready(): + print(f'Logged in as {client.user} (ID: {client.user.id})') + print('------') + +@client.event +async def on_message(event: guilded.MessageEvent): + if event.message.content == 'ping': + await event.message.channel.send('pong!') + + # This experiment also affects wait_for(), of course: + elif event.message.content == '$wait': + message = await event.message.channel.send("I'm waiting for a reaction to this message.") + try: + reaction_event = await client.wait_for( + 'message_reaction_add', + check=lambda e: e.user_id == event.message.author_id and e.message_id == message.id, + timeout=30 + ) + except asyncio.TimeoutError: + pass + else: + await message.reply(f'You reacted with **{reaction_event.emote.name}**.') + +@client.event +async def on_message_update(event: guilded.MessageUpdateEvent): + if not event.before or event.before.content == event.after.content: + # The content hasn't changed or we did not have it cached + return + + # You should not actually do this + diff = len(event.after.content) - len(event.before.content) + await event.after.reply(f'You added {diff:,} characters to your message, nice job!') + +@client.event +async def on_member_remove(event: guilded.MemberRemoveEvent): + # In this example we pretend that every server wants member logs in their default channel + + if event.server.default_channel_id: + channel = event.server.default_channel or await event.server.fetch_default_channel() + else: + return + + # Extra metadata + if event.banned: + cause = 'was banned from' + colour = guilded.Colour.red() + elif event.kicked: + cause = 'was kicked from' + colour = guilded.Colour.red() + else: + cause = 'has left' + colour = guilded.Colour.gilded() + + embed = guilded.Embed( + description=f'<@{event.user_id}> {cause} the server.', + colour=colour, + ) + await channel.send(embed=embed) + + # We also have `event.member` which will not be None if the member was cached prior to this event. + # See also on_member_ban, which is a separate WebSocket event representing ban creation. + +client.run('token') diff --git a/guilded/__init__.py b/guilded/__init__.py index b3e7370..b7c92e0 100644 --- a/guilded/__init__.py +++ b/guilded/__init__.py @@ -14,6 +14,7 @@ from .emote import * from .enums import * from .errors import * +from .events import * from .file import * from .flowbot import * from .group import * diff --git a/guilded/client.py b/guilded/client.py index e6c765c..1f510a1 100644 --- a/guilded/client.py +++ b/guilded/client.py @@ -56,10 +56,11 @@ import logging import sys import traceback -from typing import TYPE_CHECKING, Any, Callable, Coroutine, Generator, List, Optional, Type +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Generator, List, Optional, Type, Union from .errors import ClientException, HTTPException from .enums import * +from .events import BaseEvent from .gateway import GuildedWebSocket, WebSocketClosure from .http import HTTPClient from .invite import Invite @@ -144,6 +145,7 @@ def __init__( self.ws: Optional[GuildedWebSocket] = None self.http: HTTPClient = HTTPClient( max_messages=self.max_messages, + experimental_event_style=options.pop('experimental_event_style', False), ) async def __aenter__(self) -> Self: @@ -442,11 +444,17 @@ async def on_ready(): log.debug('%s has successfully been registered as an event', coro.__name__) return coro - def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: - log.debug('Dispatching event %s', event) - method = 'on_' + event + def dispatch(self, event: Union[str, BaseEvent], *args: Any, **kwargs: Any) -> None: + if isinstance(event, BaseEvent): + event_name = event.__dispatch_event__ + args = (event,) + else: + event_name = event + + log.debug('Dispatching event %s', event_name) + method = 'on_' + event_name - listeners = self._listeners.get(event) + listeners = self._listeners.get(event_name) if listeners: removed = [] for i, (future, condition) in enumerate(listeners): @@ -470,7 +478,7 @@ def dispatch(self, event: str, *args: Any, **kwargs: Any) -> None: removed.append(i) if len(removed) == len(listeners): - self._listeners.pop(event) + self._listeners.pop(event_name) else: for idx in reversed(removed): del listeners[idx] diff --git a/guilded/events.py b/guilded/events.py new file mode 100644 index 0000000..1cb3c52 --- /dev/null +++ b/guilded/events.py @@ -0,0 +1,1173 @@ +""" +MIT License + +Copyright (c) 2020-present shay (shayypy) + +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, List, Optional, Tuple, Union + +import datetime + +from .channel import ( + CalendarChannel, + CalendarEvent, + CalendarEventRSVP, + ChatChannel, + Doc, + DMChannel, + ForumTopic, + ListItem, + Thread, + VoiceChannel, +) +from .emote import Emote +from .message import ChatMessage +from .user import Member, MemberBan +from .utils import ISO8601 +from .webhook.async_ import Webhook + +if TYPE_CHECKING: + from .types import gateway as gw + + from .abc import ServerChannel + from .channel import ( + CalendarChannel, + DocsChannel, + ForumChannel, + ListChannel, + ) + from .server import Server + + +__all__ = ( + 'BaseEvent', + 'ServerEvent', + 'MessageEvent', + 'MessageUpdateEvent', + 'MessageDeleteEvent', + 'MemberJoinEvent', + 'MemberRemoveEvent', + 'BanCreateEvent', + 'BanDeleteEvent', + 'MemberUpdateEvent', + 'BulkMemberRolesUpdateEvent', + 'ServerChannelCreateEvent', + 'ServerChannelUpdateEvent', + 'ServerChannelDeleteEvent', + 'WebhookCreateEvent', + 'WebhookUpdateEvent', + 'DocCreateEvent', + 'DocUpdateEvent', + 'DocDeleteEvent', + 'CalendarEventCreateEvent', + 'CalendarEventUpdateEvent', + 'CalendarEventDeleteEvent', + 'RsvpUpdateEvent', + 'RsvpDeleteEvent', + 'BulkRsvpCreateEvent', + 'ForumTopicCreateEvent', + 'ForumTopicUpdateEvent', + 'ForumTopicDeleteEvent', + 'ListItemCreateEvent', + 'ListItemUpdateEvent', + 'ListItemDeleteEvent', + 'ListItemCompleteEvent', + 'ListItemUncompleteEvent', + 'MessageReactionAddEvent', + 'MessageReactionRemoveEvent', +) + + +class BaseEvent: + """Represents a Gateway event for dispatching to event handlers. + + All events inherit from this class, and thus have the following attributes: + + Attributes + ----------- + __gateway_event__: :class:`str` + The Guilded event name that the event corresponds to. + __dispatch_event__: :class:`str` + The internal Pythonic event name to dispatch the event with. + This is often just a snake_case version of :attr:`.__gateway_event__`. + """ + + __gateway_event__: str + __dispatch_event__: str + + +class ServerEvent(BaseEvent): + """Represents any event that happens strictly within a server. + + All events inheriting from this class have the following attributes: + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the event happened in. + server: :class:`Server` + The server that the event happened in. + """ + + __slots__: Tuple[str, ...] = ( + 'server_id', + 'server', + ) + + def __init__(self, state, data: gw._ServerEvent, /) -> None: + self.server_id: str = data.get('serverId') + self.server: Server = state._get_server(self.server_id) + + +class MessageEvent(BaseEvent): + """Represents a :gdocs:`ChatMessageCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: Optional[:class:`str`] + The ID of the server that the message was sent in. + server: Optional[:class:`Server`] + The server that the message was sent in. + message: :class:`ChatMessage` + The message that was sent. + """ + + __gateway_event__ = 'ChatMessageCreated' + __dispatch_event__ = 'message' + __slots__: Tuple[str, ...] = ( + 'server_id', + 'server', + 'message', + ) + + def __init__( + self, + state, + data: gw.ChatMessageCreatedEvent, + /, + channel: Union[ChatChannel, VoiceChannel, Thread, DMChannel], + ) -> None: + self.server_id: Optional[str] = data.get('serverId') + self.server: Optional[Server] = state._get_server(self.server_id) + + self.message = ChatMessage(state=state, channel=channel, data=data['message']) + + +class MessageUpdateEvent(BaseEvent): + """Represents a :gdocs:`ChatMessageUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: Optional[:class:`str`] + The ID of the server that the message was sent in. + server: Optional[:class:`Server`] + The server that the message was sent in. + before: Optional[:class:`ChatMessage`] + The message before modification, if it was cached. + after: :class:`ChatMessage` + The message after modification. + """ + + __gateway_event__ = 'ChatMessageUpdated' + __dispatch_event__ = 'message_update' + __slots__: Tuple[str, ...] = ( + 'server_id', + 'server', + 'before', + 'after', + ) + + def __init__( + self, + state, + data: gw.ChatMessageUpdatedEvent, + /, + before: Optional[ChatMessage], + channel: Union[ChatChannel, VoiceChannel, Thread, DMChannel], + ) -> None: + self.server_id: Optional[str] = data.get('serverId') + self.server: Optional[Server] = state._get_server(self.server_id) + + self.before = before + self.after = ChatMessage(state=state, channel=channel, data=data['message']) + + +class MessageDeleteEvent(BaseEvent): + """Represents a :gdocs:`ChatMessageDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: Optional[:class:`str`] + The ID of the server that the message was sent in. + server: Optional[:class:`Server`] + The server that the message was sent in. + channel: Optional[:class:`ChatMessage`] + The channel that the message was sent in. + message: Optional[:class:`ChatMessage`] + The message from cache, if available. + message_id: :class:`str` + The ID of the message that was deleted. + channel_id: :class:`str` + The ID of the message's channel. + deleted_at: :class:`datetime.datetime` + When the message was deleted. + private: :class:`bool` + Whether the message was private. + """ + + __gateway_event__ = 'ChatMessageDeleted' + __dispatch_event__ = 'message_delete' + __slots__: Tuple[str, ...] = ( + 'server_id', + 'server', + 'channel', + 'message', + 'message_id', + 'channel_id', + 'deleted_at', + 'private', + ) + + def __init__( + self, + state, + data: gw.ChatMessageDeletedEvent, + /, + channel: Union[ChatChannel, VoiceChannel, Thread, DMChannel], + message: Optional[ChatMessage], + ) -> None: + self.server_id: Optional[str] = data.get('serverId') + self.server: Optional[Server] = state._get_server(self.server_id) + + self.channel = channel + self.message = message + + message_data = data['message'] + self.message_id = message_data['id'] + self.channel_id = message_data['channelId'] + self.deleted_at: datetime.datetime = ISO8601(message_data['deletedAt']) + self.private = message_data.get('isPrivate') or False + + +class MemberJoinEvent(ServerEvent): + """Represents a :gdocs:`TeamMemberJoined ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the member joined. + server: :class:`Server` + The server that the member joined. + member: :class:`Member` + The member that joined. + """ + + __gateway_event__ = 'TeamMemberJoined' + __dispatch_event__ = 'member_join' + __slots__: Tuple[str, ...] = ( + 'member', + ) + + def __init__( + self, + state, + data: gw.TeamMemberJoinedEvent, + /, + ) -> None: + super().__init__(state, data) + + self.member = Member(state=state, data=data['member'], server=self.server) + + +class MemberRemoveEvent(ServerEvent): + """Represents a :gdocs:`TeamMemberRemoved ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the member was removed from. + server: :class:`Server` + The server that the member was removed from. + member: Optional[:class:`Member`] + The member that was removed, from cache, if available. + user_id: :class:`str` + The ID of the member that was removed. + kicked: :class:`bool` + Whether this removal was the result of a kick. + banned: :class:`bool` + Whether this removal was the result of a ban. + """ + + __gateway_event__ = 'TeamMemberRemoved' + __dispatch_event__ = 'member_remove' + __slots__: Tuple[str, ...] = ( + 'member', + 'user_id', + 'kicked', + 'banned', + ) + + def __init__( + self, + state, + data: gw.TeamMemberRemovedEvent, + /, + ) -> None: + super().__init__(state, data) + + self.user_id = data['userId'] + self.kicked = data.get('isKick') or False + self.banned = data.get('isBan') or False + + self.member = self.server.get_member(self.user_id) + + +class _BanEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'ban', + 'member', + ) + + def __init__( + self, + state, + data: gw.TeamMemberBanEvent, + /, + ) -> None: + super().__init__(state, data) + + self.ban = MemberBan(state=state, data=data['serverMemberBan'], server=self.server) + self.member = self.server.get_member(self.ban.user.id) + + +class BanCreateEvent(_BanEvent): + """Represents a :gdocs:`TeamMemberBanned ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the member was banned from. + server: :class:`Server` + The server that the member was banned from. + ban: :class:`MemberBan` + The ban entry that was created. + member: Optional[:class:`Member`] + The member that was banned, from cache, if available. + """ + + __gateway_event__ = 'TeamMemberBanned' + __dispatch_event__ = 'ban_create' + + +class BanDeleteEvent(_BanEvent): + """Represents a :gdocs:`TeamMemberUnbanned ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the member was unbanned from. + server: :class:`Server` + The server that the member was unbanned from. + ban: :class:`MemberBan` + The ban entry that was deleted. + member: Optional[:class:`Member`] + The member that was unbanned, from cache, if available. + """ + + __gateway_event__ = 'TeamMemberUnbanned' + __dispatch_event__ = 'ban_delete' + + +class MemberUpdateEvent(ServerEvent): + """Represents a :gdocs:`TeamMemberUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the member is in. + server: :class:`Server` + The server that the member is in. + before: Optional[:class:`Member`] + The member before modification, if they were cached. + after: :class:`Member` + The member after modification. + If ``before`` is ``None``, this will be a partial :class:`Member` + containing only the data that was modified and the ID of the user. + modified: Set[:class:`str`] + A set of attributes that were modified for the member. + This is useful if you need to know if a value was modified, + but you do not care about what the previous value was. + """ + + __gateway_event__ = 'TeamMemberUpdated' + __dispatch_event__ = 'member_update' + __slots__: Tuple[str, ...] = ( + 'before', + 'after', + # 'modified', + ) + + def __init__( + self, + state, + data: gw.TeamMemberUpdatedEvent, + /, + ) -> None: + super().__init__(state, data) + + before = self.server.get_member(data['userInfo']['id']) + self.before = before + self.after: Member + + if before is None: + member_data = { + 'serverId': self.server_id, + 'user': { + 'id': data['userInfo']['id'], + }, + 'nickname': data['userInfo'].get('nickname'), + } + self.after = Member(state=state, data=member_data, server=self.server) + + else: + self.before = Member._copy(before) + self.after = before + self.after._update(data['userInfo']) + + # self.modified = {key for key in data['userInfo'].keys() if key != 'id'} + # This is not currently how I would like it since it doesn't transform keys to what the user expects. + + +class BulkMemberRolesUpdateEvent(ServerEvent): + """Represents a :gdocs:`teamRolesUpdated ` event for dispatching to event handlers. + + This particular class only handles updates to role membership, not server roles. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the members are in. + server: :class:`Server` + The server that the members are in. + before: List[:class:`Member`] + The members before their roles were updated, if they were cached. + Not all members in ``after`` are guaranteed to be in ``before``. + after: List[:class:`Member`] + The members after their roles were updated. + """ + + __gateway_event__ = 'teamRolesUpdated' + __dispatch_event__ = 'bulk_member_roles_update' + __slots__: Tuple[str, ...] = ( + 'before', + 'after', + ) + + def __init__( + self, + state, + data: gw.TeamRolesUpdatedEvent, + /, + ) -> None: + super().__init__(state, data) + + self.before: List[Member] = [] + self.after: List[Member] = [] + + for update in data['memberRoleIds']: + before_member = self.server.get_member(update['userId']) + + if before_member is None: + member_data = { + 'serverId': self.server_id, + 'user': { + 'id': update['userId'], + }, + 'roleIds': update['roleIds'], + } + after_member = Member(state=state, data=member_data, server=self.server) + self.after.append(after_member) + + else: + self.before.append(Member._copy(before_member)) + after_member = before_member + after_member._update_roles(update['roleIds']) + self.after.append(after_member) + + +class _ServerChannelEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'channel', + ) + + def __init__( + self, + state, + data: gw.TeamChannelEvent, + /, + ) -> None: + super().__init__(state, data) + + self.channel: ServerChannel = state.create_channel(data=data['channel'], server=self.server) + + +class ServerChannelCreateEvent(_ServerChannelEvent): + """Represents a :gdocs:`TeamChannelCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the channel is in. + server: :class:`Server` + The server that the channel is in. + channel: :class:`.abc.ServerChannel` + The channel that was created. + """ + + __gateway_event__ = 'TeamChannelCreated' + __dispatch_event__ = 'server_channel_create' + + +class ServerChannelUpdateEvent(ServerEvent): + """Represents a :gdocs:`TeamChannelUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the channel is in. + server: :class:`Server` + The server that the channel is in. + before: Optional[:class:`.abc.ServerChannel`] + The channel before modification, if it was cached. + after: :class:`.abc.ServerChannel` + The channel after modification. + """ + + __gateway_event__ = 'TeamChannelUpdated' + __dispatch_event__ = 'server_channel_update' + __slots__: Tuple[str, ...] = ( + 'before', + 'after', + ) + + def __init__( + self, + state, + data: gw.TeamChannelEvent, + /, + ) -> None: + super().__init__(state, data) + + self.before = self.server.get_channel_or_thread(data['channel']['id']) + self.channel: ServerChannel = state.create_channel(data=data['channel'], server=self.server) + + +class ServerChannelDeleteEvent(_ServerChannelEvent): + """Represents a :gdocs:`TeamChannelDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the channel was in. + server: :class:`Server` + The server that the channel was in. + channel: :class:`.abc.ServerChannel` + The channel that was deleted. + """ + + __gateway_event__ = 'TeamChannelDeleted' + __dispatch_event__ = 'server_channel_delete' + + +class _WebhookEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'webhook', + ) + + def __init__( + self, + state, + data: gw.TeamWebhookEvent, + /, + ) -> None: + super().__init__(state, data) + + self.webhook = Webhook.from_state(data['webhook'], state) + + +class WebhookCreateEvent(_WebhookEvent): + """Represents a :gdocs:`TeamWebhookCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the webhook is in. + server: :class:`Server` + The server that the webhook is in. + webhook: :class:`Webhook` + The webhook that was created. + """ + + __gateway_event__ = 'TeamWebhookCreated' + __dispatch_event__ = 'webhook_create' + + +class WebhookUpdateEvent(_WebhookEvent): + """Represents a :gdocs:`TeamWebhookUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the webhook is in. + server: :class:`Server` + The server that the webhook is in. + webhook: :class:`Webhook` + The webhook after modification. + If :attr:`Webhook.deleted_at` is set, then this event indicates that + the webhook was deleted. + """ + + __gateway_event__ = 'TeamWebhookUpdated' + __dispatch_event__ = 'webhook_update' + + +class _DocEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'channel', + 'doc', + ) + + def __init__( + self, + state, + data: gw.DocEvent, + /, + channel: DocsChannel, + ) -> None: + super().__init__(state, data) + + self.channel = channel + self.doc = Doc(state=state, data=data['doc'], channel=channel) + + +class DocCreateEvent(_DocEvent): + """Represents a :gdocs:`DocCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the doc is in. + server: :class:`Server` + The server that the doc is in. + channel: :class:`DocsChannel` + The channel that the doc is in. + doc: :class:`Doc` + The doc that was created. + """ + + __gateway_event__ = 'DocCreated' + __dispatch_event__ = 'doc_create' + + +class DocUpdateEvent(_DocEvent): + """Represents a :gdocs:`DocUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the doc is in. + server: :class:`Server` + The server that the doc is in. + channel: :class:`DocsChannel` + The channel that the doc is in. + doc: :class:`Doc` + The doc after modification. + """ + + __gateway_event__ = 'DocUpdated' + __dispatch_event__ = 'doc_update' + + +class DocDeleteEvent(_DocEvent): + """Represents a :gdocs:`DocDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the doc was in. + server: :class:`Server` + The server that the doc was in. + channel: :class:`DocsChannel` + The channel that the doc was in. + doc: :class:`Doc` + The doc that was deleted. + """ + + __gateway_event__ = 'DocDeleted' + __dispatch_event__ = 'doc_delete' + + +class _CalendarEventEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'channel', + 'calendar_event', + ) + + def __init__( + self, + state, + data: gw.CalendarEventEvent, + /, + channel: CalendarChannel, + ) -> None: + super().__init__(state, data) + + self.channel = channel + self.calendar_event = CalendarEvent(state=state, data=data['calendarEvent'], channel=channel) + + +class CalendarEventCreateEvent(_CalendarEventEvent): + """Represents a :gdocs:`CalendarEventCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the calendar event is in. + server: :class:`Server` + The server that the calendar event is in. + channel: :class:`CalendarChannel` + The channel that the calendar event is in. + calendar_event: :class:`CalendarEvent` + The calendar event that was created. + """ + + __gateway_event__ = 'CalendarEventCreated' + __dispatch_event__ = 'calendar_event_create' + + +class CalendarEventUpdateEvent(_CalendarEventEvent): + """Represents a :gdocs:`CalendarEventUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the calendar event is in. + server: :class:`Server` + The server that the calendar event is in. + channel: :class:`CalendarChannel` + The channel that the calendar event is in. + calendar_event: :class:`CalendarEvent` + The calendar event after modification. + """ + + __gateway_event__ = 'CalendarEventUpdated' + __dispatch_event__ = 'calendar_event_update' + + +class CalendarEventDeleteEvent(_CalendarEventEvent): + """Represents a :gdocs:`CalendarEventDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the calendar event was in. + server: :class:`Server` + The server that the calendar event was in. + channel: :class:`CalendarChannel` + The channel that the calendar event was in. + calendar_event: :class:`CalendarEvent` + The calendar event that was deleted. + """ + + __gateway_event__ = 'CalendarEventDeleted' + __dispatch_event__ = 'calendar_event_delete' + + +class _CalendarEventRsvpEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'event', + 'rsvp', + ) + + def __init__( + self, + state, + data: gw.CalendarEventRsvpEvent, + /, + event: CalendarEvent, + ) -> None: + super().__init__(state, data) + + self.event = event + self.rsvp = CalendarEventRSVP(data=data['calendarEventRsvp'], event=event) + + +class RsvpUpdateEvent(_CalendarEventRsvpEvent): + """Represents a :gdocs:`CalendarEventRsvpUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the RSVP is in. + server: :class:`Server` + The server that the RSVP is in. + channel: :class:`ForumChannel` + The channel that the RSVP is in. + rsvp: :class:`CalendarEventRsvp` + The RSVP that was created or updated. + """ + + __gateway_event__ = 'CalendarEventRsvpUpdated' + __dispatch_event__ = 'rsvp_update' + + +class RsvpDeleteEvent(_CalendarEventRsvpEvent): + """Represents a :gdocs:`CalendarEventRsvpDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the RSVP was in. + server: :class:`Server` + The server that the RSVP was in. + channel: :class:`ForumChannel` + The channel that the RSVP was in. + rsvp: :class:`CalendarEventRsvp` + The RSVP that was deleted. + """ + + __gateway_event__ = 'CalendarEventRsvpDeleted' + __dispatch_event__ = 'rsvp_delete' + + +class BulkRsvpCreateEvent(ServerEvent): + """Represents a :gdocs:`CalendarEventRsvpManyUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the RSVPs are in. + server: :class:`Server` + The server that the RSVPs are in. + channel: :class:`ForumChannel` + The channel that the RSVPs are in. + rsvps: List[:class:`CalendarEventRsvp`] + The RSVPs that were created. + """ + + __gateway_event__ = 'CalendarEventRsvpManyUpdated' + __dispatch_event__ = 'bulk_rsvp_create' + __slots__: Tuple[str, ...] = ( + 'event', + 'rsvps', + ) + + def __init__( + self, + state, + data: gw.CalendarEventRsvpManyUpdatedEvent, + /, + event: CalendarEvent, + ) -> None: + super().__init__(state, data) + + self.event = event + self.rsvps = [CalendarEventRSVP(data=rsvp_data, event=event) for rsvp_data in data['calendarEventRsvps']] + + +class _ForumTopicEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'channel', + 'topic', + ) + + def __init__( + self, + state, + data: gw.ForumTopicEvent, + /, + channel: ForumChannel, + ) -> None: + super().__init__(state, data) + + self.channel = channel + self.topic = ForumTopic(state=state, data=data['forumTopic'], channel=channel) + + +class ForumTopicCreateEvent(_ForumTopicEvent): + """Represents a :gdocs:`ForumTopicCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the forum topic is in. + server: :class:`Server` + The server that the forum topic is in. + channel: :class:`ForumChannel` + The channel that the forum topic is in. + topic: :class:`ForumTopic` + The forum topic that was created. + """ + + __gateway_event__ = 'ForumTopicCreated' + __dispatch_event__ = 'forum_topic_create' + + +class ForumTopicUpdateEvent(_ForumTopicEvent): + """Represents a :gdocs:`ForumTopicUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the forum topic is in. + server: :class:`Server` + The server that the forum topic is in. + channel: :class:`ForumChannel` + The channel that the forum topic is in. + topic: :class:`ForumTopic` + The forum topic after modification. + """ + + __gateway_event__ = 'ForumTopicUpdated' + __dispatch_event__ = 'forum_topic_update' + + +class ForumTopicDeleteEvent(_ForumTopicEvent): + """Represents a :gdocs:`ForumTopicDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the forum topic was in. + server: :class:`Server` + The server that the forum topic was in. + channel: :class:`ForumChannel` + The channel that the forum topic was in. + topic: :class:`ForumTopic` + The forum topic that was deleted. + """ + + __gateway_event__ = 'ForumTopicDeleted' + __dispatch_event__ = 'forum_topic_delete' + + +class _ListItemEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'channel', + 'item', + ) + + def __init__( + self, + state, + data: gw.ListItemEvent, + /, + channel: ListChannel, + ) -> None: + super().__init__(state, data) + + self.channel = channel + self.item = ListItem(state=state, data=data['listItem'], channel=channel) + + +class ListItemCreateEvent(_ListItemEvent): + """Represents a :gdocs:`ListItemCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the list item is in. + server: :class:`Server` + The server that the list item is in. + channel: :class:`ListChannel` + The channel that the list item is in. + item: :class:`ListItem` + The list item that was created. + """ + + __gateway_event__ = 'ListItemCreated' + __dispatch_event__ = 'list_item_create' + + +class ListItemUpdateEvent(_ListItemEvent): + """Represents a :gdocs:`ListItemUpdated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the list item is in. + server: :class:`Server` + The server that the list item is in. + channel: :class:`ListChannel` + The channel that the list item is in. + item: :class:`ListItem` + The list item after modification. + """ + + __gateway_event__ = 'ListItemUpdated' + __dispatch_event__ = 'list_item_update' + + +class ListItemDeleteEvent(_ListItemEvent): + """Represents a :gdocs:`ListItemDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the list item was in. + server: :class:`Server` + The server that the list item was in. + channel: :class:`ListChannel` + The channel that the list item was in. + item: :class:`ListItem` + The list item that was deleted. + """ + + __gateway_event__ = 'ListItemDeleted' + __dispatch_event__ = 'list_item_delete' + + +class ListItemCompleteEvent(_ListItemEvent): + """Represents a :gdocs:`ListItemCompleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the list item is in. + server: :class:`Server` + The server that the list item is in. + channel: :class:`ListChannel` + The channel that the list item is in. + item: :class:`ListItem` + The list item that was completed. + """ + + __gateway_event__ = 'ListItemCompleted' + __dispatch_event__ = 'list_item_complete' + + +class ListItemUncompleteEvent(_ListItemEvent): + """Represents a :gdocs:`ListItemUncompleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the list item is in. + server: :class:`Server` + The server that the list item is in. + channel: :class:`ListChannel` + The channel that the list item is in. + item: :class:`ListItem` + The list item that was uncompleted. + """ + + __gateway_event__ = 'ListItemUncompleted' + __dispatch_event__ = 'list_item_uncomplete' + + +class _MessageReactionEvent(ServerEvent): + __slots__: Tuple[str, ...] = ( + 'channel_id', + 'message_id', + 'user_id', + 'emote', + 'channel', + 'message', + 'member', + ) + + def __init__( + self, + state, + data: gw.ChannelMessageReactionEvent, + /, + channel: Union[ChatChannel, VoiceChannel, Thread, DMChannel], + ) -> None: + super().__init__(state, data) + + self.channel_id = data['reaction']['channelId'] + self.message_id = data['reaction']['messageId'] + self.user_id = data['reaction']['createdBy'] + self.emote = Emote(state=state, data=data['reaction']['emote']) + + self.channel = channel + self.message: Optional[ChatMessage] = state._get_message(self.message_id) + self.member = self.server.get_member(self.user_id) + + +class MessageReactionAddEvent(_MessageReactionEvent): + """Represents a :gdocs:`ChannelMessageReactionCreated ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the reaction is in. + server: :class:`Server` + The server that the reaction is in. + channel: Union[:class:`ChatChannel`, :class:`VoiceChannel`, :class:`Thread`, :class:`DMChannel`] + The channel that the reaction is in. + message: Optional[:class:`ChatMessage`] + The message that the reaction is on, if it is cached. + member: Optional[:class:`Member`] + The member that added the reaction, if they are cached. + channel_id: :class:`str` + The ID of the channel that the reaction is in. + message_id: :class:`str` + The ID of the message that the reaction is on. + user_id: :class:`str` + The ID of the user that added the reaction. + emote: :class:`Emote` + The emote that the reaction shows. + """ + + __gateway_event__ = 'ChannelMessageReactionCreated' + __dispatch_event__ = 'message_reaction_add' + + +class MessageReactionRemoveEvent(_MessageReactionEvent): + """Represents a :gdocs:`ChannelMessageReactionDeleted ` event for dispatching to event handlers. + + Attributes + ----------- + server_id: :class:`str` + The ID of the server that the reaction is in. + server: :class:`Server` + The server that the reaction is in. + channel: Union[:class:`ChatChannel`, :class:`VoiceChannel`, :class:`Thread`, :class:`DMChannel`] + The channel that the reaction is in. + message: Optional[:class:`ChatMessage`] + The message that the reaction is on, if it is cached. + member: Optional[:class:`Member`] + The member that added the reaction, if they are cached. + channel_id: :class:`str` + The ID of the channel that the reaction is in. + message_id: :class:`str` + The ID of the message that the reaction is on. + user_id: :class:`str` + The ID of the user that added the reaction. + emote: :class:`Emote` + The emote that the reaction shows. + """ + + __gateway_event__ = 'ChannelMessageReactionDeleted' + __dispatch_event__ = 'message_reaction_remove' diff --git a/guilded/ext/commands/bot.py b/guilded/ext/commands/bot.py index 9c5d6cf..b65c779 100644 --- a/guilded/ext/commands/bot.py +++ b/guilded/ext/commands/bot.py @@ -62,6 +62,7 @@ from typing import Any, Callable, Iterable, Mapping, List, Dict, Optional, Set, Union import guilded +from guilded.events import BaseEvent, MessageEvent from . import errors from .core import Command, Group @@ -192,8 +193,15 @@ def _commands_by_alias(self): def all_commands(self): return {**self._commands, **self._commands_by_alias} - def dispatch(self, event_name, *args, **kwargs): - super().dispatch(event_name, *args, **kwargs) + def dispatch(self, event: Union[str, BaseEvent], *args, **kwargs): + super().dispatch(event, *args, **kwargs) + + if isinstance(event, BaseEvent): + event_name = event.__dispatch_event__ + args = (event,) + else: + event_name = event + ev = 'on_' + event_name for event in self.extra_events.get(ev, []): self._schedule_event(event, ev, *args, **kwargs) @@ -205,7 +213,7 @@ def add_command(self, command: Command): ----------- command: :class:`.Command` The command to register. - + Raises ------- CommandRegistrationError @@ -407,7 +415,7 @@ async def is_owner(self, user: guilded.User): else: return user.id == self.user.id - async def get_prefix(self, message: guilded.Message, /) -> Union[List[str], str]: + async def get_prefix(self, message: guilded.ChatMessage, /) -> Union[List[str], str]: """|coro| Retrieves the prefix the bot is listening to with the message as a context. @@ -505,7 +513,7 @@ async def invoke(self, ctx: Context) -> None: exc = errors.CommandNotFound(f'Command "{ctx.invoked_with}" is not found') self.dispatch('command_error', ctx, exc) - async def process_commands(self, message): + async def process_commands(self, message: guilded.ChatMessage): """|coro| This function processes the commands that have been registered to the @@ -537,7 +545,7 @@ async def process_commands(self, message): ctx = await self.get_context(message) await self.invoke(ctx) - async def on_message(self, message): + async def on_message(self, event: Union[guilded.ChatMessage, MessageEvent]): """|coro| The default handler for :func:`~.on_message` provided by the bot. @@ -545,6 +553,7 @@ async def on_message(self, message): If you are overriding this, remember to call :meth:`.process_commands` or all commands will be ignored. """ + message = event.message if isinstance(event, MessageEvent) else event await self.process_commands(message) # cogs diff --git a/guilded/gateway.py b/guilded/gateway.py index 2411bbc..bdf8ae5 100644 --- a/guilded/gateway.py +++ b/guilded/gateway.py @@ -50,6 +50,7 @@ """ from __future__ import annotations +import re import time import aiohttp @@ -60,9 +61,11 @@ import sys import threading import traceback -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Dict, Optional from .errors import GuildedException, HTTPException +from .enums import ChannelType +from . import events as ev from .channel import * from .reaction import RawReactionActionEvent, Reaction from .role import Role @@ -73,7 +76,7 @@ if TYPE_CHECKING: from typing_extensions import Self - from .types.gateway import * + from .types import gateway as gw from .client import Client @@ -197,7 +200,7 @@ async def build(cls, client: Client, *, loop: asyncio.AbstractEventLoop = None) async def received_event(self, payload: str) -> int: self.client.dispatch('socket_raw_receive', payload) - data: EventSkeleton = json.loads(payload) + data: gw.EventSkeleton = json.loads(payload) log.debug('WebSocket has received %s', data) op = data['op'] @@ -208,7 +211,7 @@ async def received_event(self, payload: str) -> int: self._last_message_id = message_id if op == self.WELCOME: - d: WelcomeEvent + d: gw.WelcomeEvent self._heartbeater = Heartbeater(ws=self, interval=d['heartbeatIntervalMs'] / 1000) self._heartbeater.start() self._last_message_id = d['lastMessageId'] @@ -249,26 +252,33 @@ async def received_event(self, payload: str) -> int: # don't want to fetch them every time and slow down bots. self.client.http.add_to_server_cache(server) - event = self._parsers.get(t, d) - if event is not None: + coro = self._parsers.get(t) + if coro is not None: # ignore unhandled events - try: - await event - except GuildedException as e: - self.client.dispatch('error', e) - raise - except Exception as e: - # wrap error if not already from the lib - exc = GuildedException(e) - self.client.dispatch('error', exc) - raise exc from e + + server_id = d.get('serverId') + if server_id and not self.client.http._get_server(server_id): + # We have a server ID but we failed to obtain the server itself + log.debug('Ignoring %s event with unknown server ID %s.', t, server_id) + + else: + try: + await coro(d) + except GuildedException as e: + self.client.dispatch('error', e) + raise + except Exception as e: + # wrap error if not already from the lib + exc = GuildedException(e) + self.client.dispatch('error', exc) + raise exc from e if op == self.INVALID_CURSOR: - d: InvalidCursorEvent + d: gw.InvalidCursorEvent log.error('Invalid cursor: %s', d['message']) if op == self.INTERNAL_ERROR: - d: InternalErrorEvent + d: gw.InternalErrorEvent log.error('Internal error: %s', d['message']) return op @@ -278,61 +288,73 @@ class WebSocketEventParsers: def __init__(self, client: Client): self.client = client self._state = client.http + self._exp_style = self._state._experimental_event_style - def get( - self, - event_name: str, - data: Dict[str, Any], - ): - coro = getattr(self, event_name, None) + def get(self, event_name: str): + # Pythonify event names, e.g. ChatMessageCreated -> chat_message_created + transformed = re.sub( + r'(?