From dccb9acc145905bae4ce799b738704abe362939b Mon Sep 17 00:00:00 2001 From: Deuchnord Date: Mon, 12 May 2025 17:49:13 +0200 Subject: [PATCH] ci: add CI (#7) --- .github/dependabot.yml | 26 +++++ .github/workflows/black.yml | 19 ++++ .github/workflows/release.yml | 32 ++++++ .github/workflows/semantic-pr.yml | 17 +++ poetry.lock | 176 +++++++++++++++++++++++++----- pyproject.toml | 7 +- twason/__main__.py | 24 ++-- twason/config.py | 104 ++++++++++-------- twason/moderator.py | 80 ++++++++------ twason/twitch_message.py | 5 +- twason/twitchbot.py | 96 +++++++++------- 11 files changed, 428 insertions(+), 158 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/black.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/semantic-pr.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ba3dcb4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 + +updates: + - package-ecosystem: pip + directory: "/" + target-branch: master + open-pull-requests-limit: 5 + schedule: + interval: daily + reviewers: + - Deuchnord + commit-message: + prefix: chore + include: scope + + - package-ecosystem: github-actions + directory: "/" + open-pull-requests-limit: 5 + target-branch: master + schedule: + interval: weekly + reviewers: + - Deuchnord + commit-message: + prefix: ci + include: scope diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 0000000..281cc1b --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,19 @@ +name: Code style + +on: + push: + branches: [master, features] + pull_request: + branches: [master, features] + +jobs: + lint: + runs-on: ubuntu-latest + + name: Code Style + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - uses: psf/black@stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d2900b1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release Application + +on: + release: + types: [published] + +jobs: + pipy: + name: Build and Release to PyPI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Prepare environment + run: | + python -m pip install --upgrade pip poetry + + - name: Build package + run: | + poetry install + make i18n build + + - name: Publish to PyPI + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_PASSWORD }} + run: | + poetry publish diff --git a/.github/workflows/semantic-pr.yml b/.github/workflows/semantic-pr.yml new file mode 100644 index 0000000..d77d8a6 --- /dev/null +++ b/.github/workflows/semantic-pr.yml @@ -0,0 +1,17 @@ +name: "Semantic Pull Request" + +on: + pull_request: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5.4.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/poetry.lock b/poetry.lock index e3d4f03..34e986f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,53 +1,179 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "black" +version = "25.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, + {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, + {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"}, + {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"}, + {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"}, + {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"}, + {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"}, + {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"}, + {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"}, + {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"}, + {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"}, + {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"}, + {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"}, + {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"}, + {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"}, + {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"}, + {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"}, + {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"}, + {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"}, + {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"}, + {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"}, + {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.2.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c"}, + {file = "click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + [[package]] name = "docopt" version = "0.6.2" description = "Pythonic argument parser, that will make you smile" -category = "main" optional = false python-versions = "*" +groups = ["main"] +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] [[package]] name = "irc3" -version = "1.1.7" +version = "1.1.10" description = "plugable irc client library based on asyncio with DCC and SASL support" -category = "main" optional = false python-versions = "*" +groups = ["main"] +files = [ + {file = "irc3-1.1.10-py3-none-any.whl", hash = "sha256:603782d669422804674442cfc097e5c8f1c9d82bee8dac67e28669220af4a601"}, + {file = "irc3-1.1.10.tar.gz", hash = "sha256:fe3afa945d4328919aa3f00ccba56644162681a4cd6dcdf9a4538b7f30258e38"}, +] [package.dependencies] docopt = "*" venusian = ">=3.0" [package.extras] -test = ["pytest-asyncio", "pytest-aiohttp", "feedparser", "requests", "pysocks", "twitter", "aiocron", "redis", "pytest", "irc3-plugins-test"] +test = ["aiocron", "feedparser", "irc3-plugins-test", "pysocks", "pytest", "pytest-aiohttp", "pytest-asyncio", "redis", "requests", "twitter"] web = ["aiohttp"] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + [[package]] name = "venusian" -version = "3.0.0" +version = "3.1.1" description = "A library for deferring decorator actions" -category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "venusian-3.1.1-py3-none-any.whl", hash = "sha256:0845808a985976acbceaa1fbb871c7fac4fb28ae75453232970e9c2c2866dbf4"}, + {file = "venusian-3.1.1.tar.gz", hash = "sha256:534fb3b355669283eb3954581931e5d1d071fce61d029d58f3219a5e3a6f0c41"}, +] [package.extras] -docs = ["sphinx", "repoze.sphinx.autointerface"] -testing = ["pytest", "pytest-cov", "coverage"] +docs = ["Sphinx (>=4.3.2)", "pylons-sphinx-themes", "repoze.sphinx.autointerface", "sphinx-copybutton"] +testing = ["coverage", "pytest", "pytest-cov"] [metadata] -lock-version = "1.1" -python-versions = "^3.7" -content-hash = "1d29e6d81c2ba0236771fc1ba673e7367e504279164d4f1556d492be4200f620" - -[metadata.files] -docopt = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, -] -irc3 = [ - {file = "irc3-1.1.7-py3-none-any.whl", hash = "sha256:088b7be88817dee5d02e7e06c9dbf503c8e807a8cccc9b36b5be419918574cf1"}, - {file = "irc3-1.1.7.tar.gz", hash = "sha256:6800c8876a889736961e199e834dc9933244affa20d4da086fb4b47f76984e12"}, -] -venusian = [ - {file = "venusian-3.0.0-py3-none-any.whl", hash = "sha256:06e7385786ad3a15c70740b2af8d30dfb063a946a851dcb4159f9e2a2302578f"}, - {file = "venusian-3.0.0.tar.gz", hash = "sha256:f6842b7242b1039c0c28f6feef29016e7e7dd3caaeb476a193acf737db31ee38"}, -] +lock-version = "2.1" +python-versions = "^3.12" +content-hash = "5d7d7513799a39e01fdb8f7159317204c36855b5948f12b89a71ef7b47bd6a1d" diff --git a/pyproject.toml b/pyproject.toml index e2cfb6e..68d59c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,11 @@ license = "AGPL-3.0-or-later" twason = 'twason.__main__:main' [tool.poetry.dependencies] -python = "^3.7" -irc3 = "^1.1.7" +python = "^3.12" +irc3 = "^1.1" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] +black = "^25.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/twason/__main__.py b/twason/__main__.py index 79e65fe..c0efa74 100755 --- a/twason/__main__.py +++ b/twason/__main__.py @@ -32,15 +32,17 @@ def main() -> int: args = get_arguments() twitchbot.config = get_config(args.config) - bot = irc3.IrcBot.from_config({ - 'nick': twitchbot.config.nickname, - 'password': twitchbot.config.token, - 'autojoins': [], - 'host': TWITCH_IRC_SERVER, - 'port': TWITCH_IRC_PORT, - 'ssl': True, - 'includes': [twitchbot.__name__] - }) + bot = irc3.IrcBot.from_config( + { + "nick": twitchbot.config.nickname, + "password": twitchbot.config.token, + "autojoins": [], + "host": TWITCH_IRC_SERVER, + "port": TWITCH_IRC_PORT, + "ssl": True, + "includes": [twitchbot.__name__], + } + ) bot.run(forever=True) @@ -49,10 +51,10 @@ def main() -> int: def get_arguments(): parser = argparse.ArgumentParser() - parser.add_argument('--config', '-c', type=str, default='config.json') + parser.add_argument("--config", "-c", type=str, default="config.json") return parser.parse_args() -if __name__ == '__main__': +if __name__ == "__main__": exit(main()) diff --git a/twason/config.py b/twason/config.py index ba1773e..7167af9 100644 --- a/twason/config.py +++ b/twason/config.py @@ -30,7 +30,9 @@ class Command: aliases: [str] disabled: bool - def __init__(self, name: str, message: str, aliases: [str] = None, disabled: bool = False): + def __init__( + self, name: str, message: str, aliases: [str] = None, disabled: bool = False + ): self.name = name self.message = message self.aliases = aliases if aliases is not None else [] @@ -39,10 +41,10 @@ class Command: @classmethod def from_dict(cls, params: dict): return Command( - params.get('name'), - params['message'], - params.get('aliases', []), - params.get('disabled', False) + params.get("name"), + params["message"], + params.get("aliases", []), + params.get("disabled", False), ) @@ -62,8 +64,8 @@ class Timer: time_between: int = 10, msgs_between: int = 10, strategy: TimerStrategy = TimerStrategy.ROUND_ROBIN, - pool: [Command] = None - ): + pool: [Command] = None, + ): self.time_between = time_between self.msgs_between = msgs_between self.strategy = strategy @@ -73,16 +75,16 @@ class Timer: def from_dict(cls, param: dict): pool = [] - for c in param.get('pool', []): + for c in param.get("pool", []): command = Command.from_dict(c) if not command.disabled: pool.append(command) return Timer( - time_between=param.get('between', {}).get('time', 10), - msgs_between=param.get('between', {}).get('messages', 10), - strategy=TimerStrategy(param.get('strategy', 'round-robin')), - pool=pool + time_between=param.get("between", {}).get("time", 10), + msgs_between=param.get("between", {}).get("messages", 10), + strategy=TimerStrategy(param.get("strategy", "round-robin")), + pool=pool, ) @@ -103,7 +105,7 @@ class Config: command_prefix: str, commands: [Command], timer: Timer, - moderators: [moderator.Moderator] + moderators: [moderator.Moderator], ): self.nickname = nickname self.channel = channel @@ -115,14 +117,14 @@ class Config: @classmethod def from_dict(cls, params: dict, token: str): - timer = Timer.from_dict(params.get('timer', {})) + timer = Timer.from_dict(params.get("timer", {})) - commands_prefix = params.get('command_prefix', '!') + commands_prefix = params.get("command_prefix", "!") commands = [] help_command = Command("help", "Voici les commandes disponibles : ") - for command in params.get('commands', []): + for command in params.get("commands", []): command = Command.from_dict(command) if command.disabled: @@ -137,43 +139,53 @@ class Config: commands.append(command) moderators = [] - for mod in params.get('moderator', []): - moderator_config = params['moderator'][mod] - if mod == 'caps-lock': - moderators.append(moderator.CapsLockModerator( - moderator_config.get("message", "{author}, stop the caps lock!"), - cls.parse_decision(moderator_config.get("decision", "delete")), - moderator_config.get("duration", None), - moderator_config.get("min-size", 5), - moderator_config.get("threshold", 50) - )) - if mod == 'flood': - moderators.append(moderator.FloodModerator( - moderator_config.get("message", "{author}, stop the flood!"), - cls.parse_decision(moderator_config.get("decision", "timeout")), - moderator_config.get("duration", None), - moderator_config.get("max-word-length", None), - moderator_config.get("raid-cooldown", None), - moderator_config.get("ignore-hashtags", False), - moderator_config.get("max-msg-occurrences", None), - moderator_config.get("min-time-between-occurrence", None) - )) + for mod in params.get("moderator", []): + moderator_config = params["moderator"][mod] + if mod == "caps-lock": + moderators.append( + moderator.CapsLockModerator( + moderator_config.get( + "message", "{author}, stop the caps lock!" + ), + cls.parse_decision(moderator_config.get("decision", "delete")), + moderator_config.get("duration", None), + moderator_config.get("min-size", 5), + moderator_config.get("threshold", 50), + ) + ) + if mod == "flood": + moderators.append( + moderator.FloodModerator( + moderator_config.get("message", "{author}, stop the flood!"), + cls.parse_decision(moderator_config.get("decision", "timeout")), + moderator_config.get("duration", None), + moderator_config.get("max-word-length", None), + moderator_config.get("raid-cooldown", None), + moderator_config.get("ignore-hashtags", False), + moderator_config.get("max-msg-occurrences", None), + moderator_config.get("min-time-between-occurrence", None), + ) + ) # Generate help command - if params.get('help', True): + if params.get("help", True): for command in commands: - help_command.message = "%s %s%s" % (help_command.message, commands_prefix, command.name) + help_command.message = "%s %s%s" % ( + help_command.message, + commands_prefix, + command.name, + ) commands.append(help_command) return Config( - params.get('channel'), - params.get('nickname'), + params.get("channel"), + params.get("nickname"), token, commands_prefix, commands, timer, - moderators + moderators, ) @classmethod @@ -183,7 +195,9 @@ class Config: elif decision_str == "timeout": decision = moderator.ModerationDecision.TIMEOUT_USER else: - print("WARNING: %s moderator's decision is invalid, it has been deactivated!") + print( + "WARNING: %s moderator's decision is invalid, it has been deactivated!" + ) decision = moderator.ModerationDecision.ABSTAIN return decision @@ -201,6 +215,6 @@ class Config: def get_config(file_path: str): - with open(file_path, 'r') as config_file: - token = environ['TWITCH_TOKEN'] + with open(file_path, "r") as config_file: + token = environ["TWITCH_TOKEN"] return Config.from_dict(json.loads(config_file.read()), token) diff --git a/twason/moderator.py b/twason/moderator.py index 219edc7..0397465 100644 --- a/twason/moderator.py +++ b/twason/moderator.py @@ -33,7 +33,12 @@ class Moderator(ABC): message: str decision: ModerationDecision - def __init__(self, message: str, decision: ModerationDecision, timeout_duration: Union[None, int]): + def __init__( + self, + message: str, + decision: ModerationDecision, + timeout_duration: Union[None, int], + ): self.message = message self.timeout_duration = timeout_duration self.decision = decision @@ -49,12 +54,12 @@ class Moderator(ABC): class CapsLockModerator(Moderator): def __init__( - self, - message: str, - decision: ModerationDecision, - timeout_duration: Union[None, int], - min_size: int, - threshold: int + self, + message: str, + decision: ModerationDecision, + timeout_duration: Union[None, int], + min_size: int, + threshold: int, ): super().__init__(message, decision, timeout_duration) @@ -62,17 +67,17 @@ class CapsLockModerator(Moderator): self.threshold = threshold / 100 def get_name(self) -> str: - return 'Caps Lock' + return "Caps Lock" def vote(self, msg: str, author: str) -> ModerationDecision: - msg = ''.join(filter(str.isalpha, msg)) + msg = "".join(filter(str.isalpha, msg)) if len(msg) < self.min_size: return ModerationDecision.ABSTAIN n = 0 for char in msg: - if char.strip() == '': + if char.strip() == "": continue if char == char.upper(): n += 1 @@ -85,15 +90,15 @@ class CapsLockModerator(Moderator): class FloodModerator(Moderator): def __init__( - self, - message: str, - decision: ModerationDecision, - timeout_duration: Union[None, int], - max_word_length: Union[None, int], - raid_cooldown: Union[None, int], - ignore_hashtags: bool, - max_msg_occurrences: Union[None, int], - min_time_between_occurrence: Union[None, int] + self, + message: str, + decision: ModerationDecision, + timeout_duration: Union[None, int], + max_word_length: Union[None, int], + raid_cooldown: Union[None, int], + ignore_hashtags: bool, + max_msg_occurrences: Union[None, int], + min_time_between_occurrence: Union[None, int], ): super().__init__(message, decision, timeout_duration) self.max_word_length = max_word_length @@ -105,15 +110,18 @@ class FloodModerator(Moderator): self.last_msgs = [] def get_name(self) -> str: - return 'Flood' + return "Flood" def vote(self, msg: str, author: str) -> ModerationDecision: - if self.raid_cooldown is not None and self.last_raid + timedelta(minutes=self.raid_cooldown) > datetime.now(): + if ( + self.raid_cooldown is not None + and self.last_raid + timedelta(minutes=self.raid_cooldown) > datetime.now() + ): return ModerationDecision.ABSTAIN if self.max_word_length is not None: - for word in msg.split(' '): - if word.startswith('#'): + for word in msg.split(" "): + if word.startswith("#"): continue if len(word) > self.max_word_length: return ModerationDecision.TIMEOUT_USER @@ -123,26 +131,32 @@ class FloodModerator(Moderator): clean_msg = None for last_msg in self.last_msgs: - if last_msg['first-occurrence'] + timedelta(seconds=self.min_time_between_occurrence) <= datetime.now(): + if ( + last_msg["first-occurrence"] + + timedelta(seconds=self.min_time_between_occurrence) + <= datetime.now() + ): clean_msg = last_msg break - if author != last_msg['author'] or msg != last_msg['message']: + if author != last_msg["author"] or msg != last_msg["message"]: break - last_msg['occurrences'] += 1 - if last_msg['occurrences'] >= self.max_msg_occurrences: + last_msg["occurrences"] += 1 + if last_msg["occurrences"] >= self.max_msg_occurrences: return ModerationDecision.TIMEOUT_USER if clean_msg is not None: self.last_msgs.remove(clean_msg) - self.last_msgs.append({ - 'first-occurrence': datetime.now(), - 'author': author, - 'message': msg, - 'occurrences': 1 - }) + self.last_msgs.append( + { + "first-occurrence": datetime.now(), + "author": author, + "message": msg, + "occurrences": 1, + } + ) return ModerationDecision.ABSTAIN diff --git a/twason/twitch_message.py b/twason/twitch_message.py index 7f94c88..01a6aa7 100644 --- a/twason/twitch_message.py +++ b/twason/twitch_message.py @@ -16,5 +16,6 @@ from irc3.rfc import raw -USERNOTICE = r'^(@(?P\S+) )?:(?P\S+) (?P(USERNOTICE)) (?P\S+)$' - +USERNOTICE = ( + r"^(@(?P\S+) )?:(?P\S+) (?P(USERNOTICE)) (?P\S+)$" +) diff --git a/twason/twitchbot.py b/twason/twitchbot.py index 0d75905..71167f4 100644 --- a/twason/twitchbot.py +++ b/twason/twitchbot.py @@ -41,19 +41,19 @@ class TwitchBot: self.nb_messages_since_timer = 0 def connection_made(self): - print('connected') + print("connected") def server_ready(self): - print('ready') + print("ready") def connection_lost(self): - print('connection lost') + print("connection lost") @staticmethod def _parse_variables(in_str: str, **kwargs): for key in kwargs: value = kwargs[key] - in_str = in_str.replace('{%s}' % key, value) + in_str = in_str.replace("{%s}" % key, value) return in_str @@ -62,15 +62,24 @@ class TwitchBot: # print(data) @irc3.event(irc3.rfc.PRIVMSG) - def on_msg(self, mask: str = None, target: str = None, data: str = None, tags: str = None, **_): - author = mask.split('!')[0] - command = self.config.find_command(data.lower().split(' ')[0]) + def on_msg( + self, + mask: str = None, + target: str = None, + data: str = None, + tags: str = None, + **_ + ): + author = mask.split("!")[0] + command = self.config.find_command(data.lower().split(" ")[0]) tags_dict = utils.parse_tags(tags) if command is not None: - print('%s: %s%s' % (author, self.config.command_prefix, command.name)) - self.bot.privmsg(target, self._parse_variables(command.message, author=author)) - elif tags_dict.get('mod') == '0': + print("%s: %s%s" % (author, self.config.command_prefix, command.name)) + self.bot.privmsg( + target, self._parse_variables(command.message, author=author) + ) + elif tags_dict.get("mod") == "0": self.moderate(tags_dict, data, author, target) self.nb_messages_since_timer += 1 @@ -79,30 +88,39 @@ class TwitchBot: @irc3.event(twitch_message.USERNOTICE) def on_user_notice(self, tags: str, **_): tags = utils.parse_tags(tags) - if tags.get('msg-id', None) == 'raid': + if tags.get("msg-id", None) == "raid": # Notice the Flood moderator a raid has just happened for moderator in self.config.moderators: - if isinstance(moderator, FloodModerator) and moderator.raid_cooldown is not None: - print("Raid received from %s. Disabling the Flood moderator." % tags.get('display-name')) + if ( + isinstance(moderator, FloodModerator) + and moderator.raid_cooldown is not None + ): + print( + "Raid received from %s. Disabling the Flood moderator." + % tags.get("display-name") + ) moderator.declare_raid() break def play_timer(self): if not self.messages_stack: - print('Filling the timer messages stack in') + print("Filling the timer messages stack in") self.messages_stack = self.config.timer.pool.copy() if self.config.timer.strategy == TimerStrategy.SHUFFLE: - print('Shuffle!') + print("Shuffle!") shuffle(self.messages_stack) - if self.nb_messages_since_timer < self.config.timer.msgs_between or \ - datetime.now() < self.last_timer_date + timedelta(minutes=self.config.timer.time_between): + if ( + self.nb_messages_since_timer < self.config.timer.msgs_between + or datetime.now() + < self.last_timer_date + timedelta(minutes=self.config.timer.time_between) + ): return command = self.messages_stack.pop(0) print("Timer: %s" % command.message) - self.bot.privmsg('#%s' % self.config.channel, command.message) + self.bot.privmsg("#%s" % self.config.channel, command.message) self.nb_messages_since_timer = 0 self.last_timer_date = datetime.now() @@ -110,40 +128,41 @@ class TwitchBot: def moderate(self, tags: {str: str}, msg: str, author: str, channel: str): def delete_msg(mod: Moderator): print("[DELETE (reason: %s)] %s: %s" % (mod.get_name(), author, msg)) - self.bot.privmsg( - channel, - "/delete %s" % tags['id'] - ) + self.bot.privmsg(channel, "/delete %s" % tags["id"]) def timeout(mod: Moderator): print("[TIMEOUT (reason: %s)] %s: %s" % (mod.get_name(), author, msg)) self.bot.privmsg( channel, - "/timeout %s %d %s" % ( + "/timeout %s %d %s" + % ( author, mod.timeout_duration, - self._parse_variables(mod.message, author=author) - ) + self._parse_variables(mod.message, author=author), + ), ) # Ignore emotes-only messages - if tags.get('emote-only', '0') == '1': + if tags.get("emote-only", "0") == "1": return message_to_moderate = msg # Remove emotes from message before moderating - for emote in tags.get('emotes', '').split('/'): - if emote == '': + for emote in tags.get("emotes", "").split("/"): + if emote == "": break - for indices in emote.split(':')[1].split(','): - [first, last] = indices.split('-') + for indices in emote.split(":")[1].split(","): + [first, last] = indices.split("-") first, last = int(first), int(last) if first == 0: - message_to_moderate = message_to_moderate[last + 1:] + message_to_moderate = message_to_moderate[last + 1 :] else: - message_to_moderate = message_to_moderate[:first - 1] + message_to_moderate[last + 1:] + message_to_moderate = ( + message_to_moderate[: first - 1] + + message_to_moderate[last + 1 :] + ) for moderator in self.config.moderators: vote = moderator.vote(message_to_moderate, author) @@ -154,19 +173,18 @@ class TwitchBot: if vote == ModerationDecision.TIMEOUT_USER: timeout(moderator) - self.bot.privmsg(channel, self._parse_variables(moderator.message, author=author)) + self.bot.privmsg( + channel, self._parse_variables(moderator.message, author=author) + ) break @irc3.event(irc3.rfc.JOIN) def on_join(self, mask, channel, **_): - print('JOINED %s as %s' % (channel, mask)) + print("JOINED %s as %s" % (channel, mask)) @irc3.event(irc3.rfc.CONNECTED) def on_connected(self, **_): - for line in [ - "CAP REQ :twitch.tv/commands", - "CAP REQ :twitch.tv/tags" - ]: + for line in ["CAP REQ :twitch.tv/commands", "CAP REQ :twitch.tv/tags"]: self.bot.send_line(line) - self.bot.join('#%s' % self.config.channel) + self.bot.join("#%s" % self.config.channel)