From: Georgios Atheridis Date: Thu, 2 Jun 2022 21:46:44 +0000 (+0300) Subject: Fixed countless bugs which were caused by controlling the bot from the command line... X-Git-Url: https://git.atheridis.org/?a=commitdiff_plain;h=2da1d56e9f05eafffff17143aaa590cb27ec840c;p=personal%2Faptbot.git Fixed countless bugs which were caused by controlling the bot from the command line. Edited README.md. Updated setup.py --- diff --git a/README.md b/README.md index a7d8e80..cc155f5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,112 @@ # aptbot +-------- A chatbot for twitch.tv + +## Dependencies + +-------------- + +* Python (any >=3.7 version should work) +* python-dotenv +* urllib3 + +## Install + +---------- + +It is highly recommended you install and run aptbot in a virtual environment. + +Clone this repository `git clone https://github.com/atheridis/aptbot.git`, +change to the directory `cd aptbot`, then install the package `pip install .`. + +## First Steps + +---------- + +### Adding an account + +After installing, you can add an account using `aptbot --add-account "account_name"`. +A directory will be created in `~/.config/aptbot/` on Linux +or `%APPDATA%\aptbot\accounts\` on Windows with the twitch name of that account. + +Each account directory should contain a `main.py` file +with the most minimal code being: + +```python +from aptbot import Bot, Message, Commands + +# Runs at the beginning, when the account connects. +# Can be used for an infinite loop within the account, +# so the bot can send messages, even when chat isn't moving. +def start(bot: Bot, message: Message, stop_event): + pass + +# Gets ran every time the IRC channel sends a message. +# This can either be a message from a user +# a raid, when a mod deletes a message, etc. +def main(bot: Bot, message: Message): + pass +``` + +When you add an account using `aptbot --add-account "account_name"` +or `aptbot -a "account_name"`, +the following python file called `main.py` will be created: + +```python +import time +from threading import Event + +from aptbot import Bot, Commands, Message + + +# Starts when the bot is enabled +# Sends the message "Hello, world!" every 2 minutes to the channel +# stop_event is set to True, when you disable the account with aptbot -d "account_name" +def start(bot: Bot, message: Message, stop_event: Event): + while not stop_event.is_set(): + bot.send_message(message.channel, "Hello, world!") + time.sleep(120) + + +def main(bot: Bot, message: Message): + # Check whether the message sent is a message by a user in chat + # and not some notification. + if message.command == Commands.PRIVMSG: + # Check the content of the message and if the first word is '!hello' + # send a reply by creating a new thread. + # You can also use `message.nick` instead of `message.tags['display-name']` + # but then the message sent back + # will contain the name of the user in all lowercase + if message.value.split()[0] == "!hello": + bot.send_message( + message.channel, + f"hello {message.tags['display-name']}", + reply=message.tags["id"], + ) +``` + +### Activating your bot + +Once you have an account set up you can enable the bot by using `aptbot --enable`. +To stop the bot from running use `aptbot --disable` on another terminal. + +After testing you would want to run `aptbot --enable` as a daemon, the easiest way +to do that on Linux is to use `nohup aptbot --enable &`. This will leave a `nohup.out` +file in the directory you're in, which may not be ideal. If you wish to have no output +other than the logs provided in `~/.cache/aptbot/logs/aptbot.log` you may use +`nohup aptbot --enable /dev/null 2>&1 &`. You are now free to control +aptbot through any terminal. Type `aptbot --help` to see all available commands. + +### More than one file + +You can import modules from the same directory that the `main.py` files are in, +but if you want `aptbot --update` to work on them you will need to reload the modules +due to how python handles module imports. Some examples will be coming soon. + +## BUGS + +------- + +* Editing any of the directory names in `~/.config/aptbot/` +or `%APPDATA%\aptbot\accounts\` while the bot is running, may cause weird behaviour. diff --git a/aptbot/bot.py b/aptbot/bot.py index e9f5d46..32d851e 100644 --- a/aptbot/bot.py +++ b/aptbot/bot.py @@ -80,7 +80,11 @@ class Bot(ABCBot): def leave_channel(self, channel: str): self._send_command(f"{Commands.PART.value} #{channel}") - self._connected_channels.remove(channel) + try: + self._connected_channels.remove(channel) + except KeyError as e: + logger.exception("Account isn't enabled") + logger.exception(e) def send_message(self, channel: str, text: Union[list[str], str], reply=None): if reply: diff --git a/aptbot/main.py b/aptbot/main.py index 5d14c83..bd164e2 100644 --- a/aptbot/main.py +++ b/aptbot/main.py @@ -7,7 +7,8 @@ import socket import sys import time import traceback -from threading import Thread +from dataclasses import dataclass +from threading import Event, Thread from types import ModuleType from dotenv import load_dotenv @@ -15,14 +16,8 @@ from dotenv import load_dotenv from . import args_logic from .args import parse_arguments from .bot import Bot, Message -from .constants import ( - CONFIG_LOGS, - CONFIG_PATH, - LOCALHOST, - LOGGING_DICT, - MAIN_FILE_NAME, - PORT, -) +from .constants import (CONFIG_LOGS, CONFIG_PATH, LOCALHOST, LOGGING_DICT, + MAIN_FILE_NAME, PORT) logging.config.dictConfig(LOGGING_DICT) logger = logging.getLogger(__name__) @@ -30,70 +25,84 @@ logger = logging.getLogger(__name__) load_dotenv() -def handle_message(bot: Bot, modules: dict[str, ModuleType]): - logger.debug("in handle_message thread") +@dataclass(frozen=True) +class Channel: + name: str + module: ModuleType + thread: Thread + stop_event: Event + + def __hash__(self): + return hash(self.name) + + def __eq__(self, other): + return self.name == other.name + + +def handle_message(bot: Bot, channels: set[Channel]): while True: messages = bot.get_messages() for message in messages: if not message.channel: continue - try: - method = Thread( - target=modules[message.channel].main, - args=( - bot, - message, - ), - ) - except KeyError: - pass - else: - method.daemon = True - method.start() + for channel in channels: + if channel.name != message.channel: + continue + Thread( + target=channel.module.main, args=(bot, message), daemon=True + ).start() + break -def start(bot: Bot, modules: dict[str, ModuleType]): - load_modules(modules) +def run_start(channels: set[Channel]): + for channel in channels: + channel.thread.start() + + +def disable_channel(channel_name: str, channels: set[Channel]): + for channel in channels: + if channel.name != channel_name: + continue + channel.stop_event.set() + logger.debug(f"Event set for {channel}") + channels.remove(channel) + logger.debug(f"{channel} removed") + break + + +def enable(bot: Bot, channels: set[Channel]): + load_modules(bot, channels) message_handler_thread = Thread( target=handle_message, args=( bot, - modules, + channels, ), + daemon=True, ) - message_handler_thread.daemon = True message_handler_thread.start() - for channel in modules: - update_channel = Thread( - target=modules[channel].start, - args=( - bot, - Message({}, "", None, channel, ""), - ), - ) - update_channel.daemon = True - update_channel.start() -def load_modules(modules: dict[str, ModuleType]): - modules.clear() - channels = [ +def load_modules(bot: Bot, channels: set[Channel]): + old_channels = channels.copy() + channels.clear() + channel_names = [ c for c in os.listdir(CONFIG_PATH) if os.path.isdir(os.path.join(CONFIG_PATH, c)) ] - channels = filter(lambda x: not x.startswith("."), channels) - for channel in channels: - account_path = os.path.join(CONFIG_PATH, channel) - sys.path.append(account_path) - module_path = os.path.join(account_path, MAIN_FILE_NAME) + channel_names = filter(lambda x: not x.startswith("."), channel_names) + for channel_name in channel_names: + channel_path = os.path.join(CONFIG_PATH, channel_name) + sys.path.append(channel_path) + module_path = os.path.join(channel_path, MAIN_FILE_NAME) spec = importlib.util.spec_from_file_location( "main", module_path, ) if not spec or not spec.loader: - logger.warning(f"Problem loading for {channel}") - sys.path.remove(account_path) + logger.warning(f"Problem loading for {channel_path}") + sys.path.remove(channel_path) continue module = importlib.util.module_from_spec(spec) try: @@ -102,8 +111,36 @@ def load_modules(modules: dict[str, ModuleType]): logger.exception(f"Problem Loading Module: {e}") logger.exception(traceback.format_exc()) else: - modules[channel] = module - sys.path.remove(account_path) + for channel in old_channels: + if channel_name != channel.name: + continue + stop_event = channel.stop_event + thread = channel.thread + logger.debug( + f"Copied stop_event and thread for account: {channel.name}" + ) + break + else: + stop_event = Event() + thread = Thread( + target=module.start, + args=(bot, Message(channel=channel_name), stop_event), + daemon=True, + ) + logger.debug( + f"Created stop event and thread for account: {channel_name}" + ) + channels.add( + Channel( + name=channel_name, + module=module, + thread=thread, + stop_event=stop_event, + ), + ) + sys.path.remove(channel_path) + new_channels = channels.difference(old_channels) + run_start(new_channels) def initialize(bot: Bot): @@ -132,20 +169,20 @@ def listener(): logger.error("Twitch couldn't authenticate your credentials") time.sleep(3) sys.exit(1) - modules = {} + initialize(bot) + channels: set[Channel] = set() message_loop = Thread( - target=start, + target=enable, args=( bot, - modules, + channels, ), + daemon=True, ) - message_loop.daemon = True message_loop.start() s = socket.socket() s.bind((LOCALHOST, PORT)) s.listen(5) - initialize(bot) while True: c, _ = s.accept() @@ -168,8 +205,9 @@ def listener(): s.close() sys.exit() elif args_logic.BotCommands.UPDATE.value in command: - load_modules(modules) + load_modules(bot, channels) elif args_logic.BotCommands.PART.value in command: + disable_channel(channel, channels) bot.leave_channel(channel) time.sleep(1) diff --git a/aptbot/resources/main.py b/aptbot/resources/main.py index 6c7bc6a..da73e9d 100644 --- a/aptbot/resources/main.py +++ b/aptbot/resources/main.py @@ -1,8 +1,16 @@ +import time +from threading import Event + from aptbot import Bot, Commands, Message -def start(bot: Bot, message: Message): - pass +# Starts when the bot is enabled +# Sends the message "Hello, world!" every 2 minutes to the channel +# stop_event is set to True, when you disable the account with aptbot -d "account_name" +def start(bot: Bot, message: Message, stop_event: Event): + while not stop_event.is_set(): + bot.send_message(message.channel, "Hello, world!") + time.sleep(120) def main(bot: Bot, message: Message): diff --git a/examples/README.md b/examples/README.md index cf69c4e..d3156fa 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,10 +2,10 @@ Add an account using `aptbot --add-account "account_name"`. A directory will be created in `~/.config/aptbot/` on Linux -or `%APPDATA%\aptbot\` on Windows with the twitch id of that account. +or `%APPDATA%\aptbot\` on Windows with the twitch name of that account. The contents of each example directory here should mimic the contents of the -directory with the twitch id. +directory with the twitch name. Each account directory should contain a `main.py` file with the most minimal code being: @@ -13,7 +13,7 @@ with the most minimal code being: ```python from aptbot import Bot, Message, Commands -# Gets ran at the beginning, when the account connects. +# Runs at the beginning, when the account connects. # Can be used for an infinite loop within the account, # so the bot send messages, even when chat is dead. def start(bot: Bot, message: Message): diff --git a/setup.py b/setup.py index 0f93609..fa8aaef 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh: setuptools.setup( name="aptbot", - version="0.2.0", + version="0.2.1", author="Georgios Atheridis", author_email="atheridis@tutamail.com", description="A chatbot for twitch.tv", @@ -28,5 +28,5 @@ setuptools.setup( "python-dotenv", "urllib3", ], - python_requires=">=3.9", + python_requires=">=3.7", )