def on_usernotice(self, chatconn, event): tags = { tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags } if event.target != self.channel: return id = tags["user-id"] login = tags["login"] name = tags["display-name"] with DBManager.create_session_scope( expire_on_commit=False) as db_session: source = User.from_basics(db_session, UserBasics(id, login, name)) if event.arguments and len(event.arguments) > 0: msg = event.arguments[0] else: msg = None # e.g. user didn't type an extra message to share with the streamer with new_message_processing_scope(self): HandlerManager.trigger("on_usernotice", source=source, message=msg, tags=tags) if msg is not None: self.parse_message(msg, source, event, tags)
def on_pubmsg(self, chatconn, event): tags = { tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags } id = tags["user-id"] login = event.source.user name = tags["display-name"] if event.source.user == self.nickname: return False if self.streamer == "forsen": if "zonothene" in login: self._ban(login) return True raw_m = event.arguments[0].lower() if raw_m.startswith("!lastseen forsen"): if len(raw_m) > len("!lastseen forsen2"): if raw_m[16] == " ": return True else: return True if raw_m.startswith("!lastseen @forsen"): if len(raw_m) > len("!lastseen @forsen2"): if raw_m[17] == " ": return True else: return True if self.streamer == "nymn": if "hades_k" in login: self.timeout_login(login, 3600, reason="Bad username") return True if "hades_b" in login: self.timeout_login(login, 3600, reason="Bad username") return True with DBManager.create_session_scope( expire_on_commit=False) as db_session: source = User.from_basics(db_session, UserBasics(id, login, name)) with new_message_processing_scope(self): res = HandlerManager.trigger("on_pubmsg", source=source, message=event.arguments[0], tags=tags) if res is False: return False self.parse_message(event.arguments[0], source, event, tags=tags)
def on_redeem(self, redeemer, redeemed_id, user_input): if user_input is not None and redeemed_id == self.settings[ "redeemed_id"]: with DBManager.create_session_scope() as db_session: user = User.from_basics(db_session, redeemer) points = random.randint(self.settings["lower_points"], self.settings["upper_points"]) user.points += points self.bot.whisper(user, f"You have been given {points}")
def on_whisper(self, chatconn, event): tags = {tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags} id = tags["user-id"] login = event.source.user name = tags["display-name"] with DBManager.create_session_scope(expire_on_commit=False) as db_session: source = User.from_basics(db_session, UserBasics(id, login, name)) self.parse_message(event.arguments[0], source, event, tags, whisper=True)
def command_masspoints(self, bot, source, message, **rest): if not message: return False pointsArgument = message.split(" ")[0] givePoints = 0 try: givePoints = int(pointsArgument) except ValueError: bot.whisper(source, "Error: You must give an integer") return False currentChatters = bot.twitch_tmi_api.get_chatter_logins_by_login( bot.streamer) numUsers = len(currentChatters) if not currentChatters: bot.say("Error fetching chatters") return False userBasics = bot.twitch_helix_api.bulk_get_user_basics_by_login( currentChatters) # Filtering userBasics = [e for e in userBasics if e is not None] with DBManager.create_session_scope() as db_session: # Convert to models userModels = [User.from_basics(db_session, e) for e in userBasics] for userModel in userModels: if userModel.num_lines < 5: continue if userModel.subscriber: userModel.points = userModel.points + givePoints * self.settings[ "sub_points"] else: userModel.points = userModel.points + givePoints bot.say( f"{source} just gave {numUsers} viewers {givePoints} points each! Enjoy FeelsGoodMan" )
def on_pubmsg(self, chatconn, event): tags = { tag["key"]: tag["value"] if tag["value"] is not None else "" for tag in event.tags } id = tags["user-id"] login = event.source.user name = tags["display-name"] if event.source.user == self.nickname: return False with DBManager.create_session_scope( expire_on_commit=False) as db_session: source = User.from_basics(db_session, UserBasics(id, login, name)) res = HandlerManager.trigger("on_pubmsg", source=source, message=event.arguments[0]) if res is False: return False self.parse_message(event.arguments[0], source, event, tags=tags)
def on_usernotice(self, source, tags, **rest): if "msg-id" not in tags: return if tags["msg-id"] == "resub": num_months = -1 substreak_count = 0 if "msg-param-months" in tags: num_months = int(tags["msg-param-months"]) if "msg-param-cumulative-months" in tags: num_months = int(tags["msg-param-cumulative-months"]) if "msg-param-streak-months" in tags: substreak_count = int(tags["msg-param-streak-months"]) if "msg-param-should-share-streak" in tags: should_share = bool(tags["msg-param-should-share-streak"]) if not should_share: substreak_count = 0 if "msg-param-sub-plan" not in tags: log.debug( f"subalert msg-id is resub, but missing msg-param-sub-plan: {tags}" ) return # log.debug('msg-id resub tags: {}'.format(tags)) # TODO: Should we check room id with streamer ID here? Maybe that's for pajbot2 instead self.on_resub(source, num_months, tags["msg-param-sub-plan"], None, substreak_count) HandlerManager.trigger("on_user_resub", user=source, num_months=num_months) elif tags["msg-id"] == "subgift": num_months = 0 substreak_count = 0 if "msg-param-months" in tags: num_months = int(tags["msg-param-months"]) if "msg-param-cumulative-months" in tags: num_months = int(tags["msg-param-cumulative-months"]) if "msg-param-streak-months" in tags: substreak_count = int(tags["msg-param-streak-months"]) if "msg-param-should-share-streak" in tags: should_share = bool(tags["msg-param-should-share-streak"]) if not should_share: substreak_count = 0 if "display-name" not in tags: log.debug( f"subalert msg-id is subgift, but missing display-name: {tags}" ) return with DBManager.create_session_scope() as db_session: receiver_id = tags["msg-param-recipient-id"] receiver_login = tags["msg-param-recipient-user-name"] receiver_name = tags["msg-param-recipient-display-name"] receiver = User.from_basics( db_session, UserBasics(receiver_id, receiver_login, receiver_name)) if num_months > 1: # Resub self.on_resub(receiver, num_months, tags["msg-param-sub-plan"], tags["display-name"], substreak_count) HandlerManager.trigger("on_user_resub", user=receiver, num_months=num_months) else: # New sub self.on_new_sub(receiver, tags["msg-param-sub-plan"], tags["display-name"]) HandlerManager.trigger("on_user_sub", user=receiver) elif tags["msg-id"] == "sub": if "msg-param-sub-plan" not in tags: log.debug( f"subalert msg-id is sub, but missing msg-param-sub-plan: {tags}" ) return self.on_new_sub(source, tags["msg-param-sub-plan"]) HandlerManager.trigger("on_user_sub", user=source) elif tags["msg-id"] == "giftpaidupgrade": self.on_gift_upgrade(source) elif tags["msg-id"] == "extendsub": self.on_extend_sub(source) else: log.debug(f"Unhandled msg-id: {tags['msg-id']} - tags: {tags}")
def authorized(): try: resp = twitch.authorized_response() except OAuthException as e: log.error(e) log.exception("An exception was caught while authorizing") next_url = get_next_url(request, "state") return redirect(next_url) except Exception as e: log.error(e) log.exception("Unhandled exception while authorizing") return render_template("login_error.html") if resp is None: if "error" in request.args and "error_description" in request.args: log.warning(f"Access denied: reason={request.args['error']}, error={request.args['error_description']}") next_url = get_next_url(request, "state") return redirect(next_url) elif type(resp) is OAuthException: log.warning(resp.message) log.warning(resp.data) log.warning(resp.type) next_url = get_next_url(request, "state") return redirect(next_url) session["twitch_token"] = (resp["access_token"],) session["twitch_token_expire"] = time.time() + resp["expires_in"] * 0.75 me_api_response = twitch.get("users") if len(me_api_response.data["data"]) < 1: return render_template("login_error.html") with DBManager.create_session_scope(expire_on_commit=False) as db_session: me = User.from_basics( db_session, UserBasics( me_api_response.data["data"][0]["id"], me_api_response.data["data"][0]["login"], me_api_response.data["data"][0]["display_name"], ), ) session["user"] = me.jsonify() # bot login if me.login == app.bot_config["main"]["nickname"].lower(): redis = RedisManager.get() token_json = UserAccessToken.from_api_response(resp).jsonify() redis.set(f"authentication:user-access-token:{me.id}", json.dumps(token_json)) log.info("Successfully updated bot token in redis") # streamer login if me.login == app.bot_config["main"]["streamer"].lower(): # there's a good chance the streamer will later log in using the normal login button. # we only update their access token if the returned scope containes the special scopes requested # in /streamer_login # We use < to say "if the granted scope is a proper subset of the required scopes", this can be case # for example when the bot is running in its own channel and you use /bot_login, # then the granted scopes will be a superset of the scopes needed for the streamer. # By doing this, both the streamer and bot token will be set if you complete /bot_login with the bot # account, and if the bot is running in its own channel. if set(resp["scope"]) < set(streamer_scopes): log.info("Streamer logged in but not all scopes present, will not update streamer token") else: redis = RedisManager.get() token_json = UserAccessToken.from_api_response(resp).jsonify() redis.set(f"authentication:user-access-token:{me.id}", json.dumps(token_json)) log.info("Successfully updated streamer token in redis") next_url = get_next_url(request, "state") return redirect(next_url)
def authorized(): # First, validate state with CSRF token # (CSRF token from request parameter must match token from session) state_str = request.args.get("state", None) if state_str is None: return render_template("login_error.html", return_to="/", detail_msg="State parameter missing"), 400 try: state = json.loads(state_str) except JSONDecodeError: return render_template("login_error.html", return_to="/", detail_msg="State parameter not valid JSON"), 400 # we now have a valid state object, we can send the user back to the place they came from return_to = state.get("return_to", None) if return_to is None: # either not present in the JSON at all, or { "return_to": null } (which is the case when you # e.g. access /bot_login or /streamer_login directly) return_to = "/" def login_error(code, detail_msg=None): return render_template("login_error.html", return_to=return_to, detail_msg=detail_msg), code csrf_token = state.get("csrf_token", None) if csrf_token is None: return login_error(400, "CSRF token missing from state") csrf_token_in_session = session.pop("csrf_token", None) if csrf_token_in_session is None: return login_error(400, "No CSRF token in session cookie") if csrf_token != csrf_token_in_session: return login_error(403, "CSRF tokens don't match") # determine if we got ?code= or ?error= (success or not) # https://tools.ietf.org/html/rfc6749#section-4.1.2 if "error" in request.args: # user was sent back with an error condition error_code = request.args["error"] optional_error_description = request.args.get("error_description", None) if error_code == "access_denied": # User pressed "Cancel" button. We don't want to show an error page, instead we will just # redirect them to where they were coming from. # See also https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for error codes and documentation for them return redirect(return_to) # All other error conditions, we show an error page. if optional_error_description is not None: user_detail_msg = f"Error returned from Twitch: {optional_error_description} (code: {error_code})" else: user_detail_msg = f"Error returned from Twitch (code: {error_code})" return login_error(400, user_detail_msg) if "code" not in request.args: return login_error(400, "No ?code or ?error present on the request") # successful authorization code = request.args["code"] try: # gets us an UserAccessToken object access_token = app.twitch_id_api.get_user_access_token(code) except: log.exception("Could not exchange given code for access token with Twitch") return login_error(500, "Could not exchange the given code for an access token.") user_basics = app.twitch_helix_api.fetch_user_basics_from_authorization( (app.api_client_credentials, access_token) ) with DBManager.create_session_scope(expire_on_commit=False) as db_session: me = User.from_basics(db_session, user_basics) session["user"] = me.jsonify() # bot login if me.login == app.bot_config["main"]["nickname"].lower(): redis = RedisManager.get() redis.set(f"authentication:user-access-token:{me.id}", json.dumps(access_token.jsonify())) log.info("Successfully updated bot token in redis") # streamer login if me.login == app.bot_config["main"]["streamer"].lower(): # there's a good chance the streamer will later log in using the normal login button. # we only update their access token if the returned scope containes the special scopes requested # in /streamer_login # We use < to say "if the granted scope is a proper subset of the required scopes", this can be case # for example when the bot is running in its own channel and you use /bot_login, # then the granted scopes will be a superset of the scopes needed for the streamer. # By doing this, both the streamer and bot token will be set if you complete /bot_login with the bot # account, and if the bot is running in its own channel. if set(access_token.scope) < set(streamer_scopes): log.info("Streamer logged in but not all scopes present, will not update streamer token") else: redis = RedisManager.get() redis.set(f"authentication:user-access-token:{me.id}", json.dumps(access_token.jsonify())) log.info("Successfully updated streamer token in redis") return redirect(return_to)
def authorized(): # First, validate state with CSRF token # (CSRF token from request parameter must match token from session) state_str = request.args.get("state", None) if state_str is None: return render_template("login_error.html", return_to="/", detail_msg="State parameter missing"), 400 try: state = json.loads(state_str) except JSONDecodeError: return render_template( "login_error.html", return_to="/", detail_msg="State parameter not valid JSON"), 400 # we now have a valid state object, we can send the user back to the place they came from return_to = state.get("return_to", None) if return_to is None: # either not present in the JSON at all, or { "return_to": null } (which is the case when you # e.g. access /bot_login or /streamer_login directly) return_to = "/" def login_error(code, detail_msg=None): return render_template("login_error.html", return_to=return_to, detail_msg=detail_msg), code csrf_token = state.get("csrf_token", None) if csrf_token is None: return login_error(400, "CSRF token missing from state") csrf_token_in_session = session.pop("csrf_token", None) if csrf_token_in_session is None: return login_error(400, "No CSRF token in session cookie") if csrf_token != csrf_token_in_session: return login_error(403, "CSRF tokens don't match") # determine if we got ?code= or ?error= (success or not) # https://tools.ietf.org/html/rfc6749#section-4.1.2 if "error" in request.args: # user was sent back with an error condition error_code = request.args["error"] optional_error_description = request.args.get( "error_description", None) if error_code == "access_denied": # User pressed "Cancel" button. We don't want to show an error page, instead we will just # redirect them to where they were coming from. # See also https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for error codes and documentation for them return redirect(return_to) # All other error conditions, we show an error page. if optional_error_description is not None: user_detail_msg = f"Error returned from Twitch: {optional_error_description} (code: {error_code})" else: user_detail_msg = f"Error returned from Twitch (code: {error_code})" return login_error(400, user_detail_msg) if "code" not in request.args: return login_error(400, "No ?code or ?error present on the request") # successful authorization code = request.args["code"] try: # gets us an UserAccessToken object access_token = app.twitch_id_api.get_user_access_token(code) except: log.exception( "Could not exchange given code for access token with Twitch") return login_error( 500, "Could not exchange the given code for an access token.") user_basics = app.twitch_helix_api.fetch_user_basics_from_authorization( (app.api_client_credentials, access_token)) with DBManager.create_session_scope( expire_on_commit=False) as db_session: me = User.from_basics(db_session, user_basics) session["user"] = me.jsonify() required_user_scopes = { # Bot app.bot_user.login.lower(): set(bot_scopes), # Streamer app.streamer.login.lower(): set(streamer_scopes), } if me.login in required_user_scopes: # Stop the user from accidentally downgrading their scopes when logging in with # the Streamer or Bot account on the normal /login page required_scopes = required_user_scopes[me.login] if set(access_token.scope) < required_scopes: log.info( f"User {me.login} logged in but not all of their required scopes are present, will not update redis token" ) else: redis = RedisManager.get() redis.set(f"authentication:user-access-token:{me.id}", json.dumps(access_token.jsonify())) log.info( f"Successfully updated {me.login}'s token in redis to {access_token.scope}" ) return redirect(return_to)
def __init__(self, config: cfg.Config, args: argparse.Namespace) -> None: self.args = args self.config = config ScheduleManager.init() DBManager.init(config["main"]["db"]) # redis redis_options = config.get("redis", {}) RedisManager.init(redis_options) utils.wait_for_redis_data_loaded(RedisManager.get()) if cfg.get_boolean(config["main"], "verified", False): self.tmi_rate_limits = TMIRateLimits.VERIFIED elif cfg.get_boolean(config["main"], "known", False): self.tmi_rate_limits = TMIRateLimits.KNOWN else: self.tmi_rate_limits = TMIRateLimits.BASE self.whisper_output_mode = WhisperOutputMode.from_config_value( config["main"].get("whisper_output_mode", "normal")) # phrases self.phrases = { "welcome": ["{nickname} {version} running! HeyGuys"], "quit": ["{nickname} {version} shutting down... BibleThump"], } if "phrases" in config: phrases = config["phrases"] if "welcome" in phrases: self.phrases["welcome"] = phrases["welcome"].splitlines() if "quit" in phrases: self.phrases["quit"] = phrases["quit"].splitlines() # Remembers whether the "welcome" phrases have already been said. We don't want to send the # welcome messages to chat again on a reconnect. self.welcome_messages_sent = False self.bot_domain = config["web"]["domain"] # do this earlier since schema upgrade can depend on the helix api self.api_client_credentials = ClientCredentials( config["twitchapi"]["client_id"], config["twitchapi"]["client_secret"], config["twitchapi"].get( "redirect_uri", f"https://{config['web']['domain']}/login/authorized"), ) self.twitch_id_api = TwitchIDAPI(self.api_client_credentials) self.twitch_tmi_api = TwitchTMIAPI() self.app_token_manager = AppAccessTokenManager(self.twitch_id_api, RedisManager.get()) self.twitch_helix_api: TwitchHelixAPI = TwitchHelixAPI( RedisManager.get(), self.app_token_manager) self.streamer: UserBasics = cfg.load_streamer(config, self.twitch_helix_api) self.channel = f"#{self.streamer.login}" self.streamer_display: str = self.streamer.name if "streamer_name" in config["web"]: # Override the streamer display name self.streamer_display = config["web"]["streamer_name"] self.bot_user: UserBasics = cfg.load_bot(config, self.twitch_helix_api) self.control_hub_user: Optional[UserBasics] = self._load_control_hub( config) self.control_hub_channel: Optional[str] = None if self.control_hub_user: self.control_hub_channel = f"#{self.control_hub_user.login}" log.debug("Loaded config") self.streamer_access_token_manager = UserAccessTokenManager( api=self.twitch_id_api, redis=RedisManager.get(), username=self.streamer.login, user_id=self.streamer.id, ) StreamHelper.init_streamer(self.streamer.login, self.streamer.id, self.streamer.name) # SQL migrations with DBManager.create_dbapi_connection_scope() as sql_conn: sql_migratable = DatabaseMigratable(sql_conn) sql_migration = Migration(sql_migratable, pajbot.migration_revisions.db, self) sql_migration.run() # Redis migrations redis_migratable = RedisMigratable(redis_options=redis_options, namespace=self.streamer.login) redis_migration = Migration(redis_migratable, pajbot.migration_revisions.redis, self) redis_migration.run() # Thread pool executor for async actions self.action_queue = ActionQueue() # refresh points_rank and num_lines_rank regularly UserRanksRefreshManager.start(self.action_queue) self.reactor = irc.client.Reactor() # SafeDefaultScheduler makes the bot not exit on exception in the main thread # e.g. on actions via bot.execute_now, etc. self.reactor.scheduler_class = SafeDefaultScheduler self.reactor.scheduler = SafeDefaultScheduler() self.start_time = utils.now() ActionParser.bot = self HandlerManager.init_handlers() self.socket_manager = SocketManager(self.streamer.login, self.execute_now) self.stream_manager = StreamManager(self) StreamHelper.init_stream_manager(self.stream_manager) self.decks = DeckManager() self.banphrase_manager = BanphraseManager(self).load() self.timer_manager = TimerManager(self).load() self.kvi = KVIManager() # bot access token if "password" in config["main"]: log.warning( "DEPRECATED - Using bot password/oauth token from file. " "You should authenticate in web gui using route /bot_login " "and remove password from config file") access_token = config["main"]["password"] if access_token.startswith("oauth:"): access_token = access_token[6:] self.bot_token_manager = UserAccessTokenManager( api=None, redis=None, username=self.bot_user.login, user_id=self.bot_user.id, token=UserAccessToken.from_implicit_auth_flow_token( access_token), ) else: self.bot_token_manager = UserAccessTokenManager( api=self.twitch_id_api, redis=RedisManager.get(), username=self.bot_user.login, user_id=self.bot_user.id, ) self.emote_manager = EmoteManager(self.twitch_helix_api, self.action_queue) self.epm_manager = EpmManager() self.ecount_manager = EcountManager() self.twitter_manager = cfg.load_twitter_manager(config)(self) self.module_manager = ModuleManager(self.socket_manager, bot=self).load() self.commands = CommandManager(socket_manager=self.socket_manager, module_manager=self.module_manager, bot=self).load() self.websocket_manager = WebSocketManager(self) HandlerManager.trigger("on_managers_loaded") # Commitable managers self.commitable = { "commands": self.commands, "banphrases": self.banphrase_manager } self.execute_every(60, self.commit_all) self.execute_every(1, self.do_tick) admin: Optional[UserBasics] = self._load_admin(config) if admin is None: log.warning( "No admin user specified. See the [main] section in the example config for its usage." ) else: with DBManager.create_session_scope() as db_session: admin_user = User.from_basics(db_session, admin) admin_user.level = 2000 # silent mode self.silent = ("flags" in config and "silent" in config["flags"] and config["flags"]["silent"] == "1") or args.silent if self.silent: log.info("Silent mode enabled") # dev mode self.dev = "flags" in config and "dev" in config["flags"] and config[ "flags"]["dev"] == "1" if self.dev: self.version_long = utils.extend_version_if_possible(VERSION) else: self.version_long = VERSION self.irc = IRCManager(self) relay_host = config["main"].get("relay_host", None) relay_password = config["main"].get("relay_password", None) if relay_host is not None or relay_password is not None: log.warning( "DEPRECATED - Relaybroker support is no longer implemented. relay_host and relay_password are ignored" ) self.data = { "broadcaster": self.streamer.login, "version": self.version_long, "version_brief": VERSION, "bot_name": self.bot_user.login, "bot_domain": self.bot_domain, "streamer_display": self.streamer_display, } self.data_cb = { "status_length": self.c_status_length, "stream_status": self.c_stream_status, "bot_uptime": self.c_uptime, "current_time": self.c_current_time, "molly_age_in_years": self.c_molly_age_in_years, } self.user_agent = f"pajbot1/{VERSION} ({self.bot_user.login})" self.thread_locals = threading.local() self.subs_only = False