Example #1
0
    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
Example #2
0
 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', '')
Example #3
0
    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()
Example #4
0
    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()
Example #5
0
 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))
Example #6
0
 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()
Example #7
0
 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)
Example #8
0
    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()
Example #9
0
 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']