Huge websocket redesign, to support more than a single connection

This commit is contained in:
DevilXD committed 2021-12-15 18:37:30 +01:00
1 parent 106831cc9a
commit a45838e088
7 files changed
+370 -236

No files matched your search

+10 -1
View File
@@ -12,7 +12,7 @@
"console": "integratedTerminal" "console": "integratedTerminal"
}, },
{ {
"name": "Run App", "name": "Run App (INFO)",
"type": "python", "type": "python",
"request": "launch", "request": "launch",
"program": "main.py", "program": "main.py",
@@ -20,5 +20,14 @@
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false "justMyCode": false
}, },
{
"name": "Run App (DEBUG)",
"type": "python",
"request": "launch",
"program": "main.py",
"args": ["-vvv"],
"console": "integratedTerminal",
"justMyCode": false
},
] ]
} }
+5 -5
View File
@@ -7,11 +7,11 @@ import logging
from copy import copy from copy import copy
from base64 import b64encode from base64 import b64encode
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional, Dict, TYPE_CHECKING from typing import Any, Optional, TYPE_CHECKING
from inventory import Game from inventory import Game
from exceptions import MinerException from exceptions import MinerException
from constants import BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG from constants import JsonType, BASE_URL, GQL_OPERATIONS, ONLINE_DELAY, DROPS_ENABLED_TAG
if TYPE_CHECKING: if TYPE_CHECKING:
from twitch import Twitch from twitch import Twitch
@@ -21,7 +21,7 @@ logger = logging.getLogger("TwitchDrops")
class Stream: class Stream:
def __init__(self, channel: Channel, data: Dict[str, Any]): def __init__(self, channel: Channel, data: JsonType):
self._twitch = channel._twitch self._twitch = channel._twitch
self.channel = channel self.channel = channel
stream = data["stream"] stream = data["stream"]
@@ -38,7 +38,7 @@ class Stream:
self._timestamp = datetime.now(timezone.utc) self._timestamp = datetime.now(timezone.utc)
@classmethod @classmethod
def from_directory(cls, channel: Channel, data: Dict[str, Any]): def from_directory(cls, channel: Channel, data: JsonType):
self = super().__new__(cls) self = super().__new__(cls)
self._twitch = channel._twitch self._twitch = channel._twitch
self.channel = channel self.channel = channel
@@ -72,7 +72,7 @@ class Channel:
await self.get_stream() await self.get_stream()
@classmethod @classmethod
def from_directory(cls, twitch: Twitch, data: Dict[str, Any]): def from_directory(cls, twitch: Twitch, data: JsonType):
self = super().__new__(cls) self = super().__new__(cls)
self._twitch = twitch self._twitch = twitch
channel = data["broadcaster"] channel = data["broadcaster"]
+43 -31
View File
@@ -1,10 +1,17 @@
from __future__ import annotations from __future__ import annotations
import logging
from copy import copy from copy import copy
from datetime import timedelta from datetime import timedelta
from typing import Any, Optional, Union, Dict, Callable from typing import Any, Optional, Union, Dict, Callable
# Typing
JsonType = Dict[str, Any]
TopicProcess = Callable[[JsonType], Any]
# Values
DEBUG_RAW = logging.DEBUG - 1
MAX_WEBSOCKETS = 8
WS_TOPICS_LIMIT = 50
# URLs # URLs
BASE_URL = "https://twitch.tv" BASE_URL = "https://twitch.tv"
AUTH_URL = "https://passport.twitch.tv" AUTH_URL = "https://passport.twitch.tv"
@@ -28,8 +35,8 @@ DROPS_ENABLED_TAG = "c2542d6d-cd10-4532-919b-3d19f30a768b"
TERMINATED_STR = "Application Terminated.\nClose the console window to exit the application." TERMINATED_STR = "Application Terminated.\nClose the console window to exit the application."
class GQLOperation(Dict[str, Any]): class GQLOperation(JsonType):
def __init__(self, name: str, sha256: str, *, variables: Optional[Dict[str, Any]] = None): def __init__(self, name: str, sha256: str, *, variables: Optional[JsonType] = None):
super().__init__( super().__init__(
operationName=name, operationName=name,
extensions={ extensions={
@@ -42,10 +49,10 @@ class GQLOperation(Dict[str, Any]):
if variables is not None: if variables is not None:
self.__setitem__("variables", variables) self.__setitem__("variables", variables)
def with_variables(self, variables: Dict[str, Any]): def with_variables(self, variables: JsonType):
modified = copy(self) modified = copy(self)
if "variables" in self: if "variables" in self:
existing_variables: Dict[str, Any] = modified["variables"] existing_variables: JsonType = modified["variables"]
existing_variables.update(variables) existing_variables.update(variables)
else: else:
modified["variables"] = variables modified["variables"] = variables
@@ -121,42 +128,47 @@ GQL_OPERATIONS: Dict[str, GQLOperation] = {
} }
def get_topic(
topic_name: str, target_id: Union[str, int], process: Callable[[Dict[str, Any]], Any]
) -> WebsocketTopic:
return WebsocketTopic(f"{WEBSOCKET_TOPICS[topic_name]}.{target_id}", process)
class WebsocketTopic: class WebsocketTopic:
def __init__(self, topic_id: str, process: Callable[[Dict[str, Any]], Any]): def __init__(
self.id: str = topic_id self,
self.process: Callable[[Dict[str, Any]], Any] = process topic_category: str,
topic_name: str,
target_id: Union[int, str],
process: TopicProcess,
):
self._id: str = f"{WEBSOCKET_TOPICS[topic_category][topic_name]}.{target_id}"
self._process: TopicProcess = process
def __call__(self, *args):
return self._process(*args)
def __str__(self) -> str: def __str__(self) -> str:
return self.id return self._id
def __eq__(self, other): def __eq__(self, other):
if isinstance(other, str): if isinstance(other, str):
return self.id == other return self._id == other
elif isinstance(other, WebsocketTopic): elif isinstance(other, WebsocketTopic):
return self.id == other.id return self._id == other._id
return NotImplemented return NotImplemented
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((self.__class__.__name__, self.id)) return hash((self.__class__.__name__, self._id))
WEBSOCKET_TOPICS: Dict[str, str] = { WEBSOCKET_TOPICS: Dict[str, Dict[str, str]] = {
# Using user_id "User": { # Using user_id
"UserDrops": "user-drop-events", "Drops": "user-drop-events",
"UserStreamState": "stream-change-v1", "StreamState": "stream-change-v1",
"UserCommunityPoints": "community-points-user-v1", "CommunityPoints": "community-points-user-v1",
"Presence": "presence", "Presence": "presence",
"Notifications": "onsite-notifications", "Notifications": "onsite-notifications",
# Using channel_id },
"ChannelDrops": "channel-drop-events", "Channel": { # Using channel_id
"ChannelStreamState": "stream-change-by-channel", "Drops": "channel-drop-events",
"ChannelCommunityPoints": "community-points-channel-v1", "StreamState": "stream-change-by-channel",
"VideoPlayback": "video-playback-by-id", "CommunityPoints": "community-points-channel-v1",
"StreamUpdate": "broadcast-settings-update", "VideoPlayback": "video-playback-by-id",
"StreamUpdate": "broadcast-settings-update",
},
} }
+7 -7
View File
@@ -1,16 +1,16 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Optional, List, Dict, TYPE_CHECKING from typing import Optional, List, Dict, TYPE_CHECKING
from constants import GQL_OPERATIONS from constants import JsonType, GQL_OPERATIONS
if TYPE_CHECKING: if TYPE_CHECKING:
from twitch import Twitch from twitch import Twitch
class Game: class Game:
def __init__(self, data: Dict[str, Any]): def __init__(self, data: JsonType):
self.id: int = int(data["id"]) self.id: int = int(data["id"])
self.name: str = data["name"] self.name: str = data["name"]
@@ -24,7 +24,7 @@ class Game:
class BaseDrop: class BaseDrop:
def __init__(self, campaign: DropsCampaign, data: Dict[str, Any]): def __init__(self, campaign: DropsCampaign, data: JsonType):
self._twitch: Twitch = campaign._twitch self._twitch: Twitch = campaign._twitch
self.id: str = data["id"] self.id: str = data["id"]
self.name: str = data["name"] self.name: str = data["name"]
@@ -80,7 +80,7 @@ class BaseDrop:
class TimedDrop(BaseDrop): class TimedDrop(BaseDrop):
def __init__(self, campaign: DropsCampaign, data: Dict[str, Any]): def __init__(self, campaign: DropsCampaign, data: JsonType):
super().__init__(campaign, data) super().__init__(campaign, data)
self.current_minutes: int = data["self"]["currentMinutesWatched"] self.current_minutes: int = data["self"]["currentMinutesWatched"]
self.required_minutes: int = data["requiredMinutesWatched"] self.required_minutes: int = data["requiredMinutesWatched"]
@@ -96,7 +96,7 @@ class TimedDrop(BaseDrop):
def progress(self) -> float: def progress(self) -> float:
return self.current_minutes / self.required_minutes return self.current_minutes / self.required_minutes
def update(self, message: Dict[str, Any]): def update(self, message: JsonType):
# {"type": "drop-progress", data: {"current_progress_min": 3, "required_progress_min": 10}} # {"type": "drop-progress", data: {"current_progress_min": 3, "required_progress_min": 10}}
# {"type": "drop-claim", data: {"drop_instance_id": ...}} # {"type": "drop-claim", data: {"drop_instance_id": ...}}
msg_type = message["type"] msg_type = message["type"]
@@ -108,7 +108,7 @@ class TimedDrop(BaseDrop):
class DropsCampaign: class DropsCampaign:
def __init__(self, twitch: Twitch, data: Dict[str, Any]): def __init__(self, twitch: Twitch, data: JsonType):
self._twitch: Twitch = twitch self._twitch: Twitch = twitch
self.id: str = data["id"] self.id: str = data["id"]
self.name: str = data["name"] self.name: str = data["name"]
+8 -7
View File
@@ -10,10 +10,10 @@ import asyncio
import warnings import warnings
import traceback import traceback
import threading import threading
from typing import Any, Dict, NoReturn from typing import NoReturn
from twitch import Twitch from twitch import Twitch
from constants import SETTINGS_PATH, TERMINATED_STR from constants import JsonType, SETTINGS_PATH, TERMINATED_STR, DEBUG_RAW
try: try:
import win32api import win32api
@@ -41,6 +41,8 @@ def terminate() -> NoReturn:
raise RuntimeError("Uh oh") # this will never run, solely for MyPy raise RuntimeError("Uh oh") # this will never run, solely for MyPy
# extra debug logging level
logging.addLevelName(DEBUG_RAW, "DEBUG_RAW")
# handle extra stackable '-v' parameter, that switches the logging level # handle extra stackable '-v' parameter, that switches the logging level
logging_level = logging.ERROR logging_level = logging.ERROR
if len(sys.argv) > 1: if len(sys.argv) > 1:
@@ -51,6 +53,9 @@ if len(sys.argv) > 1:
logging_level = logging.INFO logging_level = logging.INFO
elif arg in ("-vvv", "-v3"): elif arg in ("-vvv", "-v3"):
logging_level = logging.DEBUG logging_level = logging.DEBUG
elif arg in ("-vvvv", "-v4"):
# enables debugging raw messages
logging_level = DEBUG_RAW
# handle logging stuff # handle logging stuff
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.addHandler(logging.NullHandler()) root_logger.addHandler(logging.NullHandler())
@@ -62,7 +67,7 @@ logger.setLevel(logging_level)
# handle settings # handle settings
try: try:
with open(SETTINGS_PATH, 'r', encoding="utf8") as file: with open(SETTINGS_PATH, 'r', encoding="utf8") as file:
settings: Dict[str, Any] = json.load(file) settings: JsonType = json.load(file)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
print(f"Error while reading the settings file:\n{str(exc)}") print(f"Error while reading the settings file:\n{str(exc)}")
terminate() terminate()
@@ -99,10 +104,6 @@ except (asyncio.CancelledError, KeyboardInterrupt):
# Because we don't want anything from there to actually run during cleanup, # Because we don't want anything from there to actually run during cleanup,
# we need to explicitly cancel the task ourselves here. # we need to explicitly cancel the task ourselves here.
main_task.cancel() main_task.cancel()
# cancel all other tasks
for task in asyncio.all_tasks(loop):
if not task.done():
task.cancel()
# main_task was cancelled due to program shutting down - do the cleanup # main_task was cancelled due to program shutting down - do the cleanup
loop.run_until_complete(client.close()) loop.run_until_complete(client.close())
# notify we're free to exit # notify we're free to exit
+28 -25
View File
@@ -14,11 +14,14 @@ except ImportError:
raise ImportError("You have to run 'python -m pip install aiohttp' first") raise ImportError("You have to run 'python -m pip install aiohttp' first")
from channel import Channel from channel import Channel
from websocket import Websocket from websocket import WebsocketPool
from inventory import DropsCampaign, Game from inventory import DropsCampaign, Game
from exceptions import LoginException, CaptchaRequired from exceptions import LoginException, CaptchaRequired
from constants import ( from constants import (
JsonType,
WebsocketTopic,
CLIENT_ID, CLIENT_ID,
DEBUG_RAW,
USER_AGENT, USER_AGENT,
COOKIES_PATH, COOKIES_PATH,
AUTH_URL, AUTH_URL,
@@ -27,8 +30,6 @@ from constants import (
DROPS_ENABLED_TAG, DROPS_ENABLED_TAG,
TERMINATED_STR, TERMINATED_STR,
GQLOperation, GQLOperation,
WebsocketTopic,
get_topic,
) )
@@ -55,8 +56,6 @@ class Twitch:
self._access_token: Optional[str] = None self._access_token: Optional[str] = None
self._user_id: Optional[int] = None self._user_id: Optional[int] = None
self._is_logged_in = asyncio.Event() self._is_logged_in = asyncio.Event()
# Websocket
self.websocket = Websocket(self)
# Storing, watching and changing channels # Storing, watching and changing channels
self.channels: Dict[int, Channel] = {} self.channels: Dict[int, Channel] = {}
self._watching_channel: Optional[Channel] = None self._watching_channel: Optional[Channel] = None
@@ -65,6 +64,8 @@ class Twitch:
# Inventory # Inventory
self.inventory: List[DropsCampaign] = [] self.inventory: List[DropsCampaign] = []
self._campaign_change = asyncio.Event() self._campaign_change = asyncio.Event()
# Websocket
self.websocket = WebsocketPool(self)
def wait_until_login(self): def wait_until_login(self):
return self._is_logged_in.wait() return self._is_logged_in.wait()
@@ -77,7 +78,7 @@ class Twitch:
self._session.cookie_jar.save(COOKIES_PATH) # type: ignore self._session.cookie_jar.save(COOKIES_PATH) # type: ignore
self.stop_watching() self.stop_watching()
await self._session.close() await self._session.close()
await self.websocket.close() await self.websocket.stop()
await asyncio.sleep(1) # allows aiohttp to safely close the session await asyncio.sleep(1) # allows aiohttp to safely close the session
def is_currently_watching(self, channel: Channel) -> bool: def is_currently_watching(self, channel: Channel) -> bool:
@@ -111,7 +112,7 @@ class Twitch:
print(f"No active campaigns to farm drops for.\n\n{TERMINATED_STR}") print(f"No active campaigns to farm drops for.\n\n{TERMINATED_STR}")
await asyncio.Future() await asyncio.Future()
# Start our websocket connection, only after we confirm that there are drops to mine # Start our websocket connection, only after we confirm that there are drops to mine
self.websocket.start() await self.websocket.start()
if not channel_names: if not channel_names:
# get a list of all channels with drops enabled # get a list of all channels with drops enabled
print("Fetching suitable live channels to watch...") print("Fetching suitable live channels to watch...")
@@ -129,14 +130,16 @@ class Twitch:
channel: Channel = await Channel(self, channel_name) # type: ignore channel: Channel = await Channel(self, channel_name) # type: ignore
self.channels[channel.id] = channel self.channels[channel.id] = channel
# Sub to these channel updates # Sub to these channel updates
topics: List[WebsocketTopic] = [] topics: List[WebsocketTopic] = [
for channel_id in self.channels: WebsocketTopic(
topics.append( "Channel",
get_topic( "VideoPlayback",
"VideoPlayback", channel_id, partial(self.process_stream_state, channel_id) channel_id,
) partial(self.process_stream_state, channel_id),
) )
await self.websocket.add_topics(topics) for channel_id in self.channels
]
self.websocket.add_topics(topics)
# Repeat: Change into a channel we can watch, then reset the flag # Repeat: Change into a channel we can watch, then reset the flag
self._channel_change.set() self._channel_change.set()
@@ -152,10 +155,10 @@ class Twitch:
) )
if self._campaign_change.is_set(): if self._campaign_change.is_set():
# we need to reevaluate all campaigns # we need to reevaluate all campaigns
# first of all, stop watching # stop watching
self.stop_watching() self.stop_watching()
# reconnect the websocket # close the websocket
await self.websocket.close() await self.websocket.stop()
break # cycle the outer loop break # cycle the outer loop
# otherwise, it was the channel change one # otherwise, it was the channel change one
for channel in self.channels.values(): for channel in self.channels.values():
@@ -197,8 +200,8 @@ class Twitch:
if i == 0: if i == 0:
# ensure every 30 minutes that we don't have unclaimed points bonus # ensure every 30 minutes that we don't have unclaimed points bonus
response = await self.gql_request(op) response = await self.gql_request(op)
channel_data: Dict[str, Any] = response["data"]["community"]["channel"] channel_data: JsonType = response["data"]["community"]["channel"]
claim_available: Dict[str, Any] = ( claim_available: JsonType = (
channel_data["self"]["communityPoints"]["availableClaim"] channel_data["self"]["communityPoints"]["availableClaim"]
) )
if claim_available: if claim_available:
@@ -222,7 +225,7 @@ class Twitch:
self._watching_task = None self._watching_task = None
self._watching_channel = None self._watching_channel = None
async def process_stream_state(self, channel_id: int, message: Dict[str, Any]): async def process_stream_state(self, channel_id: int, message: JsonType):
msg_type = message["type"] msg_type = message["type"]
channel = self.channels.get(channel_id) channel = self.channels.get(channel_id)
if channel is None: if channel is None:
@@ -308,7 +311,7 @@ class Twitch:
print("\nNote: Password can be pasted in by pressing right click inside the window.\n") print("\nNote: Password can be pasted in by pressing right click inside the window.\n")
self.password = await self.get_password() self.password = await self.get_password()
payload: Dict[str, Any] = { payload: JsonType = {
"username": self.username, "username": self.username,
"password": self.password, "password": self.password,
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
@@ -407,16 +410,16 @@ class Twitch:
jar.update_cookies(cookie, URL("https://twitch.tv")) jar.update_cookies(cookie, URL("https://twitch.tv"))
jar.save(COOKIES_PATH) jar.save(COOKIES_PATH)
async def gql_request(self, op: GQLOperation) -> Dict[str, Any]: async def gql_request(self, op: GQLOperation) -> JsonType:
await self.check_login() await self.check_login()
headers = { headers = {
"Authorization": f"OAuth {self._access_token}", "Authorization": f"OAuth {self._access_token}",
"Client-Id": CLIENT_ID, "Client-Id": CLIENT_ID,
} }
logger.debug(f"GQL Request: {op}") logger.log(DEBUG_RAW, f"GQL Request: {op}")
async with self._session.post(GQL_URL, json=op, headers=headers) as response: async with self._session.post(GQL_URL, json=op, headers=headers) as response:
response_json = await response.json() response_json = await response.json()
logger.debug(f"GQL Response: {response_json}") logger.log(DEBUG_RAW, f"GQL Response: {response_json}")
return response_json return response_json
async def get_inventory(self) -> List[DropsCampaign]: async def get_inventory(self) -> List[DropsCampaign]:
@@ -427,7 +430,7 @@ class Twitch:
async def get_live_streams( async def get_live_streams(
self, games: Collection[Game], tag_ids: List[str] self, games: Collection[Game], tag_ids: List[str]
) -> Dict[Game, List[Channel]]: ) -> Dict[Game, List[Channel]]:
limit = int(45 / len(games)) limit = 100
live_streams = {} live_streams = {}
for game in games: for game in games:
response = await self.gql_request( response = await self.gql_request(
+269 -160
View File
@@ -5,22 +5,36 @@ import random
import string import string
import asyncio import asyncio
import logging import logging
from time import time
from functools import wraps from functools import wraps
from collections import deque from contextlib import suppress
from typing import Any, Optional, Union, Dict, Tuple, Set, Iterable, cast, TYPE_CHECKING from typing import Any, Optional, List, Dict, Set, Iterable, TYPE_CHECKING
from websockets.exceptions import ConnectionClosed, ConnectionClosedOK from websockets.exceptions import ConnectionClosed, ConnectionClosedOK
from websockets.client import WebSocketClientProtocol, connect as websocket_connect from websockets.client import WebSocketClientProtocol, connect as websocket_connect
from inventory import TimedDrop from inventory import TimedDrop
from exceptions import MinerException from exceptions import MinerException
from constants import WEBSOCKET_URL, PING_INTERVAL, WebsocketTopic, get_topic from constants import (
JsonType,
WebsocketTopic,
DEBUG_RAW,
WEBSOCKET_URL,
PING_INTERVAL,
MAX_WEBSOCKETS,
WS_TOPICS_LIMIT,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from twitch import Twitch from twitch import Twitch
logger = logging.getLogger("TwitchDrops") logger = logging.getLogger("TwitchDrops")
NONCE_CHARS = string.ascii_letters + string.digits
def create_nonce(length: int = 30) -> str:
return ''.join(random.choices(NONCE_CHARS, k=length))
def task_wrapper(afunc): def task_wrapper(afunc):
@@ -30,116 +44,95 @@ def task_wrapper(afunc):
await afunc(self, *args, **kwargs) await afunc(self, *args, **kwargs)
except Exception: except Exception:
logger.exception("Exception in websocket task") logger.exception("Exception in websocket task")
raise # raise up to the wrapping task
return wrapper return wrapper
class Websocket: class Websocket:
def __init__(self, twitch: Twitch): def __init__(self, pool: WebsocketPool, index: int):
self._twitch = twitch self._pool = pool
self._ws: Optional[WebSocketClientProtocol] = None self._twitch = pool._twitch
self.connected = asyncio.Event() # set when there's an active websocket connection # websocket index
self.reconnect = asyncio.Event() # set when the websocket needs to reconnect self._idx: int = index
self._send_queue: deque[Tuple[str, Dict[str, Any]]] = deque() # current websocket connection
self._recv_dict: Dict[str, asyncio.Future[Any]] = {} self._ws: WebSocketClientProtocol
self._topics: Set[WebsocketTopic] = set() # set when there's an active websocket connection
self._ping_task: Optional[asyncio.Task[Any]] = None self._connected_flag = asyncio.Event()
self._connect_task: Optional[asyncio.Task[Any]] = None # set when the websocket needs to reconnect
self._reconnect_requested = asyncio.Event()
# set when the topics changed
self._topics_changed = asyncio.Event()
# ping timestamps
self._next_ping = time()
self._max_pong = time()
# main task, responsible for receiving messages, sending them, and websocket ping
self._handle_task: Optional[asyncio.Task[Any]] = None
# topics stuff
self.topics: Dict[str, WebsocketTopic] = {}
self._submitted: Set[WebsocketTopic] = set()
async def _ping_loop(self): @property
await self.connected.wait() def connected(self) -> bool:
ping_every = PING_INTERVAL.total_seconds() return self._connected_flag.is_set()
while self.connected.is_set():
try:
await asyncio.wait_for(self.send({"type": "PING"}), timeout=10)
except asyncio.TimeoutError:
# per documentation, if there's no response for a PING, reconnect to the websocket
logger.warning("Websocket got no response to PING - reconnect")
self.reconnect.set()
break
await asyncio.sleep(ping_every)
def change_connection_state(self, state: bool): def wait_until_connected(self):
if state: return self._connected_flag.wait()
# websocket is considered connected
logger.info("Websocket Connected") def request_reconnect(self):
self.connected.set() self._reconnect_requested.set()
self._ping_task = asyncio.create_task(self._ping_loop())
async def start(self):
if self.connected:
return
if self._handle_task is None:
self._handle_task = asyncio.create_task(self._handle())
else: else:
# websocket is considered disconnected self.request_reconnect()
self.connected.clear() await self.wait_until_connected()
if self._ping_task is not None:
self._ping_task.cancel()
self._ping_task = None
def start(self): async def stop(self):
self._connect_task = asyncio.create_task(self.connect()) if self._ws is not None:
await self._ws.close()
if self._handle_task is not None:
await self._handle_task
self._handle_task = None
def stop(self): def start_nowait(self):
self.change_connection_state(False) if self.connected:
if self._connect_task is not None: return
self._connect_task.cancel() if self._handle_task is None:
self._connect_task = None self._handle_task = asyncio.create_task(self._handle())
else:
self.request_reconnect()
def stop_nowait(self):
if self._ws is not None:
asyncio.create_task(self._ws.close())
self._handle_task = None
@task_wrapper @task_wrapper
async def connect(self): async def _handle(self):
# ensure we're logged in before connecting # ensure we're logged in before connecting
await self._twitch.wait_until_login() await self._twitch.wait_until_login()
logger.info("Connecting to Websocket") logger.info("Connecting to Websocket")
# Listen to our events of choice
user_id = cast(int, self._twitch._user_id)
# Add default topics
self.add_topics([
get_topic("UserDrops", user_id, self.process_drops),
get_topic("UserCommunityPoints", user_id, self.process_points),
])
# Connect/Reconnect loop # Connect/Reconnect loop
async for websocket in websocket_connect(WEBSOCKET_URL, ssl=True, ping_interval=None): async for websocket in websocket_connect(WEBSOCKET_URL, ssl=True, ping_interval=None):
websocket.BACKOFF_MAX = 3 * 60 # type: ignore # 3 minutes websocket.BACKOFF_MAX = 3 * 60 # type: ignore # 3 minutes
self._ws = websocket self._ws = websocket
self.reconnect.clear()
self.change_connection_state(True)
# Send all our chosen topics
topics_list = list(map(str, self._topics))
logger.debug(f"Listening for: {', '.join(topics_list)}")
self.send(
{
"type": "LISTEN",
"data": {
"topics": topics_list,
"auth_token": self._twitch._access_token,
}
}
)
try: try:
while not self.reconnect.is_set(): try:
# Process receive self._reconnect_requested.clear()
try: self._connected_flag.set()
# Wait up to 0.5s for a message we're supposed to receive while not self._reconnect_requested.is_set():
raw_message = await asyncio.wait_for(websocket.recv(), timeout=0.5) await self._handle_ping()
except asyncio.TimeoutError: await self._handle_topics()
# nothing - skip handling await self._handle_recv()
pass finally:
else: self._submitted.clear()
# we've got something to process self._connected_flag.clear()
# separate method solely because the indent was getting rather ridiculus
await self.process_message(raw_message)
# Early exit if needed
if self.reconnect.is_set():
break
# Process send
while self._send_queue:
nonce, message = self._send_queue.popleft()
if nonce != "PING":
message["nonce"] = nonce
await websocket.send(json.dumps(message, separators=(',', ':')))
logger.debug(f"Websocket sent: {message}")
# A reconnect was requested # A reconnect was requested
self.change_connection_state(False)
continue continue
except ConnectionClosed as exc: except ConnectionClosed as exc:
self.change_connection_state(False)
if isinstance(exc, ConnectionClosedOK): if isinstance(exc, ConnectionClosedOK):
if exc.rcvd_then_sent: if exc.rcvd_then_sent:
# server closed the connection, not us - reconnect # server closed the connection, not us - reconnect
@@ -154,72 +147,42 @@ class Websocket:
logger.exception("Exception in Websocket - Reconnecting") logger.exception("Exception in Websocket - Reconnecting")
continue continue
async def process_message(self, raw_message: Union[bytes, str]): async def _handle_ping(self):
message = json.loads(raw_message) now = time()
logger.debug(f"Websocket received: {message}") if now >= self._next_ping:
msg_type = message["type"] self._next_ping = now + PING_INTERVAL.total_seconds()
# handle the simple PING case self._max_pong = now + 10 # wait for a PONG for up to 10s
if msg_type == "PONG": await self.send({"type": "PING"})
ping_future = self._recv_dict.pop("PING", None) elif now >= self._max_pong:
if ping_future is not None and not ping_future.done(): # it's been more than 10s and there was no PONG
ping_future.set_result(message) self.request_reconnect()
elif msg_type == "RESPONSE":
try:
self._recv_dict.pop(message["nonce"]).set_result(message)
except KeyError:
logger.exception("Received response for a request we didn't send")
elif msg_type == "RECONNECT":
# We've received a reconnect request
logger.warning("Received a Websocket Reconnect Request")
self.reconnect.set()
elif msg_type == "MESSAGE":
# request the assigned topic to process the response
target_topic = message["data"]["topic"]
for topic in self._topics:
if target_topic == topic:
# use a task to not block the websocket
asyncio.create_task(topic.process(json.loads(message["data"]["message"])))
break
else:
logger.error(f"Received unknown websocket payload: {message}")
async def close(self): async def _handle_topics(self):
self.stop() if not self._topics_changed.is_set():
if self._ws is not None: # nothing to do
await self._ws.close()
def create_nonce(self, length: int = 30) -> str:
available_chars = string.ascii_letters + string.digits
return ''.join(random.choices(available_chars, k=length))
def send(self, message: Dict[str, Any]) -> asyncio.Future[Dict[str, Any]]:
logger.debug(f"Websocket sending: {message}")
msg_type = message["type"]
if msg_type == "PING":
nonce = "PING"
else:
nonce = self.create_nonce()
self._send_queue.append((nonce, message))
future: asyncio.Future[Dict[str, Any]] = asyncio.get_running_loop().create_future()
self._recv_dict[nonce] = future
return future
def add_topics(self, topics: Iterable[WebsocketTopic]):
# ensure no topics end up duplicated
topics = set(topics)
topics.difference_update(self._topics)
if not topics:
# none left to add
return return
self._topics.update(topics) current: Set[WebsocketTopic] = set(self.topics.values())
if len(self._topics) >= 50: # handle removed topics
# TODO: Handle multiple connections (up to 10) since one allows only up to 50 topics removed = self._submitted.difference(current)
raise MinerException("Too many topics") if removed:
if self.connected.is_set(): topics_list = list(map(str, removed))
# we're already connected, so we have to send the topics list ourselves logger.debug(f"Websocket[{self._idx}]: Removing topics: {', '.join(topics_list)}")
topics_list = list(map(str, topics)) await self.send(
logger.debug(f"Listening for: {', '.join(topics_list)}") {
return self.send( "type": "UNLISTEN",
"data": {
"topics": topics_list,
"auth_token": self._twitch._access_token,
}
}
)
self._submitted.difference_update(removed)
# handle added topics
added = current.difference(self._submitted)
if added:
topics_list = list(map(str, added))
logger.debug(f"Websocket[{self._idx}]: Adding topics: {', '.join(topics_list)}")
await self.send(
{ {
"type": "LISTEN", "type": "LISTEN",
"data": { "data": {
@@ -228,12 +191,158 @@ class Websocket:
} }
} }
) )
else: self._submitted.update(added)
# no connection is made, so let it wait until there is one self._topics_changed.clear()
return self.connected.wait()
async def _gather_recv(self, messages: List[JsonType]):
"""
Gather incoming messages over the timeout specified.
Note that there's no return value - this modifies `messages` in-place.
"""
while True:
raw_message = await self._ws.recv()
message = json.loads(raw_message)
logger.log(DEBUG_RAW, f"Websocket received: {message}")
messages.append(message)
def _handle_message(self, message):
# request the assigned topic to process the response
topic_process = self.topics.get(message["data"]["topic"])
if topic_process is not None:
# use a task to not block the websocket
asyncio.create_task(topic_process(json.loads(message["data"]["message"])))
async def _handle_recv(self):
"""
Handle receiving messages from the websocket.
"""
# listen over 0.5s for incoming messages
messages: List[JsonType] = []
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._gather_recv(messages), timeout=0.5)
# process them
for message in messages:
msg_type = message["type"]
if msg_type == "MESSAGE":
self._handle_message(message)
elif msg_type == "PONG":
# move the timestamp to something much later
self._max_pong = self._next_ping
elif msg_type == "RESPONSE":
# no special handling for these (for now)
pass
elif msg_type == "RECONNECT":
# We've received a reconnect request
logger.warning("Received a Websocket Reconnect Request")
self.request_reconnect()
else:
logger.error(f"Received unknown websocket payload: {message}")
def add_topics(self, topics_set: Set[WebsocketTopic]):
while topics_set and len(self.topics) < WS_TOPICS_LIMIT:
topic = topics_set.pop()
self.topics[str(topic)] = topic
self._topics_changed.set()
def remove_topics(self, topics_set: Set[WebsocketTopic]):
existing = topics_set.intersection(self.topics.values())
if not existing:
# nothing to remove from here
return
topics_set.difference_update(existing)
for topic in existing:
del self.topics[str(topic)]
self._topics_changed.set()
async def send(self, message: JsonType):
if self._ws is None:
return
if message["type"] != "PING":
message["nonce"] = create_nonce()
await self._ws.send(json.dumps(message, separators=(',', ':')))
logger.log(DEBUG_RAW, f"Websocket sent: {message}")
class WebsocketPool:
def __init__(self, twitch: Twitch):
self._twitch: Twitch = twitch
self._running = asyncio.Event()
self.websockets: List[Websocket] = []
@property
def running(self) -> bool:
return self._running.is_set()
def wait_until_connected(self):
return self._running.wait()
async def start(self):
await self._twitch.wait_until_login()
self._running.set()
# Add default topics
assert self._twitch._user_id is not None
user_id = self._twitch._user_id
self.add_topics([
WebsocketTopic("User", "Drops", user_id, self.process_drops),
WebsocketTopic("User", "CommunityPoints", user_id, self.process_points),
])
await asyncio.gather(*(ws.start() for ws in self.websockets))
async def stop(self):
self._running.clear()
await asyncio.gather(*(ws.stop() for ws in self.websockets))
def add_topics(self, topics: Iterable[WebsocketTopic]):
# ensure no topics end up duplicated
topics_set = set(topics)
if not topics_set:
# nothing to add
return
topics_set.difference_update(ws.topics.values() for ws in self.websockets)
if not topics_set:
# none left to add
return
for ws_idx in range(MAX_WEBSOCKETS):
if ws_idx < len(self.websockets):
# just read it back
ws = self.websockets[ws_idx]
else:
# create new
ws = Websocket(self, len(self.websockets))
if self._running:
ws.start_nowait()
self.websockets.append(ws)
# ask websocket to take any topics it can - this modifies the set in-place
ws.add_topics(topics_set)
# see if there's any leftover topics for the next websocket connection
if not topics_set:
return
# if we're here, there were leftover topics after filling up all websockets
raise MinerException("Maximum topics limit has been reached")
def remove_topics(self, topics: Iterable[WebsocketTopic]):
topics_set = set(topics)
if not topics_set:
# nothing to remove
return
for ws in self.websockets:
ws.remove_topics(topics_set)
# count up all the topics - if we happen to have more websockets connected than needed,
# stop the last one and recycle topics from it - repeat until we have enough
topics = []
while True:
count = sum(len(ws.topics) for ws in self.websockets)
if count <= (len(self.websockets) - 1) * WS_TOPICS_LIMIT:
ws = self.websockets.pop()
topics.extend(ws.topics.values())
ws.stop_nowait()
else:
break
if topics:
self.add_topics(topics)
@task_wrapper @task_wrapper
async def process_drops(self, message: Dict[str, Any]): async def process_drops(self, message: JsonType):
drop_id = message["data"]["drop_id"] drop_id = message["data"]["drop_id"]
drop: Optional[TimedDrop] = None drop: Optional[TimedDrop] = None
for campaign in self._twitch.inventory: for campaign in self._twitch.inventory:
@@ -261,7 +370,7 @@ class Websocket:
self._twitch.reevaluate_campaigns() self._twitch.reevaluate_campaigns()
@task_wrapper @task_wrapper
async def process_points(self, message: Dict[str, Any]): async def process_points(self, message: JsonType):
msg_type = message["type"] msg_type = message["type"]
if msg_type == "points-earned": if msg_type == "points-earned":
points = message["data"]["point_gain"]["total_points"] points = message["data"]["point_gain"]["total_points"]