From b41723609a279c71c5be3df2e09c82b4bbcbd2b9 Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 10:23:18 +0200 Subject: [PATCH 01/13] Return early after !ping --- tts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tts.py b/tts.py index 8ab5412..ceccd82 100644 --- a/tts.py +++ b/tts.py @@ -174,6 +174,8 @@ class IRC: logging.debug('Ping check received.') self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), "Pong!") + return True + if msg.startswith('!ptts'): logging.debug("!ptts command detected") user = msg.replace('!ptts', '').strip().lower() From a65a2dcccb24e92fc05d878945263e3cafc75018 Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 11:49:07 +0200 Subject: [PATCH 02/13] Added !quickvote feature --- CHANGELOG.md | 15 +++++++++--- README.md | 12 ++++++++++ config-dist.yml | 4 ++++ tts.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 231b54c..4682217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,21 @@ All notable changes to this project will be documented in this file. -## [1.0.0] - 2022-08-11 - -Initial Release +## [1.1.0] - unreleased ### Added + * `!quickvote` feature (see README.md for details) + * `!ping` command added + ### Changed + * The bot replies with a chat message when `!ton` or `!toff` is used + ### Fixed + + * Improved error handling + +## [1.0.0] - 2022-08-11 + +Initial Release diff --git a/README.md b/README.md index 753c936..e8fe4b9 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ messages: whitelist: "Sorry, you are not allowed to use TTS." ready: "TTS bot alpha ready!" says: "says" + votestart: "Quickvote started. Send #yourchoice to participate." + voteend: "Quickvote ended. The results are:" + votenobody: "Nobody casted a vote. :(" + votes: "Votes" log: level: "INFO" @@ -100,6 +104,10 @@ whitelist: * `whitelist`: The bots reply if `whitelist` is set and user isn't on the list. * `ready`: The bots init message * `says`: Prefix to add between username and message + * `votestart`: Message when a quickvote is started. + * `voteend`: Message if a quickvote ends. + * `votenobody`: Message if quickvote ends, but nobody has voted. + * `votes`: Suffix to vote count. ##### log @@ -144,6 +152,10 @@ Additional commands (broadcaster and mods only) are: * `!dtts `: Disable TTS for the given user * `!ptts `: Allow TTS for the given user +### Additional features + +The bot also contains a `!quickvote` feature. If a broadcaster or moderator send the `!quickvote` command a vote will be started (or a already running vote will be ended). After a quickvote has been started your community can casts votes by sending a chat message starting with `#`. You can include a message after `!quickvote` (e.g. `!quickvote Is pizza hawaii any good? #yes/#no`). If you do so, this message will be repeated every 60 seconds, so everyone keeps in mind, that a vote is still active. + ## Build If you prefer to build your own `tts.exe` instead of using the shipped one, you can do as follows: diff --git a/config-dist.yml b/config-dist.yml index 1929c0d..a83fc22 100644 --- a/config-dist.yml +++ b/config-dist.yml @@ -23,6 +23,10 @@ messages: modonly: "Sorry, TTS is a mod-only feature." ready: "TTS bot alpha ready!" says: "says" + votestart: "Quickvote started. Send #yourchoice to participate." + voteend: "Quickvote ended. The results are:" + votenobody: "Nobody casted a vote. :(" + votes: "Votes" log: level: "INFO" diff --git a/tts.py b/tts.py index ceccd82..cb6c4f9 100644 --- a/tts.py +++ b/tts.py @@ -31,6 +31,7 @@ import socketserver from threading import Thread from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs +from collections import Counter class IRC: irc = socket.socket() @@ -40,6 +41,10 @@ class IRC: self.tts_denied = [] self.tts_allowed = [] self.tts_status = True + self.quickvote = False + self.votemsg = False + self.poll = {} + self.pollcount = 0 if 'WHITELIST_USER' in conf: self.tts_allowed = conf['WHITELIST_USER'] @@ -63,9 +68,9 @@ class IRC: try: self.irc.send(bytes("JOIN " + channel + "\r\n", "UTF-8")) except ConnectionResetError: - logging.warn('JOIN was refused, will try again in 5 seconds.') + logging.warning('JOIN was refused, will try again in 5 seconds.') time.sleep(5) - logging.warn('Please check your credentials, if this error persists.') + logging.warning('Please check your credentials, if this error persists.') self.irc.send(bytes("JOIN " + channel + "\r\n", "UTF-8")) def sendpriv(self, channel, user, msg): @@ -171,11 +176,48 @@ class IRC: return True if msg.startswith('!ping'): - logging.debug('Ping check received.') + logging.debug("Ping check received.") self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), "Pong!") return True + if msg.startswith('!quickvote'): + logging.info("!quickvote command detected") + if self.quickvote: + logging.debug('Quickvote stopped') + + if self.pollcount == 0: + logging.info("Nobody voted") + self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) + self.sendpriv(conf['IRC_CHANNEL'], "*", conf['MESSAGE']['VOTENOBODY']) + self.quickvote = False + self.poll = {} + return False + + logging.info("Counting votes") + count = 0 + count = Counter(self.poll.values()).most_common(5) + self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) + + logging.debug(count) + + for key, value in count: + self.sendpriv(conf['IRC_CHANNEL'], "*", str(key)+" ("+str(value)+ " "+ conf['MESSAGE']['VOTES'] + ")") + + self.quickvote = False + self.poll = {} + self.pollcount = 0 + return True + else: + logging.debug('Quickvote started') + self.quickvote = True + self.votemsg = resp.split('!quickvote', 1)[1].strip() + if self.votemsg: + self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(self.votemsg) + ")") + else: + self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART']) + return True + if msg.startswith('!ptts'): logging.debug("!ptts command detected") user = msg.replace('!ptts', '').strip().lower() @@ -193,6 +235,12 @@ class IRC: return True + if msg.startswith('#') and self.quickvote == True: + logging.info('Quickvote: Cast detected') + self.pollcount += 1 + self.poll[user] = msg.lower() + logging.debug(self.poll) + if msg.startswith('!toff'): logging.info('TTS is now turned off') msg_queue.clear() @@ -398,6 +446,11 @@ def load_config(): conf['MESSAGE']['WHITELISTONLY'] = cfg['messages']['whitelist'] or False conf['MESSAGE']['SAYS'] = cfg['messages']['says'] or "says" + conf['MESSAGE']['VOTESTART'] = cfg['messages']['votestart'] or "Quickvote started. Send #yourchoice to participate." + conf['MESSAGE']['VOTEEND'] = cfg['messages']['voteend'] or "Quickvote ended. The results are:" + conf['MESSAGE']['VOTENOBODY'] = cfg['messages']['votenobody'] or "Nobody casted a vote. :(" + conf['MESSAGE']['VOTES'] = cfg['messages']['votes'] or "Stimmen" + conf['USERMAP'] = cfg['usermapping'] or [] if 'whitelist' in cfg: @@ -473,6 +526,10 @@ def main(): if confreload - lastreload > datetime.timedelta(seconds=60): conf = load_config() lastreload = datetime.datetime.now() + + if irc.quickvote and irc.votemsg: + logging.info('Quickvote is active') + irc.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(irc.votemsg) + ")") logging.debug('Raw message queue:') logging.debug(msg_queue_raw) From 7da1e01cd66449ea7855c3091c15a93993e7b6e6 Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 12:40:00 +0200 Subject: [PATCH 03/13] Added option to start in TTS disabled state --- README.md | 6 +++++- config-dist.yml | 1 + tts.py | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e8fe4b9..3f0a02d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ http: bind: "localhost" bot: + start_enabled: True subonly: False modonly: False message_length: 200 @@ -88,7 +89,8 @@ whitelist: * `bind`: Interface/IP to bind server to (e.g. localhost) ##### bot - + + * `start_enabled`: Enable the bot on start? If `False` you need to use `!ton` first to make TTS work. * `subonly`: If `True` only Subs can use TTS * `modonly`: If `True` only Mods can use TTS * `message_length`: Maximum allowed message length for TTS @@ -113,6 +115,8 @@ whitelist: * `level`: The loglevel, valid values are: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` +Do not use `DEBUG` in a production environment. + ##### usermapping Use this section to define key:value pairs of usernames. The first value is the Twitch username, the second value is how the bot should pronouce the user, when reading the message. This is helpfull if you have regulars with numbers or strangs chars in the name. You can add new/change entries on the fly without restarting the bot (changes took up to 60 seconds). diff --git a/config-dist.yml b/config-dist.yml index a83fc22..18e1f78 100644 --- a/config-dist.yml +++ b/config-dist.yml @@ -10,6 +10,7 @@ http: bind: "localhost" bot: + start_enabled: True subonly: False modonly: False message_length: 200 diff --git a/tts.py b/tts.py index cb6c4f9..dea5e3a 100644 --- a/tts.py +++ b/tts.py @@ -40,7 +40,7 @@ class IRC: self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.tts_denied = [] self.tts_allowed = [] - self.tts_status = True + self.tts_status = conf['TTS_STARTENABLED'] self.quickvote = False self.votemsg = False self.poll = {} @@ -267,6 +267,9 @@ class IRC: self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOO_LONG']) return False + logging.debug("tts status: "+str(self.tts_status)) + logging.debug(conf['TTS_STARTENABLED']) + if not self.tts_status: logging.info('TTS is disabled') self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DISABLED']) @@ -429,6 +432,7 @@ def load_config(): conf['IRC_SUBONLY'] = cfg['bot']['subonly'] or False conf['IRC_MODONLY'] = cfg['bot']['modonly'] or False conf['IRC_TTS_LEN'] = cfg['bot']['message_length'] or 200 + conf['TTS_STARTENABLED'] = cfg['bot']['start_enabled'] or False conf['LOG_LEVEL'] = cfg['log']['level'] or "INFO" conf['HTTP_PORT'] = cfg['http']['port'] or 80 From bb01167cf30744b480d9ad229b216b75bc98c6ed Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 12:41:46 +0200 Subject: [PATCH 04/13] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4682217..b8c069f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ All notable changes to this project will be documented in this file. * `!quickvote` feature (see README.md for details) * `!ping` command added + * Option to start in TTS disabled mode ### Changed + * You need to review your `config.yml` as there a new config values added. * The bot replies with a chat message when `!ton` or `!toff` is used ### Fixed From 40108b282ba64ffe0d295b7d0bd4139dc918528d Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 13:43:55 +0200 Subject: [PATCH 05/13] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3f0a02d..ec6bd58 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ whitelist: * `server`: Twitch IRC server to be used (default should be fine) * `clearmsg_timeout`: Time to wait for an moderator to delete a message, before it's added to the TTS queue +Please note that the `oauth_token` is valid for approximately 60 days. If it become invalid the bot will not connect anymore and you will have to renew the token. + ##### http * `port`: Internal Webserver Port to listen to (e.g. 8080) From def6879ff3da93a41fbb7d34464c14b8740d0ccc Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 15:08:55 +0200 Subject: [PATCH 06/13] Added integrated OAuth token generator --- README.md | 2 + tts.py | 124 ++++++++++++++++++++++++++++++++++++------------------ 2 files changed, 85 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index ec6bd58..3b86fc5 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ whitelist: * `server`: Twitch IRC server to be used (default should be fine) * `clearmsg_timeout`: Time to wait for an moderator to delete a message, before it's added to the TTS queue +You can generate your `oauth_token` by leaving the value empty when starting `tts.exe/tts.py`. The integrated webserver will then provide an OAuth-Generator. (Due to limitations to the `redirect_url` parameter used by twitch, this is only possible if you use Port `8080` or `80` as `http:bind`.) + Please note that the `oauth_token` is valid for approximately 60 days. If it become invalid the bot will not connect anymore and you will have to renew the token. ##### http diff --git a/tts.py b/tts.py index dea5e3a..cfe2620 100644 --- a/tts.py +++ b/tts.py @@ -27,6 +27,8 @@ import sys import time import datetime import socketserver +import urllib.request +import urllib.parse from threading import Thread from http.server import BaseHTTPRequestHandler @@ -398,6 +400,39 @@ class HTTPserv(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(bytes("Internal Server error\n", "utf-8")) + elif self.path.startswith('/token') and conf['IRC_OAUTH_TOKEN'] == "Invalid": + data = {} + data['client_id'] = "ebo548vs6tq54c9zlrgin2yfzzlrrs" + data['response_type'] = "token" + data['scope'] = "chat:edit chat:read" + if conf['HTTP_PORT'] == 80: + data['redirect_uri'] = "http://localhost/token" + elif conf['HTTP_PORT'] == 8080: + data['redirect_uri'] = "http://localhost:8080/token" + else: + self.send_response(500) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes("You can only use this function if HTTP_PORT is 80 or 8080. Please change your port or use https://www.21x9.org/twitch instead.\n", "utf-8")) + return False + + try: + url_values = urllib.parse.urlencode(data) + url = "https://id.twitch.tv/oauth2/authorize" + full_url = url + "?" + url_values + data = urllib.request.urlopen(full_url) + if data: + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(bytes("OAuth Token Generator\n", "utf-8")) + else: + self.send_response(500) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes("Could not get OAuth-URL from Twitch\n", "utf-8")) + except: + logging.error('Could not fetch OAuth-URL from Twitch.') else: self.send_response(404) self.send_header('Server', 'TTS') @@ -477,7 +512,8 @@ def load_config(): if not conf['IRC_USERNAME']: raise ValueError('Please add the bots username to config.yml.') if not conf['IRC_OAUTH_TOKEN']: - raise ValueError('Please add the bots oauth-token to config.yml.') + conf['IRC_OAUTH_TOKEN'] = "Invalid" + return conf if not conf['IRC_OAUTH_TOKEN'].startswith('oauth:'): raise ValueError('Your oauth-token is invalid, it has to start with: "oauth:"') @@ -506,54 +542,60 @@ def main(): http_thread = Thread(target=http_serve_forever, daemon=True, args=(httpd, )) http_thread.start() - logging.info("Starting IRC bot") - irc = IRC() - irc.connect(conf['IRC_SERVER'], 6667, conf['IRC_CHANNEL'], conf['IRC_USERNAME'], conf['IRC_OAUTH_TOKEN']) - irc.sendpriv(conf['IRC_CHANNEL'], 'MrDestructoid', conf['MESSAGE']['READY']) + if conf['IRC_OAUTH_TOKEN'] == "Invalid": + logging.error('No OAuth Token, skipping start of IRC bot.') + while True: + logging.error('Please open http://'+str(conf['HTTP_BIND'])+':'+str(conf['HTTP_PORT'])+'/token to generate your OAuth-Token.') + time.sleep(10) + else: + logging.info("Starting IRC bot") + irc = IRC() + irc.connect(conf['IRC_SERVER'], 6667, conf['IRC_CHANNEL'], conf['IRC_USERNAME'], conf['IRC_OAUTH_TOKEN']) + irc.sendpriv(conf['IRC_CHANNEL'], 'MrDestructoid', conf['MESSAGE']['READY']) - logging.info("Please open your browser and visit: http://"+str(conf['HTTP_BIND']+":"+str(conf['HTTP_PORT'])+"/")) + logging.info("Please open your browser and visit: http://"+str(conf['HTTP_BIND']+":"+str(conf['HTTP_PORT'])+"/")) - while True: - if conf['LOG_LEVEL'] == "DEBUG": - time.sleep(1) + while True: + if conf['LOG_LEVEL'] == "DEBUG": + time.sleep(1) - try: - irc.get_response() + try: + irc.get_response() - if not irc.tts_status: - logging.debug("TTS is disabled") - if conf['LOG_LEVEL'] == "DEBUG": - time.sleep(1) - continue + if not irc.tts_status: + logging.debug("TTS is disabled") + if conf['LOG_LEVEL'] == "DEBUG": + time.sleep(1) + continue - confreload = datetime.datetime.now() - if confreload - lastreload > datetime.timedelta(seconds=60): - conf = load_config() - lastreload = datetime.datetime.now() - - if irc.quickvote and irc.votemsg: - logging.info('Quickvote is active') - irc.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(irc.votemsg) + ")") + confreload = datetime.datetime.now() + if confreload - lastreload > datetime.timedelta(seconds=60): + conf = load_config() + lastreload = datetime.datetime.now() + + if irc.quickvote and irc.votemsg: + logging.info('Quickvote is active') + irc.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(irc.votemsg) + ")") - logging.debug('Raw message queue:') - logging.debug(msg_queue_raw) - - for raw_msg in msg_queue_raw: - logging.debug('Raw msg:') + logging.debug('Raw message queue:') logging.debug(msg_queue_raw) - now = datetime.datetime.now() - if now - raw_msg['queuetime'] > datetime.timedelta(seconds=conf['IRC_CLEARMSG_TIMEOUT']): - logging.debug('clearmsg_timeout reached') - if raw_msg['timestamp'] not in msg_queue: - logging.info('Sending TTS message') - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - logging.debug(msg_queue) - else: - logging.debug('Msg is already in queue') - except KeyboardInterrupt: - logging.info('Exiting...') - sys.exit() + for raw_msg in msg_queue_raw: + logging.debug('Raw msg:') + logging.debug(msg_queue_raw) + + now = datetime.datetime.now() + if now - raw_msg['queuetime'] > datetime.timedelta(seconds=conf['IRC_CLEARMSG_TIMEOUT']): + logging.debug('clearmsg_timeout reached') + if raw_msg['timestamp'] not in msg_queue: + logging.info('Sending TTS message') + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + logging.debug(msg_queue) + else: + logging.debug('Msg is already in queue') + except KeyboardInterrupt: + logging.info('Exiting...') + sys.exit() if __name__ == "__main__": main() From a7160726125441a56111c5674e487e9f14d5e459 Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 15:11:10 +0200 Subject: [PATCH 07/13] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b86fc5..786fce1 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ whitelist: * `server`: Twitch IRC server to be used (default should be fine) * `clearmsg_timeout`: Time to wait for an moderator to delete a message, before it's added to the TTS queue -You can generate your `oauth_token` by leaving the value empty when starting `tts.exe/tts.py`. The integrated webserver will then provide an OAuth-Generator. (Due to limitations to the `redirect_url` parameter used by twitch, this is only possible if you use Port `8080` or `80` as `http:bind`.) +You can generate your `oauth_token` by leaving the value empty when starting `tts.exe/tts.py`. The integrated webserver will then provide an OAuth-Generator. Due to limitations to the `redirect_url` parameter used by twitch, this is only possible if you use Port `8080` or `80` as `http:bind`. If you use a different port, you will need to use another [Twitch OAuth Generator](https://letmegooglethat.com/?q=Twitch+OAuth+Generator). Please note that the `oauth_token` is valid for approximately 60 days. If it become invalid the bot will not connect anymore and you will have to renew the token. From c19badfb92c0456d540767c92af67fe704b46cac Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 15:25:44 +0200 Subject: [PATCH 08/13] Added port 3000 as valid port for OAuth --- tts.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tts.py b/tts.py index cfe2620..127b93e 100644 --- a/tts.py +++ b/tts.py @@ -409,11 +409,13 @@ class HTTPserv(BaseHTTPRequestHandler): data['redirect_uri'] = "http://localhost/token" elif conf['HTTP_PORT'] == 8080: data['redirect_uri'] = "http://localhost:8080/token" + elif conf['HTTP_PORT'] == 3000: + data['redirect_uri'] = "http://localhost:3000/token" else: self.send_response(500) self.send_header('Content-type', 'text/plain') self.end_headers() - self.wfile.write(bytes("You can only use this function if HTTP_PORT is 80 or 8080. Please change your port or use https://www.21x9.org/twitch instead.\n", "utf-8")) + self.wfile.write(bytes("You can only use this function if HTTP_PORT is 80, 8080 or 3000. Please change your port, or use https://www.21x9.org/twitch instead.\n", "utf-8")) return False try: From 56aac57387d3d06d3bac12a3a11f913a3ebe981c Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 16:09:56 +0200 Subject: [PATCH 09/13] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 786fce1..fcac83e 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ whitelist: * `server`: Twitch IRC server to be used (default should be fine) * `clearmsg_timeout`: Time to wait for an moderator to delete a message, before it's added to the TTS queue -You can generate your `oauth_token` by leaving the value empty when starting `tts.exe/tts.py`. The integrated webserver will then provide an OAuth-Generator. Due to limitations to the `redirect_url` parameter used by twitch, this is only possible if you use Port `8080` or `80` as `http:bind`. If you use a different port, you will need to use another [Twitch OAuth Generator](https://letmegooglethat.com/?q=Twitch+OAuth+Generator). +You can generate your `oauth_token` by leaving the value empty when starting `tts.exe/tts.py`. The integrated webserver will then provide an OAuth-Generator. Due to limitations to the `redirect_url` parameter used by twitch, this is only possible if you use Port `8080` or `80` as `http:bind`. If you use a different port, you will need to use another [Twitch OAuth Generator](https://html.duckduckgo.com/html/?q=twitch+oauth+token+generator). Please note that the `oauth_token` is valid for approximately 60 days. If it become invalid the bot will not connect anymore and you will have to renew the token. From 2ed3745b7fa65c38b13a77b1e05448edf5963ace Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 17:08:02 +0200 Subject: [PATCH 10/13] Lazy formatting and some other linting --- tts.py | 178 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 113 insertions(+), 65 deletions(-) diff --git a/tts.py b/tts.py index 127b93e..59b9bba 100644 --- a/tts.py +++ b/tts.py @@ -1,5 +1,6 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- +# pylint: disable=line-too-long """ TwitchTTS @@ -20,7 +21,6 @@ """ import json -import yaml import logging import socket import sys @@ -35,7 +35,10 @@ from http.server import BaseHTTPRequestHandler from urllib.parse import parse_qs from collections import Counter +import yaml + class IRC: + """IRC bot""" irc = socket.socket() def __init__(self): @@ -52,7 +55,8 @@ class IRC: self.tts_allowed = conf['WHITELIST_USER'] def connect(self, server, port, channel, botnick, botpass): - logging.info("Connecting to: " + server) + """Connect to Twitch IRC servers""" + logging.info("Connecting to: %s", server) try: self.irc.connect((server, port)) except ConnectionResetError: @@ -62,7 +66,9 @@ class IRC: self.irc.settimeout(1) self.irc.send(bytes("PASS " + botpass + "\r\n", "UTF-8")) - self.irc.send(bytes("USER " + botnick + " " + botnick +" " + botnick + " :python\r\n", "UTF-8")) + self.irc.send(bytes( + "USER " + botnick + " " + botnick +" " + botnick + " :python\r\n", "UTF-8") + ) self.irc.send(bytes("NICK " + botnick + "\r\n", "UTF-8")) self.irc.send(bytes("CAP REQ :twitch.tv/commands twitch.tv/tags \r\n", "UTF-8")) time.sleep(5) @@ -75,17 +81,26 @@ class IRC: logging.warning('Please check your credentials, if this error persists.') self.irc.send(bytes("JOIN " + channel + "\r\n", "UTF-8")) - def sendpriv(self, channel, user, msg): + def sendmsg(self, channel, user, msg): + """ + Send (a public) message to IRC channel + + Parameters: + channel (str): Channel to post msg to + user (str): User to address message to + msg (str): Message to send + """ self.irc.send(bytes("PRIVMSG "+channel+" :"+user+" "+msg+"\r\n", "UTF-8")) def get_response(self): + """Get and process response from IRC""" try: resp = self.irc.recv(2048).decode("UTF-8") logging.debug('resp:') logging.debug(resp) except socket.timeout: return False - except: + except Exception: # pylint: disable=broad-except logging.exception('An unknown error occured while getting a IRC response.') sys.exit(255) @@ -97,7 +112,7 @@ class IRC: logging.info('CLEARMSG received') msgid = False - global msg_queue_raw + global msg_queue_raw # pylint: disable=global-statement,invalid-name filtered_msg_queue = [] tags = resp.split(';') @@ -109,7 +124,7 @@ class IRC: for msg in list(msg_queue_raw): if msg['msgid'] == msgid: - logging.info('Suppressing message '+str(msgid)) + logging.info('Suppressing message %s', msgid) else: filtered_msg_queue.append(msg) @@ -141,22 +156,21 @@ class IRC: badges = tag.rsplit('badges=',1)[1] if tag.startswith('subscriber='): subscriber = tag.rsplit('subscriber=',1)[1] - logging.debug('Subscriber: '+str(subscriber)) + logging.debug('Subscriber: %s', subscriber) if tag.startswith('id='): msgid = tag.rsplit('id=',1)[1] - logging.debug('Message ID: '+str(msgid)) + logging.debug('Message ID: %s', msgid) if tag.startswith('display-name='): user = tag.rsplit('display-name=',1)[1].lower() - logging.debug('Username: '+str(user)) + logging.debug('Username: %s', user) msg = resp.rsplit('PRIVMSG #',1)[1] msg = msg.split(':',1)[1] msg = msg.replace('\r\n','') msglen = len(msg) - logging.debug('Msg:') - logging.debug(msg) - logging.debug('Msg length: '+str(msglen)) + logging.debug('Msg: %s', msg) + logging.debug('Msg length: %s', msglen) logging.debug('Deny List:') logging.debug(self.tts_denied) @@ -169,17 +183,17 @@ class IRC: logging.debug('Removing "@" from username') user = user.replace('@', '') if user not in self.tts_denied: - logging.info("Adding "+str(user)+" to deny list") + logging.info("Adding %s to deny list", user) self.tts_denied.append(user) if user in self.tts_allowed: - logging.info("Removing "+str(user)+" from allowed list") + logging.info("Removing %s from allowed list", user) self.tts_allowed.remove(user) return True if msg.startswith('!ping'): logging.debug("Ping check received.") - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), "Pong!") + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), "Pong!") return True @@ -190,8 +204,8 @@ class IRC: if self.pollcount == 0: logging.info("Nobody voted") - self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) - self.sendpriv(conf['IRC_CHANNEL'], "*", conf['MESSAGE']['VOTENOBODY']) + self.sendmsg(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) + self.sendmsg(conf['IRC_CHANNEL'], "*", conf['MESSAGE']['VOTENOBODY']) self.quickvote = False self.poll = {} return False @@ -199,12 +213,15 @@ class IRC: logging.info("Counting votes") count = 0 count = Counter(self.poll.values()).most_common(5) - self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) + self.sendmsg(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) logging.debug(count) for key, value in count: - self.sendpriv(conf['IRC_CHANNEL'], "*", str(key)+" ("+str(value)+ " "+ conf['MESSAGE']['VOTES'] + ")") + self.sendmsg( + conf['IRC_CHANNEL'], "*", + str(key)+" ("+str(value)+ " "+ conf['MESSAGE']['VOTES'] + ")" + ) self.quickvote = False self.poll = {} @@ -215,9 +232,12 @@ class IRC: self.quickvote = True self.votemsg = resp.split('!quickvote', 1)[1].strip() if self.votemsg: - self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(self.votemsg) + ")") + self.sendmsg( + conf['IRC_CHANNEL'], "@chat", + conf['MESSAGE']['VOTESTART'] + " (" + str(self.votemsg) + ")" + ) else: - self.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART']) + self.sendmsg(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART']) return True if msg.startswith('!ptts'): @@ -228,16 +248,16 @@ class IRC: logging.debug('Removing "@" from username') user = user.replace('@', '') - logging.info("Adding "+str(user)+" to whitelist") + logging.info("Adding %s to whitelist", user) self.tts_allowed.append(user) if user in self.tts_denied: - logging.info("Removing "+str(user)+" from deny list") + logging.info("Removing %s from deny list", user) self.tts_denied.remove(user) return True - if msg.startswith('#') and self.quickvote == True: + if msg.startswith('#') and self.quickvote is True: logging.info('Quickvote: Cast detected') self.pollcount += 1 self.poll[user] = msg.lower() @@ -248,7 +268,7 @@ class IRC: msg_queue.clear() msg_queue_raw.clear() self.tts_status = False - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOFF']) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOFF']) return True @@ -257,7 +277,7 @@ class IRC: msg_queue.clear() msg_queue_raw.clear() self.tts_status = True - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TON']) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TON']) return True @@ -266,20 +286,20 @@ class IRC: if msglen > conf['IRC_TTS_LEN']: logging.info('TTS message is to long') - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOO_LONG']) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOO_LONG']) return False - logging.debug("tts status: "+str(self.tts_status)) + logging.debug("tts status: %s", self.tts_status) logging.debug(conf['TTS_STARTENABLED']) if not self.tts_status: logging.info('TTS is disabled') - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DISABLED']) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DISABLED']) return False if user in self.tts_denied: - logging.info(str(user) + " is not allowed to use TTS") - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DENIED']) + logging.info("%s is not allowed to use TTS", user) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DENIED']) return False if conf['IRC_SUBONLY']: @@ -287,7 +307,7 @@ class IRC: logging.debug('TTS is sub-only and user has allowance') else: logging.info('TTS is sub-only') - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['SUBONLY']) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['SUBONLY']) return False if conf['IRC_MODONLY']: @@ -295,25 +315,41 @@ class IRC: logging.debug('TTS is mod-only and user has allowance') else: logging.info('TTS is sub-only') - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['MODONLY']) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['MODONLY']) return False if conf['WHITELIST']: - if not user in self.tts_allowed: + if user not in self.tts_allowed: logging.info('User is not on whitelist') logging.info(self.tts_allowed) - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['WHITELISTONLY']) + self.sendmsg( + conf['IRC_CHANNEL'], + "@"+str(user), conf['MESSAGE']['WHITELISTONLY'] + ) return False else: logging.info('Nobody is on the whitelist.') - self.sendpriv(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['WHITELISTONLY']) + self.sendmsg( + conf['IRC_CHANNEL'], + "@"+str(user), conf['MESSAGE']['WHITELISTONLY'] + ) return False logging.info('Valid TTS message, adding to raw queue') tts = True now = datetime.datetime.now() msg = msg.replace('!tts','',1) - msg = {"TTS": tts, "msg": msg, "badges": badges, "subscriber": subscriber, "msgid": msgid, "user": user, "length": msglen, "queuetime": now, "timestamp": str(time.time_ns())} + msg = { + "TTS": tts, + "msg": msg, + "badges": badges, + "subscriber": subscriber, + "msgid": msgid, + "user": user, + "length": msglen, + "queuetime": now, + "timestamp": str(time.time_ns()) + } msg_queue_raw.append(msg) return True @@ -321,48 +357,52 @@ class IRC: return False class HTTPserv(BaseHTTPRequestHandler): - def log_message(self, format, *args): + """Simple HTTP Server""" + + def log_message(self, format, *args): # pylint: disable=redefined-builtin + """Suppress HTTP log messages""" return - def do_GET(self): + def do_GET(self): # pylint: disable=invalid-name + """Process GET requests""" if self.path == '/': self.send_response(200) self.send_header('Content-type', 'text/html') self.end_headers() - with open("tts.html", "rb") as fh: - html = fh.read() + with open("tts.html", "rb") as file: + html = file.read() self.wfile.write(html) elif self.path == '/favicon.ico': self.send_response(200) self.send_header('Content-type', 'image/x-icon') self.end_headers() - with open("favicon.ico", "rb") as fh: - icon = fh.read() + with open("favicon.ico", "rb") as file: + icon = file.read() self.wfile.write(icon) elif self.path == '/tts.js': self.send_response(200) self.send_header('Content-type', 'text/javascript') self.end_headers() - with open("tts.js", "rb") as fh: - html = fh.read() + with open("tts.js", "rb") as file: + html = file.read() self.wfile.write(html) elif self.path == '/jquery.js': self.send_response(200) self.send_header('Content-type', 'text/javascript') self.end_headers() - with open("jquery.js", "rb") as fh: - html = fh.read() + with open("jquery.js", "rb") as file: + html = file.read() self.wfile.write(html) elif self.path == '/bootstrap.min.css': self.send_response(200) self.send_header('Content-type', 'text/css') self.end_headers() - with open("bootstrap.min.css", "rb") as fh: - html = fh.read() + with open("bootstrap.min.css", "rb") as file: + html = file.read() self.wfile.write(html) elif self.path.startswith('/tts_queue'): @@ -378,9 +418,9 @@ class HTTPserv(BaseHTTPRequestHandler): for key in list(sorted_tts.keys()): if key not in tts_done: if msg_queue[key][0].lower() in usermap: - tts = {key: usermap[msg_queue[key][0].lower()]+" "+str(conf['MESSAGE']['SAYS'])+":"+msg_queue[key][1]} + tts = {str(key): str(usermap[msg_queue[key][0].lower()]) + " " + str(conf['MESSAGE']['SAYS']) + ":" + str(msg_queue[key][1])} else: - tts = {key: msg_queue[key][0]+" "+str(conf['MESSAGE']['SAYS'])+":"+msg_queue[key][1]} + tts = {str(key): str(msg_queue[key][0]) + " " + str(conf['MESSAGE']['SAYS']) + ":" + str(msg_queue[key][1])} tts_json = json.dumps(tts) self.wfile.write(bytes(str(tts_json)+"\n", "utf-8")) @@ -433,7 +473,7 @@ class HTTPserv(BaseHTTPRequestHandler): self.send_header('Content-type', 'text/plain') self.end_headers() self.wfile.write(bytes("Could not get OAuth-URL from Twitch\n", "utf-8")) - except: + except Exception: # pylint: disable=broad-except logging.error('Could not fetch OAuth-URL from Twitch.') else: self.send_response(404) @@ -445,13 +485,16 @@ class HTTPserv(BaseHTTPRequestHandler): return def http_serve_forever(httpd): - httpd.serve_forever() + """httpd loop""" + httpd.serve_forever() def load_config(): + """Loading config variables""" + logging.info("Loading configfile") try: - with open("config.yml", "r") as ymlfile: + with open("config.yml", "r", encoding="UTF-8") as ymlfile: cfg = yaml.load(ymlfile, Loader=yaml.Loader) except FileNotFoundError: logging.fatal('Your config file is missing, please copy config-dist.yml to config.yml and review your settings.') @@ -459,18 +502,18 @@ def load_config(): for section in cfg: try: - logging.debug('Fetching config: '+str(section)) + logging.debug('Fetching config: %s', section) conf['IRC_CHANNEL'] = cfg['irc']['channel'] conf['IRC_USERNAME'] = cfg['irc']['username'] conf['IRC_OAUTH_TOKEN'] = cfg['irc']['oauth_token'] conf['IRC_SERVER'] = cfg['irc']['server'] or "irc.chat.twitch.tv" conf['IRC_CLEARMSG_TIMEOUT'] = cfg['irc']['clearmsg_timeout'] or 60 - + conf['IRC_SUBONLY'] = cfg['bot']['subonly'] or False conf['IRC_MODONLY'] = cfg['bot']['modonly'] or False conf['IRC_TTS_LEN'] = cfg['bot']['message_length'] or 200 conf['TTS_STARTENABLED'] = cfg['bot']['start_enabled'] or False - + conf['LOG_LEVEL'] = cfg['log']['level'] or "INFO" conf['HTTP_PORT'] = cfg['http']['port'] or 80 conf['HTTP_BIND'] = cfg['http']['bind'] or "localhost" @@ -530,12 +573,16 @@ logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(module)s %(thread sys.tracebacklimit = 0 def main(): + """Main loop""" + + global conf # pylint: disable=global-statement,invalid-name + conf = load_config() lastreload = datetime.datetime.now() logging.getLogger().setLevel(conf['LOG_LEVEL']) if conf['LOG_LEVEL'] == 'DEBUG': sys.tracebacklimit = 5 - + logging.info("Starting Webserver") httpd = socketserver.TCPServer((conf['HTTP_BIND'], conf['HTTP_PORT']), HTTPserv) httpd.allow_reuse_port = True @@ -547,15 +594,16 @@ def main(): if conf['IRC_OAUTH_TOKEN'] == "Invalid": logging.error('No OAuth Token, skipping start of IRC bot.') while True: - logging.error('Please open http://'+str(conf['HTTP_BIND'])+':'+str(conf['HTTP_PORT'])+'/token to generate your OAuth-Token.') + logging.error('Please open http://%s:%s/token to generate your OAuth-Token.', conf['HTTP_BIND'], conf['HTTP_PORT']) time.sleep(10) else: logging.info("Starting IRC bot") irc = IRC() - irc.connect(conf['IRC_SERVER'], 6667, conf['IRC_CHANNEL'], conf['IRC_USERNAME'], conf['IRC_OAUTH_TOKEN']) - irc.sendpriv(conf['IRC_CHANNEL'], 'MrDestructoid', conf['MESSAGE']['READY']) - logging.info("Please open your browser and visit: http://"+str(conf['HTTP_BIND']+":"+str(conf['HTTP_PORT'])+"/")) + irc.connect(conf['IRC_SERVER'], 6667, conf['IRC_CHANNEL'], conf['IRC_USERNAME'], conf['IRC_OAUTH_TOKEN']) + irc.sendmsg(conf['IRC_CHANNEL'], 'MrDestructoid', conf['MESSAGE']['READY']) + + logging.info("Please open your browser and visit: http://%s:%s/", conf['HTTP_BIND'], conf['HTTP_PORT']) while True: if conf['LOG_LEVEL'] == "DEBUG": @@ -574,10 +622,10 @@ def main(): if confreload - lastreload > datetime.timedelta(seconds=60): conf = load_config() lastreload = datetime.datetime.now() - + if irc.quickvote and irc.votemsg: logging.info('Quickvote is active') - irc.sendpriv(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(irc.votemsg) + ")") + irc.sendmsg(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(irc.votemsg) + ")") logging.debug('Raw message queue:') logging.debug(msg_queue_raw) From 45973580b862fe9f0ac2f598fce4ddd903c4b0fa Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 17:28:57 +0200 Subject: [PATCH 11/13] Open TTS website in browser automatically --- tts.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tts.py b/tts.py index 59b9bba..b2c0e0c 100644 --- a/tts.py +++ b/tts.py @@ -29,6 +29,7 @@ import datetime import socketserver import urllib.request import urllib.parse +import webbrowser from threading import Thread from http.server import BaseHTTPRequestHandler @@ -593,9 +594,11 @@ def main(): if conf['IRC_OAUTH_TOKEN'] == "Invalid": logging.error('No OAuth Token, skipping start of IRC bot.') - while True: - logging.error('Please open http://%s:%s/token to generate your OAuth-Token.', conf['HTTP_BIND'], conf['HTTP_PORT']) - time.sleep(10) + logging.error('Please open http://%s:%s/token to generate your OAuth-Token.', conf['HTTP_BIND'], conf['HTTP_PORT']) + url = 'http://'+str(conf['HTTP_BIND'])+':'+str(conf['HTTP_PORT'])+'/token' + webbrowser.open_new_tab(url) + logging.info('Please complete the OAuth process within the next 15 minutes.') + time.sleep(900) else: logging.info("Starting IRC bot") irc = IRC() @@ -604,6 +607,8 @@ def main(): irc.sendmsg(conf['IRC_CHANNEL'], 'MrDestructoid', conf['MESSAGE']['READY']) logging.info("Please open your browser and visit: http://%s:%s/", conf['HTTP_BIND'], conf['HTTP_PORT']) + url = 'http://'+str(conf['HTTP_BIND'])+':'+str(conf['HTTP_PORT']) + webbrowser.open_new_tab(url) while True: if conf['LOG_LEVEL'] == "DEBUG": From e462c1b9afe6e75f190233414fc2bd913644c013 Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 16:47:14 +0000 Subject: [PATCH 12/13] Update CHANGELOG.md, tts.py --- CHANGELOG.md | 5 ++++- tts.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c069f..b8e4112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ All notable changes to this project will be documented in this file. * `!quickvote` feature (see README.md for details) * `!ping` command added - * Option to start in TTS disabled mode + + * Configoption to start TTS in disabled mode + * OAuth-Token generator + * Webbrowser autostart ### Changed diff --git a/tts.py b/tts.py index b2c0e0c..e8e5286 100644 --- a/tts.py +++ b/tts.py @@ -468,7 +468,7 @@ class HTTPserv(BaseHTTPRequestHandler): self.send_response(200) self.send_header('Content-type', 'text/html') self.end_headers() - self.wfile.write(bytes("OAuth Token Generator\n", "utf-8")) + self.wfile.write(bytes("OAuth Token Generator\n", "utf-8")) else: self.send_response(500) self.send_header('Content-type', 'text/plain') From 1639eca784060c8327d8e21164b0f2f03a48c47c Mon Sep 17 00:00:00 2001 From: gpkvt Date: Fri, 12 Aug 2022 18:52:47 +0200 Subject: [PATCH 13/13] Added: Exit code for token timeout --- tts.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tts.py b/tts.py index e8e5286..2f8e8a6 100644 --- a/tts.py +++ b/tts.py @@ -599,6 +599,7 @@ def main(): webbrowser.open_new_tab(url) logging.info('Please complete the OAuth process within the next 15 minutes.') time.sleep(900) + sys.exit(250) else: logging.info("Starting IRC bot") irc = IRC()