diff --git a/examples/basic_client.py b/examples/basic_client.py index ba59dd3..0c51509 100644 --- a/examples/basic_client.py +++ b/examples/basic_client.py @@ -9,34 +9,35 @@ mc = MixerClient(scopes=s) -@mc.event("login_success") -async def login_success(): +@mc.event("ready") +async def client_ready(): print('--------------------------') print('Logged In As:') - print('username:', mc.current_user['username']) - print('id:', mc.current_user['id']) + print('username:', mc.me.name) + print('id:', mc.me.id) + print('channel id:', mc.me.channel.id) print('---------------------------') @mc.event("user_join") async def user_join(user): print('User Joined:') - print('user name:', user.data['username']) - print('user id:', user.data['id']) + print('user name:', user.name) + print('user id:', user.id) print('---------------------------') @mc.event("user_left") async def user_left(user): print('User Left:') - print('user name:', user.data['username']) - print('user id:', user.data['id']) + print('user name:', user.name) + print('user id:', user.id) print('---------------------------') @mc.event("message") -async def message_handler(message): +async def message_handler(message: message.ChatMessage): print('New Message:') - print('content:', message.data['message']['message'][0]['text']) - print('author name:', message.data['user_name']) - print('author id:', message.data['user_id']) + print('channel id:', message.channel.id) + print('content:', message.content) + print('author id:', message.author.id) print('---------------------------') mc.run(secret='', id='') diff --git a/pyxer/cache.py b/pyxer/cache.py index 715c6d3..4013bee 100644 --- a/pyxer/cache.py +++ b/pyxer/cache.py @@ -1,11 +1,13 @@ from typing import Dict, Any from .utils import get +from .mappings import Channel, ChatMessage class MixerCache: - users: Dict[int, Any] - messages: Dict[int, Any] + users: Dict[int, Any] = {} # TODO: have one base user object + messages: Dict[str, ChatMessage] = {} + channels: Dict[int, Channel] = {} def __init__(self, ws): self._ws = ws @@ -16,3 +18,9 @@ def update_user(self, data: Dict[str, Any]): def update_message(self, data: Dict[str, Any]): ... + + def get_channel(self, channel_id: int): + return self.channels.get(channel_id, None) + + def set_channel(self, channel: Channel): + self.channels[channel.id] = channel diff --git a/pyxer/client.py b/pyxer/client.py index 021aeba..43bdf14 100644 --- a/pyxer/client.py +++ b/pyxer/client.py @@ -4,10 +4,12 @@ from .http import HTTPClient from .ws import WebSocketClient +from .cache import MixerCache from .scopes import Scopes from .oauth import OAuthHandler from .utils import as_go_link from .event import Listener +from .mappings import Channel, ChatMessage, User class MixerClient: @@ -16,6 +18,7 @@ class MixerClient: oauth: OAuthHandler current_user: Dict[str, Any] ws: WebSocketClient + cache: MixerCache def __init__(self, *, scopes: Scopes): self.scopes = scopes @@ -51,14 +54,16 @@ async def login(self, *, secret: str, id: str, open_browser: bool=True): data = await self.http.get_current_user() - self.current_user = data + self.me = User(client=self, data=data) async def connect(self): - channel_id = self.current_user['channel']['id'] - user_id = self.current_user['channel']['userId'] + channel_id = self.me.channel.id + user_id = self.me.channel.user_id ws = WebSocketClient.start(self, channel_id=channel_id, user_id=user_id) self.ws = await asyncio.wait_for(ws, timeout=50.0) + self.cache = self.ws.cache + while True: await self.ws.handle_message() @@ -100,3 +105,19 @@ def wrapper(coro): self.listeners.append(ret) return ret return wrapper + + async def find_channel(self, channel_id: str): + ''' + Finds a channel. This will first check the cache if it is available, + then perform a request if not found. + ''' + cached = self.cache.get_channel(channel_id) + if cached: + return cached + + data = await self.http.get_channel(channel_id) + + channel = Channel(client=self, data=data) + self.cache.set_channel(channel) + + return channel diff --git a/pyxer/http.py b/pyxer/http.py index 50eb678..0c05880 100644 --- a/pyxer/http.py +++ b/pyxer/http.py @@ -1,6 +1,7 @@ from typing import Dict, Optional, List from yarl import URL from aiohttp import ClientSession +from .mappings.channel import Channel, ExpandedChannel class HTTPConfig: @@ -29,10 +30,24 @@ async def request(self, verb: str, endpoint: str, *, query: Optional[Dict[str, s headers['Authorization'] = f'Bearer {self.config.access_token}' async with self.session.request(verb, url, json=data, headers=headers) as r: - #data = await r.json() if r.headers['content-type'] == 'application/json' else await r.text(encoding='utf-8') + data = await r.json() if 'application/json' in r.headers.get('content-type', []) else await r.text(encoding='utf-8') - if r.status != 204: - return await r.json() + if 300 > r.status >= 200: + return data + + if r.status == 429: + retry_after = data['retry_after'] / 1000.0 + + print(f"You are being ratelimited! Retrying request after {retry_after} seconds.") + await asyncio.sleep(retry_after) + await self.request(verb, endpoint, query=query, data=data, headers=headers, clazz=clazz) + + return + + if r.status == 403: + raise Exception('Forbidden.') + elif r.status == 404: + raise Exception('Not Found.') def get_shortcode(self, scope: List[str]): scopes = " ".join(scope) @@ -67,5 +82,41 @@ def refresh_tokens(self, token: str): def get_current_user(self): return self.request('GET', 'users/current') + def get_channel_id(self, username: str): + ''' + Gets a single user's channel id + + Args: + username (str): The username of the channel you want to get + + Returns: + json: The id of the requested user + ''' + return self.request('GET', f'/channels/{username}?fields=id') + def get_connection_info(self, channel_id: int): + ''' + Gets the info of the supplied channel + + Args: + channel_id (str): The channel whos information you want to get + + Returns: + json: The channels chatroom settings if authenticated + ''' return self.request('GET', f'chats/{channel_id}') + + def get_channel(self, channel_id: str): + ''' + Gets a single channel's info + + Args: + channel_id (str): The channel ID + + Returns: + json: The extended information of the requested channel + ''' + return self.request('GET', f'channels/{channel_id}') + + def get_ingests(self): + return self.request('GET', 'ingests') diff --git a/pyxer/mappings/__init__.py b/pyxer/mappings/__init__.py new file mode 100644 index 0000000..2cb384f --- /dev/null +++ b/pyxer/mappings/__init__.py @@ -0,0 +1,3 @@ +from .channel import Channel, ChannelAdvanced, ExpandedChannel +from .message import ChatMessage +from .user import Author, User, PartialUser diff --git a/pyxer/mappings/base.py b/pyxer/mappings/base.py new file mode 100644 index 0000000..42e3748 --- /dev/null +++ b/pyxer/mappings/base.py @@ -0,0 +1,41 @@ +from datetime import datetime + + +class Object: + def __init__(self, *, client): + self.client = client + + +class Timestamped(Object): + created_at: datetime + updated_at: datetime + deleted_at: datetime + + def __init__(self, *, client, data): + super().__init__(client=client) + + created_at = data.pop('createdAt', None) + if created_at: + self.created_at = datetime.fromisoformat(created_at[:-1]) + # datetime does not support ISO 8601, + # but all datetimes should be received as UTC, + # so removing "Z" (for UTC) is enough for it to + # be parsed properly. + + updated_at = data.pop('updatedAt', None) + if updated_at: + self.updated_at = datetime.fromisoformat(updated_at[:-1]) + + deleted_at = data.pop('deletedAt', None) + if deleted_at: + self.deleted_at = datetime.fromisoformat(deleted_at[:-1]) + + +class Resource: + id: int + type: str + relid: int + url: str + store: str + remotePath: str + meta: str = None diff --git a/pyxer/mappings/channel.py b/pyxer/mappings/channel.py new file mode 100644 index 0000000..34cce80 --- /dev/null +++ b/pyxer/mappings/channel.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +from .base import Timestamped, Resource + + +class Channel(Timestamped): + id: int + userId: int + token: str + online: bool + featured: bool + featureLevel: int + partnered: bool + suspended: bool + name: str + audience: str + viewersTotal: int + viewersCurrent: int + numFollowers: int + description: str + interactive: bool + ftl: int + hasVod: bool + badgeId: int + bannerUrl: str + hosteeId: int + hasTranscodes: bool + vodsEnabled: bool + + interactiveGameId: int = None + languageId: str = None + coverId: int = None + thumbnailId: int = None + typeId: int = None + transcodingProfileId: int = None + costreamId: str = None + + def __init__(self, *, client, data): + super().__init__(client=client, data=data) + self._parse_data(data) + + def _parse_data(self, data): + self.id = data['id'] + self.user_id = data['userId'] # TODO: fetch and serialise + self.token = data.get('token') + self.online = data['online'] + self.featured = data['featured'] + self.feature_level = data['featureLevel'] + self.partnered = data['partnered'] + self.suspended = data['suspended'] + self.name = data['name'] + self.audience = data['audience'] + self.total_viewers = data['viewersTotal'] + self.current_viewers = data['viewersCurrent'] + self.followers = data['numFollowers'] + self.description = data['description'] + self.ftl = data['ftl'] + self.has_vod = data['hasVod'] + self.badge_id = data['badgeId'] + self.banner_url = data['bannerUrl'] + self.hosteeId = data['hosteeId'] + self.hasTranscodes = data['hasTranscodes'] + self.vodsEnabled = data['vodsEnabled'] + +# TODO: complete these classes + +class ChannelAdvanced(Channel): + type: dict = None + user: dict = None + + +class ExpandedChannel(ChannelAdvanced): + preferences: dict = None + thumbnail: Resource = None + cover: Resource = None + badge: Resource = None diff --git a/pyxer/mappings/message.py b/pyxer/mappings/message.py new file mode 100644 index 0000000..fd934cc --- /dev/null +++ b/pyxer/mappings/message.py @@ -0,0 +1,26 @@ +from typing import List, Dict +from .base import Timestamped, Resource +from .channel import Channel +from .user import Author + + +class MessageContent: + def __init__(self, message_list): + self._message_list = message_list + + def __str__(self): + return ''.join(m['text'] for m in self._message_list) + +class ChatMessage(Timestamped): + def __init__(self, *, client, data): + super().__init__(client=client, data=data) + + @classmethod + async def _received(cls, *, client, data): + inst = cls(client=client, data=data) + inst.channel = await client.find_channel(data.pop('channel')) + inst.id = data.pop('id') + inst.content = MessageContent(data.pop('message')['message']) + inst.author = Author(client=client, data=data) + + return inst diff --git a/pyxer/mappings/user.py b/pyxer/mappings/user.py new file mode 100644 index 0000000..f790d23 --- /dev/null +++ b/pyxer/mappings/user.py @@ -0,0 +1,43 @@ +from .base import Object, Timestamped +from .channel import Channel + + +# message authors +class Author(Object): + def __init__(self, *, client, data): + super().__init__(client=client) + self._parse_data(data) + + def _parse_data(self, data): + self.id = data['user_id'] + self.name = data['user_name'] + self.roles = data['user_roles'] + self.level = data['user_level'] + self.avatar = data['user_avatar'] + self.ascension_level = data['user_ascension_level'] + + +class PartialUser(Object): + def __init__(self, *, client, data): + super().__init__(client=client) + self._parse_data(data) + + def _parse_data(self, data): + self.id = data['id'] + self.name = data['username'] + self.roles = data['roles'] + + +class User(Timestamped): + def __init__(self, *, client, data): + super().__init__(client=client, data=data) + self._parse_data(data) + + def _parse_data(self, data): + self.id = data['id'] + self.name = data['username'] + self.channel = Channel(client=self.client, data=data['channel']) + self.experience = data['experience'] + self.level = data['level'] + self.sparkes = data['sparks'] + self.bio = data['bio'] diff --git a/pyxer/packet.py b/pyxer/packet.py new file mode 100644 index 0000000..50a05c4 --- /dev/null +++ b/pyxer/packet.py @@ -0,0 +1,17 @@ +import json + + +class Packet: + def __init__(self, **kwargs): + self._packet = dict(kwargs) + + for k, v in kwargs.items(): + setattr(self, k, v) + + @property + def dumped(self): + return json.dumps(self._packet) + + @classmethod + def received(cls, msg): + return cls(**json.loads(msg)) diff --git a/pyxer/route.py b/pyxer/route.py new file mode 100644 index 0000000..64b6a75 --- /dev/null +++ b/pyxer/route.py @@ -0,0 +1,14 @@ +from urllib.parse import quote + + +class Route: + BASE_ROUTE = 'https://mixer.com/api/v1' + + def __init__(self, method, path, **kwargs): + self.method = method + self.route = path + + self.url = self.BASE_ROUTE + self.route + if kwargs: + format_dict = {param: quote(value) for param, value in kwargs.items()} + self.url = self.url.format_map(format_dict) diff --git a/pyxer/ws.py b/pyxer/ws.py index 09d6755..df602f7 100644 --- a/pyxer/ws.py +++ b/pyxer/ws.py @@ -5,6 +5,7 @@ from .utils import get, as_snake_case from .cache import MixerCache +from . import mappings class Packet: @@ -47,7 +48,6 @@ async def start(cls, client, *, channel_id: int, user_id: int): ws.user_id = user_id ws.uri = uri ws.auth_key = auth_key - ws.client_dispatch = client.dispatch return ws @@ -107,7 +107,7 @@ async def reply(self, message: Packet): await func(method, message) async def dispatch(self, event_name: str, *args, **kwargs): - await self.client_dispatch(event_name, *args, **kwargs) + await self.client.dispatch(event_name, *args, **kwargs) async def handle_welcome_event(self, message: Packet): await self.login(self.channel_id, self.user_id, self.auth_key) @@ -115,35 +115,19 @@ async def handle_welcome_event(self, message: Packet): await self.dispatch('login') async def handle_user_join(self, message: Packet): - await self.dispatch('user_join', message) + user = mappings.PartialUser(client=self.client, data=message.data) + await self.dispatch('user_join', user) async def handle_user_left(self, message: Packet): - await self.dispatch('user_left', message) + user = mappings.PartialUser(client=self.client, data=message.data) + await self.dispatch('user_left', user) async def handle_chat_message(self, message: Packet): - # TODO: deserialize - # data: Dict[ - # channel: int - # id: str - # name: str - # user_id: int - # user_roles: List[str] - # user_level: int - # user_avatar: str - # message: Dict[ - # message: List[Dict[ - # type: str - # data: str - # text: str - # ]] - # meta: Dict[] - # ] - # ] - + message = await mappings.ChatMessage._received(client=self.client, data=message.data) await self.dispatch('message', message) async def reply_auth(self, sent: Packet, reply: Packet): - await self.dispatch('login_success') + await self.dispatch('ready') async def reply_msg(self, sent: Packet, reply: Packet): - print(sent.dumped, reply.dumped) + print(sent.dumped, reply.dumped) # TODO: change this.