diff --git a/CHANGELOG.md b/CHANGELOG.md index e99b46c..1e3018e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,143 +1,143 @@ -# Change Log - -All notable changes to this project will be documented in this file. If there is a `Changed` section please read carefully, as this often means that you will need to adapt your `config.yml`, otherwise the bot might fail to start. - -## [1.6.1] - 2022-08-25 - -### Fixed 1.6.1 - -* Type Error during pick -* Improved command handling - -## [1.6.0] - 2022-08-25 - -### Added 1.6.0 - -* `!pick` command - -## [1.5.0] - 2022-08-24 - -### Added 1.5.0 - -* `!wiki` command -* `!version` command - -### Changed 1.5.0 - -* Added `!sq` as alias for `!smartquote` - -### Fixed 1.5.0 - -* Darkmode: Options background color - -## [1.4.0] - 2022-08-23 - -### Added 1.4.0 - -* `!usermap` command added -* `!delay` command added -* Darkmode added - -## [1.3.2] - 2022-08-19 - -### Fixed 1.3.2 - -* `!smartquote` and `!addquote` are not longer Mods only. - -## [1.3.1] - 2022-08-19 - -### Added 1.3.1 - -* Added `game_name` and date to quote - -### Fixed (hopefully) 1.3.1 - -* Improved HTTP request handling (hopefully removes delay in Chrome) - -## [1.3.0] - 2022-08-18 - -### Added 1.3.0 - -* Added `!smartquote` command -* Added `!addquote` command - -## [1.2.5] - 2022-08-16 - -### Changed 1.2.5 - -* Improved logging - -## [1.2.4] - 2022-08-15 - -### Added 1.2.4 - -* Check OAuth Token via Twitch API - -### Fixed 1.2.4 - -* Internal URL when using special HTTP_BIND values - -## [1.2.3] - 2022-08-14 - -### Fixed 1.2.3 - -* Message sort order - -## [1.2.2] - 2022-08-13 - -### Changed 1.2.2 - -* The message queue is only queried when the Init button is pressed -* Further code optimization - -### Fixed 1.2.2 - -* Minor fixes - -## [1.2.1] - 2022-08-13 - -### Changed 1.2.1 - -* Reworked internal code structure - -### Fixed 1.2.1 - -* Publish vote info in chat when reloading config was not working when TTS was disabled -* Casting votes was allowed for broadcaster and mods only - -## [1.2.0] - 2022-08-13 - -### Added - -* `!random` feature (see README.md for details) - -### Changed 1.2.0 - -* The vote result will be read out - -### Fixed 1.2.0 - -* Improved handling of missing config values - -## [1.1.0] - 2022-08-12 - -### Added 1.1.0 - -* `!quickvote` feature (see README.md for details) -* `!ping` command added -* Configoption to start TTS in disabled mode -* OAuth-Token generator -* Webbrowser autostart - -### Changed 1.1.0 - -* 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 1.1.0 - -* Improved error handling - -## [1.0.0] - 2022-08-11 - -Initial Release +# Change Log + +All notable changes to this project will be documented in this file. If there is a `Changed` section please read carefully, as this often means that you will need to adapt your `config.yml`, otherwise the bot might fail to start. + +## [1.6.1] - 2022-08-25 + +### Fixed 1.6.1 + +* Type Error during pick +* Improved command handling + +## [1.6.0] - 2022-08-25 + +### Added 1.6.0 + +* `!pick` command + +## [1.5.0] - 2022-08-24 + +### Added 1.5.0 + +* `!wiki` command +* `!version` command + +### Changed 1.5.0 + +* Added `!sq` as alias for `!smartquote` + +### Fixed 1.5.0 + +* Darkmode: Options background color + +## [1.4.0] - 2022-08-23 + +### Added 1.4.0 + +* `!usermap` command added +* `!delay` command added +* Darkmode added + +## [1.3.2] - 2022-08-19 + +### Fixed 1.3.2 + +* `!smartquote` and `!addquote` are not longer Mods only. + +## [1.3.1] - 2022-08-19 + +### Added 1.3.1 + +* Added `game_name` and date to quote + +### Fixed (hopefully) 1.3.1 + +* Improved HTTP request handling (hopefully removes delay in Chrome) + +## [1.3.0] - 2022-08-18 + +### Added 1.3.0 + +* Added `!smartquote` command +* Added `!addquote` command + +## [1.2.5] - 2022-08-16 + +### Changed 1.2.5 + +* Improved logging + +## [1.2.4] - 2022-08-15 + +### Added 1.2.4 + +* Check OAuth Token via Twitch API + +### Fixed 1.2.4 + +* Internal URL when using special HTTP_BIND values + +## [1.2.3] - 2022-08-14 + +### Fixed 1.2.3 + +* Message sort order + +## [1.2.2] - 2022-08-13 + +### Changed 1.2.2 + +* The message queue is only queried when the Init button is pressed +* Further code optimization + +### Fixed 1.2.2 + +* Minor fixes + +## [1.2.1] - 2022-08-13 + +### Changed 1.2.1 + +* Reworked internal code structure + +### Fixed 1.2.1 + +* Publish vote info in chat when reloading config was not working when TTS was disabled +* Casting votes was allowed for broadcaster and mods only + +## [1.2.0] - 2022-08-13 + +### Added + +* `!random` feature (see README.md for details) + +### Changed 1.2.0 + +* The vote result will be read out + +### Fixed 1.2.0 + +* Improved handling of missing config values + +## [1.1.0] - 2022-08-12 + +### Added 1.1.0 + +* `!quickvote` feature (see README.md for details) +* `!ping` command added +* Configoption to start TTS in disabled mode +* OAuth-Token generator +* Webbrowser autostart + +### Changed 1.1.0 + +* 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 1.1.0 + +* Improved error handling + +## [1.0.0] - 2022-08-11 + +Initial Release diff --git a/README.md b/README.md index d696171..03a9632 100644 --- a/README.md +++ b/README.md @@ -1,370 +1,370 @@ -# Twitch TextToSpeech Bot - -![Latest Release](https://img.shields.io/gitlab/v/release/38486705) ![License](https://img.shields.io/gitlab/license/38486705) ![Maintenance](https://img.shields.io/maintenance/yes/2022) - -A simple Twitch TTS bot (Web Speech API) - -## Description - -The goal of this project is to provide a simple to use Text to Speech IRC bot. It's mainly focused on Twitch, but might be easily adapt to other IRC chats as well. Anyway, right now some parts are Twitch specific (like `CAP REQ :twitch.tv/commands twitch.tv/tags`). There are some other projects providing TTS for IRC based chats, like [IRC Radio (TTS)](https://play.google.com/store/apps/details?id=com.earthflare.android.ircradio&hl=en&gl=US), but they often are missing moderation features like Black-/Whitelists or deletion of single messages, before they are read. Therefore I created my own TTS bot, providing those features. Another very important aspect was that you should use almost any device capable running an HTML5-webbrowser as output. Therefore the bot uses a client-server "architecture". You can run the bot - for example - on your IRL server, but use your phone as output device. A mobile only solution always have the disadvantage of loosing messages, e.g. when your mobile connection drops. - -The project consits of a very simple IRC client, which monitors the incoming messages. If a valid​⃰ `!tts` command is detected the message will send into a queue. Depending on your config it will wait there a few seconds for deletion by you/your moderators. If nobody deletes the message, it will send into another queue. This queue will get fetched by the HTML frontend, which will be delivered by an internal webserver (backend). The HTML frontend will use the [Web Speech API](https://wicg.github.io/speech-api/) included in any modern webbrowser to read the incoming TTS message. When this is done it will report back to the webserver and the message will be removed from the queue. - -The server part is written in Python. The TTS part is written in Javascript. - -By using Javascript for the actual TTS part it's not only very easy to access the Web Speech API and the underlying Speech features of your OS, it also makes it possible to use a wide range of devices to actually play the TTS output. You can start the backend on your PC/Server and open the frontend on your Android/iOS tablet or Mobile Phone. If you expose the backend to the internet (I would recommend to use a reverse proxy, rather than exposing the backend directly) you can also use the TTS bot on the go (e.g. your IRL setup). - -(⃰sender is not on the blacklist, message is not too long, etc.) - -## Getting Started - -### Dependencies - -* Browser with Web Speech API support -* Web Speech API Voices (usually already included in your OS and/or browser) -* If you use `tts.py` see `requirements.txt`. If you use `tts.exe` all dependencies are included. - -### Installing - -1. Clone the repo, or download and unzip the newest [Release](https://gitlab.com/gpvkt/twitchtts/-/releases) -2. Rename/copy `config-dist.yml` to `config.yml` -3. Adapt `config.yml` to your needs. See `Configuration` for details. - -### Configuration - -Please use `UTF-8` as encoding, when editing `config.yml`. - -Example: - -``` lang=yaml -irc: - channel: "#gpkvt" - username: "ttsbot" - oauth_token: "oauth:ohkoace0wooghue8she9xaN0nooSau" - server: "irc.chat.twitch.tv" - clearmsg_timeout: 10 - -http: - port: 80 - bind: "localhost" - -bot: - start_enabled: True - subonly: False - modonly: False - message_length: 200 - language: de - -messages: - toff: "TTS is now inactive." - ton: "TTS is now active." - too_long: "Sorry, your TTS message is too long." - disabled: "Sorry, TTS is disabled right now." - denied: "Sorry, you are not allowed to use TTS." - subonly: "Sorry, TTS is a sub-only feature." - 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. :(" - voteresult: "Voting has ended. The result is:" - votes: "Votes" - pickstart: "Pick started. Send #pickme to participate." - pickresult: "Pick ended. The results are:" - picknone: "Nobody was picked. :(" - quotenotfound: "Sorry, no quote found." - quoteaddedprefix: "Quote:" - quoteaddedsuffix: "added." - wiki_too_many: "Sorry, there are too many possible results. Try a more narrow search." - wiki_no_result: "Sorry, there was an error fetching the wikipedia answer." - -log: - level: "INFO" - -usermapping: - gpkvt: "gpk" - -whitelist: - -``` - -#### Explanation - -##### irc - -* `channel`: Channel you want to monitor (e.g. #gpkvt) -* `username`: The bots username (e.g. gpkvt) -* `oauth_token`: The bots OAUTH-Token (e.g. oauth:ohkoace0wooghue8she9xaN0nooSau) -* `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://html.duckduckgo.com/html/?q=twitch+oauth+token+generator). The bot will need `chat:edit` and `chat:read` permissions. - -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) -* `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 -* `language`: Language for `!wiki` command - -##### messages - -* `toff`: The bots reply when `!toff` is used. -* `ton`: The bots reply when `!ton` is used. -* `too_long`: The bots reply if message exceeds `message_length` -* `disabled`: The bots reply if TTS is disabled -* `denied`: The bots reply if the user is not allowed to use TTS -* `subonly`: The bots reply if `subonly` is active and the user isn't one. -* `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 was started. -* `voteend`: Message when a quickvote ends. -* `votenobody`: Message when quickvote ends, but nobody has voted. -* `voteresult`: Prefix for the result (will be read out). -* `votes`: Suffix to vote count. -* `pickstart`: Message when `!pick` was started. -* `pickresult`: Message when `!pick` ends. -* `picknone`: Message if nobody was picked. -* `quotenotfound`: Message if requests quote wasn't found. -* `quoteaddedprefix`: Prefix for `Quote added` message. -* `quoteaddedsuffix`: Suffix for `Quote added` message. -* `wiki_too_many`: Message if `!wiki` command has more than one result. -* `wiki_no_result`: Message if `!wiki` command hasn't a valid result. - -##### log - -* `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). - -Please note: The key (real username) MUST be lowercase. - -##### whitelist - -You can add a whitelist section to `config.yml`, a whitelist will override any other settings like `subonly` and `modonly`. Only users on the whitelist are allowed to use `!tts`. Broadcasters and mods can temporarily add users (including themselfs) to the whitelist by using the `!ptts` command, though. - -A whitelist looks as follows: - -``` lang=yaml -whitelist: - - gpkvt - - foo - - bar -``` - -To disable the whitelist, remove it from `config.yml` completely. If you just leave `whitelist:` without entries, everyone must be whitelisted using `!ptts` (even broadcaster and mods). The permit is temporary until the bot restarts or (whichever happens first) if the user is removed from the whitelist using `!dtts`. - -Please note: Usernames MUST be lowercase. - -### Executing program - -Execute `tts.exe` (or `tts.py` if you have Python installed), open the TTS webpage in your browser (the URL depends on your `bind` and `port` configuration, usually it's just `http://localhost`). Click the `Init` button at the button of the TTS webpage (you should hear `Init complete`). - -Connect to the configured Twitch channel and send a message starting with `!tts`. After a few seconds (depending on your `clearmsg_timeout` config), the message should be read. - -### Additional Commands - -Additional commands (broadcaster and mods only) are: - -* `!ping`: Check if bot is alive (the bot should reply: `Pong!`) -* `!toff`: Turn TTS off (will also empty the current TTS queue) -* `!ton`: Turn TTS back on -* `!dtts `: Disable TTS for the given user -* `!ptts `: Allow TTS for the given user -* `!usermap `: Add an entry to the usermapping in `config.yml` -* `!delay `: Adjust the `clearmsg_timeout` in `config.yml` - -### Additional features - -#### !quickvote - -A simple vote system. - -**Usage:** - -1. Broadcaster/Mods: `!quickvote ` -2. User: `#choice` -3. Broadcaster/Mods: `!quickvote` - -**Example:** - -``` lang=text -mod: !quickvote Is pizza hawaii any good? #yes/#no -chat: #no -mod: !quickvote -bot: The result is: #no -``` - -If a message is given by the Mods it will be repeated every 60 seconds, so everyone keeps in mind, that a vote is still active. - -#### !pick - -Randomly choose users from chat (who wants to participate). - -**Usage:** - -1. Broadcaster/Mods: `!pick ` -2. User: #pickme -3. Broadcaster/Mods: `!pick` - -**Example:** - -``` lang=text -mod: !pick 3 -user1: #pickme -user2: #pickme -user3: #pickme -user4: #pickme -user5: #pickme -mod: !pick -bot: The picked users are: user5, user3, user1 -``` - -#### !random - -Picks a random line from a file. - -**Usage:** - -* Broadcaster/Mod: `!random ` - -You can use multiple files, if you call `!random foo` the bot fetch the random line from a file called `random_foo.txt`, `!random bar` will use the file `random_bar.txt` and so on. If no `` is given the command use the file `random.txt`. - -**Example:** - -``` lang=text -mod: !random -bot: This is a random message -``` - -#### !addquote - -The `!addquote` command adds a new line to `quotes.txt`. - -**Usage:** - -* Chat: `!addquote ` - -**Example:** - -``` lang=text -chat: !addquote gpkvt This is a very funny quote. -bot: Quote #1 was added. -``` - -#### !smartquote / !sq - -Picks a random/specific line from `quotes.txt`. - -**Usage:** - -* Chat: `!smartquote` -* Chat: `!smartquote ` -* Chat: `!smartquote ` -* Chat: `!sq` -* Chat: `!sq ` -* Chat: `!sq ` - -(`!sq` is an alias for `!smartquote`) - -**Example:** - -``` lang=text -chat: !smartquote -bot: This is a random quote -chat: !smartquote 1000 -bot: This is quote #1000 -chat: !smartquote something stupid -bot: This is a quote containing something stupid (or similiar) -``` - -The format of `quotes.txt` looks as follows: - -``` lang=text -#1: "the quote" -username/game (date) -``` - -#### !wiki - -Search the Wikipedia. - -**Usage:** - -* Chat: `!wiki ` - -**Example:** - -``` lang=text -chat: !wiki 42 answer -bot: 42 is the "Answer to the Ultimate Question of Life, the Universe, and Everything". -``` - -## Build - -If you prefer to build your own `tts.exe` instead of using the shipped one, you can do as follows: - -* Install Python 3 -* Install pyinstaller: `pip install pyinstaller` -* Install the required dependencies: `pip install -r requirements.txt -v` -* Create the executeable: `pyinstaller --onefile tts.py` - -## Voices - -The voices available depend on your Operating System and/or browser. On some systems only a default voice is available and the `Select voice` dropdown might stay empty or will only show entries after you clicked the `Init` button. Some Android devices will show a huge list of voices, but sounds the same no matter which one you choose. - -On Windows you can install additional voices via `Settings` > `Time & language` > `Speech` > `Add voices` or by simply run `Add speech voices`. - -Do not use Online-Voices, as this will result in serious delays. - -## Help - -Feel free to use the [Issuetracker](https://gitlab.com/gpvkt/twitchtts/-/issues) or send an [E-Mail](mailto:contact-project+gpvkt-twitchtts-38486705-issue-@incoming.gitlab.com) if you experience any problems. - -## Authors - -[@gpkvt](mailto:contact-project+gpvkt-twitchtts-38486705-issue-@incoming.gitlab.com) - -## Version History - -See [CHANGELOG.md](https://gitlab.com/gpvkt/twitchtts/-/blob/main/CHANGELOG.md) - -## License - -This project is licensed under the GPLv3 License - see [LICENSE](https://gitlab.com/gpvkt/twitchtts/-/blob/main/LICENSE) for details. - -## Acknowledgments - -### Ideas and Testing - -* [GERBrowny and community](https://www.twitch.tv/gerbrowny/) ![Emote](https://static-cdn.jtvnw.net/emoticons/v2/303172270/static/light/1.0) -* [DerZugger and community](https://www.twitch.tv/derzugger/) ![Emote](https://static-cdn.jtvnw.net/emoticons/v2/302400142/static/light/1.0) -* [Timmeh74 and community](https://www.twitch.tv/timmeh74/) ![Emote](https://static-cdn.jtvnw.net/emoticons/v2/300192675/static/light/1.0) - -### Libraries - -* [Python](https://www.python.org/) -* [jQuery](https://jquery.org/) -* [Bootstrap](https://getbootstrap.com/) -* [PyYAML](https://pyyaml.org/) -* [requests](https://requests.readthedocs.io/en/latest/) -* [fuzzywuzzy](https://github.com/seatgeek/fuzzywuzzy) -* [wikipedia](https://github.com/goldsmith/Wikipedia) -* [pyinstaller](https://pyinstaller.org/) - -## Disclaimer - -This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Twitch Interactive, Inc. +# Twitch TextToSpeech Bot + +![Latest Release](https://img.shields.io/gitlab/v/release/38486705) ![License](https://img.shields.io/gitlab/license/38486705) ![Maintenance](https://img.shields.io/maintenance/yes/2022) + +A simple Twitch TTS bot (Web Speech API) + +## Description + +The goal of this project is to provide a simple to use Text to Speech IRC bot. It's mainly focused on Twitch, but might be easily adapt to other IRC chats as well. Anyway, right now some parts are Twitch specific (like `CAP REQ :twitch.tv/commands twitch.tv/tags`). There are some other projects providing TTS for IRC based chats, like [IRC Radio (TTS)](https://play.google.com/store/apps/details?id=com.earthflare.android.ircradio&hl=en&gl=US), but they often are missing moderation features like Black-/Whitelists or deletion of single messages, before they are read. Therefore I created my own TTS bot, providing those features. Another very important aspect was that you should use almost any device capable running an HTML5-webbrowser as output. Therefore the bot uses a client-server "architecture". You can run the bot - for example - on your IRL server, but use your phone as output device. A mobile only solution always have the disadvantage of loosing messages, e.g. when your mobile connection drops. + +The project consits of a very simple IRC client, which monitors the incoming messages. If a valid​⃰ `!tts` command is detected the message will send into a queue. Depending on your config it will wait there a few seconds for deletion by you/your moderators. If nobody deletes the message, it will send into another queue. This queue will get fetched by the HTML frontend, which will be delivered by an internal webserver (backend). The HTML frontend will use the [Web Speech API](https://wicg.github.io/speech-api/) included in any modern webbrowser to read the incoming TTS message. When this is done it will report back to the webserver and the message will be removed from the queue. + +The server part is written in Python. The TTS part is written in Javascript. + +By using Javascript for the actual TTS part it's not only very easy to access the Web Speech API and the underlying Speech features of your OS, it also makes it possible to use a wide range of devices to actually play the TTS output. You can start the backend on your PC/Server and open the frontend on your Android/iOS tablet or Mobile Phone. If you expose the backend to the internet (I would recommend to use a reverse proxy, rather than exposing the backend directly) you can also use the TTS bot on the go (e.g. your IRL setup). + +(⃰sender is not on the blacklist, message is not too long, etc.) + +## Getting Started + +### Dependencies + +* Browser with Web Speech API support +* Web Speech API Voices (usually already included in your OS and/or browser) +* If you use `tts.py` see `requirements.txt`. If you use `tts.exe` all dependencies are included. + +### Installing + +1. Clone the repo, or download and unzip the newest [Release](https://gitlab.com/gpvkt/twitchtts/-/releases) +2. Rename/copy `config-dist.yml` to `config.yml` +3. Adapt `config.yml` to your needs. See `Configuration` for details. + +### Configuration + +Please use `UTF-8` as encoding, when editing `config.yml`. + +Example: + +``` lang=yaml +irc: + channel: "#gpkvt" + username: "ttsbot" + oauth_token: "oauth:ohkoace0wooghue8she9xaN0nooSau" + server: "irc.chat.twitch.tv" + clearmsg_timeout: 10 + +http: + port: 80 + bind: "localhost" + +bot: + start_enabled: True + subonly: False + modonly: False + message_length: 200 + language: de + +messages: + toff: "TTS is now inactive." + ton: "TTS is now active." + too_long: "Sorry, your TTS message is too long." + disabled: "Sorry, TTS is disabled right now." + denied: "Sorry, you are not allowed to use TTS." + subonly: "Sorry, TTS is a sub-only feature." + 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. :(" + voteresult: "Voting has ended. The result is:" + votes: "Votes" + pickstart: "Pick started. Send #pickme to participate." + pickresult: "Pick ended. The results are:" + picknone: "Nobody was picked. :(" + quotenotfound: "Sorry, no quote found." + quoteaddedprefix: "Quote:" + quoteaddedsuffix: "added." + wiki_too_many: "Sorry, there are too many possible results. Try a more narrow search." + wiki_no_result: "Sorry, there was an error fetching the wikipedia answer." + +log: + level: "INFO" + +usermapping: + gpkvt: "gpk" + +whitelist: + +``` + +#### Explanation + +##### irc + +* `channel`: Channel you want to monitor (e.g. #gpkvt) +* `username`: The bots username (e.g. gpkvt) +* `oauth_token`: The bots OAUTH-Token (e.g. oauth:ohkoace0wooghue8she9xaN0nooSau) +* `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://html.duckduckgo.com/html/?q=twitch+oauth+token+generator). The bot will need `chat:edit` and `chat:read` permissions. + +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) +* `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 +* `language`: Language for `!wiki` command + +##### messages + +* `toff`: The bots reply when `!toff` is used. +* `ton`: The bots reply when `!ton` is used. +* `too_long`: The bots reply if message exceeds `message_length` +* `disabled`: The bots reply if TTS is disabled +* `denied`: The bots reply if the user is not allowed to use TTS +* `subonly`: The bots reply if `subonly` is active and the user isn't one. +* `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 was started. +* `voteend`: Message when a quickvote ends. +* `votenobody`: Message when quickvote ends, but nobody has voted. +* `voteresult`: Prefix for the result (will be read out). +* `votes`: Suffix to vote count. +* `pickstart`: Message when `!pick` was started. +* `pickresult`: Message when `!pick` ends. +* `picknone`: Message if nobody was picked. +* `quotenotfound`: Message if requests quote wasn't found. +* `quoteaddedprefix`: Prefix for `Quote added` message. +* `quoteaddedsuffix`: Suffix for `Quote added` message. +* `wiki_too_many`: Message if `!wiki` command has more than one result. +* `wiki_no_result`: Message if `!wiki` command hasn't a valid result. + +##### log + +* `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). + +Please note: The key (real username) MUST be lowercase. + +##### whitelist + +You can add a whitelist section to `config.yml`, a whitelist will override any other settings like `subonly` and `modonly`. Only users on the whitelist are allowed to use `!tts`. Broadcasters and mods can temporarily add users (including themselfs) to the whitelist by using the `!ptts` command, though. + +A whitelist looks as follows: + +``` lang=yaml +whitelist: + - gpkvt + - foo + - bar +``` + +To disable the whitelist, remove it from `config.yml` completely. If you just leave `whitelist:` without entries, everyone must be whitelisted using `!ptts` (even broadcaster and mods). The permit is temporary until the bot restarts or (whichever happens first) if the user is removed from the whitelist using `!dtts`. + +Please note: Usernames MUST be lowercase. + +### Executing program + +Execute `tts.exe` (or `tts.py` if you have Python installed), open the TTS webpage in your browser (the URL depends on your `bind` and `port` configuration, usually it's just `http://localhost`). Click the `Init` button at the button of the TTS webpage (you should hear `Init complete`). + +Connect to the configured Twitch channel and send a message starting with `!tts`. After a few seconds (depending on your `clearmsg_timeout` config), the message should be read. + +### Additional Commands + +Additional commands (broadcaster and mods only) are: + +* `!ping`: Check if bot is alive (the bot should reply: `Pong!`) +* `!toff`: Turn TTS off (will also empty the current TTS queue) +* `!ton`: Turn TTS back on +* `!dtts `: Disable TTS for the given user +* `!ptts `: Allow TTS for the given user +* `!usermap `: Add an entry to the usermapping in `config.yml` +* `!delay `: Adjust the `clearmsg_timeout` in `config.yml` + +### Additional features + +#### !quickvote + +A simple vote system. + +**Usage:** + +1. Broadcaster/Mods: `!quickvote ` +2. User: `#choice` +3. Broadcaster/Mods: `!quickvote` + +**Example:** + +``` lang=text +mod: !quickvote Is pizza hawaii any good? #yes/#no +chat: #no +mod: !quickvote +bot: The result is: #no +``` + +If a message is given by the Mods it will be repeated every 60 seconds, so everyone keeps in mind, that a vote is still active. + +#### !pick + +Randomly choose users from chat (who wants to participate). + +**Usage:** + +1. Broadcaster/Mods: `!pick ` +2. User: #pickme +3. Broadcaster/Mods: `!pick` + +**Example:** + +``` lang=text +mod: !pick 3 +user1: #pickme +user2: #pickme +user3: #pickme +user4: #pickme +user5: #pickme +mod: !pick +bot: The picked users are: user5, user3, user1 +``` + +#### !random + +Picks a random line from a file. + +**Usage:** + +* Broadcaster/Mod: `!random ` + +You can use multiple files, if you call `!random foo` the bot fetch the random line from a file called `random_foo.txt`, `!random bar` will use the file `random_bar.txt` and so on. If no `` is given the command use the file `random.txt`. + +**Example:** + +``` lang=text +mod: !random +bot: This is a random message +``` + +#### !addquote + +The `!addquote` command adds a new line to `quotes.txt`. + +**Usage:** + +* Chat: `!addquote ` + +**Example:** + +``` lang=text +chat: !addquote gpkvt This is a very funny quote. +bot: Quote #1 was added. +``` + +#### !smartquote / !sq + +Picks a random/specific line from `quotes.txt`. + +**Usage:** + +* Chat: `!smartquote` +* Chat: `!smartquote ` +* Chat: `!smartquote ` +* Chat: `!sq` +* Chat: `!sq ` +* Chat: `!sq ` + +(`!sq` is an alias for `!smartquote`) + +**Example:** + +``` lang=text +chat: !smartquote +bot: This is a random quote +chat: !smartquote 1000 +bot: This is quote #1000 +chat: !smartquote something stupid +bot: This is a quote containing something stupid (or similiar) +``` + +The format of `quotes.txt` looks as follows: + +``` lang=text +#1: "the quote" -username/game (date) +``` + +#### !wiki + +Search the Wikipedia. + +**Usage:** + +* Chat: `!wiki ` + +**Example:** + +``` lang=text +chat: !wiki 42 answer +bot: 42 is the "Answer to the Ultimate Question of Life, the Universe, and Everything". +``` + +## Build + +If you prefer to build your own `tts.exe` instead of using the shipped one, you can do as follows: + +* Install Python 3 +* Install pyinstaller: `pip install pyinstaller` +* Install the required dependencies: `pip install -r requirements.txt -v` +* Create the executeable: `pyinstaller --onefile tts.py` + +## Voices + +The voices available depend on your Operating System and/or browser. On some systems only a default voice is available and the `Select voice` dropdown might stay empty or will only show entries after you clicked the `Init` button. Some Android devices will show a huge list of voices, but sounds the same no matter which one you choose. + +On Windows you can install additional voices via `Settings` > `Time & language` > `Speech` > `Add voices` or by simply run `Add speech voices`. + +Do not use Online-Voices, as this will result in serious delays. + +## Help + +Feel free to use the [Issuetracker](https://gitlab.com/gpvkt/twitchtts/-/issues) or send an [E-Mail](mailto:contact-project+gpvkt-twitchtts-38486705-issue-@incoming.gitlab.com) if you experience any problems. + +## Authors + +[@gpkvt](mailto:contact-project+gpvkt-twitchtts-38486705-issue-@incoming.gitlab.com) + +## Version History + +See [CHANGELOG.md](https://gitlab.com/gpvkt/twitchtts/-/blob/main/CHANGELOG.md) + +## License + +This project is licensed under the GPLv3 License - see [LICENSE](https://gitlab.com/gpvkt/twitchtts/-/blob/main/LICENSE) for details. + +## Acknowledgments + +### Ideas and Testing + +* [GERBrowny and community](https://www.twitch.tv/gerbrowny/) ![Emote](https://static-cdn.jtvnw.net/emoticons/v2/303172270/static/light/1.0) +* [DerZugger and community](https://www.twitch.tv/derzugger/) ![Emote](https://static-cdn.jtvnw.net/emoticons/v2/302400142/static/light/1.0) +* [Timmeh74 and community](https://www.twitch.tv/timmeh74/) ![Emote](https://static-cdn.jtvnw.net/emoticons/v2/300192675/static/light/1.0) + +### Libraries + +* [Python](https://www.python.org/) +* [jQuery](https://jquery.org/) +* [Bootstrap](https://getbootstrap.com/) +* [PyYAML](https://pyyaml.org/) +* [requests](https://requests.readthedocs.io/en/latest/) +* [fuzzywuzzy](https://github.com/seatgeek/fuzzywuzzy) +* [wikipedia](https://github.com/goldsmith/Wikipedia) +* [pyinstaller](https://pyinstaller.org/) + +## Disclaimer + +This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Twitch Interactive, Inc. diff --git a/bootstrap.min.css b/bootstrap.min.css index 4c89912..8f5f5c7 100644 --- a/bootstrap.min.css +++ b/bootstrap.min.css @@ -1,6 +1,6 @@ -@charset "UTF-8";/*! - * Bootstrap v5.2.0 (https://getbootstrap.com/) - * Copyright 2011-2022 The Bootstrap Authors - * Copyright 2011-2022 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) +@charset "UTF-8";/*! + * Bootstrap v5.2.0 (https://getbootstrap.com/) + * Copyright 2011-2022 The Bootstrap Authors + * Copyright 2011-2022 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}p{margin-top:0;margin-bottom:1rem}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,select{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.container{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}@media (min-width:1400px){.container{max-width:1320px}}.form-label{margin-bottom:.5rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.375rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:#212529;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0.375rem;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn:focus{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn:active:focus{box-shadow:var(--bs-btn-focus-box-shadow)}.btn:disabled{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0;mask-position:-200% 0}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0;mask-position:-200% 0}}.text-bg-success{color:#fff!important;background-color:RGBA(25,135,84,var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(13,202,240,var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(255,193,7,var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(220,53,69,var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(248,249,250,var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(33,37,41,var(--bs-bg-opacity,1))!important}.link-success{color:#198754!important}.link-success:focus,.link-success:hover{color:#146c43!important}.link-info{color:#0dcaf0!important}.link-info:focus,.link-info:hover{color:#3dd5f3!important}.link-warning{color:#ffc107!important}.link-warning:focus,.link-warning:hover{color:#ffcd39!important}.link-danger{color:#dc3545!important}.link-danger:focus,.link-danger:hover{color:#b02a37!important}.link-light{color:#f8f9fa!important}.link-light:focus,.link-light:hover{color:#f9fafb!important}.link-dark{color:#212529!important}.link-dark:focus,.link-dark:hover{color:#1a1e21!important}.d-flex{display:flex!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.text-start{text-align:left!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important} \ No newline at end of file diff --git a/config-dist.yml b/config-dist.yml index b3a3061..c96f9d9 100644 --- a/config-dist.yml +++ b/config-dist.yml @@ -1,48 +1,48 @@ -irc: - channel: "#changeme" # Your twitch channel - username: "changeme" # Your bots username - oauth_token: # leave this blank to start the token generator - server: "irc.chat.twitch.tv" # Twitch IRC server, default should be fine - clearmsg_timeout: 10 # Time to wait for a new message to be deleted before being read - -http: - port: 80 # Internal webserver port - bind: "localhost" # Internal webserver IP/Hostname, use 0.0.0.0 to listen on all interfaces - -bot: - start_enabled: True # Start the bot with TTS active, if set to False you need to enable TTS with !ton - subonly: False # Only Subs can use TTS - modonly: False # Only Mods can use TTS - message_length: 200 # Max. TTS message length - language: de # Language for wikipedia - -messages: # Things the bot can send as chat message - toff: "TTS is now inactive." - ton: "TTS is now active." - too_long: "Sorry, your TTS message is too long." - disabled: "Sorry, TTS is disabled right now." - denied: "Sorry, you are not allowed to use TTS." - whitelist: "Sorry, you are not allowed to use TTS." - subonly: "Sorry, TTS is a sub-only feature." - 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. :(" - voteresult: "Voting has ended. The result is:" - votes: "Votes" - pickstart: "Pick started. Send #pickme to participate." - pickresult: "Pick ended. The results are:" - picknone: "Nobody was picked. :(" - quotenotfound: "Sorry, no quote found." - quoteaddedprefix: "Quote:" - quoteaddedsuffix: "added." - wiki_too_many: "Sorry, there are too many possible results. Try a more narrow search." - wiki_no_result: "Sorry, there was an error fetching the wikipedia answer." - -log: - level: "INFO" # Loglevel, valid values are: DEBUG, INFO, WARNING, ERROR, CRITICAL (do not use DEBUG in a production environment) - -usermapping: - gpkvt: "gpk" # rename users when being read, to make their names shorter or sound better. +irc: + channel: "#changeme" # Your twitch channel + username: "changeme" # Your bots username + oauth_token: # leave this blank to start the token generator + server: "irc.chat.twitch.tv" # Twitch IRC server, default should be fine + clearmsg_timeout: 10 # Time to wait for a new message to be deleted before being read + +http: + port: 80 # Internal webserver port + bind: "localhost" # Internal webserver IP/Hostname, use 0.0.0.0 to listen on all interfaces + +bot: + start_enabled: True # Start the bot with TTS active, if set to False you need to enable TTS with !ton + subonly: False # Only Subs can use TTS + modonly: False # Only Mods can use TTS + message_length: 200 # Max. TTS message length + language: de # Language for wikipedia + +messages: # Things the bot can send as chat message + toff: "TTS is now inactive." + ton: "TTS is now active." + too_long: "Sorry, your TTS message is too long." + disabled: "Sorry, TTS is disabled right now." + denied: "Sorry, you are not allowed to use TTS." + whitelist: "Sorry, you are not allowed to use TTS." + subonly: "Sorry, TTS is a sub-only feature." + 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. :(" + voteresult: "Voting has ended. The result is:" + votes: "Votes" + pickstart: "Pick started. Send #pickme to participate." + pickresult: "Pick ended. The results are:" + picknone: "Nobody was picked. :(" + quotenotfound: "Sorry, no quote found." + quoteaddedprefix: "Quote:" + quoteaddedsuffix: "added." + wiki_too_many: "Sorry, there are too many possible results. Try a more narrow search." + wiki_no_result: "Sorry, there was an error fetching the wikipedia answer." + +log: + level: "INFO" # Loglevel, valid values are: DEBUG, INFO, WARNING, ERROR, CRITICAL (do not use DEBUG in a production environment) + +usermapping: + gpkvt: "gpk" # rename users when being read, to make their names shorter or sound better. diff --git a/requirements.txt b/requirements.txt index b795cd9..146254d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -fuzzywuzzy==0.18.0 -PyInstaller==5.3 -PyYAML==6.0 -requests==2.28.1 -wikipedia==1.4.0 +fuzzywuzzy==0.18.0 +PyInstaller==5.3 +PyYAML==6.0 +requests==2.28.1 +wikipedia==1.4.0 diff --git a/tts.html b/tts.html index c1249f5..1a1c0c1 100644 --- a/tts.html +++ b/tts.html @@ -1,49 +1,49 @@ - - - - - TTS - - - -

