diff --git a/bare.py b/bare.py index 86c4402..9d11e23 100644 --- a/bare.py +++ b/bare.py @@ -2,6 +2,7 @@ from socket import socket from overrides import bytes, bbytes from typing import NoReturn, Union +from pylast import LastFMNetwork logs = ... re = ... @@ -34,6 +35,8 @@ class bot: current: str tmpHost: str ignores: list[str] + threads: list[str] + lastfmLink: LastFMNetwork def __init__(self, server: str): ... diff --git a/bot.py b/bot.py index 173d1be..4e458fc 100644 --- a/bot.py +++ b/bot.py @@ -8,9 +8,11 @@ import commands as cmds import config as conf from time import sleep from importlib import reload +import timers import random as r import handlers import bare +from threading import Thread def mfind(message: str, find: list, usePrefix: bool = True) -> bool: @@ -43,6 +45,8 @@ class bot(bare.bot): self.queue: list[bbytes] = [] # pyright: ignore [reportInvalidTypeForm] self.sock = socket(AF_INET, SOCK_STREAM) self.current = "user" + self.threads = conf.servers[server]["threads"] + self.lastfmLink = conf.lastfmLink self.log(f"Start init for {self.server}") def connect(self) -> None: @@ -220,6 +224,16 @@ class bot(bare.bot): sleep(0.5) for chan in self.channels: self.join(chan, "null", False) + tMgr = None + if self.threads: + tdict = {} + for thread in self.threads: + tdict[thread] = timers.data[thread] + if thread in ["radio"]: + tdict[thread]["args"] = [self] + tMgr = Thread(target=timers.threadManager, args=(tdict,)) + tMgr.daemon = True + tMgr.start() while 1: raw = self.recv() ircmsg = raw.safe_decode() diff --git a/commands.py b/commands.py index 53bfce3..652c95c 100644 --- a/commands.py +++ b/commands.py @@ -21,7 +21,7 @@ def goat(bot: bare.bot, chan: str, name: str, message: str) -> None: def botlist(bot: bare.bot, chan: str, name: str, message: str) -> None: bot.msg( - f"Hi! I'm FireBot (https://git.amcforum.wiki/Firepup650/fire-ircbot)! {'My admins on this server are' + bot.adminnames + '.' if bot.adminnames else ''}", # pyright: ignore [reportOperatorIssue] + f"Hi! I'm FireBot (https://git.amcforum.wiki/Firepup650/fire-ircbot)! {'My admins on this server are' + str(bot.adminnames) + '.' if bot.adminnames else ''}", # pyright: ignore [reportOperatorIssue] chan, ) diff --git a/config.py b/config.py index d49055f..44eea4a 100644 --- a/config.py +++ b/config.py @@ -3,10 +3,10 @@ from os import environ as env from dotenv import load_dotenv # type: ignore import re, codecs from typing import Optional, Any -import bare +import bare, pylast load_dotenv() -__version__ = "v2.0.14" +__version__ = "v3.0.0" npbase: str = "\[\x0303last\.fm\x03\] [A-Za-z0-9_[\]{}\\|\-^]{1,$MAX} (is listening|last listened) to: \x02.+ - .*\x02( \([0-9]+ plays\)( \[.*\])?)?" # pyright: ignore [reportInvalidStringEscapeSequence] su = "^(su|sudo|(su .*|sudo .*))$" servers: dict[str, dict[str, Any]] = { @@ -19,6 +19,7 @@ servers: dict[str, dict[str, Any]] = { "ignores": ["#main/replirc"], "admins": [], "hosts": ["9pfs.repl.co"], + "threads": [], }, "efnet": { "address": "irc.mzima.net", @@ -26,21 +27,25 @@ servers: dict[str, dict[str, Any]] = { "ignores": [], "admins": [], "hosts": ["154.sub-174-251-241.myvzw.com"], + "threads": [], }, "replirc": { "address": "localhost", "pass": env["replirc_pass"], - "channels": {"#random": 0, "#dice": 0, "#main": 0, "#bots": 0, "#firebot": 0, "#sshchat": 0}, - "ignores": [], + "channels": {"#random": 0, "#dice": 0, "#main": 0, "#bots": 0, "#firebot": 0, "#sshchat": 0, "#firemc": 0, "#fp-radio": 0}, + "ignores": ["#fp-radio"], "admins": ["h-tl"], "hosts": ["owner.firepi"], + "threads": ["radio"], }, "backupbox": { - "address": "172.23.11.5", + "address": "localhost", + "port": 6607, "channels": {"#default": 0, "#botrebellion": 0, "#main/replirc": 0}, "ignores": ["#main/replirc"], "admins": [], - "hosts": ["172.20.171.225", "169.254.253.107"], + "hosts": ["172.20.171.225", "169.254.253.107", "2600-6c5a-637f-1a85-0000-0000-0000-6667.inf6.spectrum.com"], + "threads": [], }, } admin_hosts: list[str] = ["firepup.firepi", "47.221.227.180"] @@ -56,6 +61,7 @@ ESCAPE_SEQUENCE_RE = re.compile( re.UNICODE | re.VERBOSE, ) prefix = "." +lastfmLink = pylast.LastFMNetwork(env["FM_KEY"], env["FM_SECRET"]) npallowed: list[str] = ["FireBitBot"] def decode_escapes(s: str) -> str: diff --git a/core.py b/core.py index 179a10c..062abec 100644 --- a/core.py +++ b/core.py @@ -3,43 +3,19 @@ from os import system from time import sleep from threading import Thread from logs import log - +from timers import threadManager def launch(server: str) -> None: system(f"python3 -u ircbot.py {server}") -threads = {} -servers = [ - "ircnow", - "replirc", - # "efnet", - "backupbox", -] - - -def is_dead(thr: Thread) -> bool: - thr.join(timeout=0) - return not thr.is_alive() - - -def start(server: str) -> Thread: - t = Thread(target=launch, args=(server,)) - t.daemon = True - t.start() - return t +servers = { + "ircnow": {"noWrap": True, "func": launch, "args": ["ircnow"]}, + "replirc": {"noWrap": True, "func": launch, "args": ["replirc"]}, + # "efnet": {"noWrap": True, "func": launch, "args": ["efnet"]}, + "backupbox": {"noWrap": True, "func": launch, "args": ["backupbox"]}, +} if __name__ == "__main__": - log("Begin initialization", "CORE") - for server in servers: - threads[server] = start(server) - log("Started all instances. Idling...", "CORE") - while 1: - sleep(60) - log("Running a checkup on all running instances", "CORE") - for server in threads: - t = threads[server] - if is_dead(t): - log(f"The thread for {server} died, restarting it...", "CORE", "WARN") - threads[server] = start(server) + threadManager(servers, True, "CORE") diff --git a/logs.py b/logs.py index 7a39402..8dbb7bb 100644 --- a/logs.py +++ b/logs.py @@ -10,7 +10,7 @@ def log( level: str = "LOG", time: Union[dt, str] = "now", ) -> None: - if level in ["EXIT", "CRASH"]: + if level in ["EXIT", "CRASH", "FATAL"]: stream = stderr else: stream = stdout diff --git a/poetry.lock b/poetry.lock index f18ad84..7223f79 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,33 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.3.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, + {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] [[package]] name = "apiclient" version = "1.0.4" description = "Framework for making good API client libraries using urllib3." +category = "main" optional = false python-versions = "*" files = [ @@ -18,6 +42,7 @@ urllib3 = "*" name = "certifi" version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -25,10 +50,115 @@ files = [ {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] +[[package]] +name = "exceptiongroup" +version = "1.2.0" +description = "Backport of PEP 654 (exception groups)" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.4" +description = "A minimal low-level HTTP client." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.4-py3-none-any.whl", hash = "sha256:ac418c1db41bade2ad53ae2f3834a3a0f5ae76b56cf5aa497d2d033384fc7d73"}, + {file = "httpcore-1.0.4.tar.gz", hash = "sha256:cb2839ccfcba0d2d3c1131d3c3e26dfc327326fbe7a5dc0dbfe9f6c9151bb022"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] +trio = ["trio (>=0.22.0,<0.25.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = ">=1.0.0,<2.0.0" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "pylast" +version = "5.2.0" +description = "A Python interface to Last.fm and Libre.fm" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pylast-5.2.0-py3-none-any.whl", hash = "sha256:89c7c01ea9f08c83865999d8907835157a8096e77dd9dc23420246eb66cfcff5"}, + {file = "pylast-5.2.0.tar.gz", hash = "sha256:bb046804ef56a0c18072c750d61a282d47ac102a3b0b9c44a023eaf5b0934b0a"}, +] + +[package.dependencies] +httpx = "*" + +[package.extras] +tests = ["flaky", "pytest", "pytest-cov", "pytest-random-order", "pyyaml"] + [[package]] name = "python-dotenv" version = "1.0.0" description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -39,10 +169,35 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "typing-extensions" +version = "4.10.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, +] + [[package]] name = "urllib3" version = "2.1.0" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -58,4 +213,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "48808e6f2c2ef0f18b46ffba3d13d473eacccad61c6bc369c655a127a7df48fd" +content-hash = "5f0106196ba3a316e887fe2748bb5ca19159f2905d7678393b8ee51430f9ca45" diff --git a/pyproject.toml b/pyproject.toml index f76823c..3bc7d03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ authors = ["Firepup Sixfifty "] python = "^3.9" apiclient = "^1.0.4" python-dotenv = "^1.0.0" +pylast = "^5.2.0" [tool.poetry.dev-dependencies] diff --git a/timers.py b/timers.py new file mode 100644 index 0000000..0cdb408 --- /dev/null +++ b/timers.py @@ -0,0 +1,93 @@ +#!/usr/bin/python3 +import bare, pylast +import config as conf +import random as r +from logs import log +from typing import Any, Callable, NoReturn +from threading import Thread +from time import sleep +from traceback import format_exc + +def is_dead(thr: Thread) -> bool: + thr.join(timeout=0) + return not thr.is_alive() + + +def threadWrapper(data: dict) -> NoReturn: + if not data["noWrap"]: + while 1: + if ignoreErrors: + try: + data["func"](*data["args"]) + except Exception: + Err = format_exc() + for line in Err.split("\n"): + log(line, "Thread", "WARN") + else: + try: + data["func"](*data["args"]) + except Exception: + Err = format_exc() + for line in Err.split("\n"): + log(line, "Thread", "CRASH") + exit(1) + sleep(data["interval"]) + log("Threaded loop broken", "Thread", "FATAL") + else: + data["func"](*data["args"]) + exit(1) + + +def startThread(data: dict) -> Thread: + t = Thread(target=threadWrapper, args=(data,)) + t.daemon = True + t.start() + return t + + +def threadManager(threads: dict[str, dict[str, Any]], output: bool = False, mgr: str = "TManager", interval: int = 60) -> NoReturn: + if output: + log("Begin init of thread manager", mgr) + running = {} + for name in threads: + data = threads[name] + running[name] = startThread(data) + if output: + log("All threads running, starting checkup loop", mgr) + while 1: + sleep(interval) + if output: + log("Checking threads", mgr) + for name in running: + t = running[name] + if is_dead(t): + if output: + log(f"Thread {name} has died, restarting", mgr, "WARN") + data = threads[name] + running[name] = startThread(data) + log("Thread manager loop broken", mgr, "FATAL") + exit(1) + + +def radio(instance: bare.bot) -> NoReturn: + lastTrack = "" + while 1: + try: + newTrack = instance.lastfmLink.get_user("Firepup650").get_now_playing() + if newTrack: + thisTrack = newTrack.__str__() + if thisTrack != lastTrack: + lastTrack = thisTrack + instance.msg("f.sp " + thisTrack, "#fp-radio") + instance.sendraw(f"TOPIC #fp-radio :Firepup radio ({thisTrack}) - https://open.spotify.com/playlist/4ctNy3O0rOwhhXIKyLvUZM") + except Exception: + Err = format_exc() + for line in Err.split("\n"): + instance.log(line, "WARN") + sleep(2) + instance.log("Thread while loop broken", "FATAL") + exit(1) + +data: dict[str, dict[str, Any]] = { + "radio": {"noWrap": True, "func": radio, "args": []}, +}