Passed
Push — main ( 3493fc...06758f )
by
unknown
01:40 queued 11s
created

pincer.core.gateway   A

Complexity

Total Complexity 12

Size/Duplication

Total Lines 244
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 12
eloc 109
dl 0
loc 244
rs 10
c 0
b 0
f 0

5 Methods

Rating   Name   Duplication   Size   Complexity  
B Dispatcher.__dispatcher() 0 41 5
A Dispatcher.close() 0 11 1
A Dispatcher.run() 0 15 2
A Dispatcher.__handler_manager() 0 34 2
B Dispatcher.__init__() 0 73 2
1
# -*- coding: utf-8 -*-
0 ignored issues
show
introduced by
Missing module docstring
Loading history...
2
# MIT License
3
#
4
# Copyright (c) 2021 Pincer
5
#
6
# Permission is hereby granted, free of charge, to any person obtaining
7
# a copy of this software and associated documentation files
8
# (the "Software"), to deal in the Software without restriction,
9
# including without limitation the rights to use, copy, modify, merge,
10
# publish, distribute, sublicense, and/or sell copies of the Software,
11
# and to permit persons to whom the Software is furnished to do so,
12
# subject to the following conditions:
13
#
14
# The above copyright notice and this permission notice shall be
15
# included in all copies or substantial portions of the Software.
16
#
17
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
20
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
21
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
22
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
23
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
25
from __future__ import annotations
26
27
import logging
28
from asyncio import get_event_loop, AbstractEventLoop, ensure_future
29
from platform import system
30
from typing import Dict, Callable, Awaitable
31
32
from websockets import connect
33
from websockets.exceptions import ConnectionClosedError
34
from websockets.legacy.client import WebSocketClientProtocol
35
36
from pincer import __package__
1 ignored issue
show
Bug Best Practice introduced by
This seems to re-define the built-in __package__.

It is generally discouraged to redefine built-ins as this makes code very hard to read.

Loading history...
37
from pincer._config import GatewayConfig
38
from pincer.core.dispatch import GatewayDispatch
39
from pincer.core.heartbeat import (
40
    handle_hello, handle_heartbeat, update_sequence
41
)
42
from pincer.exceptions import (
43
    PincerError, InvalidTokenError, UnhandledException,
44
    _InternalPerformReconnectError, DisallowedIntentsError
45
)
46
47
Handler = Callable[[WebSocketClientProtocol, GatewayDispatch], Awaitable[None]]
48
log = logging.getLogger(__package__)
49
50
51
class Dispatcher:
52
    """
53
    The Dispatcher handles all interactions with discord websocket API.
54
    This also contains the main event loop, and handles the heartbeat.
55
56
    Running the dispatcher will create a connection with the
57
    Discord WebSocket API on behalf of the provided token.
58
59
    This token must be a bot token.
60
    (Which can be found on `/developers/applications/<bot_id>/bot`)
61
    """
62
63
    # TODO: Add intents argument
0 ignored issues
show
Coding Style introduced by
TODO and FIXME comments should generally be avoided.
Loading history...
64
    def __init__(self, token: str, *, handlers: Dict[int, Handler]) -> None:
65
        """
66
        :param token: Bot token for discord's API.
67
        """
68
69
        if len(token) != 59:
70
            raise InvalidTokenError(
71
                "Discord Token must have exactly 59 characters."
72
            )
73
74
        self.__token = token
75
        self.__keep_alive = True
76
77
        async def identify_and_handle_hello(
78
                socket: WebSocketClientProtocol,
79
                payload: GatewayDispatch
80
        ):
81
            """
82
            Identifies the client to the Discord Websocket API, this
83
            gets done when the client receives the `hello` (opcode 10)
84
            message from discord. Right after we send our identification
85
            the heartbeat starts.
86
87
            :param socket:
88
                The current socket, which can be used to interact
89
                with the Discord API.
90
91
            :param payload: The received payload from Discord.
92
            """
93
            log.debug("Sending authentication/identification message.")
94
95
            await socket.send(
96
                str(
97
                    GatewayDispatch(
98
                        2, {
99
                            "token": token,
100
                            "intents": 0,
101
                            "properties": {
102
                                "$os": system(),
103
                                "$browser": __package__,
104
                                "$device": __package__
105
                            }
106
                        }
107
                    )
108
                )
109
            )
110
111
            await handle_hello(socket, payload)
112
113
        async def handle_reconnect(_, payload: GatewayDispatch):
