Fixed countless bugs which were caused by controlling the bot from the command line...
authorGeorgios Atheridis <atheridis@tutamail.com>
Thu, 2 Jun 2022 21:46:44 +0000 (00:46 +0300)
committerGeorgios Atheridis <atheridis@tutamail.com>
Thu, 2 Jun 2022 21:46:44 +0000 (00:46 +0300)
README.md
aptbot/bot.py
aptbot/main.py
aptbot/resources/main.py
examples/README.md
setup.py

index a7d8e80c8eff6fbb0d24d4f23ea4cfa42f3b2083..cc155f5d21940623d1008fb7d49668223b3ef306 100644 (file)
--- 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 >/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.
index e9f5d46c94dea6aea17f280ca494e8b0e05d2df7..32d851efa43890dd6675f06668e34e13392c6a58 100644 (file)
@@ -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:
index 5d14c837ad0b3701c414cc1f6147607ff57f9093..bd164e2ecb574c3d8658b052577bf161f540bffc 100644 (file)
@@ -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)
index 6c7bc6acbcb92cb75ca2d5252c82b5620fb9bc31..da73e9d4931fe242a3a238fc42b578b897a68532 100644 (file)
@@ -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):
index cf69c4e5595c10207885956ea0152f1a6d3f89ce..d3156faf2bd991afe22934dbf48c5181e1acefef 100644 (file)
@@ -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):
index 0f936099ddb815301077f1c16a4d618c9a137167..fa8aaef8f768cf03ce4a540bc5493dfc962afec5 100644 (file)
--- 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",
 )