stackl
Advanced tools
| import sys | ||
| import threading | ||
| from logging import StreamHandler | ||
| import logging | ||
| import os.path | ||
| import pickle | ||
| import json | ||
| import requests | ||
| from bs4 import BeautifulSoup | ||
| from stackl.errors import LoginError, InvalidOperationError | ||
| from stackl.models import Room | ||
| from stackl.events import Event | ||
| from stackl.wsclient import WSClient | ||
| VERSION = '0.0.1' | ||
| class ChatClient: | ||
| def __init__(self, **kwargs): | ||
| """ | ||
| Initialise a new ChatClient object. Valid kwargs are: | ||
| :param kwargs['default_server']: one of stackexchange.com, stackoverflow.com, or meta.stackexchange.com, | ||
| depending on where you want the client to default to. | ||
| :param kwargs['log_location']: a logging.Handler object (e.g. StreamHandler or FileHandler) specifying a log | ||
| location | ||
| :param kwargs['log_level']: an integer, usually one of the logging.* constants such as logging.DEBUG, specifying | ||
| the minimum effective log level | ||
| """ | ||
| self.default_server = kwargs.get('default_server') or 'stackexchange.com' | ||
| log_location = kwargs.get('log_location') or StreamHandler(stream=sys.stdout) | ||
| log_level = kwargs.get('log_level') or logging.DEBUG | ||
| self.logger = logging.getLogger('stackl') | ||
| self.logger.setLevel(log_level) | ||
| self.logger.addHandler(log_location) | ||
| self.session = requests.Session() | ||
| self.session.headers.update({'User-Agent': 'stackl'}) | ||
| self.rooms = [] | ||
| self._handlers = [] | ||
| self._sockets = {} | ||
| self._fkeys = {} | ||
| self._authed_servers = [] | ||
| def login(self, email, password, **kwargs): | ||
| """ | ||
| Log the client instance into Stack Exchange. Will default to logging in using cached cookies, if provided, and | ||
| fall back to logging in with credentials. | ||
| :param email: the email of the Stack Exchange account you want to log in as | ||
| :param password: the corresponding account password | ||
| :param kwargs: pass "cookie_file" to specify where cached cookies are located (must be a pickle file) | ||
| :return: the logged-in requests.Session if successful | ||
| """ | ||
| logged_in = False | ||
| if 'cookie_file' in kwargs and os.path.exists(kwargs['cookie_file']): | ||
| with open(kwargs['cookie_file'], 'rb') as f: | ||
| self.session.cookies.update(pickle.load(f)) | ||
| logged_in = self._verify_login(kwargs.get('servers') or [self.default_server]) | ||
| if logged_in is False: | ||
| self.logger.warn('Cookie login failed. Falling back to credential login.') | ||
| for n, v in self.session.cookies.items(): | ||
| self.logger.info('{}: {}'.format(n, v)) | ||
| if not logged_in: | ||
| logged_in = self._credential_authenticate(email, password, kwargs.get('servers') or [self.default_server]) | ||
| if not logged_in: | ||
| self.logger.critical('All login methods failed. Cannot log in to SE.') | ||
| raise LoginError('All available login methods failed.') | ||
| else: | ||
| self._authed_servers = kwargs.get('servers') or [self.default_server] | ||
| return self.session | ||
| def join(self, room_id, server): | ||
| """ | ||
| Join a room and start processing events from it. | ||
| :param room_id: the ID of the room you wish to join | ||
| :param server: the server on which the room is hosted | ||
| :return: None | ||
| """ | ||
| if server not in self._authed_servers: | ||
| raise InvalidOperationError('Cannot join a room on a host we haven\'t authenticated to!') | ||
| room = Room(server, room_id=room_id) | ||
| self.rooms.append(room) | ||
| self.session.get("https://chat.{}/rooms/{}".format(server, room_id), data={'fkey': self._fkeys[server]}) | ||
| events = self.session.post("https://chat.{}/chats/{}/events".format(server, room_id), data={ | ||
| 'fkey': self._fkeys[server], | ||
| 'since': 0, | ||
| 'mode': 'Messages', | ||
| 'msgCount': 100 | ||
| }).json()['events'] | ||
| event_data = [Event(x, server) for x in events] | ||
| room.add_events(event_data) | ||
| ws_auth_data = self.session.post("https://chat.{}/ws-auth".format(server), data={ | ||
| 'fkey': self._fkeys[server], | ||
| 'roomid': room_id | ||
| }).json() | ||
| cookie_string = '' | ||
| for cookie in self.session.cookies: | ||
| if cookie.domain == 'chat.{}'.format(server) or cookie.domain == '.{}'.format(server): | ||
| cookie_string += '{}={};'.format(cookie.name, cookie.value) | ||
| last_event_time = sorted(events, key=lambda x: x['time_stamp'])[-1]['time_stamp'] | ||
| ws_uri = '{}?l={}'.format(ws_auth_data['url'], last_event_time) | ||
| if server in self._sockets and self._sockets[server].open: | ||
| self._sockets[server].close() | ||
| self._sockets[server] = WSClient(ws_uri, cookie_string, server, self._on_message) | ||
| def send(self, content, room=None, server=None): | ||
| """ | ||
| Send a message to the specified room. | ||
| :param content: the contents of the message you wish to send | ||
| :param room: the ID of the room you wish to send it to | ||
| :param server: the server on which the room is hosted | ||
| :return: None | ||
| """ | ||
| if room is None or server is None: | ||
| raise InvalidOperationError('Cannot send a message to a non-existent room or a non-existent server.') | ||
| # TODO | ||
| def add_handler(self, handler, **kwargs): | ||
| """ | ||
| Add an event handler for messages received from the chat websocket. | ||
| :param handler: the handler method to call for each received event | ||
| :return: None | ||
| """ | ||
| self._handlers.append([handler, kwargs]) | ||
| def _credential_authenticate(self, email, password, servers): | ||
| """ | ||
| Authenticate with Stack Exchange using provided credentials. | ||
| :param email: the email of the Stack Exchange account you want to log in as | ||
| :param password: the corresponding account password | ||
| :return: a success boolean | ||
| """ | ||
| fkey_page = self.session.get("https://stackapps.com/users/login") | ||
| fkey_soup = BeautifulSoup(fkey_page.text, 'html.parser') | ||
| fkey_input = fkey_soup.select('input[name="fkey"]') | ||
| if len(fkey_input) <= 0: | ||
| raise LoginError('Failed to get fkey from StackApps. Wat?') | ||
| fkey = fkey_input[0].get('value') | ||
| login_post = self.session.post("https://stackapps.com/users/login", data={ | ||
| 'email': email, | ||
| 'password': password, | ||
| 'fkey': fkey | ||
| }) | ||
| login_soup = BeautifulSoup(login_post.text, 'html.parser') | ||
| iframes = login_soup.find_all('iframe') | ||
| if any(['captcha' in x.get('src') for x in iframes]): | ||
| raise LoginError('Login triggered a CAPTCHA - cannot proceed.') | ||
| tokens = self.session.post("https://stackapps.com/users/login/universal/request", headers={ | ||
| 'Referer': 'https://stackapps.com/' | ||
| }).json() | ||
| for site_token in tokens: | ||
| self.session.get("https://{}/users/login/universal.gif".format(site_token['Host']), data={ | ||
| 'authToken': site_token['Token'], | ||
| 'nonce': site_token['Nonce'] | ||
| }, headers={ | ||
| 'Referer': 'https://stackapps.com/' | ||
| }) | ||
| return self._verify_login(servers) | ||
| def _verify_login(self, servers): | ||
| """ | ||
| Verifies that login with cached cookies has been successful for all the given chat servers. | ||
| :param servers: a list of servers to check for successful logins | ||
| :return: a success boolean | ||
| """ | ||
| statuses = [] | ||
| for server in servers: | ||
| chat_home = self.session.get("https://chat.{}/".format(server)) | ||
| chat_soup = BeautifulSoup(chat_home.text, 'html.parser') | ||
| self._fkeys[server] = chat_soup.select('input[name="fkey"]')[0].get('value') | ||
| topbar_links = chat_soup.select('.topbar-links span.topbar-menu-links a') | ||
| if len(topbar_links) <= 0: | ||
| raise LoginError('Unable to verify login because page layout wasn\'t as expected. Wat?') | ||
| elif topbar_links[0].text == 'log in': | ||
| raise LoginError('Failed to log in to {}'.format(server)) | ||
| else: | ||
| statuses.append(True) | ||
| return len(statuses) == 3 and all(statuses) | ||
| def _on_message(self, data, server): | ||
| data = json.loads(data) | ||
| events = [v['e'] for k, v in data.items() if k[0] == 'r' and 'e' in v] | ||
| events = [x for s in events for x in s] | ||
| for event_data in events: | ||
| event = Event(event_data, server) | ||
| handlers = [x[0] for x in self._handlers | ||
| if all([k in event_data and event_data[k] == v for k, v in x[1].items()])] | ||
| for handler in handlers: | ||
| def run_handler(): | ||
| handler(event, server) | ||
| threading.Thread(name='handler_runner', target=run_handler).start() |
| class LoginError(BaseException): | ||
| pass | ||
| class InvalidOperationError(BaseException): | ||
| pass |
+122
| from collections import namedtuple | ||
| from stackl.models import * | ||
| EventClassData = namedtuple('EventClassData', ['id', 'classes']) | ||
| EventClass = namedtuple('EventClass', ['type', 'fields', 'target_prop']) | ||
| EventField = namedtuple('EventField', ['target_prop', 'source_prop']) | ||
| EVENT_SHORTHAND = ['message', 'edit', 'entrance', 'exit', 'rename', 'star', 'debug', 'mention', 'flag', 'delete', | ||
| 'file', 'mod-flag', 'settings', 'gnotif', 'level', 'lnotif', 'invite', 'reply', 'move-out', | ||
| 'move-in', 'time', 'feed', 'suspended', 'merge'] | ||
| EVENT_NAME = ['Message Posted', 'Message Edited', 'User Entered', 'User Left', 'Room Name Changed', 'Message Starred', | ||
| 'Debug Message', 'User Mentioned', 'Message Flagged', 'Message Deleted', 'File Added', 'Moderator Flag', | ||
| 'User Settings Changed', 'Global Notification', 'Access Level Changed', 'User Notification', | ||
| 'Invitation', 'Message Reply', 'Message Moved Out', 'Message Moved In', 'Time Break', 'Feed Ticker', | ||
| 'User Suspended', 'User Merged'] | ||
| EVENT_CLASSES = [ | ||
| EventClassData(1, [ | ||
| EventClass(Message, [ | ||
| EventField('timestamp', 'time_stamp'), | ||
| EventField('content', None), | ||
| EventField('room_id', None), | ||
| EventField('user_id', None), | ||
| EventField('message_id', None), | ||
| EventField('parent_id', None) | ||
| ], None) | ||
| ]), | ||
| EventClassData(2, [ | ||
| EventClass(Message, [ | ||
| EventField('timestamp', 'time_stamp'), | ||
| EventField('content', None), | ||
| EventField('room_id', None), | ||
| EventField('user_id', None), | ||
| EventField('message_id', None) | ||
| ], None) | ||
| ]), | ||
| EventClassData(3, [ | ||
| EventClass(User, [ | ||
| EventField('user_id', 'target_user_id') | ||
| ], None), | ||
| EventClass(Room, [ | ||
| EventField('room_id', None) | ||
| ], None) | ||
| ]), | ||
| EventClassData(4, [ | ||
| EventClass(User, [ | ||
| EventField('user_id', 'target_user_id') | ||
| ], None), | ||
| EventClass(Room, [ | ||
| EventField('room_id', None) | ||
| ], None) | ||
| ]), | ||
| EventClassData(5, [ | ||
| EventClass(Room, [ | ||
| EventField('room_id', None) | ||
| ], None) | ||
| ]), | ||
| EventClassData(8, [ | ||
| EventClass(Message, [ | ||
| EventField('timestamp', 'time_stamp'), | ||
| EventField('content', None), | ||
| EventField('room_id', None), | ||
| EventField('user_id', None), | ||
| EventField('message_id', None) | ||
| ], None), | ||
| EventClass(User, [ | ||
| EventField('user_id', None) | ||
| ], 'source_user'), | ||
| EventClass(User, [ | ||
| EventField('user_id', 'target_user_id') | ||
| ], 'target_user') | ||
| ]), | ||
| EventClassData(18, [ | ||
| EventClass(Message, [ | ||
| EventField('timestamp', 'time_stamp'), | ||
| EventField('content', None), | ||
| EventField('room_id', None), | ||
| EventField('user_id', None), | ||
| EventField('message_id', None) | ||
| ], None) | ||
| ]) | ||
| ] | ||
| class Event: | ||
| def __init__(self, event_dict, server): | ||
| self.type_id = int(event_dict['event_type']) | ||
| self.name = EVENT_NAME[self.type_id - 1] | ||
| self.shorthand = EVENT_SHORTHAND[self.type_id - 1] | ||
| self.raw = event_dict | ||
| self.server = server | ||
| class_data = [x for x in EVENT_CLASSES if x.id == self.type_id] | ||
| if len(class_data) > 0: | ||
| class_data = class_data[0] | ||
| for type in class_data.classes: | ||
| clazz = type.type | ||
| if type.target_prop is not None: | ||
| method_name = type.target_prop | ||
| else: | ||
| method_name = type.type.__name__.lower() | ||
| initialization_props = {} | ||
| for field in type.fields: | ||
| if field.source_prop is not None: | ||
| if field.source_prop not in event_dict: | ||
| continue | ||
| initialization_props[field.target_prop] = event_dict[field.source_prop] | ||
| else: | ||
| if field.target_prop not in event_dict: | ||
| continue | ||
| initialization_props[field.target_prop] = event_dict[field.target_prop] | ||
| type_object = clazz(server, **initialization_props) | ||
| setattr(self, method_name, type_object) | ||
| for k, v in event_dict.items(): | ||
| setattr(self, k, v) | ||
| def __repr__(self): | ||
| return '<Event {}>'.format(self.__dict__) |
| class Helpers: | ||
| _cache = {} | ||
| @classmethod | ||
| def cached(cls, key, scope=None, func=None): | ||
| if scope is not None: | ||
| if scope not in cls._cache: | ||
| cls._cache[scope] = {} | ||
| if key in cls._cache[scope]: | ||
| return cls._cache[scope][key] | ||
| else: | ||
| result = None if func is None else func() | ||
| cls._cache[scope][key] = result | ||
| return result | ||
| else: | ||
| if key in cls._cache: | ||
| return cls._cache[key] | ||
| else: | ||
| result = None if func is None else func() | ||
| cls._cache[key] = result | ||
| return result | ||
| @classmethod | ||
| def cache(cls, key, scope=None, object=None): | ||
| if scope is not None: | ||
| if scope not in cls._cache: | ||
| cls._cache[scope] = {} | ||
| cls._cache[scope][key] = object | ||
| else: | ||
| cls._cache[key] = object |
| import re | ||
| import requests | ||
| from bs4 import BeautifulSoup | ||
| from stackl.helpers import Helpers | ||
| from stackl.tasks import Tasks | ||
| class Room: | ||
| def __init__(self, server, **kwargs): | ||
| self.id = int(kwargs.get('room_id')) | ||
| self.server = server | ||
| self.url = "https://chat.{}/rooms/{}".format(server, kwargs.get('room_id')) | ||
| self.owners = [] | ||
| self.events = [] | ||
| Tasks.do(self._scrape_room_info) | ||
| def _scrape_room_info(self): | ||
| info_page = requests.get("https://chat.{}/rooms/info/{}".format(self.server, self.id)) | ||
| room_soup = BeautifulSoup(info_page.text, 'html.parser') | ||
| metadata_card = room_soup.select('.roomcard-xxl')[0] | ||
| self.name = metadata_card.find('h1').text | ||
| self.description = metadata_card.find('p').text | ||
| owner_cards = room_soup.select('.room-ownercards .usercard') | ||
| for card in owner_cards: | ||
| user_id = card.get('id').split('-')[-1] | ||
| self.owners.append(Helpers.cached(int(user_id), 'users', lambda: User(self.server, user_id=user_id))) | ||
| Helpers.cache(self.id, 'rooms', self) | ||
| def add_events(self, events): | ||
| self.events.extend(events) | ||
| class User: | ||
| def __init__(self, server, **kwargs): | ||
| self.id = int(kwargs.get('user_id')) | ||
| self.server = server | ||
| self.url = "https://chat.{}/users/{}".format(server, kwargs.get('user_id')) | ||
| self.in_rooms = [] | ||
| self.owns_rooms = [] | ||
| Tasks.do(self._scrape_user_info) | ||
| def _scrape_user_info(self): | ||
| user_page = requests.get(self.url) | ||
| user_soup = BeautifulSoup(user_page.text, 'html.parser') | ||
| self.username = user_soup.select('.usercard-xxl .user-status')[0].text | ||
| self.bio = user_soup.select('.user-stats tr')[3].select('td')[-1].text | ||
| in_room_cards = user_soup.select('#user-roomcards-container .roomcard') | ||
| self.in_rooms.extend(self._initialize_rooms(in_room_cards)) | ||
| owns_room_cards = user_soup.select('#user-owningcards .roomcard') | ||
| self.owns_rooms.extend(self._initialize_rooms(owns_room_cards)) | ||
| Helpers.cache(self.id, 'users', self) | ||
| def _initialize_rooms(self, card_list): | ||
| for room_card in card_list: | ||
| room_id = room_card.get('id').split('-')[-1] | ||
| yield Helpers.cached(int(room_id), 'rooms', lambda: Room(self.server, room_id=room_id)) | ||
| class Message: | ||
| def __init__(self, server, **kwargs): | ||
| self.server = server | ||
| self.id = int(kwargs.get('message_id')) | ||
| self.timestamp = kwargs.get('timestamp') | ||
| self.content = kwargs.get('content') | ||
| self.room = Helpers.cached(int(kwargs.get('room_id')), 'rooms', | ||
| lambda: Room(server, room_id=kwargs.get('room_id'))) | ||
| self.user = Helpers.cached(int(kwargs.get('user_id')), 'users', | ||
| lambda: User(server, user_id=kwargs.get('user_id'))) | ||
| self.parent_id = kwargs.get('parent_id') | ||
| self._setup_delegate_methods() | ||
| def reply(self, client, content): | ||
| client.send(':{} {}'.format(self.id, content), room=self.room, server=self.server) | ||
| def is_reply(self): | ||
| return re.match(r'^:\d+ ', self.content) is not None | ||
| # Less ugly than having a method for every one of these that does exactly the same thing. | ||
| def _setup_delegate_methods(self): | ||
| method_names = ['toggle_star', 'star_count', 'star', 'unstar', 'has_starred', 'cancel_stars', 'delete', 'edit', | ||
| 'toggle_pin', 'pin', 'unpin', 'is_pinned'] | ||
| def create_delegate(method_name): | ||
| def delegate(client): | ||
| getattr(client, method_name)(self.id, self.server) | ||
| return delegate | ||
| for name in method_names: | ||
| setattr(self, name, create_delegate(name)) |
| import asyncio | ||
| import threading | ||
| class Tasks: | ||
| loop = asyncio.new_event_loop() | ||
| @classmethod | ||
| def _run(cls): | ||
| asyncio.set_event_loop(cls.loop) | ||
| try: | ||
| cls.loop.run_forever() | ||
| finally: | ||
| cls.loop.close() | ||
| @classmethod | ||
| def do(cls, func, *args, **kwargs): | ||
| handle = cls.loop.call_soon(lambda: func(*args, **kwargs)) | ||
| cls.loop._write_to_self() | ||
| return handle | ||
| @classmethod | ||
| def later(cls, func, *args, after=None, **kwargs): | ||
| handle = cls.loop.call_later(after, lambda: func(*args, **kwargs)) | ||
| cls.loop._write_to_self() | ||
| return handle | ||
| @classmethod | ||
| def periodic(cls, func, *args, interval=None, **kwargs): | ||
| @asyncio.coroutine | ||
| def f(): | ||
| while True: | ||
| yield from asyncio.sleep(interval) | ||
| func(*args, **kwargs) | ||
| handle = cls.loop.create_task(f()) | ||
| cls.loop._write_to_self() | ||
| return handle | ||
| threading.Thread(name="tasks", target=Tasks._run, daemon=True).start() |
| import threading | ||
| import websocket as ws | ||
| import requests | ||
| class WSClient: | ||
| def __init__(self, url, cookies, server, handler): | ||
| self.url = url | ||
| self.server = server | ||
| self.cookies = cookies | ||
| self.handler = handler | ||
| self.open = False | ||
| self._close_socket = False | ||
| self.ws = None | ||
| threading.Thread(name='wsclient', target=self._run_websocket).start() | ||
| def _run_websocket(self): | ||
| self.ws = ws.create_connection(self.url, origin='https://chat.{}'.format(self.server), cookie=self.cookies) | ||
| self.open = True | ||
| while not self._close_socket: | ||
| try: | ||
| data = self.ws.recv() | ||
| except (ws.WebSocketConnectionClosedException, requests.ConnectionError): | ||
| self.open = False | ||
| threading.Thread(name='wsclient', target=self._run_websocket).start() | ||
| break | ||
| self.handler(data, self.server) | ||
| if self.ws.connected: | ||
| self.ws.close() | ||
| def close(self): | ||
| self._close_socket = True | ||
| if self.ws is not None: | ||
| self.ws.close() |
+2
-2
| Metadata-Version: 2.1 | ||
| Name: stackl | ||
| Version: 0.0.0a0 | ||
| Version: 0.0.1 | ||
| Summary: Python library for connecting to Stack Exchange chat | ||
| Home-page: https://github.com/ArtOfCode-/stat | ||
| Home-page: https://github.com/ArtOfCode-/stackl | ||
| Author: ArtOfCode | ||
@@ -7,0 +7,0 @@ Author-email: hello@artofcode.co.uk |
+3
-2
| import setuptools | ||
| import stackl | ||
@@ -8,3 +9,3 @@ with open("README.md", "r") as fh: | ||
| name="stackl", | ||
| version="0.0.0a", | ||
| version=stackl.VERSION, | ||
| author="ArtOfCode", | ||
@@ -15,3 +16,3 @@ author_email="hello@artofcode.co.uk", | ||
| long_description_content_type="text/markdown", | ||
| url="https://github.com/ArtOfCode-/stat", | ||
| url="https://github.com/ArtOfCode-/stackl", | ||
| packages=setuptools.find_packages(), | ||
@@ -18,0 +19,0 @@ classifiers=[ |
| Metadata-Version: 2.1 | ||
| Name: stackl | ||
| Version: 0.0.0a0 | ||
| Version: 0.0.1 | ||
| Summary: Python library for connecting to Stack Exchange chat | ||
| Home-page: https://github.com/ArtOfCode-/stat | ||
| Home-page: https://github.com/ArtOfCode-/stackl | ||
| Author: ArtOfCode | ||
@@ -7,0 +7,0 @@ Author-email: hello@artofcode.co.uk |
| README.md | ||
| setup.py | ||
| stackl/__init__.py | ||
| stackl/errors.py | ||
| stackl/events.py | ||
| stackl/helpers.py | ||
| stackl/models.py | ||
| stackl/tasks.py | ||
| stackl/wsclient.py | ||
| stackl.egg-info/PKG-INFO | ||
| stackl.egg-info/SOURCES.txt | ||
| stackl.egg-info/dependency_links.txt | ||
| stackl.egg-info/top_level.txt | ||
| stat/__init__.py | ||
| stackl.egg-info/top_level.txt |
@@ -1,1 +0,1 @@ | ||
| stat | ||
| stackl |
| name = "stackl" |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
22795
1059.46%15
66.67%478
2290%