├── src ├── discord_tracker.py ├── chat_bot.py ├── database.py ├── view_tracker.py ├── follower_tracker.py ├── environment.py ├── models.py ├── bot.py ├── app.py └── command.py ├── .gitignore ├── credentials.env.sample ├── requirements.txt └── README.md /src/discord_tracker.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | *.env 3 | *.pyc 4 | *.db 5 | *.ipynb 6 | *.png 7 | -------------------------------------------------------------------------------- /src/chat_bot.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select, update, insert 2 | from bot import Bot 3 | from environment import env 4 | from datetime import datetime 5 | from sqlalchemy import select, insert 6 | from database import Base, Session, engine 7 | from models import TextCommands, BotTime 8 | 9 | 10 | def main(): 11 | # create all tables 12 | Base.metadata.create_all(bind=engine) 13 | 14 | # log bot startup time 15 | engine.execute(insert(BotTime)) 16 | 17 | bot = Bot() 18 | bot.connect_to_channel() 19 | 20 | # loop forever 21 | bot.check_for_messages() 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | 27 | -------------------------------------------------------------------------------- /credentials.env.sample: -------------------------------------------------------------------------------- 1 | CLIENT_ID = "Put your client ID between these quotation marks!" 2 | CLIENT_SECRET = "Put your client secret between these quotation marks!" 3 | OAUTH_TOKEN = "Put your OAUTH token between these quotation marks!" 4 | 5 | BOT_NAME = "Put the username of your bot (can be the same as your channel) between these quotation marks!" 6 | CHANNEL = "Put your channel name between these quotation marks!" 7 | 8 | DB_NAME = "stream_data" 9 | DB_USERNAME = "Put your Postgres database username here!" 10 | DB_PASSWORD = "Put your Postgres account password here! (required)" 11 | 12 | CALLBACK_ADDRESS = "Put your callback address here (ngrok works fine)" 13 | 14 | -------------------------------------------------------------------------------- /src/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import sessionmaker 6 | from sqlalchemy.exc import ProgrammingError 7 | load_dotenv("../credentials.env") 8 | 9 | DB_USERNAME = os.getenv("DB_USERNAME") 10 | DB_PASSWORD = os.getenv("DB_PASSWORD") 11 | DB_NAME = os.getenv("DB_NAME") 12 | 13 | DB_BASE_URL = f"postgresql+psycopg2://{DB_USERNAME}:{DB_PASSWORD}@localhost:5432/" 14 | DB_FINAL_URL = DB_BASE_URL + DB_NAME 15 | 16 | base_engine = create_engine(DB_BASE_URL) 17 | 18 | # create database if it doesn't exist 19 | try: 20 | conn = base_engine.connect() 21 | 22 | # commit empty statement as a sqlalchemy workaround 23 | conn.execute("commit") 24 | 25 | # create database 26 | conn.execute(f"CREATE DATABASE {DB_NAME};") 27 | conn.close() 28 | 29 | except ProgrammingError: 30 | pass 31 | 32 | # final engine, Base, and Session used in other scripts 33 | engine = create_engine(DB_FINAL_URL) 34 | Session = sessionmaker(bind=engine) 35 | Base = declarative_base() 36 | 37 | -------------------------------------------------------------------------------- /src/view_tracker.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from sqlalchemy import insert, select 4 | from database import engine 5 | from models import Viewership 6 | from environment import env 7 | 8 | # get stream data from Twitch 9 | def get_stream_data(env=env) -> dict: 10 | url = "https://api.twitch.tv/helix/streams" 11 | headers = { 12 | "Authorization": f"Bearer {env.get_bearer()}", 13 | "Client-Id": env.client_id 14 | } 15 | params = {"user_id": env.user_id} 16 | response = requests.get(url=url, headers=headers, params=params).json() 17 | data = response["data"][0] 18 | return data 19 | 20 | 21 | # write stream data to db 22 | def write_stream_data(entry: dict) -> None: 23 | engine.execute( 24 | insert(Viewership) 25 | .values(entry) 26 | ) 27 | result = engine.execute( 28 | select(Viewership) 29 | ).fetchall() 30 | 31 | 32 | # once per minute 33 | def main(): 34 | # get data 35 | data = get_stream_data() 36 | datapoints = [ 37 | "title", 38 | "game_id", 39 | "game_name", 40 | "viewer_count" 41 | ] 42 | entry = {k:data[k] for k in datapoints} 43 | entry["stream_id"] = data["id"] 44 | 45 | # write data 46 | write_stream_data(entry) 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | 52 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blinker==1.4 2 | Brlapi==0.8.2 3 | certifi==2020.6.20 4 | chardet==4.0.0 5 | chrome-gnome-shell==0.0.0 6 | click==7.1.2 7 | colorama==0.4.4 8 | command-not-found==0.3 9 | cryptography==3.3.2 10 | cupshelpers==1.0 11 | dbus-python==1.2.16 12 | defer==1.0.6 13 | distro==1.5.0 14 | distro-info==1.0 15 | Flask==2.0.1 16 | greenlet==1.1.0 17 | hidpidaemon==18.4.6 18 | httplib2==0.18.1 19 | idna==2.10 20 | itsdangerous==2.0.1 21 | jeepney==0.6.0 22 | Jinja2==3.0.1 23 | kernelstub==3.1.4 24 | keyring==22.2.0 25 | language-selector==0.1 26 | launchpadlib==1.10.13 27 | lazr.restfulclient==0.14.2 28 | lazr.uri==1.0.5 29 | louis==3.16.0 30 | macaroonbakery==1.3.1 31 | MarkupSafe==2.0.1 32 | msgpack==1.0.0 33 | netifaces==0.10.9 34 | oauthlib==3.1.0 35 | pop-transition==1.1.2 36 | protobuf==3.12.4 37 | psycopg2==2.9.1 38 | pycairo==1.16.2 39 | pycups==2.0.1 40 | pydbus==0.6.0 41 | PyGObject==3.38.0 42 | PyJWT==1.7.1 43 | pymacaroons==0.13.0 44 | PyNaCl==1.4.0 45 | pynvim==0.4.2 46 | pyRFC3339==1.1 47 | python-apt===2.2.0-ubuntu0.21.04.1 48 | python-dateutil==2.8.1 49 | python-debian==0.1.39 50 | python-dotenv==0.18.0 51 | python-xlib==0.29 52 | pytz==2021.1 53 | pyxdg==0.27 54 | PyYAML==5.3.1 55 | repolib==1.5.2 56 | repoman==1.4.0 57 | requests==2.25.1 58 | screen-resolution-extra==0.0.0 59 | SecretStorage==3.3.1 60 | sessioninstaller==0.0.0 61 | simplejson==3.17.2 62 | six==1.15.0 63 | SQLAlchemy==1.4.20 64 | systemd-python==234 65 | ubuntu-advantage-tools==27.0 66 | ubuntu-drivers-common==0.0.0 67 | ufw==0.36 68 | urllib3==1.26.2 69 | wadllib==1.3.5 70 | Werkzeug==2.0.1 71 | xdg==5 72 | xkit==0.0.0 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Update: This project is no longer supported. There's a newer, better one [here](https://github.com/MitchellHarrison/stream-environment). 2 | This project began as a learning project. Part of that learning, for me, was relizing that my design got out of hand 3 | and became a nightmare to add features to in its current state. This monolithic design was fun to build, but I've moved 4 | to a microservice-based project with more robust features. You can find that project [here](https://github.com/MitchellHarrison/stream-environment). 5 | Currently, the new design is all Python and uses Docker and Docker Compose, which should make using it much easier. 6 | 7 | Thank you for enjoying this project in its incomplete state. Feel free to star the new one and watch me build it 8 | [on stream](https://twitch.tv/mitchsworkshop). 9 | 10 | # A Python-Powered Twitch Chatbot. 11 | ### An all-in-one Twitch engagement and analytics project, [built live on stream](https://twitch.tv/MitchsWorkshop). 12 | Just add your credentials and go. 13 | 14 | ## ✅ Requirements 15 | - Python 3 16 | - PostgreSQL 17 | 18 | ## 🤖 Using the bot 19 | 1. Get a Client ID and Client Secret [from Twitch](https://dev.twitch.tv/api/). 20 | 2. Get an Oauth token [here](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth). 21 | 3. Fill `credentials.env.sample` with the above tokens, as well as the name of the bot and the channel it will join. 22 | 4. Rename `credentials.env.sample` -> `credentials.env` 23 | 5. Run `pip install -r requirements.txt` 24 | 6. Run `chat_bot.py`. 25 | 7. In Twitch chat, add, edit, or delete commands with `!addcommand`, `!editcommand`, or `!delcommand` respectively. 26 | 27 | ## 🖥 Data Gathering 28 | The bot will store data in a PostgreSQL database called `stream_data` which is created by the bot at startup. It 29 | stores every message sent, and every command used. Additional insights about stream length, title, average viewership, 30 | new followers/subscribers, cheers, tips, and other data points are in the works. 31 | 32 | ## 🏡 Hosted Locally. 33 | All of the data the bot gathers is stored locally. Keep in mind that no one can hide Twitch data from Twitch itself. 34 | Still, there is no need to rely on third party bots like StreamElements or StreamLabs to gain access to your chat data, 35 | and you won't have to slog through Twitch's terrible analytics. The data is all yours, in real time. Once the data viz 36 | app is constructed, there will be no programming knowledge required to get these insights. 37 | 38 | ## 🎥 Built On Stream 39 | If you want to see the progress on this project being made live, feel free to come by my own [Twitch channel](https://twitch.tv/MitchsWorkshop) 40 | to ask questions or comment on the code. When I'm offline, feel free to reach out on the Workshop [Discord Server](https://discord.gg/7nefPK6) 41 | and offer up your questions, comments, critiques, or feature proposals. There are hundreds people in there who love to 42 | help give advice. It's a fun place that I'm proud of. Thanks, friends! 43 | -------------------------------------------------------------------------------- /src/follower_tracker.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from datetime import datetime, timedelta 3 | from environment import env 4 | from sqlalchemy import select, insert, func, delete, update 5 | from database import Base, engine 6 | from models import Followers 7 | 8 | Base.metadata.create_all(bind=engine) 9 | 10 | FOLLOW_URL = "https://api.twitch.tv/helix/users/follows" 11 | FOLLOW_HEADERS = { 12 | "Authorization": f"Bearer {env.app_access}", 13 | "Client-Id": env.client_id 14 | } 15 | 16 | def get_follower_count(env=env) -> int: 17 | # api response 18 | response = requests.get( 19 | url=FOLLOW_URL, 20 | headers=FOLLOW_HEADERS, 21 | params={"to_id":env.user_id} 22 | ).json() 23 | follow_count = response["total"] 24 | return follow_count 25 | 26 | 27 | # get followers from database 28 | def get_db_followers() -> int: 29 | # count the number of followers currently in the database 30 | followers = engine.execute( 31 | select([func.count()]).select_from(Followers) 32 | ).fetchone()[0] 33 | 34 | return followers 35 | 36 | # reconstuct follower table 37 | def refresh_follow_table(env=env) -> None: 38 | params = { 39 | "to_id": env.user_id, 40 | "first": 100 # max allowed by twitch 41 | } 42 | response = requests.get( 43 | url=FOLLOW_URL, 44 | headers=FOLLOW_HEADERS, 45 | params=params 46 | ).json() 47 | print(response["total"]) 48 | 49 | # run until end of follower list 50 | while True: 51 | data = response["data"] 52 | 53 | # KeyError if on last page of followers 54 | try: 55 | cursor = response["pagination"]["cursor"] 56 | params["after"] = cursor 57 | except KeyError: 58 | pass 59 | 60 | # check every follower 61 | for follower in data: 62 | entry = { 63 | "user_id": follower["from_id"], 64 | "follow_time": follower["followed_at"], 65 | "username": follower["from_name"] 66 | } 67 | 68 | # if follower in table, update last_seen 69 | try: 70 | update_entry = { 71 | "last_seen": datetime.now(), 72 | "username": entry["username"] 73 | } 74 | 75 | engine.execute( 76 | update(Followers) 77 | .where(Followers.user_id == entry["user_id"]) 78 | .values(update_entry) 79 | ) 80 | 81 | # if follower not in database, add them 82 | except Exception as e: 83 | print(e) 84 | # write new follower 85 | engine.execute( 86 | insert(Followers) 87 | .values(entry) 88 | ) 89 | print("new follower added") 90 | 91 | # stop running on last page 92 | if not response["pagination"]: 93 | break 94 | 95 | # make new request with new cursor 96 | response = requests.get( 97 | url=FOLLOW_URL, 98 | headers=FOLLOW_HEADERS, 99 | params=params 100 | ).json() 101 | 102 | 103 | # delete unfollows 104 | # remove rows with last_seen values > 12 hours 105 | engine.execute( 106 | delete(Followers) 107 | .where(Followers.last_seen < datetime.now() - timedelta(hours=12)) 108 | ) 109 | 110 | 111 | def main(): 112 | followers = get_follower_count() 113 | db_followers = get_db_followers() 114 | 115 | if followers != db_followers: 116 | refresh_follow_table() 117 | 118 | 119 | if __name__ == "__main__": 120 | main() 121 | 122 | -------------------------------------------------------------------------------- /src/environment.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | import json 4 | from database import Base, Session, engine 5 | from sqlalchemy import select, delete, insert 6 | from models import Tokens 7 | from dotenv import load_dotenv 8 | load_dotenv("../credentials.env") 9 | 10 | Base.metadata.create_all(bind=engine) 11 | session = Session() 12 | 13 | class Environment(): 14 | def __init__(self): 15 | # get creds from env file 16 | self.channel = os.getenv("CHANNEL") 17 | self.bot_name = os.getenv("BOT_NAME") 18 | self.client_id = os.getenv("CLIENT_ID") 19 | self.client_secret = os.getenv("CLIENT_SECRET") 20 | self.oauth = os.getenv("OAUTH_TOKEN") 21 | self.callback_address = os.getenv("CALLBACK_ADDRESS") 22 | 23 | # these are pre-defined 24 | self.irc_port = 6667 25 | self.irc_server = "irc.twitch.tv" 26 | 27 | # required token scopes 28 | self.scopes = [ 29 | "bits:read", 30 | "channel:read:subscriptions", 31 | "channel:moderate", 32 | "channel:read:redemptions", 33 | ] 34 | 35 | # start with new tokens 36 | self.refresh_bearer() 37 | self.refresh_app_access() 38 | 39 | self.user_id = self.get_user_id() 40 | self.app_access = self.get_app_access() 41 | 42 | 43 | # get new bearer token 44 | def refresh_bearer(self) -> None: 45 | # delete existing bearer 46 | engine.execute( 47 | delete(Tokens) 48 | .where(Tokens.name == "Bearer") 49 | ) 50 | 51 | # get new bearer from Twitch 52 | url = "https://id.twitch.tv/oauth2/token" 53 | params = { 54 | "client_id" : self.client_id, 55 | "client_secret" : self.client_secret, 56 | "grant_type" : "client_credentials" 57 | } 58 | response = requests.post(url, params = params, timeout=3) 59 | data = json.loads(response.content) 60 | bearer = data["access_token"] 61 | 62 | # write new bearer to database 63 | entry = { 64 | "name": "Bearer", 65 | "token": bearer 66 | } 67 | engine.execute( 68 | insert(Tokens) 69 | .values(entry) 70 | ) 71 | 72 | 73 | # get bearer from database 74 | def get_bearer(self) -> str: 75 | result = engine.execute( 76 | select(Tokens.token) 77 | .where(Tokens.name == "Bearer") 78 | ).fetchone() 79 | 80 | # return bearer from result 81 | bearer = result[0] 82 | return bearer 83 | 84 | 85 | def get_user_id(self) -> str: 86 | url = f"https://api.twitch.tv/helix/users?login={self.channel}" 87 | headers = { 88 | "client-id": self.client_id, 89 | "authorization": f"Bearer {self.get_bearer()}" 90 | } 91 | response = requests.get(url, headers = headers) 92 | data = json.loads(response.content) 93 | user_id = data["data"][0]["id"] 94 | return user_id 95 | 96 | 97 | def refresh_app_access(self) -> None: 98 | # delete old app access token 99 | engine.execute( 100 | delete(Tokens) 101 | .where(Tokens.name=="App_Access") 102 | ) 103 | 104 | # get new app access token 105 | url = "https://id.twitch.tv/oauth2/token" 106 | params = { 107 | "client_id": self.client_id, 108 | "client_secret": self.client_secret, 109 | "grant_type": "client_credentials" 110 | } 111 | response = requests.post(url, params=params) 112 | token = response.json()["access_token"] 113 | 114 | # write new token to db 115 | entry = { 116 | "name": "App_Access", 117 | "token": token 118 | } 119 | engine.execute( 120 | insert(Tokens) 121 | .values(entry) 122 | ) 123 | 124 | 125 | # read app access token from db 126 | def get_app_access(self): 127 | result = engine.execute( 128 | select(Tokens.token) 129 | .where(Tokens.name=="App_Access") 130 | ).fetchone() 131 | token = result[0] 132 | return token 133 | 134 | 135 | # TODO: use longer sql statement to update if exists 136 | def set_user_access(self, token:str) -> None: 137 | # delete old token 138 | engine.execute( 139 | delete(Tokens) 140 | .where(Tokens.name=="User_Access") 141 | ) 142 | 143 | # set new token 144 | entry = { 145 | "name": "User_Access", 146 | "token": token 147 | } 148 | engine.execute( 149 | insert(Tokens) 150 | .values(entry) 151 | ) 152 | 153 | 154 | def get_user_access(self) -> str: 155 | result = engine.execute( 156 | select(Tokens.token) 157 | .where(Tokens.name=="User_Access") 158 | ).fetchone() 159 | token = result[0] 160 | return token 161 | 162 | 163 | # TODO: use longer sql statement to update if exists 164 | def set_refresh_token(self, token:str) -> None: 165 | # del old token 166 | engine.execute( 167 | delete(Tokens) 168 | .where(Tokens.name=="Refresh") 169 | ) 170 | 171 | # write new token 172 | entry = { 173 | "name": "Refresh", 174 | "token": token 175 | } 176 | engine.execute( 177 | insert(Tokens) 178 | .values(entry) 179 | ) 180 | 181 | 182 | def get_refresh_token(self) -> str: 183 | result = engine.execute( 184 | select(Tokens.token) 185 | .where(Tokens.name=="Refresh") 186 | ).fetchone() 187 | token = result[0] 188 | return token 189 | 190 | env = Environment() 191 | 192 | -------------------------------------------------------------------------------- /src/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Text, Integer, DateTime, Boolean 2 | from sqlalchemy.dialects.postgresql import UUID 3 | from datetime import datetime 4 | from database import Base 5 | 6 | class ChatMessages(Base): 7 | __tablename__ = "chat_messages" 8 | 9 | id_ = Column("id", Integer, primary_key=True) 10 | time = Column("time", DateTime, default=datetime.now()) 11 | username = Column("username", Text) 12 | user_id = Column("user_id", Text) 13 | message = Column("message", Text) 14 | 15 | def __init__(self): 16 | self.time = time 17 | self.username = username 18 | self.user_id = user_id 19 | self.message = message 20 | 21 | 22 | # TODO: separate table for user info based on ID 23 | # | user_id | anders14_ | follower or not | sub month | 24 | class Viewers(Base): 25 | __tablename__ = "viewers" 26 | 27 | id_ = Column("id", Integer, primary_key=True) 28 | username = Column("username", Text) 29 | display_name = Column("display_name", Text) 30 | is_follower = Column("is_follower", Boolean) 31 | follow_time = Column("follow_time", DateTime) 32 | banned = Column("banned", Boolean) 33 | banned_time = Column("banned_time", DateTime) 34 | 35 | def __init__(self): 36 | self.username == username 37 | self.display_name = display_name 38 | self.is_follower = is_follower 39 | self.follow_time = follow_time 40 | self.banned = banned 41 | self.banned_time = banned_time 42 | 43 | 44 | class CommandUse(Base): 45 | __tablename__ = "command_use" 46 | 47 | id_ = Column("id", Integer, primary_key=True) 48 | time = Column("time", DateTime, default=datetime.now()) 49 | user = Column("user", Text) 50 | command = Column("command", Text) 51 | is_custom = Column("is_custom", Integer) 52 | 53 | def __init__(self): 54 | self.time = time 55 | self.user = user 56 | self.command = command 57 | self.is_custom = is_custom 58 | 59 | 60 | class TextCommands(Base): 61 | __tablename__ = "text_commands" 62 | 63 | id_ = Column("id", Integer, primary_key=True) 64 | command = Column("command", Text) 65 | message = Column("message", Text) 66 | 67 | def __init__(self): 68 | self.command = command 69 | self.message = message 70 | 71 | 72 | class FalseCommands(Base): 73 | __tablename__ = "false_commands" 74 | 75 | id_ = Column("id", Integer, primary_key=True) 76 | time = Column("time", DateTime, default=datetime.now()) 77 | user = Column("user", Text) 78 | command = Column("command", Text) 79 | 80 | def __init__(self): 81 | self.time = time 82 | self.user = user 83 | self.command = command 84 | 85 | 86 | # single row to keep track of time bot was started 87 | class BotTime(Base): 88 | __tablename__ = "bot_time" 89 | 90 | id_ = Column("id", Integer, primary_key=True) 91 | uptime = Column("uptime", DateTime, default=datetime.now()) 92 | 93 | def __init__(self): 94 | self.uptime = uptime 95 | 96 | 97 | # stream uptime 98 | class StreamUptime(Base): 99 | __tablename__ = "stream_uptime" 100 | 101 | id_ = Column("id", Integer, primary_key=True) 102 | uptime = Column("uptime", DateTime, default=datetime.now()) 103 | 104 | def __init__(self): 105 | self.uptime = uptime 106 | 107 | 108 | # list of followers by user ID and follow time 109 | class Followers(Base): 110 | __tablename__ = "followers" 111 | 112 | user_id = Column("user_id", Integer, primary_key=True) 113 | follow_time = Column("follow_time", DateTime) 114 | username = Column("username", Text) 115 | last_seen = Column("last_seen", DateTime, default=datetime.now()) 116 | 117 | def __init__(self): 118 | self.user_id = user_id 119 | self.follow_time = follow_time 120 | self.username = username 121 | self.last_seen = last_seen 122 | 123 | 124 | class FeatureRequest(Base): 125 | __tablename__ = "feature_requests" 126 | 127 | id_ = Column("id", Integer, primary_key=True) 128 | time = Column("time", DateTime) 129 | user = Column("user", Text) 130 | message = Column("message", Text) 131 | 132 | def __init__(self): 133 | self.time = time 134 | self.user = user 135 | self.message = message 136 | 137 | 138 | class Tokens(Base): 139 | __tablename__ = "tokens" 140 | 141 | id_ = Column("id", Integer, primary_key=True) 142 | name = Column("name", Text, unique=True) 143 | token = Column("token", Text) 144 | 145 | def __init__(self): 146 | self.name = name 147 | self.token = token 148 | 149 | 150 | class Subscriptions(Base): 151 | __tablename__ = "subscriptions" 152 | 153 | id_ = Column("id", Integer, primary_key=True) 154 | sub_name = Column("sub_name", Text, unique=True) 155 | sub_id = Column("sub_id", Text, unique=True) 156 | sub_type = Column("sub_type", Text) 157 | 158 | def __init__(self): 159 | self.sub_name = sub_name 160 | self.sub_id = sub_id 161 | self.sub_type = sub_type 162 | 163 | 164 | # tracking viewership with view_tracker.py 165 | class Viewership(Base): 166 | __tablename__ = "viewership" 167 | 168 | id_ = Column("id", Integer, primary_key=True) 169 | time = Column("time", DateTime, default=datetime.now()) 170 | stream_id = Column("stream_id", Text) 171 | title = Column("title", Text) 172 | category_id = Column("game_id", Text) 173 | category = Column("game_name", Text) 174 | viewers = Column("viewer_count", Integer) 175 | 176 | def __init__(self): 177 | self.stream_id = stream_id 178 | self.title = title 179 | self.game_id = game_id 180 | self.game = game 181 | self.viewer_count = viewer_count 182 | 183 | 184 | class ChannelPointRewards(Base): 185 | __tablename__ = "cp_rewards" 186 | 187 | id_ = Column("id", Integer, primary_key=True) 188 | event_id = Column("event_id", UUID(as_uuid=True)) 189 | time = Column("redeemed_at", DateTime, default=datetime.now()) 190 | reward_id = Column("reward_id", UUID(as_uuid=True)) 191 | title = Column("title", Text) 192 | cost = Column("cost", Integer) 193 | user = Column("user", Text) 194 | 195 | def __init__(self): 196 | self.event_id = event_id 197 | self.time = time 198 | self.reward_id = reward_id 199 | self.title = title 200 | self.cost = cost 201 | self.user = user 202 | 203 | -------------------------------------------------------------------------------- /src/bot.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | import command 4 | from environment import env 5 | from datetime import datetime 6 | from sqlalchemy import insert, select 7 | from database import Session, Base, engine 8 | from models import ChatMessages, CommandUse, FalseCommands, BotTime, TextCommands 9 | 10 | session = Session() 11 | Base.metadata.create_all(bind=engine) 12 | 13 | def get_text_commands() -> dict: 14 | command_rows = engine.execute(select(TextCommands.command, TextCommands.message)) 15 | text_commands = {k:v for k,v in [e for e in command_rows]} 16 | return text_commands 17 | 18 | 19 | class Bot(): 20 | def __init__(self, server:str = env.irc_server, port:int = env.irc_port, oauth_token:str = env.oauth, 21 | bot_name:str = env.bot_name, channel:str = env.channel, user_id:str = env.user_id, 22 | client_id:str = env.client_id): 23 | self.server = server 24 | self.port = port 25 | self.oauth_token = oauth_token 26 | self.bot_name = bot_name 27 | self.channel = channel 28 | self.user_id = user_id 29 | self.client_id = client_id 30 | self.commands = {s.command_name: s for s in (c(self) for c in command.CommandBase.__subclasses__())} 31 | self.text_commands = get_text_commands() 32 | 33 | 34 | # connect to IRC server and begin checking for messages 35 | def connect_to_channel(self): 36 | self.irc = socket.socket() 37 | self.irc.connect((self.server, self.port)) 38 | self.irc_command(f"PASS oauth:{self.oauth_token}") 39 | self.irc_command(f"NICK {self.bot_name}") 40 | self.irc_command(f"JOIN #{self.channel}") 41 | self.irc_command(f"CAP REQ :twitch.tv/tags") 42 | self.send_message("I AM ALIVE!!") 43 | 44 | 45 | # execute IRC commands 46 | def irc_command(self, command: str): 47 | self.irc.send((command + "\r\n").encode()) 48 | 49 | 50 | # send privmsg's, which are normal chat messages 51 | def send_message(self, message: str): 52 | self.irc_command(f"PRIVMSG #{self.channel} :{message}") 53 | 54 | 55 | # main loop 56 | def check_for_messages(self): 57 | while True: 58 | messages = self.irc.recv(1024).decode() 59 | 60 | # respond to pings from Twitch 61 | if messages.startswith("PING"): 62 | self.irc_command("PONG :tmi.twitch.tv") 63 | continue 64 | 65 | for m in messages.split("\r\n"): 66 | self.parse_message(m) 67 | 68 | 69 | # check for command being executed 70 | def parse_message(self, message: str): 71 | try: 72 | if not message.startswith("PING :"): 73 | # regex pattern 74 | pat_message = re.compile( 75 | fr"badges=(?P[^;]*).*color=(?P[^;]*).*display-name=(?P[^;]*).*emotes=(?P[^;]*);.+user-id=(?P[\d]+).+:(?P[\d\w]+)![^:]+:(?P.*)", 76 | flags=re.IGNORECASE 77 | ) 78 | 79 | # get all message data as dict by group name 80 | message_data = pat_message.search(message).groupdict() 81 | 82 | # TODO: emote storage 83 | # emotes look like: 84 | # 86:0-9,11-20,22-31,33-42,44-53 85 | 86 | # convert badges string to list of badges 87 | badges = re.sub("/\d+,?", " ", message_data["badges"]).split() 88 | 89 | text = message_data["text"] 90 | user = message_data["username"] 91 | display_name = message_data["display_name"] 92 | chatter_id = message_data["user_id"] 93 | user_color = message_data["color"].lstrip("#") 94 | 95 | # set color of username for display in terminal 96 | default_color = (56, 146, 66) 97 | if not user_color: 98 | rgb = default_color 99 | else: 100 | # convert hex to RGB tuple 101 | rgb = tuple(int(user_color[i:i+2], 16) for i in (0,2,4)) 102 | 103 | # print colored chat message to terminal 104 | print(f"\033[38;2;{rgb[0]};{rgb[1]};{rgb[2]}m" + f"{display_name}" + "\033[38;2;255;255;255m", f"{text}\n") 105 | 106 | # check for commands being used 107 | if text.startswith("!"): 108 | command = text.split()[0].lower() 109 | if command not in self.text_commands and command not in self.commands: 110 | self.store_wrong_command(user, command) 111 | else: 112 | self.execute_command(user, command, text, badges) 113 | self.store_message_data(user, chatter_id, text) 114 | 115 | except AttributeError: 116 | pass 117 | 118 | 119 | # store data on commands attempted that don't exist 120 | def store_wrong_command(self, user: str, command: str): 121 | entry = { 122 | "user" : user, 123 | "command" : command 124 | } 125 | 126 | engine.execute( 127 | insert(FalseCommands) 128 | .values(entry) 129 | ) 130 | 131 | 132 | # insert data to db 133 | def store_message_data(self, user: str, user_id: str, message: str) -> None: 134 | entry = { 135 | "username" : user, 136 | "user_id" : user_id, 137 | "message" : message 138 | } 139 | 140 | engine.execute( 141 | insert(ChatMessages) 142 | .values(entry) 143 | ) 144 | 145 | 146 | # insert data to SQLite db 147 | def store_command_data(self, user: str, command: str, is_custom: int): 148 | entry = { 149 | "user" : user, 150 | "command" : command, 151 | "is_custom" : is_custom 152 | } 153 | engine.execute( 154 | insert(CommandUse) 155 | .values(entry) 156 | ) 157 | 158 | 159 | # execute each command 160 | def execute_command(self, user: str, command: str, message: str, badges: list): 161 | # execute hard-coded command 162 | if command in self.commands.keys(): 163 | self.commands[command].execute(user, message, badges) 164 | is_custom_command = 0 165 | self.store_command_data(user, command, is_custom_command) 166 | 167 | # refresh text commands dict if admin command used 168 | if self.commands[command].restricted: 169 | self.text_commands = self.reload_text_commands() 170 | 171 | # execute custom text commands 172 | elif command in self.text_commands.keys(): 173 | self.send_message( 174 | self.text_commands[command] 175 | ) 176 | is_custom_command = 1 177 | self.store_command_data(user, command, is_custom_command) 178 | 179 | 180 | def reload_text_commands(self): 181 | stmt = select( 182 | TextCommands.command, 183 | TextCommands.message 184 | ) 185 | commands = {k:v for k,v in [e for e in engine.execute(stmt)]} 186 | return commands 187 | 188 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import requests 4 | import hashlib 5 | import webbrowser 6 | import urllib.parse 7 | from uuid import UUID 8 | from environment import env 9 | from bot import Bot 10 | from flask import Flask, Response, make_response 11 | from flask import request as flask_request 12 | from database import engine, Base 13 | from sqlalchemy import insert, update 14 | from models import Subscriptions, ChannelPointRewards 15 | 16 | SUB_URL = "https://api.twitch.tv/helix/eventsub/subscriptions" 17 | CALLBACK = env.callback_address 18 | HOST = "localhost" 19 | PORT = "5000" 20 | LOCAL_ADDRESS = f"https://{HOST}:{PORT}" 21 | SECRET = "abc1234def" 22 | 23 | Base.metadata.create_all(bind=engine) 24 | app = Flask(__name__) 25 | 26 | # chat bot for sending messages 27 | bot = Bot( 28 | env.irc_server, 29 | env.irc_port, 30 | env.oauth, 31 | env.bot_name, 32 | env.channel, 33 | env.user_id, 34 | env.client_id 35 | ) 36 | bot.connect_to_channel() 37 | 38 | 39 | # TODO: values aren't matching expected 40 | # get sha256 value from headers 41 | def get_secret(headers:list) -> str: 42 | message_id = headers["Twitch-Eventsub-Message-Id"] 43 | timestamp = headers["Twitch-Eventsub-Message-Timestamp"] 44 | body = flask_request.data 45 | 46 | # concatenate different headers to pass into sha256 per Twitch documentation 47 | hmac_message = message_id + timestamp + body.decode("utf-8") 48 | result = hmac_message.encode(encoding="UTF-8", errors="strict") 49 | 50 | # get output of sha256 51 | signature = hashlib.sha256(result) 52 | 53 | # twitch demands this concatenation for validation 54 | return "sha256=" + signature.hexdigest() 55 | 56 | 57 | # check if header is valid 58 | def validate_headers(headers:dict) -> bool: 59 | signature = get_secret(headers) 60 | expected = headers["Twitch-Eventsub-Message-Signature"] 61 | 62 | # check for matching sha256 values 63 | print("SIGNATURE CHECK") 64 | if signature == expected: 65 | print("signature is valid") 66 | else: 67 | print("signature is invalid") 68 | return signature == expected 69 | 70 | 71 | # TODO: re-establish user access with correct scopes 72 | def request_user_auth(env=env): 73 | url = "https://id.twitch.tv/oauth2/authorize" 74 | 75 | # create appropriate url for authorizing permissions 76 | params = { 77 | "client_id": env.client_id, 78 | "redirect_uri": f"{LOCAL_ADDRESS}/authorize", 79 | "response_type": "code", 80 | "scope": " ".join(env.scopes), 81 | "force_verify": "true" 82 | } 83 | get_url = url + "?" + urllib.parse.urlencode(params) 84 | 85 | # open browser window for user to accept required permissions 86 | webbrowser.open(get_url) 87 | 88 | 89 | # write subscription parameters to the db 90 | def store_sub_info(sub_name:str, sub_id:str, sub_type:str) -> None: 91 | entry = { 92 | "sub_name": sub_name, 93 | "sub_id": sub_id, 94 | "sub_type": sub_type 95 | } 96 | engine.execute( 97 | insert(Subscriptions) 98 | .values(entry) 99 | ) 100 | 101 | 102 | # create a new eventsub subscription 103 | def create_subscription(callback:str, type_:str, env=env, content_type="application/json", 104 | url=SUB_URL, secret=SECRET) -> dict: 105 | headers = { 106 | "Client-ID": env.client_id, 107 | "Authorization": f"Bearer {env.get_app_access()}", 108 | "Content-Type": content_type 109 | } 110 | data = { 111 | "type": type_, 112 | "version": "1", 113 | "condition": {"broadcaster_user_id": str(env.user_id)}, 114 | "transport": { 115 | "method": "webhook", 116 | "callback": callback, 117 | "secret": secret 118 | } 119 | } 120 | response = requests.post(url, headers=headers, data=json.dumps(data)) 121 | print("SUBSCRIPTION REQUEST RESULT") 122 | print(response.json()) 123 | return response.json() 124 | 125 | 126 | # delete an active subscription by id 127 | def delete_subscription(sub_id:str, env=env, url=SUB_URL) -> None: 128 | headers = { 129 | "Client-ID": env.client_id, 130 | "Authorization": f"Bearer {env.get_app_access()}" 131 | } 132 | params = {"id": sub_id} 133 | requests.delete(url=url, headers=headers, params=params) 134 | 135 | 136 | # list active subscriptions 137 | def get_subscriptions(env=env, url=SUB_URL) -> dict: 138 | headers = { 139 | "Client-ID": env.client_id, 140 | "Authorization": f"Bearer {env.get_app_access()}" 141 | } 142 | response = requests.get(url=url, headers=headers) 143 | data = response.json() 144 | subs = data["data"] 145 | 146 | # key-values pair of sub types -> sub id 147 | result = {} 148 | for s in subs: 149 | result[s["type"]] = s["id"] 150 | return result 151 | 152 | 153 | def refresh_user_access(env=env) -> None: 154 | base_url = "https://id.twitch.tv/oauth2/token" 155 | params = { 156 | "client_id": env.client_id, 157 | "client_secret": env.client_secret, 158 | "grant_type": "refresh_token", 159 | "refresh_token": env.get_refresh_token() 160 | } 161 | if scopes: 162 | params["scopes"] = " ".join(env.scopes) 163 | url = base_url + "?" + urllib.parse.urlencode(params) 164 | result = requests.post(url) 165 | 166 | 167 | # reply to Twitch's challenge when creating subscription 168 | def challenge_reply(payload): 169 | challenge = payload["challenge"] 170 | response = make_response(challenge, 200) 171 | response.mimetype = "text/plain" 172 | return response 173 | 174 | 175 | # default route 176 | @app.route("/") 177 | def hello_chat(): 178 | request_user_auth() 179 | subs = get_subscriptions() 180 | print(subs) 181 | desired_subs = { 182 | "channel.follow": CALLBACK+"/event/new_follower", 183 | "channel.update": CALLBACK+"/event/stream_info_update", 184 | "stream.online": CALLBACK+"/event/stream_online", 185 | "stream.offline": CALLBACK+"/event/stream_offline", 186 | "channel.channel_points_custom_reward_redemption.add": CALLBACK+"/event/cp_redemption" 187 | } 188 | 189 | # for sub in subs: 190 | # delete_subscription(subs[sub]) 191 | 192 | # create subs that are missing 193 | for sub in desired_subs: 194 | if sub not in subs: 195 | create_subscription(desired_subs[sub], sub) 196 | 197 | subs = get_subscriptions() 198 | print("LIST OF CURRENT SUBSCRIPTIONS") 199 | print([k for k,v in subs.items()]) 200 | return Response(status=200) 201 | 202 | 203 | # desperate attempt at authorizing 204 | @app.route("/authorize", methods=["GET", "POST"]) 205 | def authorize(): 206 | # get code from Twitch's POST request 207 | code = flask_request.args["code"] 208 | print("AUTH CODE:\n" + code) 209 | 210 | # get user access token using the above code 211 | url = "https://id.twitch.tv/oauth2/token" 212 | params = { 213 | "client_id": env.client_id, 214 | "client_secret": env.client_secret, 215 | "code": code, 216 | "grant_type": "authorization_code", 217 | "redirect_uri": f"{LOCAL_ADDRESS}/authorize" 218 | } 219 | response = requests.post(url=url, params=params) 220 | 221 | data = response.json() 222 | 223 | user_access = data["access_token"] 224 | refresh_token = data["refresh_token"] 225 | 226 | # write user access token to DB 227 | env.set_user_access(user_access) 228 | print("USER ACCESS TOKEN WRITTEN") 229 | 230 | # write refresh token 231 | env.set_refresh_token(refresh_token) 232 | print("REFRESH TOKEN WRITTEN") 233 | 234 | return Response(status=200) 235 | 236 | 237 | # in the event of channel point redemption 238 | @app.route("/event/cp_redemption", methods=["POST"]) 239 | def handle_cp(): 240 | print("CHANNEL POINT URL CALLED") 241 | headers = flask_request.headers 242 | message_type = headers["Twitch-Eventsub-Message-Type"] 243 | payload = flask_request.json 244 | 245 | # if callback is being used for validating a new subscription 246 | if message_type == "webhook_callback_verification": 247 | return challenge_reply(payload) 248 | 249 | elif message_type == "notification": 250 | # handle new channel point redemption 251 | event = payload["event"] 252 | reward = event["reward"] 253 | 254 | # write cp data to db 255 | entry = { 256 | "event_id": UUID(event["id"]), 257 | "reward_id": UUID(reward["id"]), 258 | "title": reward["title"], 259 | "cost": reward["cost"], 260 | "user": event["user_name"] 261 | } 262 | 263 | engine.execute( 264 | insert(ChannelPointRewards) 265 | .values(entry) 266 | ) 267 | 268 | else: 269 | print(flask_request.json) 270 | 271 | return Response(status=200) 272 | 273 | 274 | # new follower function 275 | @app.route("/event/new_follower", methods=["POST"]) 276 | def handle_follower(): 277 | print("FOLLOWER URL USED") 278 | headers = flask_request.headers 279 | message_type = headers["Twitch-Eventsub-Message-Type"] 280 | payload = flask_request.json 281 | 282 | # if callback is being used for validating a new subscription 283 | if message_type == "webhook_callback_verification": 284 | return challenge_reply(payload) 285 | 286 | # if message is a follower notification 287 | elif message_type == "notification": 288 | event = payload["event"] 289 | user = event["user_name"] 290 | bot.send_message(f"Welcome aboard, {user}!") 291 | 292 | else: 293 | print(flask_request.json) 294 | 295 | return Response(status=200) 296 | 297 | 298 | # stream info changes 299 | @app.route("/event/stream_info_update", methods=["POST"]) 300 | def handle_stream_info_update(): 301 | print("STREAM UPDATE LINK USED") 302 | headers = flask_request.headers 303 | message_type = headers["Twitch-Eventsub-Message-Type"] 304 | payload = flask_request.json 305 | 306 | if message_type == "webhook_callback_verification": 307 | # validate with challenge from Twitch 308 | return challenge_reply(payload) 309 | 310 | elif message_type == "notification": 311 | event = payload["event"] 312 | title = event["title"] 313 | 314 | print(f"The new title of the stream is:\n{title}") 315 | 316 | else: 317 | print(flask_request.json) 318 | 319 | return Response(status=200) 320 | 321 | 322 | # stream goes online 323 | @app.route("/event/stream_online", methods=["POST"]) 324 | def handle_stream_online(): 325 | headers = flask_request.headers 326 | message_type = headers["Twitch-Eventsub-Message-Type"] 327 | payload = flask_request.json 328 | 329 | if message_type == "webhook_callback_verification": 330 | return challenge_reply(payload) 331 | 332 | elif message_type == "notification": 333 | event = payload["event"] 334 | engine.execute(insert(StreamUptime)) 335 | 336 | else: 337 | print(flask_request.json) 338 | 339 | return Response(status=200) 340 | 341 | 342 | # stream goes offline 343 | @app.route("/event/stream_offline", methods=["POST"]) 344 | def handle_stream_offline(): 345 | headers = flask_request.headers 346 | message_type = headers["Twitch-Eventsub-Message-Type"] 347 | payload = flask_request.json 348 | 349 | if message_type == "webhook_callback_verification": 350 | return challenge_reply(payload) 351 | 352 | elif message_type == "notification": 353 | pass 354 | 355 | else: 356 | print(flask_request.json) 357 | 358 | return Response(status=200) 359 | 360 | 361 | # run app 362 | if __name__ == "__main__": 363 | app.run(debug=False, ssl_context="adhoc", host=HOST, port=PORT) 364 | 365 | -------------------------------------------------------------------------------- /src/command.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import re 3 | import random 4 | import json 5 | from datetime import datetime 6 | from dateutil import relativedelta 7 | from abc import ABC, abstractmethod 8 | from sqlalchemy import select, insert, delete, update, func 9 | from database import engine, Session, Base 10 | from models import BotTime, Followers, TextCommands, ChatMessages, CommandUse, FeatureRequest, StreamUptime 11 | from environment import env 12 | 13 | Base.metadata.create_all(bind=engine) 14 | session = Session() 15 | 16 | class CommandBase(ABC): 17 | def __init__(self, bot): 18 | self.bot = bot 19 | 20 | 21 | @property 22 | @abstractmethod 23 | def command_name(self): 24 | raise NotImplementedError 25 | 26 | 27 | @property 28 | def restricted(self): 29 | return False 30 | 31 | 32 | @abstractmethod 33 | def execute(self): 34 | raise NotImplementedError 35 | 36 | 37 | def __repr__(self): 38 | return self.command_name 39 | 40 | 41 | def get_commands(self): 42 | # get all text commands 43 | result = engine.execute(select(TextCommands.command)).fetchall() 44 | text_commands = [c[0] for c in result] 45 | return [*text_commands, *self.bot.commands] 46 | 47 | 48 | def get_command_users(self, command): 49 | # query database for number of times each user used a given command 50 | result = engine.execute( 51 | select(CommandUse.user) 52 | .where(CommandUse.command == command) 53 | .group_by(CommandUse.user) 54 | .order_by(func.count(CommandUse.user).desc()) 55 | ) 56 | return [u[0] for u in result] 57 | 58 | 59 | def get_top_chatters(self): 60 | # get count of unique chatters from chat_messages table 61 | result = engine.execute( 62 | select(ChatMessages.username) 63 | .group_by(ChatMessages.username) 64 | .order_by(func.count(ChatMessages.username).desc()) 65 | ) 66 | return [u[0] for u in result] 67 | 68 | 69 | def get_timedelta_message(self, uptime, message_base, error_message) -> str: 70 | now = datetime.now() 71 | 72 | # get timedelta 73 | delta = relativedelta.relativedelta(now, uptime) 74 | uptime_stats = { 75 | "year": delta.years, 76 | "month": delta.months, 77 | "day": delta.days, 78 | "hour": delta.hours, 79 | "minute": delta.minutes 80 | } 81 | 82 | # send specific message if bot has been alive for under a minute 83 | if all(v==0 for v in uptime_stats.values()): 84 | return error_message 85 | 86 | # build output message 87 | message = message_base 88 | for k,v in uptime_stats.items(): 89 | if v > 0: 90 | message += f" {v} {k}" 91 | if v > 1: 92 | message += "s" 93 | message += "!" 94 | return message 95 | 96 | 97 | class AddCommand(CommandBase): 98 | @property 99 | def command_name(self): 100 | return "!addcommand" 101 | 102 | @property 103 | def restricted(self): 104 | return True 105 | 106 | def execute(self, user, message, badges): 107 | # only mods can run this command 108 | if "moderator" in badges or "broadcaster" in badges: 109 | first_word = message.split()[1].lower() 110 | 111 | # check for invalid characters in command name 112 | if re.match(r"[^a-zA-Z\d]", first_word): 113 | self.bot.send_message(f"That command name contains invalid characters, {user}.") 114 | return 115 | 116 | command = first_word if first_word.startswith("!") else "!" + first_word 117 | result = " ".join(message.split()[2:]) 118 | 119 | # check for missing command output 120 | if len(result) == 0: 121 | self.bot.send_message(f"Every command needs text, {user}.") 122 | return 123 | 124 | # check for duplicate command 125 | if command in self.bot.text_commands.keys(): 126 | self.bot.send_message(f"That command already exists, {user}.") 127 | return 128 | 129 | entry = {"command":command, "message":result} 130 | engine.execute( 131 | insert(TextCommands) 132 | .values(entry) 133 | ) 134 | 135 | self.bot.send_message(f"{command} added successfully!") 136 | 137 | 138 | class DeleteCommand(CommandBase): 139 | @property 140 | def command_name(self): 141 | return "!delcommand" 142 | 143 | @property 144 | def restricted(self): 145 | return True 146 | 147 | def execute(self, user, message, badges): 148 | # only mods can run this command 149 | if "moderator" in badges or "broadcaster" in badges: 150 | try: 151 | first_word = message.split()[1] 152 | except IndexError: 153 | self.bot.send_message("You didn't select a command to delete!") 154 | return 155 | 156 | command = first_word if first_word.startswith("!") else "!" + first_word 157 | 158 | # select all commands from TextCommands table 159 | result = engine.execute(select(TextCommands.command)).fetchall() 160 | current_commands = [c[0] for c in result] 161 | 162 | if command not in current_commands: 163 | self.bot.send_message(f"The {command} command doesn't exist, {user}.") 164 | return 165 | 166 | entry = {"command": command} 167 | 168 | engine.execute( 169 | delete(TextCommands) 170 | .where(TextCommands.command == command) 171 | ) 172 | 173 | self.bot.send_message(f"{command} command deleted!") 174 | 175 | 176 | # edit existing text command 177 | class EditCommand(CommandBase): 178 | @property 179 | def command_name(self): 180 | return "!editcommand" 181 | 182 | @property 183 | def restricted(self): 184 | return True 185 | 186 | def execute(self, user, message, badges): 187 | # only mods and streamer can run this command 188 | if "moderator" in badges or "broadcaster" in badges: 189 | first_word = message.split()[1] 190 | command = first_word if first_word.startswith("!") else "!" + first_word 191 | 192 | result = engine.execute(select(TextCommands.command)).fetchall() 193 | current_commands = [c[0] for c in result] 194 | 195 | if command not in current_commands: 196 | self.bot.send_message(f"That command doesn't exist, {user}.") 197 | return 198 | 199 | new_message = " ".join(message.split()[2:]) 200 | 201 | # edit the message for a given command 202 | engine.execute( 203 | update(TextCommands) 204 | .where(TextCommands.command == command) 205 | .values(message=new_message) 206 | ) 207 | 208 | self.bot.send_message(f"{command} command edit complete!") 209 | 210 | 211 | # check joke API for joke of length that fits in a chat message 212 | class JokeCommand(CommandBase): 213 | @property 214 | def command_name(self): 215 | return "!joke" 216 | 217 | 218 | def execute(self, user, message, badges): 219 | max_message_len = 500 220 | url = "https://icanhazdadjoke.com/" 221 | headers = {"accept" : "application/json"} 222 | for _ in range(10): 223 | result = requests.get(url, headers = headers).json() 224 | joke = result["joke"] 225 | if len(joke) <= max_message_len: 226 | self.bot.send_message(joke) 227 | return 228 | 229 | self.bot.send_message(f"I'm sorry! I couldn't find a short enough joke. :(") 230 | 231 | 232 | class PoemCommand(CommandBase): 233 | @property 234 | def command_name(self): 235 | return "!poem" 236 | 237 | 238 | def execute(self, user, message, badges): 239 | num_lines = 4 240 | url = f"https://poetrydb.org/linecount/{num_lines}/lines" 241 | result = requests.get(url) 242 | poems = json.loads(result.text) 243 | num_poems = len(poems) 244 | for _ in range(5): 245 | idx = random.randint(0, num_poems) 246 | lines = poems[idx]["lines"] 247 | poem = "; ".join(lines) 248 | if len(poem) <= 500: 249 | self.bot.send_message(poem) 250 | return 251 | 252 | self.bot.send_message(f"@{user}, I couldn't find a short enough poem. I'm sorry. :(") 253 | 254 | 255 | class CommandsCommand(CommandBase): 256 | @property 257 | def command_name(self): 258 | return "!commands" 259 | 260 | 261 | def execute(self, user, message, badges): 262 | result = engine.execute(select(TextCommands.command)).fetchall() 263 | subclasses = (s(self) for s in CommandBase.__subclasses__()) 264 | text_commands = [c[0] for c in result] 265 | hard_commands = [c.command_name for c in subclasses if not c.restricted] 266 | 267 | commands_str = ", ".join(text_commands) + ", " + ", ".join(hard_commands) 268 | 269 | # check if commands fit in chat; dropping 270 | while len(commands_str) > 500: 271 | commands = commands_str.split() 272 | commands = commands[:-2] 273 | commands_str = " ".join(commands) 274 | 275 | self.bot.send_message(commands_str) 276 | 277 | 278 | # TODO: fill follower table with new script, update with eventsub 279 | #class FollowAgeCommand(CommandBase): 280 | # @property 281 | # def command_name(self): 282 | # return "!followage" 283 | # 284 | # 285 | # def execute(self, user, message, badges): 286 | # if len(message.split()) > 1: 287 | # user = message.split()[1].strip("@").lower() 288 | # 289 | # # get user's follow time 290 | # user_entry = engine.execute( 291 | # select(Followers.time) 292 | # .where(Followers.username == user) 293 | # ).fetchone() 294 | # 295 | # follow_time = user_entry[0] 296 | # 297 | # # current time 298 | # now = datetime.now() 299 | # 300 | # # get time delta 301 | # delta = relativedelta.relativedelta(now, follow_time) 302 | # follow_stats = { 303 | # "year": delta.years, 304 | # "month": delta.months, 305 | # "day": delta.days, 306 | # "hour": delta.hours, 307 | # "minute": delta.minutes 308 | # } 309 | # 310 | # # create message 311 | # f"{user} has been following for" 312 | # for k,v in follow_stats.items(): 313 | # if v > 0: 314 | # message += f" {v} {k}" 315 | # if v > 1: 316 | # message += "s" 317 | # message += "!" 318 | # 319 | # # send message 320 | # self.bot.send_message( 321 | # 322 | # message 323 | # ) 324 | 325 | 326 | class BotTimeCommand(CommandBase): 327 | @property 328 | def command_name(self): 329 | return "!bottime" 330 | 331 | 332 | def execute(self, user, message, badges): 333 | # get most recent uptime 334 | result = engine.execute( 335 | select(BotTime.uptime) 336 | .order_by(BotTime.uptime.desc()) 337 | ).fetchone() 338 | 339 | uptime = result[0] 340 | message_base = "I have been alive for" 341 | error_message = "Give me a minute, I just woke up!" 342 | 343 | # create message from time delta 344 | message = self.get_timedelta_message(uptime, message_base, error_message) 345 | 346 | self.bot.send_message(message) 347 | 348 | 349 | class RankCommand(CommandBase): 350 | @property 351 | def command_name(self): 352 | return "!rank" 353 | 354 | 355 | def execute(self, user, message, badges): 356 | if len(message.split()) > 1: 357 | command = message.split()[1] 358 | 359 | # command use rank 360 | if not command.startswith("!"): 361 | command = f"!{command}" 362 | 363 | commands = self.get_commands() 364 | 365 | if command not in commands: 366 | self.bot.send_message(f"I don't have a {command} command! Sorry!") 367 | return 368 | 369 | # query database for number of times each user used a given command 370 | users = self.get_command_users(command) 371 | 372 | try: 373 | user_rank = users.index(user) + 1 374 | except ValueError: 375 | self.bot.send_message( 376 | f"{user}, you haven't used that command since I've been listening. Sorry!" 377 | ) 378 | return 379 | 380 | message = f"{user}, you are the number {user_rank} user of the {command} command out of {len(users)} users." 381 | self.bot.send_message(message) 382 | 383 | else: 384 | chatters = self.get_top_chatters() 385 | 386 | try: 387 | # find rank of a given user 388 | user_rank = chatters.index(user) + 1 389 | 390 | # send the rank in chat 391 | message = f"{user}, you are number {user_rank} out of {len(chatters)} chatters!" 392 | self.bot.send_message(message) 393 | 394 | except ValueError: 395 | self.bot.send_message(f"{user}, I don't have you on my list. This is awkward...") 396 | 397 | 398 | class FeatureRequestCommand(CommandBase): 399 | @property 400 | def command_name(self): 401 | return "!featurerequest" 402 | 403 | 404 | def execute(self, user, message, badges): 405 | entry = { 406 | "user": user, 407 | "message": " ".join(message.split()[1:]) 408 | } 409 | engine.execute( 410 | insert(FeatureRequest) 411 | .values(entry) 412 | ) 413 | 414 | self.bot.send_message(f"Got it! Thanks for your help, {user}!") 415 | 416 | 417 | class LurkCommand(CommandBase): 418 | @property 419 | def command_name(self): 420 | return "!lurk" 421 | 422 | 423 | def execute(self, user, message, badges): 424 | self.bot.send_message(f"Don't worry {user}, we got mad love for the lurkers! <3") 425 | 426 | 427 | class ShoutoutCommand(CommandBase): 428 | @property 429 | def command_name(self): 430 | return "!so" 431 | 432 | 433 | def execute(self, user, message, badges): 434 | # check if user shouting out no one 435 | if len(message.split()) < 2: 436 | self.bot.send_message(f"I can't shoutout no one, {user}!") 437 | 438 | # if shouting someone 439 | else: 440 | so_user = message.split()[1].strip("@") 441 | 442 | # correct for users trying to shout themselves out 443 | if user.lower() == so_user.lower(): 444 | self.bot.send_message(f"You can't shoutout yourself, {user}!") 445 | return 446 | 447 | # api only returns users that have streamed in the past six months 448 | url = f"https://api.twitch.tv/helix/search/channels?query={so_user}" 449 | headers = { 450 | "client-id" : env.client_id, 451 | "authorization" : f"Bearer {env.get_bearer()}" 452 | } 453 | 454 | response = requests.get(url, headers=headers) 455 | data = json.loads(response.content)["data"][0] 456 | so_display_name = data["display_name"] 457 | so_login = data["broadcaster_login"] 458 | 459 | # validates that user is real 460 | # TODO: can't find absenth762 specifically 461 | if so_user.lower() == so_login: 462 | so_url = f"https://twitch.tv/{so_login}" 463 | self.bot.send_message(f"Shoutout to {so_display_name}! Check them out here! {so_url}") 464 | 465 | # user could not exist or not have streamed in 6 months 466 | else: 467 | self.bot.send_message(f"{so_user} isn't a frequent streamer, {user}.") 468 | 469 | 470 | # TODO: !leaderboard command 471 | class LeaderboardCommand(CommandBase): 472 | @property 473 | def command_name(self): 474 | return "!leaderboard" 475 | 476 | 477 | def execute(self, user, message, badges): 478 | if len(message.split()) > 1: 479 | # command-specific leaderboard 480 | command = message.split()[1] 481 | if not command.startswith("!"): 482 | command = "!"+command 483 | 484 | commands = self.get_commands() 485 | if command not in commands: 486 | self.bot.send_message(f"Sorry {user}, that command doesn't exist!") 487 | return 488 | 489 | users = self.get_command_users(command) 490 | 491 | else: 492 | users = self.get_top_chatters() 493 | 494 | top_n = 5 495 | leaders = users[:top_n] 496 | message_ranks = [f"{i}. {user}" for i,user in enumerate(leaders, start=1)] 497 | 498 | self.bot.send_message(", ".join(message_ranks)) 499 | 500 | 501 | class AliasCommand(CommandBase): 502 | @property 503 | def command_name(self): 504 | return "!clone" 505 | 506 | @property 507 | def restricted(self): 508 | return True 509 | 510 | 511 | # this function adds an alias to the text_commands table 512 | def add_alias(self, command, alias): 513 | entry = { 514 | "command": alias, 515 | "message": self.bot.text_commands[command] 516 | } 517 | engine.execute( 518 | insert(TextCommands) 519 | .values(entry) 520 | ) 521 | 522 | 523 | def execute(self, user, message, badges): 524 | if "moderator" in badges or "broadcaster" in badges: 525 | params = message.split() 526 | # correct if user doesn't pass enough parameters 527 | if len(params) < 3: 528 | self.bot.send_message( 529 | f"You didn't give me enough direction, {user}. I am now lost in this world. :(" 530 | ) 531 | return 532 | 533 | else: 534 | # set commands to be aliases of one another 535 | command1 = params[1] if params[1].startswith("!") else f"!{params[1]}" 536 | command2 = params[2] if params[2].startswith("!") else f"!{params[2]}" 537 | 538 | 539 | if command1 in self.bot.text_commands: 540 | self.add_alias(command1, command2) 541 | 542 | elif command2 in self.bot.text_commands: 543 | self.add_alias(command2, command1) 544 | 545 | # if neither command is a text command 546 | else: 547 | self.bot.send_message(f"I don't have those commands, {user}. Sorry!") 548 | return 549 | 550 | self.bot.send_message("Clone created!") 551 | 552 | 553 | # fun fact command 554 | class FactCommand(CommandBase): 555 | @property 556 | def command_name(self): 557 | return "!funfact" 558 | 559 | 560 | def execute(self, user, message, badges): 561 | url = "https://uselessfacts.jsph.pl/random.json?language=en" 562 | response = requests.get(url).json() 563 | fact = response["text"] 564 | 565 | # check that fact fits in a chat message 566 | while len(fact) > 450: 567 | response =requests.get(url).json() 568 | fact = response["text"] 569 | 570 | self.bot.send_message(f"FUN FACT: {fact}") 571 | 572 | 573 | # number fact command 574 | class YearCommand(CommandBase): 575 | @property 576 | def command_name(self): 577 | return "!year" 578 | 579 | 580 | def execute(self, user, message, badges): 581 | words = message.split() 582 | if len(words) < 2: 583 | self.bot.send_message(f"I need a year to check, {user}.") 584 | return 585 | 586 | else: 587 | # get user's year choice 588 | year = words[1] 589 | 590 | # get fact from api 591 | url = f"http://numbersapi.com/{year}/year" 592 | fact = requests.get(url).text 593 | 594 | # send fact in chat 595 | self.bot.send_message(fact) 596 | 597 | 598 | class UptimeCommand(CommandBase): 599 | @property 600 | def command_name(self): 601 | return "!uptime" 602 | 603 | 604 | def execute(self, user, message, badges): 605 | result = engine.execute( 606 | select(StreamUptime.uptime) 607 | .order_by(StreamUptime.uptime.desc()) 608 | ).fetchone() 609 | 610 | try: 611 | uptime = result[0] 612 | message_base = "Stream has been live for" 613 | error_message = "The stream isn't online...yet!" 614 | 615 | message = self.get_timedelta_message(uptime, message_base, error_message) 616 | self.bot.send_message(message) 617 | 618 | except TypeError: 619 | self.bot.send_message("I don't track stream uptimes yet!") 620 | 621 | --------------------------------------------------------------------------------