Select Voice

- -
-
-

Volume

- - 1 -
-
-

Rate

- - 1 -
-
-

Pitch

- - 1 -
-
-
- - - - -
- - - - + + + + + TTS + + + +

Select Voice

+ +
+
+

Volume

+ + 1 +
+
+

Rate

+ + 1 +
+
+

Pitch

+ + 1 +
+
+
+ + + + +
+ + + + diff --git a/tts.py b/tts.py index d057cfd..0a605e6 100644 --- a/tts.py +++ b/tts.py @@ -1,1317 +1,1317 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# pylint: disable=line-too-long,too-many-lines - -""" - TwitchTTS - Copyright (C) 2022 gpkvt - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -""" - -import os -import sys -import time -import json -import signal -import socket -import random -import logging -import datetime -import webbrowser -import urllib.request - -from threading import Thread -from collections import Counter -from urllib.parse import parse_qs -from urllib.error import HTTPError -from http.server import HTTPServer, BaseHTTPRequestHandler -from socketserver import ThreadingMixIn - -import yaml -import requests -import wikipedia - -from fuzzywuzzy import process - -class IRC: - """ IRC bot """ - irc = socket.socket() - - def __init__(self): - self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.tts_denied = [] - self.tts_allowed = [] - self.tts_status = conf['TTS_STARTENABLED'] - self.quickvote_status = False - self.pick_status = False - self.votemsg = False - self.poll = {} - self.pollcount = 0 - self.pickme = [] - self.picknumber = 1 - self.pickcount = 0 - - if 'WHITELIST_USER' in conf: - self.tts_allowed = conf['WHITELIST_USER'] - - def connect(self, server, port, channel, botnick, botpass): - """ Connect to Twitch IRC servers """ - logging.info("Connecting to: %s", server) - logging.info('Waiting...') - try: - self.irc.connect((server, port)) - except ConnectionResetError: - logging.fatal('Twitch refused to connect, please check your settings and try again.') - sys.exit(252) - - 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("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) - - try: - self.irc.send(bytes("JOIN " + channel + "\r\n", "UTF-8")) - except ConnectionResetError: - logging.warning('JOIN was refused, will try again in 30 seconds.') - time.sleep(30) - logging.warning('Please check your credentials, if this error persists.') - self.irc.send(bytes("JOIN " + channel + "\r\n", "UTF-8")) - - 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 resp_ping(self): - """ Respond to PING """ - logging.debug('PING received') - self.irc.send(bytes('PONG :tmi.twitch.tv\r\n', "UTF-8")) - - def resp_notice(self, resp): - """ Respond to NOTICE """ - if 'Login authentication failed' in resp: - try: - raise RuntimeError() - except RuntimeError: - logging.exception('Login failed, please check your credentials and try again.') - sys.exit(251) - - def resp_clearmsg(self, resp): - """ Respond to CLEARMSG """ - logging.info('CLEARMSG received') - msgid = False - - global msg_queue_raw # pylint: disable=global-statement,invalid-name - filtered_msg_queue = [] - - tags = resp.split(';') - for tag in tags: - if "target-msg-id=" in tag: - msgid = tag.rsplit('target-msg-id=',1)[1] - logging.debug('Trying to suppress message') - logging.debug(msgid) - - for msg in list(msg_queue_raw): - if msg['msgid'] == msgid: - logging.info('Suppressing message %s', msgid) - else: - filtered_msg_queue.append(msg) - - msg_queue_raw = filtered_msg_queue - - def resp_privmsg(self, resp): - """ Respond to PRIVMSG """ - logging.debug('PRIVMSG received') - - tags = self.get_tags(resp) - message = self.get_message(resp) - - user = tags['user'] - msg = message['message'] - msglen = message['length'] - - logging.debug('Msg: %s', msg) - logging.debug('Msg length: %s', msglen) - logging.debug('Deny List:') - logging.debug(self.tts_denied) - - self.priviledged_commands(message, tags) - - if msg.startswith('#pickme') and self.pick_status is True: - logging.info('Pickme detected') - self.pickcount = self.pickcount + 1 - self.pickme.append(user) - logging.debug("pickme %s added", user) - return - - if msg.startswith('#') and self.quickvote_status is True: - logging.info('Quickvote: Cast detected') - self.pollcount += 1 - self.poll[user] = msg.lower() - logging.debug("poll: %s", self.poll) - return - - if msg.startswith('!tts'): - logging.info('!tts command detected') - self.Commands.tts(self, message, tags) - return - - if msg.startswith('!addquote'): - logging.debug("!addquote command detected") - self.Commands.addquote(self, tags, msg) - return - - if msg.startswith('!wiki'): - logging.debug("!wiki command detected") - self.Commands.wiki(self, tags, msg) - return - - if msg.startswith('!smartquote') or msg.startswith('!sq'): - logging.debug("!smartquote command detected") - self.Commands.quote(self, tags, msg) - return - - def get_tags(self, resp): - """ Strip tags from response """ - - tags = resp.split(';') - for tag in tags: - if tag.startswith('badges='): - badges = tag.rsplit('badges=',1)[1] - if tag.startswith('subscriber='): - subscriber = tag.rsplit('subscriber=',1)[1] - logging.debug('Subscriber: %s', subscriber) - if tag.startswith('id='): - msgid = tag.rsplit('id=',1)[1] - logging.debug('Message ID: %s', msgid) - if tag.startswith('display-name='): - user = tag.rsplit('display-name=',1)[1].lower() - logging.debug('Username: %s', user) - - tags = {} - tags['badges'] = badges - tags['subscriber'] = subscriber - tags['msgid'] = msgid - tags['user'] = user - - return tags - - def get_message(self, resp): - """ Process message """ - - msg = {} - msg['message'] = resp.rsplit('PRIVMSG #',1)[1].split(':',1)[1].replace('\r\n','') - msg['length'] = len(msg['message']) - - return msg - - def priviledged_commands(self, message, tags): - """ Process priviledged commands """ - - msg = message['message'] - badges = tags['badges'] - user = tags['user'] - - if 'broadcaster' in badges or 'moderator' in badges: - if msg.startswith('!ping'): - logging.debug("Ping check received.") - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), "Pong!") - - elif msg.startswith('!version'): - logging.debug("!version command detected") - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), VERSION) - - elif msg.startswith('!pick'): - logging.debug("!pick command detected") - self.Commands.pick(self, msg) - - elif msg.startswith('!dtts'): - logging.debug("!dtts command detected") - self.Commands.dtts(self, msg) - - elif msg.startswith('!random'): - logging.info('!random command detected') - self.Commands.random(self, msg) - - elif msg.startswith('!quickvote'): - logging.info("!quickvote command detected") - self.Commands.quickvote(self, msg) - - elif msg.startswith('!ptts'): - logging.debug("!ptts command detected") - self.Commands.ptts(self, msg) - - elif msg.startswith('!usermap'): - logging.info('!usermap command detected') - self.Commands.usermap(self, msg) - - elif msg.startswith('!delay'): - logging.info('!delay command detected') - self.Commands.delay(self, msg) - - elif msg.startswith('!toff'): - logging.info('TTS is now turned off') - msg_queue.clear() - msg_queue_raw.clear() - self.tts_status = False - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOFF']) - - elif msg.startswith('!ton'): - logging.info('TTS is now turned on') - msg_queue.clear() - msg_queue_raw.clear() - self.tts_status = True - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TON']) - - def check_subonly(self, tags): - """ subonly """ - - if not conf['IRC_SUBONLY']: - return False - - subscriber = tags['subscriber'] - badges = tags['badges'] - user = tags['user'] - - if subscriber != "0" or 'moderator' in badges or 'broadcaster' in badges: - logging.info('TTS is sub-only and user has allowance') - return False - - logging.debug('TTS is sub-only') - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['SUBONLY']) - return True - - def check_modonly(self, tags): - """ modonly """ - - if not conf['IRC_MODONLY']: - return False - - badges = tags['badges'] - user = tags['user'] - - if 'moderator' in badges or 'broadcaster' in badges: - logging.info('TTS is mod-only and user has allowance') - return False - - logging.debug('TTS is mod-only') - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['MODONLY']) - return True - - def check_tts_disabled(self, user): - """ Check if TTS is disabled """ - if not self.tts_status: - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DISABLED']) - return True - - logging.debug('TTS is enabled') - return False - - def check_msg_too_long(self, message, user): - """ Check if message is too long """ - - if message['length'] > conf['IRC_TTS_LEN']: - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOO_LONG']) - return True - - logging.debug('Check length: Message is ok') - return False - - def check_user_denied(self, user): - """ Check if user is on denied list """ - if user in self.tts_denied: - logging.info("%s is not allowed to use TTS", user) - self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DENIED']) - return True - - logging.debug("%s is allowed to use TTS", user) - return False - - def check_whitelist(self, user): - """ Check Whitelist """ - - if conf['WHITELIST']: - if user not in self.tts_allowed: - logging.debug("tts_allowed: %s", self.tts_allowed) - self.sendmsg( - conf['IRC_CHANNEL'], - "@"+str(user), conf['MESSAGE']['WHITELISTONLY'] - ) - return False - return True - return False - - def send_tts_msg(self, message, tags): - """ Send message to TTS queue """ - logging.info('Valid TTS message, adding to raw queue') - - tts = True - now = datetime.datetime.now() - timestamp = str(time.time_ns()) - - user = tags['user'] - msgid = tags['msgid'] - badges = tags['badges'] - subscriber = tags['subscriber'] - - msg = message['message'] - msglen = message['length'] - msg = msg.replace('!tts','',1) - - msg = { - "TTS": tts, - "msg": msg, - "badges": badges, - "subscriber": subscriber, - "msgid": msgid, - "user": user, - "length": msglen, - "queuetime": now, - "timestamp": timestamp - } - - msg_queue_raw.append(msg) - - 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 - except Exception: # pylint: disable=broad-except - logging.exception('An unknown error occured while getting a IRC response.') - sys.exit(255) - - if resp.find('PING') != -1: - self.resp_ping() - - if resp.find('CLEARMSG') != -1: - self.resp_clearmsg(resp) - - if resp.find('NOTICE') != -1: - self.resp_notice(resp) - - if resp.find('PRIVMSG') != -1: - self.resp_privmsg(resp) - - class Commands(): - """ Bot commands """ - - def __init__(self): - self.tts_denied = [] - self.tts_allowed = [] - self.quickvote_status = self.quickvote_status - self.pick_status = self.pick_status - self.votemsg = self.votemsg - self.poll = self.poll - self.pollcount = self.pollcount - self.pickme = self.pickme - self.picknumber = self.picknumber - self.pickcount = self.pickcount - - def tts(self, msg, tags): - """ !tts command - - Check if message is valid and send it to queue - - :param str msg: The IRC message triggering the command - :param dict tags: The message metadata - """ - - user = tags['user'] - - if IRC.check_tts_disabled(self, user): - logging.info('TTS is disabled') - elif IRC.check_msg_too_long(self, msg, user): - logging.info('TTS message is too long') - elif IRC.check_user_denied(self, user): - logging.info('User is not allowed to use TTS') - elif IRC.check_subonly(self, tags): - logging.info('TTS is sub-only') - elif IRC.check_modonly(self, tags): - logging.info('TTS is mod-only') - elif IRC.check_whitelist(self, user): - logging.info('User is not on whitelist') - else: - logging.info('Sending TTS message to raw_queue') - IRC.send_tts_msg(self, msg, tags) - - def addquote(self, tags, msg): - """ !addquote command - - Adds a newline to quotes.txt - """ - - user = tags['user'] - - if IRC.check_user_denied(self, user): - logging.info('User is not allowed to use TTS') - elif IRC.check_subonly(self, tags): - logging.info('TTS is sub-only') - else: - try: - with open("quotes.txt", "rb") as file: - nol = len(file.readlines()) - file.close() - except FileNotFoundError: - logging.warning("quotes.txt does not exists, will create") - nol = 0 - - nol = nol + 1 - quote = msg.replace("!addquote ", "").strip() - quote = quote.split(" ",1) - username = quote[0] - - date = time.strftime("%d.%m.%Y") - - try: - token = conf['IRC_OAUTH_TOKEN'].replace('oauth:','') - login = conf['IRC_CHANNEL'].replace('#','') - api_endpoint = "https://api.twitch.tv/helix/users?login="+str(login) - headers = { - 'Content-type': 'application/x-form-urlencoded', - 'Authorization': 'Bearer '+token, - 'Client-Id': 'ebo548vs6tq54c9zlrgin2yfzzlrrs' - } - req = requests.get(url=api_endpoint, headers=headers) - data = req.json() - user_id = data['data'][0]['id'] - - api_endpoint = "https://api.twitch.tv/helix/channels?broadcaster_id="+str(user_id) - headers = { - 'Content-type': 'application/x-form-urlencoded', - 'Authorization': 'Bearer '+token, - 'Client-Id': 'ebo548vs6tq54c9zlrgin2yfzzlrrs' - } - req = requests.get(url=api_endpoint, headers=headers) - data = req.json() - game = data['data'][0]['game_name'] - - quote = f"#{nol}: \"{quote[1]}\" -{username}/{game} ({date})\n" - except: # pylint: disable=bare-except - logging.warning('Could not get metadata for quote') - quote = f"#{nol}: \"{quote[1]}\" -{username} ({date})\n" - - logging.info('Adding quote %s', quote) - with open("quotes.txt", "ab") as file: - file.write(quote.encode('utf-8')) - - msg = f"{conf['MESSAGE']['QUOTE_ADDED_PREFIX']} #{nol} {conf['MESSAGE']['QUOTE_ADDED_SUFFIX']}" - - raw_msg = { - "TTS": True, - "msg": msg, - "badges": True, - "subscriber": True, - "msgid": True, - "user": conf['IRC_USERNAME'], - "length": conf['IRC_TTS_LEN'], - "queuetime": datetime.datetime.now(), - "timestamp": str(time.time_ns()) - } - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), msg) - - def wiki(self, tags, msg): - """ !wiki command """ - - try: - user = tags['user'] - wikipedia.set_lang(conf['WIKI_LANG']) - msg = msg.replace('!wiki', '').strip() - wikiresult = wikipedia.summary(msg, sentences=3) - - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), wikiresult) - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), wikipedia.page(msg).url) - - wikiresult = wikiresult.replace('==', '') - - raw_msg = { - "TTS": True, - "msg": wikiresult, - "badges": True, - "subscriber": True, - "msgid": True, - "user": 'wikipedia', - "length": conf['IRC_TTS_LEN'], - "queuetime": datetime.datetime.now(), - "timestamp": str(time.time_ns()) - } - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - - except wikipedia.exceptions.DisambiguationError: - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['WIKI_TOO_MANY']) - except: # pylint: disable=bare-except - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['WIKI_NO_RESULT']) - - def quote(self, tags, msg = False): - """ !smartquote command - - Gets a line from quotes.txt. If a number if given as msg - it fetch the given line number. If a string is given - it fetch the best matching line. If nothing is given - it fetch a random line. - """ - - try: - user = tags['user'] - query = msg.replace('!smartquote', '').strip() - query = msg.replace('!sq', '').strip() - - if query.isdigit(): - logging.info('Fetching quote #%s', query) - - file = open("quotes.txt", "rb") - quotes = file.readlines() - - for line in quotes: - if line.decode('utf-8').startswith("#"+str(query)+":"): - quote = line - break - file.close() - - elif query != "": - logging.info('Fetching match for %s', query) - - file = open("quotes.txt", "rb") - quotes = file.readlines() - matches = process.extract(query, quotes, limit=10) - quotes = [] - - for match, score in matches: - if score >= 60: - quotes.append(match) - - logging.debug('Quotes: %s', quotes) - if len(quotes) >= 5: - quote = random.choice(quotes) - else: - quote = quotes[0] - - else: - logging.info('Fetching random quote') - - with open("quotes.txt", "rb") as file: - lines = file.read().splitlines() - quote = random.choice(lines) - except FileNotFoundError: - logging.error('"quotes.txt does not exists.') - except IndexError: - logging.error('Error fetching quote.') - - if not 'quote' in vars(): - logging.info('No quote found.') - quote = conf['MESSAGE']['QUOTE_NOT_FOUND'] - IRC.sendmsg(self, conf['IRC_CHANNEL'], "", quote) - return False - - if not isinstance(quote, str): - quote = quote.decode('utf-8') - - if IRC.check_tts_disabled(self, user): - logging.info('TTS is disabled') - elif IRC.check_user_denied(self, user): - logging.info('User is not allowed to use TTS') - elif IRC.check_subonly(self, tags): - logging.info('TTS is sub-only') - elif IRC.check_modonly(self, tags): - logging.info('TTS is mod-only') - elif IRC.check_whitelist(self, user): - logging.info('User is not on whitelist') - else: - logging.info('Sending quote to TTS') - logging.debug("Quote: %s", quote) - IRC.sendmsg(self, conf['IRC_CHANNEL'], "", quote) - - quote = quote.rsplit('(', 1)[0] - - raw_msg = { - "TTS": True, - "msg": quote, - "badges": True, - "subscriber": True, - "msgid": True, - "user": conf['IRC_USERNAME'], - "length": conf['IRC_TTS_LEN'], - "queuetime": datetime.datetime.now(), - "timestamp": str(time.time_ns()) - } - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - - return True - - def delay(self, msg): - """ !delay command - - Adjust the delay setting in config.yml - - :param str msg: The IRC message triggering the command - """ - - try: - delay = msg.split(' ')[1] - except: # pylint: disable=bare-except - delay = False - - if delay: - with open('config.yml','r', encoding='utf-8') as yamlfile: - cur_yaml = yaml.safe_load(yamlfile) - cur_yaml['irc']['clearmsg_timeout'] = int(delay) - - if cur_yaml: - with open('config.yml','w', encoding='utf-8') as yamlfile: - yaml.safe_dump(cur_yaml, yamlfile) - load_config() - - - def usermap(self, msg): - """ !usermap command - - Adds new entries to usermapping in config.yml - - :param str msg: The IRC message triggering the command - """ - - try: - msg = msg.replace('!usermap ', '') - splitmsg = msg.split(" ") - username, *mappingname = splitmsg - mappingname = ' '.join(mappingname) - except: # pylint: disable=bare-except - username = False - mappingname = False - - if username and mappingname: - with open('config.yml','r', encoding='utf-8') as yamlfile: - cur_yaml = yaml.safe_load(yamlfile) - cur_yaml['usermapping'].update({username: mappingname}) - - if cur_yaml: - with open('config.yml','w', encoding='utf-8') as yamlfile: - yaml.safe_dump(cur_yaml, yamlfile) - load_config() - - def pick(self, msg): - """ !pick command """ - - if self.pick_status: - logging.info('Pick stopped') - logging.debug("Got %s participats, wanted %s", self.pickcount, self.picknumber) - - try: - if int(self.pickcount) > int(self.picknumber): - picks = random.sample(self.pickme, self.picknumber) - logging.info('Got more than the requested number of participants') - else: - picks = self.pickme - logging.info('Got less than or exactly the requested number of participants') - - converted_picks = [str(element) for element in picks] - joined_picks = " ".join(converted_picks) - except: # pylint: disable=bare-except - logging.error("There was an error during picking.") - joined_picks = False - - if joined_picks: - raw_msg = { - "TTS": True, - "msg": conf['MESSAGE']['PICKRESULT'] +" "+ str(joined_picks), - "badges": True, - "subscriber": True, - "msgid": True, - "user": conf['IRC_USERNAME'], - "length": conf['IRC_TTS_LEN'], - "queuetime": datetime.datetime.now(), - "timestamp": str(time.time_ns()) - } - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - - IRC.sendmsg(self, - conf['IRC_CHANNEL'], "", - conf['MESSAGE']['PICKRESULT'] - ) - IRC.sendmsg(self, - conf['IRC_CHANNEL'], "*", - joined_picks - ) - else: - IRC.sendmsg(self, - conf['IRC_CHANNEL'], "*", - conf['MESSAGE']['PICKNONE'] - ) - - self.pick_status = False - self.pickme = [] - self.pickcount = 0 - - return - - logging.debug('Pick started') - self.pick_status = True - try: - msg = msg.split(' ')[1].strip() - self.picknumber = msg - except IndexError: - self.picknumber = self.picknumber - - logging.info("Will pick %s participants", self.picknumber) - - IRC.sendmsg(self, - conf['IRC_CHANNEL'], "@chat", - conf['MESSAGE']['PICKSTART'] - ) - return - - def quickvote(self, msg): - """ !quickvote command - - Starts or stops the !quickvote function. On stop calculates the 5 most casted - votes and send them to chat. The highest vote is send to msg_queue. - - :param str msg: The IRC message triggering the command - """ - - if self.quickvote_status: - logging.debug('Quickvote stopped') - - if self.pollcount == 0: - logging.info("Nobody voted") - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) - IRC.sendmsg(self, conf['IRC_CHANNEL'], "*", conf['MESSAGE']['VOTENOBODY']) - - raw_msg = { - "TTS": True, - "msg": conf['MESSAGE']['VOTENOBODY'], - "badges": True, - "subscriber": True, - "msgid": True, - "user": conf['IRC_USERNAME'], - "length": conf['IRC_TTS_LEN'], - "queuetime": datetime.datetime.now(), - "timestamp": str(time.time_ns()) - } - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - - logging.info('The result is: %s', conf['MESSAGE']['VOTENOBODY']) - logging.debug('Votemsg: %s', msg) - - self.quickvote_status = False - self.poll = {} - return - - logging.info("Counting votes") - count = 0 - count = Counter(self.poll.values()).most_common(5) - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) - - logging.debug(count) - - raw_msg = { - "TTS": True, - "msg": conf['MESSAGE']['VOTERESULT'] +" "+ str(count[0][0].replace('#','')), - "badges": True, - "subscriber": True, - "msgid": True, - "user": conf['IRC_USERNAME'], - "length": conf['IRC_TTS_LEN'], - "queuetime": datetime.datetime.now(), - "timestamp": str(time.time_ns()) - } - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - - logging.info('The result is: %s', conf['MESSAGE']['VOTERESULT'] +" "+ str(count[0])) - logging.debug('Votemsg: %s', msg) - - for key, value in count: - IRC.sendmsg( - self, - conf['IRC_CHANNEL'], "*", - str(key)+" ("+str(value)+ " "+ conf['MESSAGE']['VOTES'] + ")" - ) - - self.quickvote_status = False - self.poll = {} - self.pollcount = 0 - return - - logging.debug('Quickvote started') - self.quickvote_status = True - self.votemsg = msg.split('!quickvote', 1)[1].strip() - if self.votemsg: - IRC.sendmsg(self, - conf['IRC_CHANNEL'], "@chat", - conf['MESSAGE']['VOTESTART'] + " (" + str(self.votemsg) + ")" - ) - else: - IRC.sendmsg(self, conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART']) - - return - - def random(self, msg): - """ !random command - - Read a random line from randomfile and put it into msg_queue - If no file is given in msg a standard file will be used - - :param str msg: The IRC message triggering the command - :raise: FileNotFoundError if randomfile does not exists - :return: True if line was successfully read and added to msg_queue - :rtype: bool - """ - - randomfile = msg.replace('!random', '').strip().lower() - - if randomfile: - randomfile = "random_"+str(os.path.basename(randomfile))+".txt" - else: - randomfile = "random.txt" - - try: - with open(randomfile,"r", encoding="utf-8") as file: - lines = file.read().splitlines() - random_msg = random.choice(lines) - except FileNotFoundError: - logging.error('%s not found', randomfile) - return False - - raw_msg = { - "TTS": True, - "msg": random_msg, - "badges": True, - "subscriber": True, - "msgid": True, - "user": conf['IRC_USERNAME'], - "length": conf['IRC_TTS_LEN'], - "queuetime": datetime.datetime.now(), - "timestamp": str(time.time_ns()) - } - msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] - - return True - - def ptts(self, msg): - """ !ptts command - - Add user to tts_allowed list and remove user from tts_denied list - - :param str msg: The IRC message triggering the command - """ - - user = msg.replace('!ptts', '').strip().lower() - - if user.startswith('@'): - logging.debug('Removing "@" from username') - user = user.replace('@', '') - - logging.info("Adding %s to whitelist", user) - self.tts_allowed.append(user) - - if user in self.tts_denied: - logging.info("Removing %s from deny list", user) - self.tts_denied.remove(user) - - return - - def dtts(self, msg): - """ !dtts command - - Add user to tts_denied list and remove user from tts_allowed list - - :param str msg: The IRC message triggering the command - """ - - user = msg.replace('!dtts', '').strip().lower() - - if user.startswith('@'): - logging.debug('Removing "@" from username') - user = user.replace('@', '') - - if user not in self.tts_denied: - logging.info("Adding %s to deny list", user) - self.tts_denied.append(user) - - if user in self.tts_allowed: - logging.info("Removing %s from allowed list", user) - self.tts_allowed.remove(user) - - return - -class ThreadingSimpleServer(ThreadingMixIn, HTTPServer): - """ Threaded HTTP Server """ - -class HTTPserv(BaseHTTPRequestHandler): - """Simple HTTP Server""" - - def log_message(self, format, *args): # pylint: disable=redefined-builtin - """Suppress HTTP log messages""" - return - - 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 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 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 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 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 file: - html = file.read() - self.wfile.write(html) - - elif self.path.startswith('/tts_queue'): - tts_json = "" - tts = {} - self.send_response(200) - self.send_header('Content-type', 'text/json') - self.end_headers() - - usermap = conf['USERMAP'] - sorted_tts = {k: msg_queue[k] for k in sorted(msg_queue, reverse=True)} - logging.debug(usermap) - - for key in list(sorted_tts.keys()): - if key not in tts_done: - if msg_queue[key][0].lower() in usermap: - logging.debug('Using usermap for user: %s (%s)', msg_queue[key][0], usermap[msg_queue[key][0].lower()]) - tts = {str(key): str(usermap[msg_queue[key][0].lower()]) + " " + str(conf['MESSAGE']['SAYS']) + ":" + str(msg_queue[key][1])} - else: - logging.debug('No usermap entry found for user: %s', msg_queue[key][0]) - 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")) - - elif self.path.startswith('/tts_done'): - get_params = parse_qs(self.path) - if '/tts_done?id' in get_params: - logging.info("Removing message from queue") - tts_done.append(get_params['/tts_done?id'][0]) - self.send_response(200) - self.send_header('Content-type', 'text/plain') - self.end_headers() - self.wfile.write(bytes("OK\n", "utf-8")) - else: - self.send_response(500) - self.send_header('Content-type', 'text/plain') - self.end_headers() - self.wfile.write(bytes("Internal Server error\n", "utf-8")) - - elif self.path.startswith('/token'): - 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" - 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, 8080 or 3000. 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 Exception: # pylint: disable=broad-except - logging.error('Could not fetch OAuth-URL from Twitch.') - else: - self.send_response(404) - self.send_header('Server', 'TTS') - self.send_header('Content-type', 'text/plain') - self.end_headers() - self.wfile.write(bytes("File not found.\n", "utf-8")) - - return - -def http_serve_forever(httpd): - """httpd loop""" - httpd.serve_forever() - -def load_config(): - """Loading config variables""" - - logging.info("Loading configfile") - - try: - 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.') - sys.exit(253) - - for section in cfg: - try: - logging.debug('Fetching config: %s', section) - conf['IRC_CHANNEL'] = cfg.get('irc', {}).get('channel', False) - conf['IRC_USERNAME'] = cfg.get('irc', {}).get('username', False) - conf['IRC_OAUTH_TOKEN'] = cfg.get('irc', {}).get('oauth_token', False) - conf['IRC_SERVER'] = cfg.get('irc', {}).get('server', "irc.chat.twitch.tv") - conf['IRC_CLEARMSG_TIMEOUT'] = cfg.get('irc', {}).get('clearmsg_timeout', 60) - - conf['IRC_SUBONLY'] = cfg.get('bot', {}).get('subonly', False) - conf['IRC_MODONLY'] = cfg.get('bot', {}).get('modonly', False) - conf['IRC_TTS_LEN'] = cfg.get('bot', {}).get('message_length', 200) - conf['TTS_STARTENABLED'] = cfg.get('bot', {}).get('start_enabled', True) - conf['WIKI_LANG'] = cfg.get('bot', {}).get('language', 'en') - - conf['LOG_LEVEL'] = cfg.get('log', {}).get('level', "INFO") - conf['HTTP_PORT'] = cfg.get('http', {}).get('port', 80) - conf['HTTP_BIND'] = cfg.get('http', {}).get('bind', "localhost") - - conf['MESSAGE'] = {} - conf['MESSAGE']['TOFF'] = cfg.get('messages', {}).get('toff', "TTS is now disabled.") - conf['MESSAGE']['TON'] = cfg.get('messages', {}).get('ton', "TTS is now active.") - conf['MESSAGE']['TOO_LONG'] = cfg.get('messages', {}).get('too_long', "Sorry, your message is too long.") - conf['MESSAGE']['DISABLED'] = cfg.get('messages', {}).get('disabled', "Sorry, TTS is disabled.") - conf['MESSAGE']['DENIED'] = cfg.get('messages', {}).get('denied', "Sorry, you're not allowed to use TTS.") - conf['MESSAGE']['SUBONLY'] = cfg.get('messages', {}).get('subonly', "Sorry, TTS is sub-only.") - conf['MESSAGE']['MODONLY'] = cfg.get('messages', {}).get('modonly', "Sorry, TTS is mod-only.") - conf['MESSAGE']['READY'] = cfg.get('messages', {}).get('ready', "TTS bot is ready.") - conf['MESSAGE']['WHITELISTONLY'] = cfg.get('messages', {}).get('whitelist', False) - conf['MESSAGE']['SAYS'] = cfg.get('messages', {}).get('says', "says") - - conf['MESSAGE']['VOTESTART'] = cfg.get('messages', {}).get('votestart', "Quickvote started. Send #yourchoice to participate.") - conf['MESSAGE']['VOTEEND'] = cfg.get('messages', {}).get('voteend', "Quickvote ended. The results are:") - conf['MESSAGE']['VOTENOBODY'] = cfg.get('messages', {}).get('votenobody', "Nobody casted a vote. :(") - conf['MESSAGE']['VOTERESULT'] = cfg.get('messages', {}).get('voteresult', "Voting has ended. The result is:") - conf['MESSAGE']['VOTES'] = cfg.get('messages', {}).get('votes', "Votes") - - conf['MESSAGE']['PICKSTART'] = cfg.get('messages', {}).get('pickstart', "Pick started. Send #pickme to participate.") - conf['MESSAGE']['PICKRESULT'] = cfg.get('messages', {}).get('pickresult', "Pick ended. The results are:") - conf['MESSAGE']['PICKNONE'] = cfg.get('messages', {}).get('picknone', "Pick ended. Nobody was picked.") - - conf['MESSAGE']['QUOTE_NOT_FOUND'] = cfg.get('messages', {}).get('quotenotfound', "Sorry, no quote found.") - conf['MESSAGE']['QUOTE_ADDED_PREFIX'] = cfg.get('messages', {}).get('quoteaddedprefix', "Quote:") - conf['MESSAGE']['QUOTE_ADDED_SUFFIX'] = cfg.get('messages', {}).get('quoteaddedsuffix', "added.") - - conf['MESSAGE']['WIKI_TOO_MANY'] = cfg.get('messages', {}).get('wiki_too_many', "Sorry, there are too many possible results. Try a more narrow search.") - conf['MESSAGE']['WIKI_NO_RESULT'] = cfg.get('messages', {}).get('wiki_no_result', "Sorry, there was an error fetching the wikipedia answer.") - - conf['USERMAP'] = cfg.get('usermapping', []) - - if 'whitelist' in cfg: - conf['WHITELIST'] = True - conf['WHITELIST_USER'] = cfg['whitelist'] - else: - conf['WHITELIST'] = False - - except KeyError: - logging.exception('Your config file is invalid, please check and try again.') - sys.exit(254) - - if conf['WHITELIST']: - logging.info('Whitelist mode enabled') - logging.debug('Whitelist: %s', conf['WHITELIST_USER']) - - if not conf['IRC_CHANNEL']: - raise ValueError('Please add your twitch channel to config.yml.') - if not conf['IRC_USERNAME']: - raise ValueError('Please add the bots username to config.yml.') - if not conf['IRC_OAUTH_TOKEN']: - 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:"') - - return conf - -def send_tts_queue(): - """ Send messages to TTS """ - - for raw_msg in msg_queue_raw: - logging.debug('Raw msg: %s', 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: %s", msg_queue) - else: - logging.debug('Msg is already in queue') - -def get_url(path=False): - """ Generate a valid URL from config values """ - if conf['HTTP_BIND'] == "0.0.0.0": - url = "localhost" - else: - url = conf['HTTP_BIND'] - - url = "http://"+str(url)+":"+str(conf['HTTP_PORT'])+"/" - - if path: - url = url+str(path) - - return url - -def check_oauth_token(): - """ Check for valid authentication via Twitch API """ - global conf # pylint: disable=global-statement,invalid-name - logging.debug('Checking OAuth Token') - - try: - url = 'https://id.twitch.tv/oauth2/validate' - oauth = "OAuth "+str(conf['IRC_OAUTH_TOKEN'].replace('oauth:','')) - request = urllib.request.Request(url) - request.add_header('Authorization', oauth) - urllib.request.urlopen(request) - except HTTPError: - logging.fatal('Twitch rejected your OAuth Token. Please check and generate a new one.') - logging.info('Please open http://%s:%s/token to generate your OAuth-Token.', conf['HTTP_BIND'], conf['HTTP_PORT']) - - url = get_url("token") - webbrowser.open_new_tab(url) - logging.info('Please complete the OAuth process and add the token into your "config.yml" within the next 5 minutes.') - time.sleep(300) - conf = load_config() - check_oauth_token() - - logging.info('OAuth Token is valid') - return conf - -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 = ThreadingSimpleServer((conf['HTTP_BIND'], conf['HTTP_PORT']), HTTPserv) - - http_thread = Thread(target=http_serve_forever, daemon=True, args=(httpd, )) - http_thread.start() - check_oauth_token() - - logging.info("Starting IRC bot") - irc = IRC() - - 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('Connected and joined') - url = get_url() - logging.info("Please open your browser and visit: %s", url) - webbrowser.open_new_tab(url) - - while True: - if conf['LOG_LEVEL'] == "DEBUG": - time.sleep(1) - - try: - irc.get_response() - - confreload = datetime.datetime.now() - if confreload - lastreload > datetime.timedelta(seconds=60): - conf = load_config() - lastreload = datetime.datetime.now() - - if irc.quickvote_status and irc.votemsg: - logging.info('Quickvote is active') - irc.sendmsg(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(irc.votemsg) + ")") - - if not irc.tts_status: - continue - - logging.debug('msg_queue_raw: %s', msg_queue_raw) - send_tts_queue() - - except KeyboardInterrupt: - httpd.shutdown() - logging.info('Exiting...') - os.kill(os.getpid(), signal.SIGTERM) - -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(module)s %(threadName)s %(levelname)s: %(message)s') - sys.tracebacklimit = 3 - - VERSION = "1.6.1" - conf = {} - tts_done = [] - msg_queue_raw = [] - msg_queue = {} - - if sys.argv[1:]: - if sys.argv[1] == "--version": - print('Simple TTS Bot') - print('Version %s', VERSION) - sys.exit(1) - - main() +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# pylint: disable=line-too-long,too-many-lines + +""" + TwitchTTS + Copyright (C) 2022 gpkvt + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +""" + +import os +import sys +import time +import json +import signal +import socket +import random +import logging +import datetime +import webbrowser +import urllib.request + +from threading import Thread +from collections import Counter +from urllib.parse import parse_qs +from urllib.error import HTTPError +from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn + +import yaml +import requests +import wikipedia + +from fuzzywuzzy import process + +class IRC: + """ IRC bot """ + irc = socket.socket() + + def __init__(self): + self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.tts_denied = [] + self.tts_allowed = [] + self.tts_status = conf['TTS_STARTENABLED'] + self.quickvote_status = False + self.pick_status = False + self.votemsg = False + self.poll = {} + self.pollcount = 0 + self.pickme = [] + self.picknumber = 1 + self.pickcount = 0 + + if 'WHITELIST_USER' in conf: + self.tts_allowed = conf['WHITELIST_USER'] + + def connect(self, server, port, channel, botnick, botpass): + """ Connect to Twitch IRC servers """ + logging.info("Connecting to: %s", server) + logging.info('Waiting...') + try: + self.irc.connect((server, port)) + except ConnectionResetError: + logging.fatal('Twitch refused to connect, please check your settings and try again.') + sys.exit(252) + + 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("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) + + try: + self.irc.send(bytes("JOIN " + channel + "\r\n", "UTF-8")) + except ConnectionResetError: + logging.warning('JOIN was refused, will try again in 30 seconds.') + time.sleep(30) + logging.warning('Please check your credentials, if this error persists.') + self.irc.send(bytes("JOIN " + channel + "\r\n", "UTF-8")) + + 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 resp_ping(self): + """ Respond to PING """ + logging.debug('PING received') + self.irc.send(bytes('PONG :tmi.twitch.tv\r\n', "UTF-8")) + + def resp_notice(self, resp): + """ Respond to NOTICE """ + if 'Login authentication failed' in resp: + try: + raise RuntimeError() + except RuntimeError: + logging.exception('Login failed, please check your credentials and try again.') + sys.exit(251) + + def resp_clearmsg(self, resp): + """ Respond to CLEARMSG """ + logging.info('CLEARMSG received') + msgid = False + + global msg_queue_raw # pylint: disable=global-statement,invalid-name + filtered_msg_queue = [] + + tags = resp.split(';') + for tag in tags: + if "target-msg-id=" in tag: + msgid = tag.rsplit('target-msg-id=',1)[1] + logging.debug('Trying to suppress message') + logging.debug(msgid) + + for msg in list(msg_queue_raw): + if msg['msgid'] == msgid: + logging.info('Suppressing message %s', msgid) + else: + filtered_msg_queue.append(msg) + + msg_queue_raw = filtered_msg_queue + + def resp_privmsg(self, resp): + """ Respond to PRIVMSG """ + logging.debug('PRIVMSG received') + + tags = self.get_tags(resp) + message = self.get_message(resp) + + user = tags['user'] + msg = message['message'] + msglen = message['length'] + + logging.debug('Msg: %s', msg) + logging.debug('Msg length: %s', msglen) + logging.debug('Deny List:') + logging.debug(self.tts_denied) + + self.priviledged_commands(message, tags) + + if msg.startswith('#pickme') and self.pick_status is True: + logging.info('Pickme detected') + self.pickcount = self.pickcount + 1 + self.pickme.append(user) + logging.debug("pickme %s added", user) + return + + if msg.startswith('#') and self.quickvote_status is True: + logging.info('Quickvote: Cast detected') + self.pollcount += 1 + self.poll[user] = msg.lower() + logging.debug("poll: %s", self.poll) + return + + if msg.startswith('!tts'): + logging.info('!tts command detected') + self.Commands.tts(self, message, tags) + return + + if msg.startswith('!addquote'): + logging.debug("!addquote command detected") + self.Commands.addquote(self, tags, msg) + return + + if msg.startswith('!wiki'): + logging.debug("!wiki command detected") + self.Commands.wiki(self, tags, msg) + return + + if msg.startswith('!smartquote') or msg.startswith('!sq'): + logging.debug("!smartquote command detected") + self.Commands.quote(self, tags, msg) + return + + def get_tags(self, resp): + """ Strip tags from response """ + + tags = resp.split(';') + for tag in tags: + if tag.startswith('badges='): + badges = tag.rsplit('badges=',1)[1] + if tag.startswith('subscriber='): + subscriber = tag.rsplit('subscriber=',1)[1] + logging.debug('Subscriber: %s', subscriber) + if tag.startswith('id='): + msgid = tag.rsplit('id=',1)[1] + logging.debug('Message ID: %s', msgid) + if tag.startswith('display-name='): + user = tag.rsplit('display-name=',1)[1].lower() + logging.debug('Username: %s', user) + + tags = {} + tags['badges'] = badges + tags['subscriber'] = subscriber + tags['msgid'] = msgid + tags['user'] = user + + return tags + + def get_message(self, resp): + """ Process message """ + + msg = {} + msg['message'] = resp.rsplit('PRIVMSG #',1)[1].split(':',1)[1].replace('\r\n','') + msg['length'] = len(msg['message']) + + return msg + + def priviledged_commands(self, message, tags): + """ Process priviledged commands """ + + msg = message['message'] + badges = tags['badges'] + user = tags['user'] + + if 'broadcaster' in badges or 'moderator' in badges: + if msg.startswith('!ping'): + logging.debug("Ping check received.") + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), "Pong!") + + elif msg.startswith('!version'): + logging.debug("!version command detected") + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), VERSION) + + elif msg.startswith('!pick'): + logging.debug("!pick command detected") + self.Commands.pick(self, msg) + + elif msg.startswith('!dtts'): + logging.debug("!dtts command detected") + self.Commands.dtts(self, msg) + + elif msg.startswith('!random'): + logging.info('!random command detected') + self.Commands.random(self, msg) + + elif msg.startswith('!quickvote'): + logging.info("!quickvote command detected") + self.Commands.quickvote(self, msg) + + elif msg.startswith('!ptts'): + logging.debug("!ptts command detected") + self.Commands.ptts(self, msg) + + elif msg.startswith('!usermap'): + logging.info('!usermap command detected') + self.Commands.usermap(self, msg) + + elif msg.startswith('!delay'): + logging.info('!delay command detected') + self.Commands.delay(self, msg) + + elif msg.startswith('!toff'): + logging.info('TTS is now turned off') + msg_queue.clear() + msg_queue_raw.clear() + self.tts_status = False + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOFF']) + + elif msg.startswith('!ton'): + logging.info('TTS is now turned on') + msg_queue.clear() + msg_queue_raw.clear() + self.tts_status = True + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TON']) + + def check_subonly(self, tags): + """ subonly """ + + if not conf['IRC_SUBONLY']: + return False + + subscriber = tags['subscriber'] + badges = tags['badges'] + user = tags['user'] + + if subscriber != "0" or 'moderator' in badges or 'broadcaster' in badges: + logging.info('TTS is sub-only and user has allowance') + return False + + logging.debug('TTS is sub-only') + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['SUBONLY']) + return True + + def check_modonly(self, tags): + """ modonly """ + + if not conf['IRC_MODONLY']: + return False + + badges = tags['badges'] + user = tags['user'] + + if 'moderator' in badges or 'broadcaster' in badges: + logging.info('TTS is mod-only and user has allowance') + return False + + logging.debug('TTS is mod-only') + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['MODONLY']) + return True + + def check_tts_disabled(self, user): + """ Check if TTS is disabled """ + if not self.tts_status: + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DISABLED']) + return True + + logging.debug('TTS is enabled') + return False + + def check_msg_too_long(self, message, user): + """ Check if message is too long """ + + if message['length'] > conf['IRC_TTS_LEN']: + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['TOO_LONG']) + return True + + logging.debug('Check length: Message is ok') + return False + + def check_user_denied(self, user): + """ Check if user is on denied list """ + if user in self.tts_denied: + logging.info("%s is not allowed to use TTS", user) + self.sendmsg(conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['DENIED']) + return True + + logging.debug("%s is allowed to use TTS", user) + return False + + def check_whitelist(self, user): + """ Check Whitelist """ + + if conf['WHITELIST']: + if user not in self.tts_allowed: + logging.debug("tts_allowed: %s", self.tts_allowed) + self.sendmsg( + conf['IRC_CHANNEL'], + "@"+str(user), conf['MESSAGE']['WHITELISTONLY'] + ) + return False + return True + return False + + def send_tts_msg(self, message, tags): + """ Send message to TTS queue """ + logging.info('Valid TTS message, adding to raw queue') + + tts = True + now = datetime.datetime.now() + timestamp = str(time.time_ns()) + + user = tags['user'] + msgid = tags['msgid'] + badges = tags['badges'] + subscriber = tags['subscriber'] + + msg = message['message'] + msglen = message['length'] + msg = msg.replace('!tts','',1) + + msg = { + "TTS": tts, + "msg": msg, + "badges": badges, + "subscriber": subscriber, + "msgid": msgid, + "user": user, + "length": msglen, + "queuetime": now, + "timestamp": timestamp + } + + msg_queue_raw.append(msg) + + 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 + except Exception: # pylint: disable=broad-except + logging.exception('An unknown error occured while getting a IRC response.') + sys.exit(255) + + if resp.find('PING') != -1: + self.resp_ping() + + if resp.find('CLEARMSG') != -1: + self.resp_clearmsg(resp) + + if resp.find('NOTICE') != -1: + self.resp_notice(resp) + + if resp.find('PRIVMSG') != -1: + self.resp_privmsg(resp) + + class Commands(): + """ Bot commands """ + + def __init__(self): + self.tts_denied = [] + self.tts_allowed = [] + self.quickvote_status = self.quickvote_status + self.pick_status = self.pick_status + self.votemsg = self.votemsg + self.poll = self.poll + self.pollcount = self.pollcount + self.pickme = self.pickme + self.picknumber = self.picknumber + self.pickcount = self.pickcount + + def tts(self, msg, tags): + """ !tts command + + Check if message is valid and send it to queue + + :param str msg: The IRC message triggering the command + :param dict tags: The message metadata + """ + + user = tags['user'] + + if IRC.check_tts_disabled(self, user): + logging.info('TTS is disabled') + elif IRC.check_msg_too_long(self, msg, user): + logging.info('TTS message is too long') + elif IRC.check_user_denied(self, user): + logging.info('User is not allowed to use TTS') + elif IRC.check_subonly(self, tags): + logging.info('TTS is sub-only') + elif IRC.check_modonly(self, tags): + logging.info('TTS is mod-only') + elif IRC.check_whitelist(self, user): + logging.info('User is not on whitelist') + else: + logging.info('Sending TTS message to raw_queue') + IRC.send_tts_msg(self, msg, tags) + + def addquote(self, tags, msg): + """ !addquote command + + Adds a newline to quotes.txt + """ + + user = tags['user'] + + if IRC.check_user_denied(self, user): + logging.info('User is not allowed to use TTS') + elif IRC.check_subonly(self, tags): + logging.info('TTS is sub-only') + else: + try: + with open("quotes.txt", "rb") as file: + nol = len(file.readlines()) + file.close() + except FileNotFoundError: + logging.warning("quotes.txt does not exists, will create") + nol = 0 + + nol = nol + 1 + quote = msg.replace("!addquote ", "").strip() + quote = quote.split(" ",1) + username = quote[0] + + date = time.strftime("%d.%m.%Y") + + try: + token = conf['IRC_OAUTH_TOKEN'].replace('oauth:','') + login = conf['IRC_CHANNEL'].replace('#','') + api_endpoint = "https://api.twitch.tv/helix/users?login="+str(login) + headers = { + 'Content-type': 'application/x-form-urlencoded', + 'Authorization': 'Bearer '+token, + 'Client-Id': 'ebo548vs6tq54c9zlrgin2yfzzlrrs' + } + req = requests.get(url=api_endpoint, headers=headers) + data = req.json() + user_id = data['data'][0]['id'] + + api_endpoint = "https://api.twitch.tv/helix/channels?broadcaster_id="+str(user_id) + headers = { + 'Content-type': 'application/x-form-urlencoded', + 'Authorization': 'Bearer '+token, + 'Client-Id': 'ebo548vs6tq54c9zlrgin2yfzzlrrs' + } + req = requests.get(url=api_endpoint, headers=headers) + data = req.json() + game = data['data'][0]['game_name'] + + quote = f"#{nol}: \"{quote[1]}\" -{username}/{game} ({date})\n" + except: # pylint: disable=bare-except + logging.warning('Could not get metadata for quote') + quote = f"#{nol}: \"{quote[1]}\" -{username} ({date})\n" + + logging.info('Adding quote %s', quote) + with open("quotes.txt", "ab") as file: + file.write(quote.encode('utf-8')) + + msg = f"{conf['MESSAGE']['QUOTE_ADDED_PREFIX']} #{nol} {conf['MESSAGE']['QUOTE_ADDED_SUFFIX']}" + + raw_msg = { + "TTS": True, + "msg": msg, + "badges": True, + "subscriber": True, + "msgid": True, + "user": conf['IRC_USERNAME'], + "length": conf['IRC_TTS_LEN'], + "queuetime": datetime.datetime.now(), + "timestamp": str(time.time_ns()) + } + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), msg) + + def wiki(self, tags, msg): + """ !wiki command """ + + try: + user = tags['user'] + wikipedia.set_lang(conf['WIKI_LANG']) + msg = msg.replace('!wiki', '').strip() + wikiresult = wikipedia.summary(msg, sentences=3) + + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), wikiresult) + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), wikipedia.page(msg).url) + + wikiresult = wikiresult.replace('==', '') + + raw_msg = { + "TTS": True, + "msg": wikiresult, + "badges": True, + "subscriber": True, + "msgid": True, + "user": 'wikipedia', + "length": conf['IRC_TTS_LEN'], + "queuetime": datetime.datetime.now(), + "timestamp": str(time.time_ns()) + } + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + + except wikipedia.exceptions.DisambiguationError: + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['WIKI_TOO_MANY']) + except: # pylint: disable=bare-except + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@"+str(user), conf['MESSAGE']['WIKI_NO_RESULT']) + + def quote(self, tags, msg = False): + """ !smartquote command + + Gets a line from quotes.txt. If a number if given as msg + it fetch the given line number. If a string is given + it fetch the best matching line. If nothing is given + it fetch a random line. + """ + + try: + user = tags['user'] + query = msg.replace('!smartquote', '').strip() + query = msg.replace('!sq', '').strip() + + if query.isdigit(): + logging.info('Fetching quote #%s', query) + + file = open("quotes.txt", "rb") + quotes = file.readlines() + + for line in quotes: + if line.decode('utf-8').startswith("#"+str(query)+":"): + quote = line + break + file.close() + + elif query != "": + logging.info('Fetching match for %s', query) + + file = open("quotes.txt", "rb") + quotes = file.readlines() + matches = process.extract(query, quotes, limit=10) + quotes = [] + + for match, score in matches: + if score >= 60: + quotes.append(match) + + logging.debug('Quotes: %s', quotes) + if len(quotes) >= 5: + quote = random.choice(quotes) + else: + quote = quotes[0] + + else: + logging.info('Fetching random quote') + + with open("quotes.txt", "rb") as file: + lines = file.read().splitlines() + quote = random.choice(lines) + except FileNotFoundError: + logging.error('"quotes.txt does not exists.') + except IndexError: + logging.error('Error fetching quote.') + + if not 'quote' in vars(): + logging.info('No quote found.') + quote = conf['MESSAGE']['QUOTE_NOT_FOUND'] + IRC.sendmsg(self, conf['IRC_CHANNEL'], "", quote) + return False + + if not isinstance(quote, str): + quote = quote.decode('utf-8') + + if IRC.check_tts_disabled(self, user): + logging.info('TTS is disabled') + elif IRC.check_user_denied(self, user): + logging.info('User is not allowed to use TTS') + elif IRC.check_subonly(self, tags): + logging.info('TTS is sub-only') + elif IRC.check_modonly(self, tags): + logging.info('TTS is mod-only') + elif IRC.check_whitelist(self, user): + logging.info('User is not on whitelist') + else: + logging.info('Sending quote to TTS') + logging.debug("Quote: %s", quote) + IRC.sendmsg(self, conf['IRC_CHANNEL'], "", quote) + + quote = quote.rsplit('(', 1)[0] + + raw_msg = { + "TTS": True, + "msg": quote, + "badges": True, + "subscriber": True, + "msgid": True, + "user": conf['IRC_USERNAME'], + "length": conf['IRC_TTS_LEN'], + "queuetime": datetime.datetime.now(), + "timestamp": str(time.time_ns()) + } + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + + return True + + def delay(self, msg): + """ !delay command + + Adjust the delay setting in config.yml + + :param str msg: The IRC message triggering the command + """ + + try: + delay = msg.split(' ')[1] + except: # pylint: disable=bare-except + delay = False + + if delay: + with open('config.yml','r', encoding='utf-8') as yamlfile: + cur_yaml = yaml.safe_load(yamlfile) + cur_yaml['irc']['clearmsg_timeout'] = int(delay) + + if cur_yaml: + with open('config.yml','w', encoding='utf-8') as yamlfile: + yaml.safe_dump(cur_yaml, yamlfile) + load_config() + + + def usermap(self, msg): + """ !usermap command + + Adds new entries to usermapping in config.yml + + :param str msg: The IRC message triggering the command + """ + + try: + msg = msg.replace('!usermap ', '') + splitmsg = msg.split(" ") + username, *mappingname = splitmsg + mappingname = ' '.join(mappingname) + except: # pylint: disable=bare-except + username = False + mappingname = False + + if username and mappingname: + with open('config.yml','r', encoding='utf-8') as yamlfile: + cur_yaml = yaml.safe_load(yamlfile) + cur_yaml['usermapping'].update({username: mappingname}) + + if cur_yaml: + with open('config.yml','w', encoding='utf-8') as yamlfile: + yaml.safe_dump(cur_yaml, yamlfile) + load_config() + + def pick(self, msg): + """ !pick command """ + + if self.pick_status: + logging.info('Pick stopped') + logging.debug("Got %s participats, wanted %s", self.pickcount, self.picknumber) + + try: + if int(self.pickcount) > int(self.picknumber): + picks = random.sample(self.pickme, self.picknumber) + logging.info('Got more than the requested number of participants') + else: + picks = self.pickme + logging.info('Got less than or exactly the requested number of participants') + + converted_picks = [str(element) for element in picks] + joined_picks = " ".join(converted_picks) + except: # pylint: disable=bare-except + logging.error("There was an error during picking.") + joined_picks = False + + if joined_picks: + raw_msg = { + "TTS": True, + "msg": conf['MESSAGE']['PICKRESULT'] +" "+ str(joined_picks), + "badges": True, + "subscriber": True, + "msgid": True, + "user": conf['IRC_USERNAME'], + "length": conf['IRC_TTS_LEN'], + "queuetime": datetime.datetime.now(), + "timestamp": str(time.time_ns()) + } + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + + IRC.sendmsg(self, + conf['IRC_CHANNEL'], "", + conf['MESSAGE']['PICKRESULT'] + ) + IRC.sendmsg(self, + conf['IRC_CHANNEL'], "*", + joined_picks + ) + else: + IRC.sendmsg(self, + conf['IRC_CHANNEL'], "*", + conf['MESSAGE']['PICKNONE'] + ) + + self.pick_status = False + self.pickme = [] + self.pickcount = 0 + + return + + logging.debug('Pick started') + self.pick_status = True + try: + msg = msg.split(' ')[1].strip() + self.picknumber = msg + except IndexError: + self.picknumber = self.picknumber + + logging.info("Will pick %s participants", self.picknumber) + + IRC.sendmsg(self, + conf['IRC_CHANNEL'], "@chat", + conf['MESSAGE']['PICKSTART'] + ) + return + + def quickvote(self, msg): + """ !quickvote command + + Starts or stops the !quickvote function. On stop calculates the 5 most casted + votes and send them to chat. The highest vote is send to msg_queue. + + :param str msg: The IRC message triggering the command + """ + + if self.quickvote_status: + logging.debug('Quickvote stopped') + + if self.pollcount == 0: + logging.info("Nobody voted") + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) + IRC.sendmsg(self, conf['IRC_CHANNEL'], "*", conf['MESSAGE']['VOTENOBODY']) + + raw_msg = { + "TTS": True, + "msg": conf['MESSAGE']['VOTENOBODY'], + "badges": True, + "subscriber": True, + "msgid": True, + "user": conf['IRC_USERNAME'], + "length": conf['IRC_TTS_LEN'], + "queuetime": datetime.datetime.now(), + "timestamp": str(time.time_ns()) + } + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + + logging.info('The result is: %s', conf['MESSAGE']['VOTENOBODY']) + logging.debug('Votemsg: %s', msg) + + self.quickvote_status = False + self.poll = {} + return + + logging.info("Counting votes") + count = 0 + count = Counter(self.poll.values()).most_common(5) + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTEEND']) + + logging.debug(count) + + raw_msg = { + "TTS": True, + "msg": conf['MESSAGE']['VOTERESULT'] +" "+ str(count[0][0].replace('#','')), + "badges": True, + "subscriber": True, + "msgid": True, + "user": conf['IRC_USERNAME'], + "length": conf['IRC_TTS_LEN'], + "queuetime": datetime.datetime.now(), + "timestamp": str(time.time_ns()) + } + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + + logging.info('The result is: %s', conf['MESSAGE']['VOTERESULT'] +" "+ str(count[0])) + logging.debug('Votemsg: %s', msg) + + for key, value in count: + IRC.sendmsg( + self, + conf['IRC_CHANNEL'], "*", + str(key)+" ("+str(value)+ " "+ conf['MESSAGE']['VOTES'] + ")" + ) + + self.quickvote_status = False + self.poll = {} + self.pollcount = 0 + return + + logging.debug('Quickvote started') + self.quickvote_status = True + self.votemsg = msg.split('!quickvote', 1)[1].strip() + if self.votemsg: + IRC.sendmsg(self, + conf['IRC_CHANNEL'], "@chat", + conf['MESSAGE']['VOTESTART'] + " (" + str(self.votemsg) + ")" + ) + else: + IRC.sendmsg(self, conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART']) + + return + + def random(self, msg): + """ !random command + + Read a random line from randomfile and put it into msg_queue + If no file is given in msg a standard file will be used + + :param str msg: The IRC message triggering the command + :raise: FileNotFoundError if randomfile does not exists + :return: True if line was successfully read and added to msg_queue + :rtype: bool + """ + + randomfile = msg.replace('!random', '').strip().lower() + + if randomfile: + randomfile = "random_"+str(os.path.basename(randomfile))+".txt" + else: + randomfile = "random.txt" + + try: + with open(randomfile,"r", encoding="utf-8") as file: + lines = file.read().splitlines() + random_msg = random.choice(lines) + except FileNotFoundError: + logging.error('%s not found', randomfile) + return False + + raw_msg = { + "TTS": True, + "msg": random_msg, + "badges": True, + "subscriber": True, + "msgid": True, + "user": conf['IRC_USERNAME'], + "length": conf['IRC_TTS_LEN'], + "queuetime": datetime.datetime.now(), + "timestamp": str(time.time_ns()) + } + msg_queue[raw_msg['timestamp']] = [raw_msg['user'], raw_msg['msg']] + + return True + + def ptts(self, msg): + """ !ptts command + + Add user to tts_allowed list and remove user from tts_denied list + + :param str msg: The IRC message triggering the command + """ + + user = msg.replace('!ptts', '').strip().lower() + + if user.startswith('@'): + logging.debug('Removing "@" from username') + user = user.replace('@', '') + + logging.info("Adding %s to whitelist", user) + self.tts_allowed.append(user) + + if user in self.tts_denied: + logging.info("Removing %s from deny list", user) + self.tts_denied.remove(user) + + return + + def dtts(self, msg): + """ !dtts command + + Add user to tts_denied list and remove user from tts_allowed list + + :param str msg: The IRC message triggering the command + """ + + user = msg.replace('!dtts', '').strip().lower() + + if user.startswith('@'): + logging.debug('Removing "@" from username') + user = user.replace('@', '') + + if user not in self.tts_denied: + logging.info("Adding %s to deny list", user) + self.tts_denied.append(user) + + if user in self.tts_allowed: + logging.info("Removing %s from allowed list", user) + self.tts_allowed.remove(user) + + return + +class ThreadingSimpleServer(ThreadingMixIn, HTTPServer): + """ Threaded HTTP Server """ + +class HTTPserv(BaseHTTPRequestHandler): + """Simple HTTP Server""" + + def log_message(self, format, *args): # pylint: disable=redefined-builtin + """Suppress HTTP log messages""" + return + + 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 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 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 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 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 file: + html = file.read() + self.wfile.write(html) + + elif self.path.startswith('/tts_queue'): + tts_json = "" + tts = {} + self.send_response(200) + self.send_header('Content-type', 'text/json') + self.end_headers() + + usermap = conf['USERMAP'] + sorted_tts = {k: msg_queue[k] for k in sorted(msg_queue, reverse=True)} + logging.debug(usermap) + + for key in list(sorted_tts.keys()): + if key not in tts_done: + if msg_queue[key][0].lower() in usermap: + logging.debug('Using usermap for user: %s (%s)', msg_queue[key][0], usermap[msg_queue[key][0].lower()]) + tts = {str(key): str(usermap[msg_queue[key][0].lower()]) + " " + str(conf['MESSAGE']['SAYS']) + ":" + str(msg_queue[key][1])} + else: + logging.debug('No usermap entry found for user: %s', msg_queue[key][0]) + 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")) + + elif self.path.startswith('/tts_done'): + get_params = parse_qs(self.path) + if '/tts_done?id' in get_params: + logging.info("Removing message from queue") + tts_done.append(get_params['/tts_done?id'][0]) + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes("OK\n", "utf-8")) + else: + self.send_response(500) + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes("Internal Server error\n", "utf-8")) + + elif self.path.startswith('/token'): + 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" + 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, 8080 or 3000. 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 Exception: # pylint: disable=broad-except + logging.error('Could not fetch OAuth-URL from Twitch.') + else: + self.send_response(404) + self.send_header('Server', 'TTS') + self.send_header('Content-type', 'text/plain') + self.end_headers() + self.wfile.write(bytes("File not found.\n", "utf-8")) + + return + +def http_serve_forever(httpd): + """httpd loop""" + httpd.serve_forever() + +def load_config(): + """Loading config variables""" + + logging.info("Loading configfile") + + try: + 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.') + sys.exit(253) + + for section in cfg: + try: + logging.debug('Fetching config: %s', section) + conf['IRC_CHANNEL'] = cfg.get('irc', {}).get('channel', False) + conf['IRC_USERNAME'] = cfg.get('irc', {}).get('username', False) + conf['IRC_OAUTH_TOKEN'] = cfg.get('irc', {}).get('oauth_token', False) + conf['IRC_SERVER'] = cfg.get('irc', {}).get('server', "irc.chat.twitch.tv") + conf['IRC_CLEARMSG_TIMEOUT'] = cfg.get('irc', {}).get('clearmsg_timeout', 60) + + conf['IRC_SUBONLY'] = cfg.get('bot', {}).get('subonly', False) + conf['IRC_MODONLY'] = cfg.get('bot', {}).get('modonly', False) + conf['IRC_TTS_LEN'] = cfg.get('bot', {}).get('message_length', 200) + conf['TTS_STARTENABLED'] = cfg.get('bot', {}).get('start_enabled', True) + conf['WIKI_LANG'] = cfg.get('bot', {}).get('language', 'en') + + conf['LOG_LEVEL'] = cfg.get('log', {}).get('level', "INFO") + conf['HTTP_PORT'] = cfg.get('http', {}).get('port', 80) + conf['HTTP_BIND'] = cfg.get('http', {}).get('bind', "localhost") + + conf['MESSAGE'] = {} + conf['MESSAGE']['TOFF'] = cfg.get('messages', {}).get('toff', "TTS is now disabled.") + conf['MESSAGE']['TON'] = cfg.get('messages', {}).get('ton', "TTS is now active.") + conf['MESSAGE']['TOO_LONG'] = cfg.get('messages', {}).get('too_long', "Sorry, your message is too long.") + conf['MESSAGE']['DISABLED'] = cfg.get('messages', {}).get('disabled', "Sorry, TTS is disabled.") + conf['MESSAGE']['DENIED'] = cfg.get('messages', {}).get('denied', "Sorry, you're not allowed to use TTS.") + conf['MESSAGE']['SUBONLY'] = cfg.get('messages', {}).get('subonly', "Sorry, TTS is sub-only.") + conf['MESSAGE']['MODONLY'] = cfg.get('messages', {}).get('modonly', "Sorry, TTS is mod-only.") + conf['MESSAGE']['READY'] = cfg.get('messages', {}).get('ready', "TTS bot is ready.") + conf['MESSAGE']['WHITELISTONLY'] = cfg.get('messages', {}).get('whitelist', False) + conf['MESSAGE']['SAYS'] = cfg.get('messages', {}).get('says', "says") + + conf['MESSAGE']['VOTESTART'] = cfg.get('messages', {}).get('votestart', "Quickvote started. Send #yourchoice to participate.") + conf['MESSAGE']['VOTEEND'] = cfg.get('messages', {}).get('voteend', "Quickvote ended. The results are:") + conf['MESSAGE']['VOTENOBODY'] = cfg.get('messages', {}).get('votenobody', "Nobody casted a vote. :(") + conf['MESSAGE']['VOTERESULT'] = cfg.get('messages', {}).get('voteresult', "Voting has ended. The result is:") + conf['MESSAGE']['VOTES'] = cfg.get('messages', {}).get('votes', "Votes") + + conf['MESSAGE']['PICKSTART'] = cfg.get('messages', {}).get('pickstart', "Pick started. Send #pickme to participate.") + conf['MESSAGE']['PICKRESULT'] = cfg.get('messages', {}).get('pickresult', "Pick ended. The results are:") + conf['MESSAGE']['PICKNONE'] = cfg.get('messages', {}).get('picknone', "Pick ended. Nobody was picked.") + + conf['MESSAGE']['QUOTE_NOT_FOUND'] = cfg.get('messages', {}).get('quotenotfound', "Sorry, no quote found.") + conf['MESSAGE']['QUOTE_ADDED_PREFIX'] = cfg.get('messages', {}).get('quoteaddedprefix', "Quote:") + conf['MESSAGE']['QUOTE_ADDED_SUFFIX'] = cfg.get('messages', {}).get('quoteaddedsuffix', "added.") + + conf['MESSAGE']['WIKI_TOO_MANY'] = cfg.get('messages', {}).get('wiki_too_many', "Sorry, there are too many possible results. Try a more narrow search.") + conf['MESSAGE']['WIKI_NO_RESULT'] = cfg.get('messages', {}).get('wiki_no_result', "Sorry, there was an error fetching the wikipedia answer.") + + conf['USERMAP'] = cfg.get('usermapping', []) + + if 'whitelist' in cfg: + conf['WHITELIST'] = True + conf['WHITELIST_USER'] = cfg['whitelist'] + else: + conf['WHITELIST'] = False + + except KeyError: + logging.exception('Your config file is invalid, please check and try again.') + sys.exit(254) + + if conf['WHITELIST']: + logging.info('Whitelist mode enabled') + logging.debug('Whitelist: %s', conf['WHITELIST_USER']) + + if not conf['IRC_CHANNEL']: + raise ValueError('Please add your twitch channel to config.yml.') + if not conf['IRC_USERNAME']: + raise ValueError('Please add the bots username to config.yml.') + if not conf['IRC_OAUTH_TOKEN']: + 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:"') + + return conf + +def send_tts_queue(): + """ Send messages to TTS """ + + for raw_msg in msg_queue_raw: + logging.debug('Raw msg: %s', 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: %s", msg_queue) + else: + logging.debug('Msg is already in queue') + +def get_url(path=False): + """ Generate a valid URL from config values """ + if conf['HTTP_BIND'] == "0.0.0.0": + url = "localhost" + else: + url = conf['HTTP_BIND'] + + url = "http://"+str(url)+":"+str(conf['HTTP_PORT'])+"/" + + if path: + url = url+str(path) + + return url + +def check_oauth_token(): + """ Check for valid authentication via Twitch API """ + global conf # pylint: disable=global-statement,invalid-name + logging.debug('Checking OAuth Token') + + try: + url = 'https://id.twitch.tv/oauth2/validate' + oauth = "OAuth "+str(conf['IRC_OAUTH_TOKEN'].replace('oauth:','')) + request = urllib.request.Request(url) + request.add_header('Authorization', oauth) + urllib.request.urlopen(request) + except HTTPError: + logging.fatal('Twitch rejected your OAuth Token. Please check and generate a new one.') + logging.info('Please open http://%s:%s/token to generate your OAuth-Token.', conf['HTTP_BIND'], conf['HTTP_PORT']) + + url = get_url("token") + webbrowser.open_new_tab(url) + logging.info('Please complete the OAuth process and add the token into your "config.yml" within the next 5 minutes.') + time.sleep(300) + conf = load_config() + check_oauth_token() + + logging.info('OAuth Token is valid') + return conf + +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 = ThreadingSimpleServer((conf['HTTP_BIND'], conf['HTTP_PORT']), HTTPserv) + + http_thread = Thread(target=http_serve_forever, daemon=True, args=(httpd, )) + http_thread.start() + check_oauth_token() + + logging.info("Starting IRC bot") + irc = IRC() + + 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('Connected and joined') + url = get_url() + logging.info("Please open your browser and visit: %s", url) + webbrowser.open_new_tab(url) + + while True: + if conf['LOG_LEVEL'] == "DEBUG": + time.sleep(1) + + try: + irc.get_response() + + confreload = datetime.datetime.now() + if confreload - lastreload > datetime.timedelta(seconds=60): + conf = load_config() + lastreload = datetime.datetime.now() + + if irc.quickvote_status and irc.votemsg: + logging.info('Quickvote is active') + irc.sendmsg(conf['IRC_CHANNEL'], "@chat", conf['MESSAGE']['VOTESTART'] + " (" + str(irc.votemsg) + ")") + + if not irc.tts_status: + continue + + logging.debug('msg_queue_raw: %s', msg_queue_raw) + send_tts_queue() + + except KeyboardInterrupt: + httpd.shutdown() + logging.info('Exiting...') + os.kill(os.getpid(), signal.SIGTERM) + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(module)s %(threadName)s %(levelname)s: %(message)s') + sys.tracebacklimit = 3 + + VERSION = "1.6.1" + conf = {} + tts_done = [] + msg_queue_raw = [] + msg_queue = {} + + if sys.argv[1:]: + if sys.argv[1] == "--version": + print('Simple TTS Bot') + print('Version %s', VERSION) + sys.exit(1) + + main()