114
            """
115
            Closes the client and then reconnects it.
116
            """
117
            log.debug("Reconnecting client...")
118
            self.close()
119
120
            update_sequence(payload.seq)
121
            self.run()
122
123
        self.__dispatch_handlers: Dict[int, Handler] = {
124
            **handlers,
125
            7: handle_reconnect,
126
            9: handle_reconnect,
127
            10: identify_and_handle_hello,
128
            11: handle_heartbeat
129
        }
130
131
        self.__dispatch_errors: Dict[int, PincerError] = {
132
            4000: _InternalPerformReconnectError(),
133
            4004: InvalidTokenError(),
134
            4007: _InternalPerformReconnectError(),
135
            4009: _InternalPerformReconnectError(),
136
            4014: DisallowedIntentsError()
137
        }
138
139
    async def __handler_manager(
140
            self,
141
            socket: WebSocketClientProtocol,
142
            payload: GatewayDispatch,
143
            loop: AbstractEventLoop
144
    ):
145
        """
146
        This manages all handles for given OP codes.
147
        This method gets invoked for every message that is received from
148
        Discord.
149
150
        :param socket:
151
            The current socket, which can be used to interact
152
            with the Discord API.
153
        :param payload: The received payload from Discord.
154
        :param loop: The current async loop on which the future is bound.
155
        """
156
        log.debug(
0 ignored issues
show
Coding Style Best Practice introduced by
Use lazy % formatting in logging functions
Loading history...
157
            f"New event received, checking if handler exists for opcode: "
0 ignored issues
show
introduced by
Using an f-string that does not have any interpolated variables
Loading history...
158
            + str(payload.op)
159
        )
160
161
        handler: Handler = self.__dispatch_handlers.get(payload.op)
162
163
        if not handler:
164
            log.error(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
165
                f"No handler was found for opcode {payload.op}, "
166
                "please report this to the pincer dev team!"
167
            )
168
169
            raise UnhandledException(f"Unhandled payload: {payload}")
170
171
        log.debug("Event handler found, ensuring async future in current loop.")
172
        ensure_future(handler(socket, payload), loop=loop)
173
174
    async def __dispatcher(self, loop: AbstractEventLoop):
175
        """
176
        The main event loop.
177
        This handles all interactions with the websocket API.
178
        """
179
        log.debug(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
180
            f"Establishing websocket connection with `{GatewayConfig.uri()}`"
181
        )
182
183
        async with connect(GatewayConfig.uri()) as socket:
184
            log.debug(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
185
                "Successfully established websocket connection with "
186
                f"`{GatewayConfig.uri()}`"
187
            )
188
189
            while self.__keep_alive:
190
                try:
191
                    log.debug("Waiting for new event.")
192
                    await self.__handler_manager(
193
                        socket,
194
                        GatewayDispatch.from_string(await socket.recv()),
195
                        loop
196
                    )
197
198
                except ConnectionClosedError as exc:
199
                    log.debug(
0 ignored issues
show
introduced by
Use lazy % formatting in logging functions
Loading history...
200
                        f"The connection with `{GatewayConfig.uri()}` "
201
                        f"has been broken unexpectedly. "
202
                        f"({exc.code}, {exc.reason})"
203
                    )
204
205
                    self.close()
206
                    exception = self.__dispatch_errors.get(exc.code)
207
208
                    if isinstance(exception, _InternalPerformReconnectError):
209
                        update_sequence(0)
210
                        self.close()
211
                        return self.run()
212
213
                    raise exception or UnhandledException(
214
                        f"Dispatch error ({exc.code}): {exc.reason}"
215
                    )
216
217
    def run(self, loop: AbstractEventLoop = None):
218
        """
219
        Instantiate the dispatcher, this will create a connection to the
220
        Discord websocket API on behalf of the client who's token has
221
        been passed.
222
        """
223
        log.debug("Starting GatewayDispatcher")
224
        loop = loop or get_event_loop()
225
        loop.run_until_complete(self.__dispatcher(loop))
226
        loop.close()
227
228
        # Prevent client from disconnecting
229
        if self.__keep_alive:
230
            log.debug("Reconnecting client!")
231
            self.run()
232
233
    def close(self):
234
        """
235
        Stop the dispatcher from listening and responding to gateway
236
        events. This should let the client close on itself.
237
        """
238
        log.debug(
239
            "Setting keep_alive to False, "
240
            "this will terminate the heartbeat."
241
        )
242
243
        self.__keep_alive = False
244