Skip to content

Commit d6856b8

Browse files
authored
Initial Pythonista API Websocket implementation. (#37)
* Add API module for websocket connection. * Add pythonista to config types. * Add pythonista to config * Add Pythonista API constants. * Run black. * Run isort. * Update subscription name as per API. * Update subscription name as per API. * Run black
1 parent a06b383 commit d6856b8

File tree

4 files changed

+214
-1
lines changed

4 files changed

+214
-1
lines changed

Diff for: config.template.toml

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ owner_ids = [123, 456, 789] # user or role ids, optional
55
bot = ''
66
idevision = ''
77
mystbin = ''
8+
pythonista = ''
89

910
[DATABASE]
1011
dsn = 'postgres://pythonistabot:pythonistabot@database:5432/pythonistabot' # assumed default

Diff for: constants/constants.py

+39-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,16 @@
2323
from ._meta import CONSTANTS
2424

2525

26-
__all__ = ("Roles", "Colours", "Channels", "ForumTags")
26+
__all__ = (
27+
"Roles",
28+
"Colours",
29+
"Channels",
30+
"ForumTags",
31+
"PAPIWebsocketSubscriptions",
32+
"PAPIWebsocketCloseCodes",
33+
"PAPIWebsocketNotificationTypes",
34+
"PAPIWebsocketOPCodes",
35+
)
2736

2837

2938
class Roles(CONSTANTS):
@@ -66,3 +75,32 @@ class ForumTags(CONSTANTS):
6675
DISCORDPY = 1006716972802789457
6776
OTHER = 1006717008613740596
6877
RESOLVED = 1006769269201195059
78+
79+
80+
class PAPIWebsocketCloseCodes(CONSTANTS):
81+
NORMAL: int = 1000
82+
ABNORMAL: int = 1006
83+
84+
85+
class PAPIWebsocketOPCodes(CONSTANTS):
86+
# Received from Pythonista API...
87+
HELLO: int = 0
88+
EVENT: int = 1
89+
NOTIFICATION: int = 2
90+
91+
# Sent to Pythonista API...
92+
SUBSCRIBE: str = "subscribe"
93+
UNSUBSCRIBE: str = "unsubscribe"
94+
95+
96+
class PAPIWebsocketSubscriptions(CONSTANTS):
97+
DPY_MODLOG: str = "dpy_modlog"
98+
99+
100+
class PAPIWebsocketNotificationTypes(CONSTANTS):
101+
# Subscriptions...
102+
SUBSCRIPTION_ADDED: str = "subscription_added"
103+
SUBSCRIPTION_REMOVED: str = "subscription_removed"
104+
105+
# Failures...
106+
UNKNOWN_OP: str = "unknown_op"

Diff for: modules/api.py

+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""MIT License
2+
3+
Copyright (c) 2021-Present PythonistaGuild
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
22+
"""
23+
from __future__ import annotations
24+
25+
import asyncio
26+
import logging
27+
from typing import Any
28+
29+
import aiohttp
30+
from discord.backoff import ExponentialBackoff
31+
32+
import core
33+
from constants import (
34+
PAPIWebsocketCloseCodes,
35+
PAPIWebsocketNotificationTypes,
36+
PAPIWebsocketOPCodes,
37+
PAPIWebsocketSubscriptions,
38+
)
39+
40+
41+
LOGGER = logging.getLogger(__name__)
42+
43+
44+
WS_URL: str = "wss://api.pythonista.gg/v1/websocket"
45+
46+
47+
class API(core.Cog):
48+
def __init__(self, bot: core.Bot) -> None:
49+
self.bot = bot
50+
51+
self.session: aiohttp.ClientSession | None = None
52+
self.backoff: ExponentialBackoff = ExponentialBackoff() # type: ignore
53+
self.websocket: aiohttp.ClientWebSocketResponse | None = None
54+
55+
self.connection_task: asyncio.Task[None] | None = None
56+
self.keep_alive_task: asyncio.Task[None] | None = None
57+
58+
@property
59+
def headers(self) -> dict[str, Any]:
60+
return {"Authorization": core.CONFIG["TOKENS"]["pythonista"]}
61+
62+
async def cog_load(self) -> None:
63+
self.session = aiohttp.ClientSession(headers=self.headers)
64+
self.connection_task = asyncio.create_task(self.connect())
65+
66+
async def cog_unload(self) -> None:
67+
if self.connection_task:
68+
try:
69+
self.connection_task.cancel()
70+
except Exception as e:
71+
LOGGER.debug(f'Unable to cancel Pythonista API connection_task in "cog_unload": {e}')
72+
73+
if self.is_connected():
74+
assert self.websocket
75+
await self.websocket.close(code=PAPIWebsocketCloseCodes.NORMAL)
76+
77+
if self.keep_alive_task:
78+
try:
79+
self.keep_alive_task.cancel()
80+
except Exception as e:
81+
LOGGER.debug(f'Unable to cancel Pythonista API keep_alive_task in "cog_unload": {e}')
82+
83+
def dispatch(self, *, data: dict[str, Any]) -> None:
84+
subscription: str = data["subscription"]
85+
self.bot.dispatch(f"papi_{subscription}", data)
86+
87+
def is_connected(self) -> bool:
88+
return self.websocket is not None and not self.websocket.closed
89+
90+
async def connect(self) -> None:
91+
token: str | None = core.CONFIG["TOKENS"].get("pythonista")
92+
93+
if not token:
94+
self.connection_task = None
95+
return
96+
97+
if self.keep_alive_task:
98+
try:
99+
self.keep_alive_task.cancel()
100+
except Exception as e:
101+
LOGGER.debug(f"Failed to cancel Pythonista API Websocket keep alive. This is likely not a problem: {e}")
102+
103+
while True:
104+
try:
105+
self.websocket = await self.session.ws_connect(url=WS_URL) # type: ignore
106+
except Exception as e:
107+
if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 403:
108+
LOGGER.critical("Unable to connect to Pythonista API Websocket, due to an incorrect token.")
109+
return
110+
else:
111+
LOGGER.debug(f"Unable to connect to Pythonista API Websocket: {e}.")
112+
113+
if self.is_connected():
114+
break
115+
else:
116+
delay: float = self.backoff.delay() # type: ignore
117+
LOGGER.debug(f'Retrying Pythonista API Websocket connection in "{delay}" seconds.')
118+
119+
await asyncio.sleep(delay)
120+
121+
self.connection_task = None
122+
self.keep_alive_task = asyncio.create_task(self.keep_alive())
123+
124+
async def keep_alive(self) -> None:
125+
assert self.websocket
126+
127+
initial: dict[str, Any] = {
128+
"op": PAPIWebsocketOPCodes.SUBSCRIBE,
129+
"subscriptions": [PAPIWebsocketSubscriptions.DPY_MODLOG],
130+
}
131+
await self.websocket.send_json(data=initial)
132+
133+
while True:
134+
message: aiohttp.WSMessage = await self.websocket.receive()
135+
136+
closing: tuple[aiohttp.WSMsgType, aiohttp.WSMsgType, aiohttp.WSMsgType] = (
137+
aiohttp.WSMsgType.CLOSED,
138+
aiohttp.WSMsgType.CLOSING,
139+
aiohttp.WSMsgType.CLOSE,
140+
)
141+
if message.type in closing: # pyright: ignore[reportUnknownMemberType]
142+
LOGGER.debug("Received a CLOSING/CLOSED/CLOSE message type from Pythonista API.")
143+
144+
self.connection_task = asyncio.create_task(self.connect())
145+
return
146+
147+
data: dict[str, Any] = message.json()
148+
op: int | None = data.get("op")
149+
150+
if op == PAPIWebsocketOPCodes.HELLO:
151+
LOGGER.info(f'Received HELLO from Pythonista API: user={data["user_id"]}')
152+
153+
elif op == PAPIWebsocketOPCodes.EVENT:
154+
self.dispatch(data=data)
155+
156+
elif op == PAPIWebsocketOPCodes.NOTIFICATION:
157+
type_: str = data["type"]
158+
159+
if type_ == PAPIWebsocketNotificationTypes.SUBSCRIPTION_ADDED:
160+
subscribed: str = ", ".join(data["subscriptions"])
161+
LOGGER.info(f"Pythonista API added our subscription, currently subscribed: `{subscribed}`")
162+
elif type_ == PAPIWebsocketNotificationTypes.SUBSCRIPTION_REMOVED:
163+
subscribed: str = ", ".join(data["subscriptions"])
164+
LOGGER.info(f"Pythonista API removed our subscription, currently subscribed: `{subscribed}`")
165+
elif type_ == PAPIWebsocketNotificationTypes.UNKNOWN_OP:
166+
LOGGER.info(f'We sent an UNKNOWN OP to Pythonista API: `{data["received"]}`')
167+
168+
else:
169+
LOGGER.info("Received an UNKNOWN OP from Pythonista API.")
170+
171+
172+
async def setup(bot: core.Bot) -> None:
173+
await bot.add_cog(API(bot))

Diff for: types_/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Tokens(TypedDict):
99
idevision: str
1010
mystbin: str
1111
github_bot: str
12+
pythonista: str
1213

1314

1415
class Database(TypedDict):

0 commit comments

Comments
 (0)