async def subscribe( self, websocket: websockets.WebSocketClientProtocol) -> Dict[str, str]: channel_id = self.twitch_util.get_channel_id( self.twitch_util.streamer_username) nonce = twitch_util.nonce() await websocket.send( json.dumps({ 'type': 'LISTEN', 'nonce': nonce, 'data': { 'topics': [ f'chat_moderator_actions.{channel_id}', ], 'auth_token': self.twitch_util.oauth.access_token, } })) # Keep listening until we get a response with the correct nonce. It's generally the first # one. async for message in websocket: response = json.loads(message) logger.debug(response) if response['nonce'] == nonce: break else: raise base.ServerError('Websocket closed without response.') if response['type'] != 'RESPONSE': raise base.ServerError(f'Bad pubsub response: {response}') return response
def _fetch(self, params: Dict[str, str]) -> Tuple[str, str]: response = requests.post('https://id.twitch.tv/oauth2/token', params={ 'client_id': secret.TWITCH_CLIENT_ID, 'client_secret': secret.TWITCH_CLIENT_SECRET, **params }) if response.status_code != 200: raise base.ServerError(f'{response.status_code} {response.text}') body = response.json() if 'error' in body: raise base.ServerError(body) return body['access_token'], body.get('refresh_token', '')
async def run_coro(self, on_event: base.EventCallback) -> None: while not self.shutdown_event.is_set(): async with websockets.connect('wss://pubsub-edge.twitch.tv', close_timeout=1) as self.websocket: response = await self.subscribe(self.websocket) if response['error'] == 'ERR_BADAUTH': self.twitch_util.oauth.refresh() response = await self.subscribe(self.websocket) if response['error'] == 'ERR_BADAUTH': raise base.ServerError( 'Two BADAUTH errors, giving up.') ping_task = asyncio.create_task(_ping_forever(self.websocket)) try: async for message in self.websocket: logger.debug(message) body = json.loads(message) if body['type'] == 'RECONNECT': logger.info('Reconnecting by request...') break self.handle_message(on_event, body) except websockets.ConnectionClosed: pass ping_task.cancel()
def finish_authorization(self, code: str) -> None: try: access_token, refresh_token = self._fetch({ 'grant_type': 'authorization_code', 'code': code, 'redirect_uri': secret.TWITCH_REDIRECT_URI, }) except base.ServerError: logger.exception("Couldn't exchange code for token") raise response = requests.get('https://api.twitch.tv/helix/users', headers={ 'Client-ID': secret.TWITCH_CLIENT_ID, 'Authorization': f'Bearer {access_token}' }) if response.status_code != 200: logger.error( "Couldn't fetch user info with new bearer token: %d %s", response.status_code, response.text) raise base.ServerError(f'{response.status_code} {response.text}') body = response.json() login = body['data'][0]['login'] if login != self.streamer_username.lower(): display_name = body['data'][0]['display_name'] raise base.UserError( f"You're logged into Twitch as {display_name}. Please log in " f"as {self.streamer_username} to authorize the bot.") self.data.set('access_token', access_token) self.data.set('refresh_token', refresh_token) self.auth_finished.set()
def handle_message(self, on_event: base.EventCallback, body: Dict[str, Any]) -> None: if body['type'] == 'PONG': return if body['type'] != 'MESSAGE': raise base.ServerError(body) topic = body['data']['topic'] msg = json.loads(body['data']['message']) if '_moderator_actions' not in topic: return mdata = msg['data'] if mdata['moderation_action'] not in { 'ban', 'unban', 'timeout', 'untimeout', 'delete' }: logger.info(f'Ignoring mod action {mdata["moderation_action"]}') return user = self.twitch_user(mdata['created_by'], is_moderator=True) if mdata['moderation_action'] == 'ban': [target_username, reason] = mdata['args'] on_event( Ban(self.reply_conn, user, self.twitch_user(target_username), reason)) elif mdata['moderation_action'] == 'unban': [target_username] = mdata['args'] on_event( Unban(self.reply_conn, user, self.twitch_user(target_username))) elif mdata['moderation_action'] == 'timeout': [target_username, duration_sec, reason] = mdata['args'] on_event( Timeout(self.reply_conn, user, self.twitch_user(target_username), datetime.timedelta(seconds=int(duration_sec)), reason)) elif mdata['moderation_action'] == 'untimeout': [target_username] = mdata['args'] on_event( Untimeout(self.reply_conn, user, self.twitch_user(target_username))) elif mdata['moderation_action'] == 'delete': [target_username, message_text, message_id] = mdata['args'] on_event( Delete(self.reply_conn, user, self.twitch_user(target_username), message_text))
def _helix(self, method: str, path: str, token_type: Literal['user', 'app'], expected_status: int, params: Optional[Union[Dict[str, Any], List[Tuple[str, Any]]]] = None, json: Optional[Dict[str, Any]] = None) -> Dict: token = self.oauth.access_token if token_type == 'user' else self.oauth.app_access_token request = requests.Request(method=method, url=f'https://api.twitch.tv/helix/{path}', params=params, json=json, headers={ 'Client-ID': secret.TWITCH_CLIENT_ID, 'Authorization': f'Bearer {token}', }) with requests.Session() as s: response = s.send(request.prepare()) if response.status_code == 401: if token_type == 'user': self.oauth.refresh() token = self.oauth.access_token else: self.oauth.refresh_app_access_token() token = self.oauth.app_access_token request.headers['Authorization'] = f'Bearer {token}' with requests.Session() as s: response = s.send(request.prepare()) if response.status_code != expected_status: logger.error(request.prepare()) logger.error('%s %s %s %s %s', request.method, request.url, request.params, request.json, request.headers) logger.error('%d %s', response.status_code, response.text) raise base.ServerError(f'{response.status_code} {response.text}') if not response.text: return {} return response.json()
def delete(self, message: TwitchMessage, reply: Optional[str] = None) -> None: if not message.id: raise base.ServerError(f"Message {message} is missing id, can't delete") self.command(f'.delete {message.id}') if reply: self.say(reply)
def _irc_command_as_streamer(self, commands: Union[str, List[str]], success_msg: str, failure_msgs: Container[str], retry_on_error: bool = True) -> None: if isinstance(commands, str): commands = [commands] channel = '#' + self.oauth.streamer_username.lower() pubnotices: queue.Queue[str] = queue.Queue() welcome = threading.Event() def on_welcome(_c: client.ServerConnection, _e: client.Event) -> None: welcome.set() def on_pubnotice(_: client.ServerConnection, event: client.Event) -> None: msg_ids = [i['value'] for i in event.tags if i['key'] == 'msg-id'] if not msg_ids: return if len(msg_ids) > 1: logger.error('Multiple msg-id tags: %s', event) # ... but continue anyway, and just use the first one. pubnotices.put(msg_ids[0]) self.oauth.refresh() reactor = client.Reactor() connection = reactor.server().connect( 'irc.chat.twitch.tv', 6667, self.oauth.streamer_username.lower(), password=f'oauth:{self.oauth.access_token}') connection.add_global_handler('welcome', on_welcome) connection.add_global_handler('pubnotice', on_pubnotice) reactor.process_once(timeout=5) if not welcome.wait(timeout=5): connection.disconnect() if retry_on_error: self._irc_command_as_streamer(commands, success_msg, failure_msgs, retry_on_error=False) return else: raise base.ServerError('WELCOME not received.') connection.cap('REQ', 'twitch.tv/commands', 'twitch.tv/tags') connection.cap('END') reactor.process_once(timeout=5) for command in commands: connection.privmsg(channel, command) result = '' unknown_msgs = [] deadline = (datetime.datetime.utcnow() + datetime.timedelta(seconds=10)) while result == '' and datetime.datetime.utcnow() < deadline: timeout = deadline - datetime.datetime.utcnow() reactor.process_once(timeout=timeout.total_seconds()) while True: try: msg = pubnotices.get_nowait() except queue.Empty: break if (msg == success_msg or msg in failure_msgs) and result == '': result = msg else: unknown_msgs.append(msg) if result == success_msg: logger.info('%s: success', command) elif result: logger.error('%s: %s', command, result) else: if unknown_msgs: logger.error('%s: No response. Unknown pubnotices: %s', command, unknown_msgs) else: logger.error('%s: No response.') if retry_on_error: connection.disconnect() self._irc_command_as_streamer(commands, success_msg, failure_msgs, retry_on_error=False) return connection.disconnect()
def game_name(self, game_id: int) -> str: body = self.helix_get('games', {'id': game_id}) if not body['data']: raise base.ServerError(f'No Game with ID {game_id}') return body['data'][0]['name']