From 512915dd4587491dad19c13cde92daada5756d72 Mon Sep 17 00:00:00 2001 From: Guillermo Serrahima Date: Mon, 2 Feb 2026 02:43:14 +0100 Subject: [PATCH] Initial commit. Achieved minimum functionality --- README.md | 11 + config.example.toml | 22 ++ main.py | 84 ++++++ poetry.lock | 502 ++++++++++++++++++++++++++++++++ pyproject.toml | 20 ++ velascobot2/bot.py | 606 +++++++++++++++++++++++++++++++++++++++ velascobot2/database.py | 257 +++++++++++++++++ velascobot2/messenger.py | 140 +++++++++ velascobot2/parrot.py | 135 +++++++++ velascobot2/utils.py | 48 ++++ 10 files changed, 1825 insertions(+) create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 main.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 velascobot2/bot.py create mode 100644 velascobot2/database.py create mode 100644 velascobot2/messenger.py create mode 100644 velascobot2/parrot.py create mode 100644 velascobot2/utils.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..48bbba9 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Velascobot2 + +Rewriting velascobot from scratch, with all my new Python knowledge from the last 5 years. + +## TODO + +- Add testing +- Revise all function type hints +- Sort imports +- Rewrite README from legacy velascobot +- Explain config.toml diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..4f833df --- /dev/null +++ b/config.example.toml @@ -0,0 +1,22 @@ +[messenger] +name = "@bot_username" +bosses = ["your_id_here"] +nicknames = ["any", "names", "the", "bot", "recognizes"] +reply = 0.2 +repeat = 0.1 +wake_up = false +mute_time = 60 +max_len = 50 +min_period = 1 +max_period = 10000 + +[bot] +token = "your_bot_token_here" +motd_chats = [] +motd_list = [ + "Good morning San Francisco!" +] +whitelist = [] + +[logging] +level = "DEBUG" diff --git a/main.py b/main.py new file mode 100644 index 0000000..86fbf4b --- /dev/null +++ b/main.py @@ -0,0 +1,84 @@ +import logging +from pathlib import Path + +import click +import coloredlogs +import tomllib + +from velascobot2.bot import Bot +from velascobot2.messenger import Messenger + +logger = logging.getLogger(__name__) + + +def load_config(config_path: Path | None) -> dict: + assert config_path is not None + with open(config_path, "rb") as f: + return tomllib.load(f) + + +def run_bot(bot: Bot) -> None: + bot.app.run_polling(poll_interval=1.0) + assert "@" + bot.app.bot.username == bot.messenger.username + + +@click.command() +@click.option( + "--config", + type=click.Path(exists=True, path_type=Path), + default="./config.toml", + help="path to config.toml file (default: ./config.toml)", +) +@click.option("--token", help="telegram bot token (overrides config file)") +def main(config: Path | None, token: str | None) -> None: + if not token and not (config and config.exists()): + raise click.UsageError( + "must provide either --token, --config or a valid config file " + "at ./config.toml" + ) + cfg = load_config(config) + + bot_name = "Telegram Bot" + if token: + bot_token = token + else: + bot_token = cfg["bot"]["token"] + + bot_name = cfg.get("messenger", {}).get("name", bot_name) + bosses = cfg.get("messenger", {}).get("bosses", []) + nicknames = cfg.get("messenger", {}).get("nicknames", []) + reply = cfg.get("messenger", {}).get("reply", 0.1) + repeat = cfg.get("messenger", {}).get("repeat", 0.05) + wake_up = cfg.get("messenger", {}).get("wake_up", False) + mute_time = cfg.get("messenger", {}).get("mute_time", 60) + max_len = cfg.get("messenger", {}).get("max_len", 50) + + motd_chats = cfg.get("bot", {}).get("motd_chats", []) + motd_list = cfg.get("bot", {}).get("motd_list", []) + whitelist = cfg.get("bot", {}).get("whitelist", None) + + log_format = ( + "[{}][%(asctime)s]%(name)s::%(levelname)s:%(message)s" + ).format(bot_name.upper()) + + coloredlogs.install(level=logging.INFO, fmt=log_format) + + messenger = Messenger( + bot_name, + bosses, + nicknames, + reply, + repeat, + wake_up, + mute_time=mute_time, + max_len=max_len, + ) + + bot = Bot( + messenger, bot_token, motd_chats, motd_list, whitelist, testing=True + ) + run_bot(bot) + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..25b72ca --- /dev/null +++ b/poetry.lock @@ -0,0 +1,502 @@ +# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.12.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] + +[[package]] +name = "black" +version = "26.1.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "black-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168"}, + {file = "black-26.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d"}, + {file = "black-26.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0"}, + {file = "black-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24"}, + {file = "black-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89"}, + {file = "black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5"}, + {file = "black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68"}, + {file = "black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14"}, + {file = "black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c"}, + {file = "black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4"}, + {file = "black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f"}, + {file = "black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6"}, + {file = "black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a"}, + {file = "black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791"}, + {file = "black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954"}, + {file = "black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304"}, + {file = "black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9"}, + {file = "black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b"}, + {file = "black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b"}, + {file = "black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca"}, + {file = "black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115"}, + {file = "black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79"}, + {file = "black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af"}, + {file = "black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f"}, + {file = "black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0"}, + {file = "black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede"}, + {file = "black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=1.0.0" +platformdirs = ">=2" +pytokens = ">=0.3.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[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 = "certifi" +version = "2026.1.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, +] + +[[package]] +name = "click" +version = "8.3.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[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 = "coloredlogs" +version = "15.0.1" +description = "Colored terminal output for Python's logging module" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, + {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, +] + +[package.dependencies] +humanfriendly = ">=9.1" + +[package.extras] +cron = ["capturer (>=2.4)"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "humanfriendly" +version = "10.0" +description = "Human friendly output for text interfaces using Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[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 = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, + {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + +[[package]] +name = "platformdirs" +version = "4.5.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, + {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, +] + +[package.extras] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +description = "A python implementation of GNU readline." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"}, + {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"}, +] + +[package.extras] +dev = ["build", "flake8", "mypy", "pytest", "twine"] + +[[package]] +name = "python-telegram-bot" +version = "22.6" +description = "We have made you a wrapper you can't refuse" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0"}, + {file = "python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742"}, +] + +[package.dependencies] +httpcore = {version = ">=1.0.9", markers = "python_version >= \"3.14\""} +httpx = ">=0.27,<0.29" + +[package.extras] +all = ["aiolimiter (>=1.1,<1.3)", "apscheduler (>=3.10.4,<3.12.0)", "cachetools (>=5.3.3,<6.3.0)", "cffi (>=1.17.0rc1) ; python_version > \"3.12\"", "cryptography (>=39.0.1)", "httpx[http2]", "httpx[socks]", "tornado (>=6.5,<7.0)"] +callback-data = ["cachetools (>=5.3.3,<6.3.0)"] +ext = ["aiolimiter (>=1.1,<1.3)", "apscheduler (>=3.10.4,<3.12.0)", "cachetools (>=5.3.3,<6.3.0)", "tornado (>=6.5,<7.0)"] +http2 = ["httpx[http2]"] +job-queue = ["apscheduler (>=3.10.4,<3.12.0)"] +passport = ["cffi (>=1.17.0rc1) ; python_version > \"3.12\"", "cryptography (>=39.0.1)"] +rate-limiter = ["aiolimiter (>=1.1,<1.3)"] +socks = ["httpx[socks]"] +webhooks = ["tornado (>=6.5,<7.0)"] + +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "ruff" +version = "0.14.14" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed"}, + {file = "ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c"}, + {file = "ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974"}, + {file = "ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3"}, + {file = "ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b"}, + {file = "ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167"}, + {file = "ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd"}, + {file = "ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c"}, + {file = "ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b"}, +] + +[[package]] +name = "tomli" +version = "2.4.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"}, + {file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"}, + {file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"}, + {file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"}, + {file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"}, + {file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"}, + {file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"}, + {file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"}, + {file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"}, + {file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"}, + {file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"}, + {file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"}, + {file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"}, + {file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"}, + {file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"}, + {file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"}, + {file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"}, + {file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"}, + {file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"}, + {file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"}, + {file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"}, + {file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"}, + {file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"}, + {file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"}, + {file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"}, + {file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"}, + {file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"}, + {file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"}, + {file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"}, + {file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"}, + {file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"}, + {file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"}, + {file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {main = "python_version < \"3.13\"", dev = "python_version == \"3.10\""} + +[metadata] +lock-version = "2.1" +python-versions = "^3.10" +content-hash = "f2eaeae5deb1812f056eed3cebf792a30a6f16f15815c1466626636520ef9c3f" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d9aa260 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "velascobot2" +version = "0.1.0" +description = "sqlite-backed markov chain text generator" +authors = ["Guillermo Serrahima "] +readme = "README.md" +packages = [{include = "velascobot2"}] + +[tool.poetry.dependencies] +python = "^3.10" +python-telegram-bot = "^22.6" +coloredlogs = "^15.0.1" + +[tool.poetry.group.dev.dependencies] +ruff = "^0.14.14" +black = "^26.1.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/velascobot2/bot.py b/velascobot2/bot.py new file mode 100644 index 0000000..5914906 --- /dev/null +++ b/velascobot2/bot.py @@ -0,0 +1,606 @@ +import random +import logging +import time +import sys + +from telegram import Chat, ChatMember, Message, Update +import telegram +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + MessageHandler, + filters, +) +from telegram.error import NetworkError + +from velascobot2.utils import ChatData, GraphicType, Response +from velascobot2.messenger import Messenger + +logger = logging.getLogger(__name__) + + +start_msg = ( + "Hello there! Ask me for /help to see an overview of the available" + " commands." +) + + +wake_msg = "Good morning. I just woke up" + + +help_msg = """I answer to the following commands: + +/start - I say hi. +/about - What I'm about. +/explain - I explain how I work. +/help - I send this message. +/count - I tell you how many messages from this chat I have processed. +/speak - Forces me to speak (if you are allowed to). +/period - Change the period of my messages. (Maximum of 100000) +/answer - Change my chance to answer at will. (Decimal between 0 and 1) +/restrict - Toggle restriction of configuration commands to admins only. +/silence - Toggle restriction on mentions by the bot. +/ping - Pong! +/where - Tell my configuration for this chat. +""" + + +about_msg = ( + "I am yet another Markov Bot experiment. I read everything you type to me" + " and then spit back nonsensical messages that look like yours.\n\nYou can" + " send /explain if you want further explanation." +) + + +explanation = ( + "I decompose every message I read in groups of 3 consecutive words, so for" + " each consecutive pair I save the word that can follow them. I then use" + " this to make my own messages. At first I will only repeat your messages" + " because for each 2 words I will have very few possible following" + " words.\n\nI also separate my vocabulary by chats, so anything I learn in" + " one chat I will only say in that chat. For privacy, you know. Also, I" + " save my vocabulary in the form of a json dictionary, so no logs are" + " kept.\n\nMy default period in private chats is one message of mine from" + " each 2 messages received, and in group chats it's 10 messages I read for" + " each message I send." +) + + +def static_reply(text: str, format=None): + async def reply(update: Update, context: ContextTypes.DEFAULT_TYPE): + assert update.message + await update.message.reply_text(text, parse_mode=format) + + return reply + + +async def stop(update: Update, context: ContextTypes.DEFAULT_TYPE): + assert update.message + chat = update.message.chat + chat_id = str(chat.id) + # TODO: remove chat from DB + logger.warning( + "I got blocked by {} [{}]".format(chat.effective_name, chat_id) + ) + + +# Legacy function from original velascobot, created before "effective_name" +# was implemented in the telegram library. Kept to ensure consistency +def get_chat_title(chat: Chat): + """Returns group chat name or falls back to user name on a private chat.""" + if chat.title is not None: + return chat.title + elif chat.first_name is not None: + if chat.last_name is not None: + return chat.first_name + " " + chat.last_name + else: + return chat.first_name + else: + return "" + + +async def ping(update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: + """Pongs back.""" + + assert update.message is not None + await update.message.reply_text("Pong!") + + +async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE): + logger.error(context.error) + sys.exit(1) + + +class Bot: + def __init__( + self, + messenger: Messenger, + bot_token: str, + motd_chats: list[str] = [], + motd_list: list[str] = [], + whitelist: list[str] | None = None, + testing: bool = False, + ) -> None: + self.messenger = messenger + self.motd_chats = motd_chats + self.motd_list = motd_list + self.whitelist = whitelist + self.testing = testing + self.countdown: dict[str, int] = {} + + if testing: + assert ( + whitelist is not None + ), "Testing mode enabled but no whitelist was given." + + # Create the Application and pass it your bot's token. + app = ( + Application.builder() + .token(bot_token) + .concurrent_updates(True) + .build() + ) + + # on different commands - answer in Telegram + app.add_handler(CommandHandler("start", static_reply(start_msg))) + app.add_handler(CommandHandler("about", static_reply(about_msg))) + app.add_handler(CommandHandler("explain", static_reply(explanation))) + app.add_handler(CommandHandler("help", static_reply(help_msg))) + app.add_handler(CommandHandler("stop", stop)) + app.add_handler(CommandHandler("ping", ping)) + app.add_handler(CommandHandler("count", self.get_processed_messages)) + app.add_handler(CommandHandler("speak", self.speak_command)) + app.add_handler(CommandHandler("period", self.period)) + app.add_handler(CommandHandler("answer", self.answer)) + app.add_handler(CommandHandler("restrict", self.restrict)) + app.add_handler(CommandHandler("silence", self.silence)) + app.add_handler( + CommandHandler("where", static_reply("Not implemented")) + ) + app.add_handler( + MessageHandler( + ( + filters.TEXT + | filters.Sticker.ALL + | filters.ANIMATION + | filters.VIDEO + ), + self.read, + ) + ) + + if self.testing: + app.add_error_handler(error_handler) + + self.app = app + + async def get_processed_messages( + self, update: Update, _context: ContextTypes.DEFAULT_TYPE + ) -> None: + assert update.message + + chat_id = str(update.message.chat.id) + chat_data = self.messenger.get_chat_data(chat_id) + if chat_data is None: + await update.message.reply_text( + "I've processed no messages so far." + ) + return + + num = ( + str(chat_data.processed_messages) + if chat_data.processed_messages > 0 + else "no" + ) + await update.message.reply_text( + "I've processed {} messages so far.".format(num) + ) + + async def period( + self, update: Update, _context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Responds with the current message period or sets a new one.""" + assert update.message + chat = update.message.chat + + chat_id = str(chat.id) + chat_data = self.messenger.get_chat_data(chat_id) + assert chat_data + + assert update.message.text + words = update.message.text.split() + if len(words) <= 1: + await update.message.reply_text( + f"The current speech period is {chat_data.message_period}" + ) + return + + if chat_data.restricted: + assert update.message.from_user + user = await chat.get_member(update.message.from_user.id) + if not self.superuser(user): + await update.message.reply_text( + "You do not have permissions to do that." + ) + return + try: + period = int(words[1]) + self.messenger.update_message_period(chat_id, period) + await update.message.reply_text( + f"Period of speaking set to {period}." + ) + except Exception: + await update.message.reply_text( + "Format was confusing; period unchanged from" + f" {chat_data.message_period}." + ) + + async def answer( + self, update: Update, _context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Responds with the current answer chance or sets a new one.""" + assert update.message + chat = update.message.chat + + chat_id = str(chat.id) + chat_data = self.messenger.get_chat_data(chat_id) + assert chat_data + + assert update.message.text + words = update.message.text.split() + if len(words) <= 1: + await update.message.reply_text( + f"The current answer chance is {chat_data.answer_chance}" + ) + return + + if chat_data.restricted: + assert update.message.from_user + user = await chat.get_member(update.message.from_user.id) + if not self.superuser(user): + await update.message.reply_text( + "You do not have permissions to do that." + ) + return + try: + chance = float(words[1]) + self.messenger.update_answer_chance(chat_id, chance) + await update.message.reply_text( + f"Chance of answering set to {chance}." + ) + except Exception: + await update.message.reply_text( + "Format was confusing; answer chance unchanged from" + f" {chat_data.answer_chance}." + ) + + async def restrict( + self, update: Update, _context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Toggles the current restriction state.""" + + assert update.message + if "group" not in update.message.chat.type: + await update.message.reply_text("That only works in groups.") + return + chat = update.message.chat + chat_id = str(chat.id) + assert update.message.from_user + user = await chat.get_member(update.message.from_user.id) + chat_data = self.messenger.get_chat_data(chat_id) + assert chat_data + if chat_data.restricted: + if not self.superuser(user): + await update.message.reply_text( + "You do not have permissions to do that." + ) + return + self.messenger.update_set_restricted(chat_id, not chat_data.restricted) + allowed = ( + "let only admins" if not chat_data.restricted else "let everyone" + ) + await update.message.reply_text(f"I will {allowed} configure me now.") + + async def silence( + self, update: Update, _context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Toggles the current silence state.""" + + assert update.message + if "group" not in update.message.chat.type: + await update.message.reply_text("That only works in groups.") + return + chat = update.message.chat + chat_id = str(chat.id) + assert update.message.from_user + user = await chat.get_member(update.message.from_user.id) + chat_data = self.messenger.get_chat_data(chat_id) + assert chat_data + if chat_data.silenced: + if not self.superuser(user): + await update.message.reply_text( + "You do not have permissions to do that." + ) + return + self.messenger.update_set_silenced(chat_id, not chat_data.silenced) + allowed = "avoid mentioning" if not chat_data.silenced else "mention" + await update.message.reply_text(f"I will {allowed} people now.") + + async def send( + self, + chat_id: int | str, + message_type: Response, + content: str | tuple[GraphicType, str], + **kwargs, + ): + bot = self.app.bot + + if message_type == Response.TEXT: + assert isinstance(content, str) + if content == "": + logger.warning("Generated empty text response.") + return + max_length = telegram.constants.MessageLimit.MAX_TEXT_LENGTH + if len(content) > max_length: + content = content[:max_length] + msg = "reply" if (kwargs.get("reply_to_message_id")) else "message" + logger.info( + "Sending a {} to {}: '{}'".format(msg, chat_id, content) + ) + await bot.send_message(chat_id, content, **kwargs) + else: + assert isinstance(content, tuple) + graph_type, graph_id = content + if graph_id == "": + logger.warning(f"Generated empty {graph_type} response.") + return + logger.info( + 'Sending {} "{}" to {}'.format(graph_type, graph_id, chat_id) + ) + + if graph_type == GraphicType.STICKER: + sent = await bot.send_sticker( + chat_id=chat_id, sticker=graph_id, **kwargs + ) + elif graph_type == GraphicType.ANIMATION: + sent = await bot.send_animation( + chat_id=chat_id, animation=graph_id, **kwargs + ) + elif graph_type == GraphicType.VIDEO: + sent = await bot.send_video( + chat_id=chat_id, video=graph_id, **kwargs + ) + else: + raise Exception("Sending unkown graphic type") + logger.debug(f"Sent graphic: {sent}") + + async def announce(self, announcement, check=(lambda _: True)) -> None: + """Sends an announcement to all chats that pass a check.""" + for chat in self.messenger.get_all_chats(): + if check(chat): + try: + await self.send(chat.id, Response.TEXT, announcement) + logger.info( + "Sending announcement to chat {}".format(chat.id) + ) + except Exception: + pass + + async def wake_up(self, exclusive: bool = False) -> None: + if not self.messenger.wake_up: + return + if len(self.motd_list) == 0: + return + + def valid_chat(chat: ChatData) -> bool: + if self.whitelist is not None and chat.id not in self.whitelist: + return False + if self.motd_chats and exclusive: + return chat.id in self.motd_chats + if not self.motd_chats: + return "group" in chat.type + else: + return "group" in chat.type or chat.id in self.motd_chats + + wake_text = random.choice(self.motd_list) + await self.announce(wake_text, valid_chat) + + async def should_reply(self, message: Message, chat: ChatData) -> bool: + """Returns True if the bot should reply to a message.""" + if self.messenger.is_mute(): + # Not if mute time hasn't finished + return False + if not self.testing and chat.restricted: + # If we're not in testing mode and the chat is restricted + assert message.from_user + user = await message.chat.get_member(message.from_user.id) + if not self.superuser(user): + # ...And the user has no permissions, should not reply + return False + + # otherwise (testing mode, or the chat is unrestricted, + # or the user is a superuser) + replied_message = message.reply_to_message + text = message.text.casefold() if message.text else "" + replies_to_me = ( + replied_message is not None + and replied_message.from_user + and replied_message.from_user.name == self.messenger.username + ) + # Only if it's a reply to a message of ours or the bot is mentioned in + # the message + return replies_to_me or self.messenger.mentioned(text) + + def superuser(self, member: ChatMember) -> bool: + """Returns True if the user is a group chat admin, creator or in the + bosses list.""" + logger.info( + "user {} ({}) requesting a restricted action".format( + str(member.user.id), member.user.name + ) + ) + return ( + (member.status == "creator") + or (member.status == "administrator") + or (member.user.id in self.messenger.bosses) + ) + + async def speak(self, chat_id: str, **kwargs) -> None: + """Send a new message.""" + + if self.whitelist is not None and chat_id not in self.whitelist: + # Don't, if there's a whitelist and this chat is not in it + return + if self.messenger.is_mute(): + # Don't, if mute time isn't over + return + try: + response, content = self.messenger.give_response(chat_id) + await self.send(chat_id, response, content, **kwargs) + while random.random() <= self.messenger.repeat: + del kwargs["reply_to_message_id"] + response, content = self.messenger.give_response(chat_id) + await self.send(chat_id, response, content, **kwargs) + # Consider any Network Error as a Telegram temporary ban, as I + # couldn't find out in the documentation how error 429 is handled by + # python-telegram-bot + except NetworkError as e: + logger.error("Sending a message caused network error:") + logger.exception(e) + logger.error( + "Going mute for {} seconds.".format(self.messenger.mute_time) + ) + self.messenger.set_mute_timer(int(time.perf_counter())) + except Exception as e: + logger.error("Sending a message caused exception:") + logger.exception(e) + + def extract_graphic(self, message: Message) -> tuple[GraphicType, str]: + logger.debug(f"Sticker: {message.sticker}") + logger.debug(f"Animation: {message.animation}") + logger.debug(f"Sticker: {message.video}") + if message.sticker is not None: + return GraphicType.STICKER, message.sticker.file_id + elif message.animation is not None: + return GraphicType.ANIMATION, message.animation.file_id + if message.video is not None: + return GraphicType.VIDEO, message.video.file_id + else: + raise Exception("Unknown graphic type") + + # Rolls the chance for answering in this specific chat, + # according to the answer probability + def is_answering(self, answer_chance: float) -> bool: + """Rolls the chance for answering to a message.""" + if answer_chance == 1: + return True + elif answer_chance == 0: + return False + rand = random.random() + return rand <= answer_chance + + async def read(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Reads a non-command message.""" + # Ignore non-message updates + if update.message is None: + return + + message = update.message + chat = message.chat + chat_id = str(chat.id) + chat_name = get_chat_title(chat) + + self.messenger.ensure_chat_exists(chat_id, chat.type, chat_name) + chat_data = self.messenger.get_chat_data(chat_id) + assert chat_data + + if message.text is not None: + self.messenger.process_text(chat_id, message.text) + else: + graph_type, graph_id = self.extract_graphic(message) + self.messenger.process_graphic(chat_id, graph_type, graph_id) + + # Update the ChatData name if it has changed + if chat_name != chat_data.name: + self.messenger.update_chat_name(chat_id, chat_name) + + # Check if it's a "replyable" message & roll the chance to do so + should_reply = await self.should_reply(update.message, chat_data) + if should_reply and self.is_answering(chat_data.answer_chance): + await self.speak(chat_id, reply_to_message_id=message.message_id) + return + + # Decrease the countdown for the chat, and send a message if it + # reached 0 + if chat_id not in self.countdown: + self.countdown[chat_id] = chat_data.message_period + self.countdown[chat_id] -= 1 + if self.countdown[chat_id] < 0: + self.countdown[chat_id] = chat_data.message_period + await self.speak(chat_id) + + async def speak_command( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ): + """Handles the /speak command.""" + chat = update.message.chat + chat_id = str(chat.id) + chat_data = self.messenger.get_chat_data(chat_id) + + if chat_data is None: + return + + if not self.testing and chat_data.restricted: + user = update.message.chat.get_member(update.message.from_user.id) + if not self.superuser(user): + # update.message.reply_text("You do not have permissions to do that.") + return + + mid = str(update.message.message_id) + replied = update.message.reply_to_message + # Reply to the message that the command replies to, otherwise to the + # command itself + rid = replied.message_id if replied else mid + # words = update.message.text.split() + # if len(words) > 1: + # self.messenger.process_text(chat_id, ' '.join(words[1:])) + if chat_data.processed_messages > 0: + await self.speak(chat_id, reply_to_message_id=rid) + else: + empty_gen_warning = "I haven't learned a single word yet." + await self.send( + chat_id, + Response.TEXT, + content=empty_gen_warning, + reply_to_message_id=rid, + ) + + async def show_config( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ): + """Sends a summary of the chat configuration.""" + msg = update.message + assert msg + chat = msg.chat + chat_id = str(chat.id) + chat_data = self.messenger.get_chat_data(chat_id) + assert chat_data + if chat_data.restricted and chat_data.silenced: + permissions = "restricted and silenced" + elif chat_data.restricted: + permissions = "restricted but not silenced" + elif chat_data.silenced: + permissions = "not restricted but silenced" + else: + permissions = "neither restricted nor silenced" + + answer = ( + f"You're messaging in the chat of saved title" + f"__{chat_data.name}__, with id `{chat_data.id}`, message count " + f"{chat_data.processed_messages}, period " + f"{chat_data.message_period}, and answer probability " + f"{chat_data.answer_chance}.\n\nThis chat is {permissions}." + ) + + await msg.reply_markdown(answer) diff --git a/velascobot2/database.py b/velascobot2/database.py new file mode 100644 index 0000000..582495f --- /dev/null +++ b/velascobot2/database.py @@ -0,0 +1,257 @@ +import sqlite3 +import traceback +from typing import Any + +from velascobot2.utils import ChatData + + +class _SQLiteContextMgr(object): + def __init__( + self, + database: str = "sqlitedb", + isolation_level: str = "", + ignore_exc: bool = False, + ): + self.database = database + self.isolation_level = isolation_level + self.ignore_exc = ignore_exc + self.connection = None + self.cursor = None + + def __enter__(self): + try: + self.connection = sqlite3.connect( + database=self.database, isolation_level=self.isolation_level + ) + self.cursor = self.connection.cursor() + return self.cursor + except Exception as ex: + traceback.print_exc() + raise ex + + def __exit__(self, exc_type, exc_val, exc_tb): + try: + if exc_type is not None: + self.connection.rollback() + return self.ignore_exc + else: + self.connection.commit() + except Exception as ex: + traceback.print_exc() + raise ex + finally: + self.cursor.close() + self.connection.close() + + +class Database: + def __init__(self, bot_name: str): + self.db_name = f"{bot_name}.sqlite" + self._init_database() + + def _init_database(self): + """creates schema on first startup""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute(""" + CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + processed_messages INTEGER DEFAULT 0, + message_period INTEGER DEFAULT 10, + answer_chance REAL DEFAULT 0.5, + restricted INTEGER DEFAULT 0, + silenced INTEGER DEFAULT 0 + ) + """) + + db.execute(""" + CREATE TABLE IF NOT EXISTS vocab ( + chat_id TEXT NOT NULL, + key TEXT NOT NULL, + word TEXT NOT NULL, + count INTEGER DEFAULT 1, + PRIMARY KEY (chat_id, key, word), + FOREIGN KEY (chat_id) REFERENCES chats(id) + ) + """) + + db.execute(""" + CREATE INDEX IF NOT EXISTS vocab_idx + ON vocab(chat_id, key) + """) + + db.execute(""" + CREATE TABLE IF NOT EXISTS graphics ( + chat_id TEXT NOT NULL, + type TEXT NOT NULL, + graph_id TEXT NOT NULL, + count INTEGER DEFAULT 1, + PRIMARY KEY (chat_id, graph_id), + FOREIGN KEY (chat_id) REFERENCES chats(id) + ) + """) + + db.execute(""" + CREATE INDEX IF NOT EXISTS graphics_idx + ON graphics(chat_id) + """) + + def ensure_chat_exists( + self, chat_id: str, chat_type: str, chat_name: str + ) -> bool: + """ + checks if the chat exists, stores it with defaults if not. + returns True if newly created, False if already existed + """ + with _SQLiteContextMgr(database=self.db_name) as db: + # check existence + result = db.execute( + "SELECT id FROM chats WHERE id = ?", (chat_id,) + ).fetchone() + + if result: + return False + + # create new chat config entry + db.execute( + "INSERT INTO chats (id, type, name) VALUES (?, ?, ?)", + (chat_id, chat_type, chat_name), + ) + + return True + + def add_words(self, chat_id: str, key: str, word: str, count: int): + """adds or updates a word ocurrence""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "INSERT INTO vocab (chat_id, key, word, count) VALUES (?, ?, ?," + " ?) ON CONFLICT(chat_id, key, word) DO UPDATE SET count =" + " count + ?", + (chat_id, key, word, count, count), + ) + + def add_graphic(self, chat_id: str, graph_type: str, graph_id: str): + """adds or updates a graphic ocurrence""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "INSERT INTO graphics (chat_id, type, graph_id, count) VALUES" + " (?, ?, ?, ?) ON CONFLICT(chat_id, graph_id) DO UPDATE" + " SET count = count + ?", + (chat_id, graph_type, graph_id, 1, 1), + ) + + def get_next_words(self, chat_id: str, key: str) -> list[tuple[str, int]]: + """returns list of possible words to follow the key n-gram""" + with _SQLiteContextMgr(database=self.db_name) as db: + rows = db.execute( + "SELECT word, count FROM vocab WHERE chat_id = ? AND key = ?", + (chat_id, key), + ).fetchall() + + if not rows: + return [] + + return rows + + def get_graphics(self, chat_id: str): + with _SQLiteContextMgr(database=self.db_name) as db: + rows = db.execute( + "SELECT type, graph_id, count FROM graphics WHERE chat_id = ?", + (chat_id,), + ).fetchall() + + if not rows: + return [] + + return rows + + def _update_chat(self, chat_id: str, field: str, value: Any): + """updates a field in the chats table""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + f"UPDATE chats SET {field} = ? WHERE id = ?", (value, chat_id) + ) + + def update_chat_name(self, chat_id: str, chat_name: str): + """updates a field in the chats table""" + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET name = ? WHERE id = ?", (chat_name, chat_id) + ) + + def update_chat_answer_chance(self, chat_id: str, answer_chance: float): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET answer_chance = ? WHERE id = ?", + (answer_chance, chat_id), + ) + + def update_chat_message_period(self, chat_id: str, period: int): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET message_period = ? WHERE id = ?", + (period, chat_id), + ) + + def update_chat_set_restricted(self, chat_id: str, restricted: bool): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET restricted = ? WHERE id = ?", + (int(restricted), chat_id), + ) + + def update_chat_set_silenced(self, chat_id: str, silenced: bool): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET silenced = ? WHERE id = ?", + (int(silenced), chat_id), + ) + + def incr_processed_messages(self, chat_id: str): + with _SQLiteContextMgr(database=self.db_name) as db: + db.execute( + "UPDATE chats SET processed_messages = processed_messages + ?" + " WHERE id = ?", + (1, chat_id), + ) + + def count_graphics(self, chat_id: str) -> int: + """Returns the number of graphics stored for a given chat id.""" + with _SQLiteContextMgr(database=self.db_name) as db: + count = ( + db.execute( + "SELECT SUM(count) FROM graphics WHERE chat_id = ?", + (chat_id,), + ).fetchone()[0] + or 0 + ) + return count + + def count_words(self, chat_id: str, key: str) -> int: + """Returns the number of words that follow a given key for a given chat id.""" + with _SQLiteContextMgr(database=self.db_name) as db: + count = ( + db.execute( + "SELECT SUM(count) FROM vocab WHERE chat_id = ? AND" + " key = ?", + (chat_id, key), + ).fetchone()[0] + or 0 + ) + return count + + def get_all_chats(self) -> list[ChatData]: + with _SQLiteContextMgr(database=self.db_name) as db: + rows = db.execute("SELECT * FROM chats").fetchall() + return [ChatData.from_sql_row(row) for row in rows] + + def get_chat_data(self, chat_id: str) -> ChatData | None: + with _SQLiteContextMgr(database=self.db_name) as db: + result = db.execute( + "SELECT * FROM chats WHERE id = ?", (chat_id,) + ).fetchone() + + if result: + return ChatData.from_sql_row(result) + return None diff --git a/velascobot2/messenger.py b/velascobot2/messenger.py new file mode 100644 index 0000000..7298cac --- /dev/null +++ b/velascobot2/messenger.py @@ -0,0 +1,140 @@ +import time +import logging +from enum import Enum + +from velascobot2.utils import ChatData, GraphicType, Response +from velascobot2.parrot import Parrot + +logger = logging.getLogger(__name__) + + +class ReplyMode(Enum): + FIXED = 1 + CHANCE = 2 + + +class Messenger: + def __init__( + self, + bot_name: str, + bosses: list[str] = [], + nicknames: list[str] = [], + reply: float = 0.1, + repeat: float = 0.05, + wake_up: bool = False, + mode: ReplyMode = ReplyMode.FIXED, + mute_time: int = 60, + max_len: int = 50, + min_period: int = 1, + max_period: int = 100000, + ): + # List of nicknames (other than the username) that the bot can react to + self.names = nicknames + # Mute time for Telegram network errors + self.mute_time = mute_time + # Last mute timestamp + self.mute_timer = None + # The bot's username, "@" included. Updated on Bot creation + self.username = "@" + bot_name + # Wakeup flag that determines if it should send a wakeup message to + # stored groupchats + self.wake_up = wake_up + # Chance of sending messages as replies + self.reply = reply + # Chance of sending 2 messages in a row + self.repeat = repeat + # Bot bosses ID + self.bosses = bosses + # Max word length for a message + self.max_len = max_len + # Whether a message is guaranteed after period_messages, or if it's a + # random chance + self.mode = mode + # minimum value allowed for message_period + self.min_period = min_period + # maximum value allowed for message_period + self.max_period = max_period + + self._parrot = Parrot(bot_name) + + def get_all_chats(self) -> list[ChatData]: + return self._parrot.get_all_chats() + + def mentioned(self, text: str) -> bool: + """Returns True if the bot's username or any of its nicknames is + mentioned in the text, as long as it's not some other user's + username""" + if self.username in text: + return True + for name in self.names: + if name in text and "@{}".format(name) not in text: + return True + return False + + def is_mute(self) -> bool: + """Returns True if not enough time has passed since the last time the + bot got muted.""" + current_time = int(time.perf_counter()) + return ( + self.mute_timer is not None + and (current_time - self.mute_timer) < self.mute_time + ) + + def give_response( + self, chat_id: str + ) -> tuple[Response, str | tuple[GraphicType, str]]: + response = self._parrot.choose_response_type(chat_id) + if response == Response.TEXT: + return response, self._parrot.give_text(chat_id) + elif response == Response.GRAPHIC: + return response, self._parrot.give_graphic(chat_id) + + raise Exception("Response type not implemented") + + def set_mute_timer(self, counter: int): + self.mute_timer = counter + + def process_text(self, chat_id: str, text: str): + logger.debug(f"{chat_id} - Processing text: {text}") + for name in self.names: + if name.casefold() in text.casefold() and len(text.split()) <= 3: + # If it's less than 3 words and one of the bot's names is in + # the message, ignore it as it's most probably just a summon + return + self._parrot.parse_text(chat_id, text) + + def process_graphic( + self, chat_id: str, graph_type: GraphicType, graph_id: str + ): + logger.debug(f"{chat_id} - Processing graphic {graph_type}: {graph_id}") + self._parrot.parse_graphic(chat_id, graph_type, graph_id) + + def get_chat_data(self, chat_id: str) -> ChatData | None: + return self._parrot.get_chat_data(chat_id) + + def ensure_chat_exists(self, chat_id: str, chat_type: str, chat_name: str): + self._parrot.ensure_chat_exists(chat_id, chat_type, chat_name) + + def update_chat_name(self, chat_id: str, chat_name: str): + self._parrot.update_chat_name(chat_id, chat_name) + + def update_message_period(self, chat_id: str, period: int): + if period < self.min_period or period > self.max_period: + raise Exception( + f"The period must be between {self.min_period} and" + f" {self.max_period}, but got {period}" + ) + self._parrot.update_chat_message_period(chat_id, period) + + def update_answer_chance(self, chat_id: str, chance: float): + if chance < 0 or chance > 1: + raise Exception( + f"The period must be between 0 and 1, but got {chance}" + ) + self._parrot.update_chat_answer_chance(chat_id, chance) + + def update_set_restricted(self, chat_id: str, restricted: bool): + self._parrot.update_chat_set_restricted(chat_id, restricted) + + def update_set_silenced(self, chat_id: str, silenced: bool): + self._parrot.update_chat_set_silenced(chat_id, silenced) diff --git a/velascobot2/parrot.py b/velascobot2/parrot.py new file mode 100644 index 0000000..c7ba217 --- /dev/null +++ b/velascobot2/parrot.py @@ -0,0 +1,135 @@ +import random +import logging +from velascobot2.database import Database +from velascobot2.utils import GraphicType, Response, ChatData + +logger = logging.getLogger(__name__) + + +def _rewrite(text: str) -> list[str]: + """ + Splits strings into lists of words delimited by whitespaces. + Extra whitespaces are inserted around newline characters so they are + included as their own Markov chain element, to avoid polluting with + very similar words that would only differ in having a whitespace + attached or not + """ + words = text.replace("\n", " \n ").split(" ") + return [word.strip(" ") for word in words if len(word.strip(" ")) > 0] + + +def _normalize_key(*words: str) -> str: + """Returns a case insensitive n-gram key by joining them with whitespaces""" + return " ".join(word.casefold() for word in words) + + +def _triplets(wordlist: list[str]): + """Generates triplets of words from the given data string. So if our string + were "What a lovely day", we'd generate (What, a, lovely) and then + (a, lovely, day). + """ + if len(wordlist) < 3: + return + + for i in range(len(wordlist) - 2): + yield (wordlist[i], wordlist[i + 1], wordlist[i + 2]) + + +class Parrot: + def __init__(self, bot_name: str): + self.db = Database(bot_name) + + def parse_text(self, chat_id: str, text: str) -> None: + logger.debug(f"{chat_id} - Processing text: {text}") + words = [w for w in _rewrite(text) if w] + words.append("") # end of message + words = ["", ""] + words # start of message + + for key, word, count in self.parse_words(words): + self.db.add_words(chat_id, key, word, count) + + self.db.incr_processed_messages(chat_id) + + def parse_words(self, words: list[str]): + word_counts: dict[tuple[str, str], int] = {} + + for w1, w2, w3 in _triplets(words): + key = _normalize_key(w1, w2) + word_counts[(key, w3)] = word_counts.get((key, w3), 0) + 1 + + for (key, word), count in word_counts.items(): + yield (key, word, count) + + def parse_graphic( + self, chat_id: str, graph_type: GraphicType, graph_id: str + ): + logger.debug(f"{chat_id} - Parsing graphic {graph_type}: {graph_id}") + assert graph_type in GraphicType + self.db.add_graphic(chat_id, graph_type, graph_id) + + def give_text(self, chat_id: str) -> str: + w1, w2 = "", "" + w3 = "invalid" + words = [] + while w3: + key = _normalize_key(w1, w2) + weighted_next_words = self.db.get_next_words(chat_id, key) + w3 = self.choose_word(weighted_next_words) + words.append(w3) + w1, w2 = w2, w3 + return " ".join(words) + + def give_graphic(self, chat_id: str) -> tuple[GraphicType, str]: + weighted_graphics = self.db.get_graphics(chat_id) + return self.choose_graphic(weighted_graphics) + + def choose_word(self, weighted_words: list[tuple[str, int]]) -> str: + words, weights = zip(*weighted_words) + return random.choices(words, weights=weights)[0] + + def choose_graphic( + self, weighted_graphics: list[tuple[str, str, int]] + ) -> tuple[GraphicType, str]: + graphics = [ + (graph_type, graph_id) + for graph_type, graph_id, _ in weighted_graphics + ] + weights = [count for _, _, count in weighted_graphics] + chosen_type, chosen_id = random.choices(graphics, weights=weights)[0] + return GraphicType(chosen_type), chosen_id + + def choose_response_type(self, chat_id: str) -> Response: + graphics_count = self.db.count_graphics(chat_id) + words_count = self.db.count_words(chat_id, _normalize_key("", "")) + total = graphics_count + words_count + + if random.random() < graphics_count / total: + return Response.GRAPHIC + else: + return Response.TEXT + + def get_all_chats(self) -> list[ChatData]: + return self.db.get_all_chats() + + def get_chat_data(self, chat_id: str) -> ChatData | None: + return self.db.get_chat_data(chat_id) + + def ensure_chat_exists( + self, chat_id: str, chat_type: str, chat_name: str + ) -> bool: + return self.db.ensure_chat_exists(chat_id, chat_type, chat_name) + + def update_chat_name(self, chat_id: str, chat_name: str): + self.db.update_chat_name(chat_id, chat_name) + + def update_chat_answer_chance(self, chat_id: str, answer_chance: float): + self.db.update_chat_answer_chance(chat_id, answer_chance) + + def update_chat_message_period(self, chat_id: str, period: int): + self.db.update_chat_message_period(chat_id, period) + + def update_chat_set_restricted(self, chat_id: str, restricted: bool): + self.db.update_chat_set_restricted(chat_id, restricted) + + def update_chat_set_silenced(self, chat_id: str, silenced: bool): + self.db.update_chat_set_silenced(chat_id, silenced) diff --git a/velascobot2/utils.py b/velascobot2/utils.py new file mode 100644 index 0000000..e89e1d4 --- /dev/null +++ b/velascobot2/utils.py @@ -0,0 +1,48 @@ +from enum import StrEnum + + +class GraphicType(StrEnum): + STICKER = "STICKER" + ANIMATION = "ANIMATION" + VIDEO = "VIDEO" + + +class Response(StrEnum): + TEXT = "TEXT" + GRAPHIC = "GRAPHIC" + + +class ChatData: + def __init__( + self, + id: str, + chat_type: str, + name: str, + processed_messages: int = 0, + message_period: int = 10, + answer_chance: float = 0.5, + restricted: bool = False, + silenced: bool = False, + ) -> None: + self.id = id + self.type = chat_type + self.name = name + self.processed_messages = processed_messages + self.message_period = message_period + self.answer_chance = answer_chance + self.restricted = restricted + self.silenced = silenced + + @staticmethod + def from_sql_row(row: tuple[str, str, str, int, int, float, int, int]): + print("From SQL row:", repr(row)) + return ChatData( + row[0], + row[1], + row[2], + int(row[3]), + int(row[4]), + float(row[5]), + int(row[6]) == 1, + int(row[7]) == 1, + ) -- 2.51.2