diff --git a/poetry.lock b/poetry.lock index 345ef6b0..56aca837 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiofiles" @@ -91,6 +91,34 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "apscheduler" +version = "3.11.2" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d"}, + {file = "apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytest-timeout", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + [[package]] name = "attrs" version = "25.4.0" @@ -294,7 +322,6 @@ files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] -markers = {main = "sys_platform != \"emscripten\""} [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} @@ -306,11 +333,11 @@ 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 = ["main", "dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "sys_platform != \"emscripten\" and platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -499,6 +526,27 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "fastapi" +version = "0.115.14" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, + {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.47.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "flake8" version = "7.2.0" @@ -550,6 +598,59 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<1.0)"] +[[package]] +name = "httptools" +version = "0.7.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, + {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b"}, + {file = "httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec"}, + {file = "httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c"}, + {file = "httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650"}, + {file = "httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca"}, + {file = "httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad"}, + {file = "httptools-0.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023"}, + {file = "httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9"}, +] + [[package]] name = "httpx" version = "0.28.1" @@ -731,7 +832,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.25.0" @@ -1318,6 +1419,89 @@ files = [ {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + [[package]] name = "referencing" version = "0.37.0" @@ -1474,41 +1658,39 @@ files = [ [[package]] name = "sse-starlette" -version = "3.2.0" +version = "3.0.3" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf"}, - {file = "sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422"}, + {file = "sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431"}, + {file = "sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971"}, ] [package.dependencies] anyio = ">=4.7.0" -starlette = ">=0.49.1" [package.extras] daphne = ["daphne (>=4.2.0)"] -examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "uvicorn (>=0.34.0)"] +examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.49.1)", "uvicorn (>=0.34.0)"] granian = ["granian (>=2.3.1)"] uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.52.1" +version = "0.46.2" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"}, - {file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"}, + {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, + {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, ] [package.dependencies] anyio = ">=3.6.2,<5" -typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] @@ -1598,28 +1780,323 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "tzdata" +version = "2025.3" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, + {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.34.3" description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" groups = ["main"] -markers = "sys_platform != \"emscripten\"" files = [ - {file = "uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee"}, - {file = "uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea"}, + {file = "uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885"}, + {file = "uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a"}, ] [package.dependencies] click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} h11 = ">=0.8" +httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] +[[package]] +name = "uvloop" +version = "0.22.1" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.1" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7"}, + {file = "uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] + +[[package]] +name = "watchfiles" +version = "1.1.1" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"}, + {file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863"}, + {file = "watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82"}, + {file = "watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4"}, + {file = "watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844"}, + {file = "watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5"}, + {file = "watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff"}, + {file = "watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701"}, + {file = "watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10"}, + {file = "watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4"}, + {file = "watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d"}, + {file = "watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb"}, + {file = "watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94"}, + {file = "watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43"}, + {file = "watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9"}, + {file = "watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18"}, + {file = "watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae"}, + {file = "watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b"}, + {file = "watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374"}, + {file = "watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42"}, + {file = "watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da"}, + {file = "watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04"}, + {file = "watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef"}, + {file = "watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5"}, + {file = "watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510"}, + {file = "watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6"}, + {file = "watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81"}, + {file = "watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a"}, + {file = "watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21"}, + {file = "watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc"}, + {file = "watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099"}, + {file = "watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70"}, + {file = "watchfiles-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2"}, + {file = "watchfiles-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be"}, + {file = "watchfiles-1.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f"}, + {file = "watchfiles-1.1.1-cp39-cp39-win32.whl", hash = "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b"}, + {file = "watchfiles-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d"}, + {file = "watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24"}, + {file = "watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc"}, + {file = "watchfiles-1.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e"}, + {file = "watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "3f95855ee445f08e1d6e2920c0c6ecd0a602aba25f721c0a9563f633a3d7cfd3" +content-hash = "4c4f0520748e484b6a3b6484d4f5e25bb796cbf0985d8ba1971dcf9c6312f586" diff --git a/pyproject.toml b/pyproject.toml index e2f4ddec..c65991ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ aiofiles = "^24.1.0" aiosqlite = "^0.21.0" anthropic = "^0.40.0" claude-agent-sdk = "^0.1.30" +fastapi = "^0.115.0" +uvicorn = {version = "^0.34.0", extras = ["standard"]} +apscheduler = "^3.10" [tool.poetry.scripts] claude-telegram-bot = "src.main:run" diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 00000000..4f64f11b --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1,5 @@ +"""Webhook API server for receiving external events.""" + +from .server import create_api_app, run_api_server + +__all__ = ["create_api_app", "run_api_server"] diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 00000000..c85bf463 --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,61 @@ +"""Webhook signature verification for external providers. + +Each provider has its own signing mechanism: +- GitHub: HMAC-SHA256 with X-Hub-Signature-256 header +- Generic: shared secret in Authorization header +""" + +import hashlib +import hmac +from typing import Optional + +import structlog + +logger = structlog.get_logger() + + +def verify_github_signature( + payload_body: bytes, + signature_header: Optional[str], + secret: str, +) -> bool: + """Verify GitHub webhook HMAC-SHA256 signature. + + GitHub sends the signature as: sha256= + """ + if not signature_header: + logger.warning("GitHub webhook missing signature header") + return False + + if not signature_header.startswith("sha256="): + logger.warning("GitHub webhook signature has unexpected format") + return False + + expected_signature = ( + "sha256=" + + hmac.new( + secret.encode("utf-8"), + payload_body, + hashlib.sha256, + ).hexdigest() + ) + + return hmac.compare_digest(expected_signature, signature_header) + + +def verify_shared_secret( + authorization_header: Optional[str], + secret: str, +) -> bool: + """Verify a simple shared secret in the Authorization header. + + Expects: Bearer + """ + if not authorization_header: + return False + + if not authorization_header.startswith("Bearer "): + return False + + token = authorization_header[7:] + return hmac.compare_digest(token, secret) diff --git a/src/api/server.py b/src/api/server.py new file mode 100644 index 00000000..0cea5bc1 --- /dev/null +++ b/src/api/server.py @@ -0,0 +1,192 @@ +"""FastAPI webhook server. + +Runs in the same process as the bot, sharing the event loop. +Receives external webhooks and publishes them as events on the bus. +""" + +import uuid +from typing import Any, Dict, Optional + +import structlog +from fastapi import FastAPI, Header, HTTPException, Request + +from ..config.settings import Settings +from ..events.bus import EventBus +from ..events.types import WebhookEvent +from ..storage.database import DatabaseManager +from .auth import verify_github_signature, verify_shared_secret + +logger = structlog.get_logger() + + +def create_api_app( + event_bus: EventBus, + settings: Settings, + db_manager: Optional[DatabaseManager] = None, +) -> FastAPI: + """Create the FastAPI application.""" + + app = FastAPI( + title="Claude Code Telegram - Webhook API", + version="0.1.0", + docs_url="/docs" if settings.development_mode else None, + redoc_url=None, + ) + + @app.get("/health") + async def health_check() -> Dict[str, str]: + return {"status": "ok"} + + @app.post("/webhooks/{provider}") + async def receive_webhook( + provider: str, + request: Request, + x_hub_signature_256: Optional[str] = Header(None), + x_github_event: Optional[str] = Header(None), + x_github_delivery: Optional[str] = Header(None), + authorization: Optional[str] = Header(None), + ) -> Dict[str, str]: + """Receive and validate webhook from an external provider.""" + body = await request.body() + + # Verify signature based on provider + if provider == "github": + secret = settings.github_webhook_secret + if not secret: + raise HTTPException( + status_code=500, + detail="GitHub webhook secret not configured", + ) + if not verify_github_signature(body, x_hub_signature_256, secret): + logger.warning( + "GitHub webhook signature verification failed", + delivery_id=x_github_delivery, + ) + raise HTTPException(status_code=401, detail="Invalid signature") + + event_type_name = x_github_event or "unknown" + delivery_id = x_github_delivery or str(uuid.uuid4()) + else: + # Generic provider — require auth (fail-closed) + secret = settings.webhook_api_secret + if not secret: + raise HTTPException( + status_code=500, + detail=( + "Webhook API secret not configured. " + "Set WEBHOOK_API_SECRET to accept " + "webhooks from this provider." + ), + ) + if not verify_shared_secret(authorization, secret): + raise HTTPException(status_code=401, detail="Invalid authorization") + event_type_name = request.headers.get("X-Event-Type", "unknown") + delivery_id = request.headers.get("X-Delivery-ID", str(uuid.uuid4())) + + # Parse JSON payload + try: + payload: Dict[str, Any] = await request.json() + except Exception: + payload = {"raw_body": body.decode("utf-8", errors="replace")[:5000]} + + # Atomic dedupe: attempt INSERT first, only publish if new + if db_manager and delivery_id: + is_new = await _try_record_webhook( + db_manager, + event_id=str(uuid.uuid4()), + provider=provider, + event_type=event_type_name, + delivery_id=delivery_id, + payload=payload, + ) + if not is_new: + logger.info( + "Duplicate webhook delivery ignored", + provider=provider, + delivery_id=delivery_id, + ) + return { + "status": "duplicate", + "delivery_id": delivery_id, + } + + # Publish event to the bus + event = WebhookEvent( + provider=provider, + event_type_name=event_type_name, + payload=payload, + delivery_id=delivery_id, + ) + + await event_bus.publish(event) + + logger.info( + "Webhook received and published", + provider=provider, + event_type=event_type_name, + delivery_id=delivery_id, + event_id=event.id, + ) + + return {"status": "accepted", "event_id": event.id} + + return app + + +async def _try_record_webhook( + db_manager: DatabaseManager, + event_id: str, + provider: str, + event_type: str, + delivery_id: str, + payload: Dict[str, Any], +) -> bool: + """Atomically insert a webhook event, returning whether it was new. + + Uses INSERT OR IGNORE on the unique delivery_id column. + If the row already exists the insert is a no-op and changes() == 0. + Returns True if the event is new (inserted), False if duplicate. + """ + import json + + async with db_manager.get_connection() as conn: + await conn.execute( + """ + INSERT OR IGNORE INTO webhook_events + (event_id, provider, event_type, delivery_id, payload, + processed) + VALUES (?, ?, ?, ?, ?, 1) + """, + ( + event_id, + provider, + event_type, + delivery_id, + json.dumps(payload), + ), + ) + cursor = await conn.execute("SELECT changes()") + row = await cursor.fetchone() + inserted = row[0] > 0 if row else False + await conn.commit() + return inserted + + +async def run_api_server( + event_bus: EventBus, + settings: Settings, + db_manager: Optional[DatabaseManager] = None, +) -> None: + """Run the FastAPI server using uvicorn.""" + import uvicorn + + app = create_api_app(event_bus, settings, db_manager) + + config = uvicorn.Config( + app=app, + host="0.0.0.0", + port=settings.api_server_port, + log_level="info" if not settings.debug else "debug", + ) + server = uvicorn.Server(config) + await server.serve() diff --git a/src/bot/core.py b/src/bot/core.py index 324f0273..4b17f625 100644 --- a/src/bot/core.py +++ b/src/bot/core.py @@ -40,7 +40,10 @@ def __init__(self, settings: Settings, dependencies: Dict[str, Any]): self.feature_registry: Optional[FeatureRegistry] = None async def initialize(self) -> None: - """Initialize bot application.""" + """Initialize bot application. Idempotent — safe to call multiple times.""" + if self.app is not None: + return + logger.info("Initializing Telegram bot") # Create application diff --git a/src/bot/handlers/callback.py b/src/bot/handlers/callback.py index e09bb5f8..ff0cb271 100644 --- a/src/bot/handlers/callback.py +++ b/src/bot/handlers/callback.py @@ -1007,8 +1007,10 @@ async def handle_git_callback( else: # Clean up diff output for Telegram # Remove emoji symbols that interfere with markdown parsing - clean_diff = diff_output.replace("➕", "+").replace("➖", "-").replace("📍", "@") - + clean_diff = ( + diff_output.replace("➕", "+").replace("➖", "-").replace("📍", "@") + ) + # Limit diff output max_length = 2000 if len(clean_diff) > max_length: @@ -1159,7 +1161,26 @@ def _format_file_size(size: int) -> str: def _escape_markdown(text: str) -> str: """Escape special markdown characters in text for Telegram.""" # Escape characters that have special meaning in Telegram Markdown - special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] + special_chars = [ + "_", + "*", + "[", + "]", + "(", + ")", + "~", + "`", + ">", + "#", + "+", + "-", + "=", + "|", + "{", + "}", + ".", + "!", + ] for char in special_chars: - text = text.replace(char, f'\\{char}') + text = text.replace(char, f"\\{char}") return text diff --git a/src/bot/handlers/command.py b/src/bot/handlers/command.py index ef0f24c7..761eebfa 100644 --- a/src/bot/handlers/command.py +++ b/src/bot/handlers/command.py @@ -230,7 +230,9 @@ async def continue_session(update: Update, context: ContextTypes.DEFAULT_TYPE) - from ..utils.formatting import ResponseFormatter formatter = ResponseFormatter(settings) - formatted_messages = formatter.format_claude_response(claude_response.content) + formatted_messages = formatter.format_claude_response( + claude_response.content + ) for msg in formatted_messages: await update.message.reply_text( @@ -987,7 +989,26 @@ def _format_file_size(size: int) -> str: def _escape_markdown(text: str) -> str: """Escape special markdown characters in text for Telegram.""" # Escape characters that have special meaning in Telegram Markdown - special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] + special_chars = [ + "_", + "*", + "[", + "]", + "(", + ")", + "~", + "`", + ">", + "#", + "+", + "-", + "=", + "|", + "{", + "}", + ".", + "!", + ] for char in special_chars: - text = text.replace(char, f'\\{char}') + text = text.replace(char, f"\\{char}") return text diff --git a/src/bot/handlers/message.py b/src/bot/handlers/message.py index 92c6584d..296e0925 100644 --- a/src/bot/handlers/message.py +++ b/src/bot/handlers/message.py @@ -122,7 +122,12 @@ def _format_error_message(error_str: str) -> str: # Generic error handling # Escape special markdown characters in error message # Replace problematic chars that break Telegram markdown - safe_error = error_str.replace("_", "\\_").replace("*", "\\*").replace("`", "\\`").replace("[", "\\[") + safe_error = ( + error_str.replace("_", "\\_") + .replace("*", "\\*") + .replace("`", "\\`") + .replace("[", "\\[") + ) # Truncate very long errors if len(safe_error) > 200: safe_error = safe_error[:200] + "..." diff --git a/src/claude/facade.py b/src/claude/facade.py index aa825747..cc2f591d 100644 --- a/src/claude/facade.py +++ b/src/claude/facade.py @@ -148,9 +148,7 @@ async def stream_handler(update: StreamUpdate): try: # Continue session if we have a real (non-temporary) session ID is_new = getattr(session, "is_new_session", False) - has_real_session = ( - not is_new and not session.session_id.startswith("temp_") - ) + has_real_session = not is_new and not session.session_id.startswith("temp_") should_continue = has_real_session # For new sessions, don't pass the temporary session_id to Claude Code @@ -167,9 +165,10 @@ async def stream_handler(update: StreamUpdate): except Exception as resume_error: # If resume failed (e.g., session expired on Claude's side), # retry as a fresh session - if should_continue and "no conversation found" in str( - resume_error - ).lower(): + if ( + should_continue + and "no conversation found" in str(resume_error).lower() + ): logger.warning( "Session resume failed, starting fresh session", failed_session_id=claude_session_id, diff --git a/src/claude/integration.py b/src/claude/integration.py index 02edd73b..6f775973 100644 --- a/src/claude/integration.py +++ b/src/claude/integration.py @@ -322,9 +322,7 @@ async def _handle_process_output( # Check for MCP-related errors if "mcp" in error_msg.lower(): - raise ClaudeMCPError( - f"MCP server error: {error_msg}" - ) + raise ClaudeMCPError(f"MCP server error: {error_msg}") # Generic error handling for other cases raise ClaudeProcessError( @@ -558,7 +556,7 @@ def _parse_result(self, result: Dict, messages: List[Dict]) -> ClaudeResponse: logger.debug( "Using fallback content from assistant messages", num_texts=len(assistant_texts), - content_length=len(content) + content_length=len(content), ) return ClaudeResponse( diff --git a/src/claude/sdk_integration.py b/src/claude/sdk_integration.py index 45f98c67..fef675d5 100644 --- a/src/claude/sdk_integration.py +++ b/src/claude/sdk_integration.py @@ -17,11 +17,11 @@ import structlog from claude_agent_sdk import ( AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKError, CLIConnectionError, CLIJSONDecodeError, CLINotFoundError, - ClaudeAgentOptions, - ClaudeSDKError, Message, ProcessError, ResultMessage, @@ -181,9 +181,7 @@ async def execute_command( # Pass MCP server configuration if enabled if self.config.enable_mcp and self.config.mcp_config_path: - options.mcp_servers = self._load_mcp_config( - self.config.mcp_config_path - ) + options.mcp_servers = self._load_mcp_config(self.config.mcp_config_path) logger.info( "MCP servers configured", mcp_config_path=str(self.config.mcp_config_path), @@ -282,9 +280,7 @@ async def execute_command( ) # Check if the process error is MCP-related if "mcp" in error_str.lower(): - raise ClaudeMCPError( - f"MCP server error: {error_str}" - ) + raise ClaudeMCPError(f"MCP server error: {error_str}") raise ClaudeProcessError(f"Claude process error: {error_str}") except CLIConnectionError as e: @@ -292,9 +288,7 @@ async def execute_command( logger.error("Claude connection error", error=error_str) # Check if the connection error is MCP-related if "mcp" in error_str.lower() or "server" in error_str.lower(): - raise ClaudeMCPError( - f"MCP server connection failed: {error_str}" - ) + raise ClaudeMCPError(f"MCP server connection failed: {error_str}") raise ClaudeProcessError(f"Failed to connect to Claude: {error_str}") except CLIJSONDecodeError as e: @@ -474,7 +468,9 @@ def _load_mcp_config(self, config_path: Path) -> Dict[str, Any]: config_data = json.load(f) return config_data.get("mcpServers", {}) except (json.JSONDecodeError, OSError) as e: - logger.error("Failed to load MCP config", path=str(config_path), error=str(e)) + logger.error( + "Failed to load MCP config", path=str(config_path), error=str(e) + ) return {} def _update_session(self, session_id: str, messages: List[Message]) -> None: diff --git a/src/config/features.py b/src/config/features.py index 7e91ad91..a2f58ec4 100644 --- a/src/config/features.py +++ b/src/config/features.py @@ -56,6 +56,16 @@ def development_features_enabled(self) -> bool: """Check if development features are enabled.""" return self.settings.development_mode + @property + def api_server_enabled(self) -> bool: + """Check if the webhook API server is enabled.""" + return self.settings.enable_api_server + + @property + def scheduler_enabled(self) -> bool: + """Check if the job scheduler is enabled.""" + return self.settings.enable_scheduler + def is_feature_enabled(self, feature_name: str) -> bool: """Generic feature check by name.""" feature_map = { @@ -67,6 +77,8 @@ def is_feature_enabled(self, feature_name: str) -> bool: "token_auth": self.token_auth_enabled, "webhook": self.webhook_enabled, "development": self.development_features_enabled, + "api_server": self.api_server_enabled, + "scheduler": self.scheduler_enabled, } return feature_map.get(feature_name, False) @@ -89,4 +101,8 @@ def get_enabled_features(self) -> list[str]: features.append("webhook") if self.development_features_enabled: features.append("development") + if self.api_server_enabled: + features.append("api_server") + if self.scheduler_enabled: + features.append("scheduler") return features diff --git a/src/config/settings.py b/src/config/settings.py index aebff6dd..98c1f6b4 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -149,14 +149,28 @@ class Settings(BaseSettings): webhook_port: int = Field(8443, description="Webhook port") webhook_path: str = Field("/webhook", description="Webhook path") + # Agentic platform settings + enable_api_server: bool = Field(False, description="Enable FastAPI webhook server") + api_server_port: int = Field(8080, description="Webhook API server port") + enable_scheduler: bool = Field(False, description="Enable job scheduler") + github_webhook_secret: Optional[str] = Field( + None, description="GitHub webhook HMAC secret" + ) + webhook_api_secret: Optional[str] = Field( + None, description="Shared secret for generic webhook providers" + ) + notification_chat_ids: Optional[List[int]] = Field( + None, description="Default Telegram chat IDs for proactive notifications" + ) + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - @field_validator("allowed_users", mode="before") + @field_validator("allowed_users", "notification_chat_ids", mode="before") @classmethod - def parse_allowed_users(cls, v: Any) -> Optional[List[int]]: - """Parse comma-separated user IDs.""" + def parse_int_list(cls, v: Any) -> Optional[List[int]]: + """Parse comma-separated integer lists.""" if v is None: return None if isinstance(v, int): @@ -214,12 +228,16 @@ def validate_mcp_config(cls, v: Any, info: Any) -> Optional[Path]: if "mcpServers" not in config_data: raise ValueError( "MCP config file must contain a 'mcpServers' key. " - "Expected format: {\"mcpServers\": {\"server-name\": {\"command\": \"...\", ...}}}" + 'Expected format: {"mcpServers": {"server-name": {"command": "...", ...}}}' ) if not isinstance(config_data["mcpServers"], dict): - raise ValueError("'mcpServers' must be an object mapping server names to configurations") + raise ValueError( + "'mcpServers' must be an object mapping server names to configurations" + ) if not config_data["mcpServers"]: - raise ValueError("'mcpServers' must contain at least one server configuration") + raise ValueError( + "'mcpServers' must contain at least one server configuration" + ) return v # type: ignore[no-any-return] @field_validator("log_level") diff --git a/src/events/__init__.py b/src/events/__init__.py new file mode 100644 index 00000000..3569b137 --- /dev/null +++ b/src/events/__init__.py @@ -0,0 +1,18 @@ +"""Event bus system for decoupling triggers from agent runtime.""" + +from .bus import Event, EventBus +from .types import ( + AgentResponseEvent, + ScheduledEvent, + UserMessageEvent, + WebhookEvent, +) + +__all__ = [ + "Event", + "EventBus", + "AgentResponseEvent", + "ScheduledEvent", + "UserMessageEvent", + "WebhookEvent", +] diff --git a/src/events/bus.py b/src/events/bus.py new file mode 100644 index 00000000..0a51be03 --- /dev/null +++ b/src/events/bus.py @@ -0,0 +1,153 @@ +"""Central async event bus. + +Decouples event sources (Telegram, webhooks, cron) from handlers +(agent execution, notifications). All inputs become typed events +routed to registered handlers. +""" + +import asyncio +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, Coroutine, Dict, List, Optional, Type + +import structlog + +logger = structlog.get_logger() + + +@dataclass +class Event: + """Base event class. All events carry an ID, timestamp, and source.""" + + id: str = field(default_factory=lambda: str(uuid.uuid4())) + timestamp: datetime = field(default_factory=datetime.utcnow) + source: str = "unknown" + + @property + def event_type(self) -> str: + return type(self).__name__ + + +EventHandler = Callable[[Event], Coroutine[Any, Any, None]] + + +class EventBus: + """Async event bus with typed subscriptions. + + Handlers subscribe to specific event types and are called + concurrently when a matching event is published. + """ + + def __init__(self) -> None: + self._handlers: Dict[Type[Event], List[EventHandler]] = {} + self._global_handlers: List[EventHandler] = [] + self._running = False + self._queue: asyncio.Queue[Event] = asyncio.Queue() + self._processor_task: Optional[asyncio.Task[None]] = None + + def subscribe( + self, + event_type: Type[Event], + handler: EventHandler, + ) -> None: + """Register a handler for a specific event type.""" + if event_type not in self._handlers: + self._handlers[event_type] = [] + self._handlers[event_type].append(handler) + logger.debug( + "Handler subscribed", + event_type=event_type.__name__, + handler=handler.__qualname__, + ) + + def subscribe_all(self, handler: EventHandler) -> None: + """Register a handler that receives all events.""" + self._global_handlers.append(handler) + + async def publish(self, event: Event) -> None: + """Publish an event to be processed by matching handlers.""" + logger.info( + "Event published", + event_type=event.event_type, + event_id=event.id, + source=event.source, + ) + await self._queue.put(event) + + async def start(self) -> None: + """Start processing events from the queue.""" + if self._running: + return + self._running = True + self._processor_task = asyncio.create_task(self._process_events()) + logger.info("Event bus started") + + async def stop(self) -> None: + """Stop processing events and drain the queue.""" + if not self._running: + return + self._running = False + if self._processor_task: + self._processor_task.cancel() + try: + await self._processor_task + except asyncio.CancelledError: + pass + logger.info("Event bus stopped") + + async def _process_events(self) -> None: + """Main event processing loop.""" + while self._running: + try: + event = await asyncio.wait_for(self._queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + + await self._dispatch(event) + + async def _dispatch(self, event: Event) -> None: + """Dispatch event to all matching handlers concurrently.""" + handlers: List[EventHandler] = [] + + # Collect type-specific handlers (including parent classes) + for event_type, type_handlers in self._handlers.items(): + if isinstance(event, event_type): + handlers.extend(type_handlers) + + # Add global handlers + handlers.extend(self._global_handlers) + + if not handlers: + logger.debug("No handlers for event", event_type=event.event_type) + return + + # Run all handlers concurrently + results = await asyncio.gather( + *(self._safe_call(handler, event) for handler in handlers), + return_exceptions=True, + ) + + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error( + "Event handler failed", + event_type=event.event_type, + event_id=event.id, + handler=handlers[i].__qualname__, + error=str(result), + ) + + async def _safe_call(self, handler: EventHandler, event: Event) -> None: + """Call handler with error isolation.""" + try: + await handler(event) + except Exception: + logger.exception( + "Unhandled error in event handler", + handler=handler.__qualname__, + event_type=event.event_type, + ) + raise diff --git a/src/events/handlers.py b/src/events/handlers.py new file mode 100644 index 00000000..74ceaaad --- /dev/null +++ b/src/events/handlers.py @@ -0,0 +1,186 @@ +"""Event handlers that bridge the event bus to Claude and Telegram. + +AgentHandler: translates events into ClaudeIntegration.run_command() calls. +NotificationHandler: subscribes to AgentResponseEvent and delivers to Telegram. +""" + +from pathlib import Path +from typing import Any, Dict, List + +import structlog + +from ..claude.facade import ClaudeIntegration +from .bus import Event, EventBus +from .types import AgentResponseEvent, ScheduledEvent, WebhookEvent + +logger = structlog.get_logger() + + +class AgentHandler: + """Translates incoming events into Claude agent executions. + + Webhook and scheduled events are converted into prompts and sent + to ClaudeIntegration.run_command(). The response is published + back as an AgentResponseEvent for delivery. + """ + + def __init__( + self, + event_bus: EventBus, + claude_integration: ClaudeIntegration, + default_working_directory: Path, + default_user_id: int = 0, + ) -> None: + self.event_bus = event_bus + self.claude = claude_integration + self.default_working_directory = default_working_directory + self.default_user_id = default_user_id + + def register(self) -> None: + """Subscribe to events that need agent processing.""" + self.event_bus.subscribe(WebhookEvent, self.handle_webhook) + self.event_bus.subscribe(ScheduledEvent, self.handle_scheduled) + + async def handle_webhook(self, event: Event) -> None: + """Process a webhook event through Claude.""" + if not isinstance(event, WebhookEvent): + return + + logger.info( + "Processing webhook event through agent", + provider=event.provider, + event_type=event.event_type_name, + delivery_id=event.delivery_id, + ) + + prompt = self._build_webhook_prompt(event) + + try: + response = await self.claude.run_command( + prompt=prompt, + working_directory=self.default_working_directory, + user_id=self.default_user_id, + ) + + if response.content: + # We don't know which chat to send to from a webhook alone. + # The notification service needs configured target chats. + # Publish with chat_id=0 — the NotificationService + # will broadcast to configured notification_chat_ids. + await self.event_bus.publish( + AgentResponseEvent( + chat_id=0, + text=response.content, + originating_event_id=event.id, + ) + ) + except Exception: + logger.exception( + "Agent execution failed for webhook event", + provider=event.provider, + event_id=event.id, + ) + + async def handle_scheduled(self, event: Event) -> None: + """Process a scheduled event through Claude.""" + if not isinstance(event, ScheduledEvent): + return + + logger.info( + "Processing scheduled event through agent", + job_id=event.job_id, + job_name=event.job_name, + ) + + prompt = event.prompt + if event.skill_name: + prompt = ( + f"/{event.skill_name}\n\n{prompt}" if prompt else f"/{event.skill_name}" + ) + + working_dir = event.working_directory or self.default_working_directory + + try: + response = await self.claude.run_command( + prompt=prompt, + working_directory=working_dir, + user_id=self.default_user_id, + ) + + if response.content: + for chat_id in event.target_chat_ids: + await self.event_bus.publish( + AgentResponseEvent( + chat_id=chat_id, + text=response.content, + originating_event_id=event.id, + ) + ) + + # Also broadcast to default chats if no targets specified + if not event.target_chat_ids: + await self.event_bus.publish( + AgentResponseEvent( + chat_id=0, + text=response.content, + originating_event_id=event.id, + ) + ) + except Exception: + logger.exception( + "Agent execution failed for scheduled event", + job_id=event.job_id, + event_id=event.id, + ) + + def _build_webhook_prompt(self, event: WebhookEvent) -> str: + """Build a Claude prompt from a webhook event.""" + payload_summary = self._summarize_payload(event.payload) + + return ( + f"A {event.provider} webhook event occurred.\n" + f"Event type: {event.event_type_name}\n" + f"Payload summary:\n{payload_summary}\n\n" + f"Analyze this event and provide a concise summary. " + f"Highlight anything that needs my attention." + ) + + def _summarize_payload(self, payload: Dict[str, Any], max_depth: int = 2) -> str: + """Create a readable summary of a webhook payload.""" + lines: List[str] = [] + self._flatten_dict(payload, lines, max_depth=max_depth) + # Cap at 2000 chars to keep prompt reasonable + summary = "\n".join(lines) + if len(summary) > 2000: + summary = summary[:2000] + "\n... (truncated)" + return summary + + def _flatten_dict( + self, + data: Any, + lines: list, + prefix: str = "", + depth: int = 0, + max_depth: int = 2, + ) -> None: + """Flatten a nested dict into key: value lines.""" + if depth >= max_depth: + lines.append(f"{prefix}: ...") + return + + if isinstance(data, dict): + for key, value in data.items(): + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, (dict, list)): + self._flatten_dict(value, lines, full_key, depth + 1, max_depth) + else: + val_str = str(value) + if len(val_str) > 200: + val_str = val_str[:200] + "..." + lines.append(f"{full_key}: {val_str}") + elif isinstance(data, list): + lines.append(f"{prefix}: [{len(data)} items]") + for i, item in enumerate(data[:3]): # Show first 3 items + self._flatten_dict(item, lines, f"{prefix}[{i}]", depth + 1, max_depth) + else: + lines.append(f"{prefix}: {data}") diff --git a/src/events/middleware.py b/src/events/middleware.py new file mode 100644 index 00000000..8c293a02 --- /dev/null +++ b/src/events/middleware.py @@ -0,0 +1,67 @@ +"""Event bus middleware wrapping existing security and auth systems. + +Provides event-level validation before handlers process events, +reusing SecurityValidator and AuthenticationManager. +""" + +import structlog + +from ..security.auth import AuthenticationManager +from ..security.validators import SecurityValidator +from .bus import Event, EventBus +from .types import UserMessageEvent, WebhookEvent + +logger = structlog.get_logger() + + +class EventSecurityMiddleware: + """Validates events before they reach handlers. + + Wraps the existing SecurityValidator for path/input validation + and AuthenticationManager for user authentication. + """ + + def __init__( + self, + event_bus: EventBus, + security_validator: SecurityValidator, + auth_manager: AuthenticationManager, + ) -> None: + self.event_bus = event_bus + self.security = security_validator + self.auth = auth_manager + + def register(self) -> None: + """Subscribe as a global handler to validate all events.""" + self.event_bus.subscribe(UserMessageEvent, self.validate_user_message) + self.event_bus.subscribe(WebhookEvent, self.validate_webhook) + + async def validate_user_message(self, event: Event) -> None: + """Validate user message events.""" + if not isinstance(event, UserMessageEvent): + return + + # Validate the working directory + is_valid, _, error = self.security.validate_path(str(event.working_directory)) + if not is_valid: + logger.warning( + "Event rejected: invalid working directory", + event_id=event.id, + user_id=event.user_id, + error=error, + ) + raise ValueError(f"Event security validation failed: {error}") + + async def validate_webhook(self, event: Event) -> None: + """Validate webhook events (signature verified upstream in API layer).""" + if not isinstance(event, WebhookEvent): + return + + # Webhooks are signature-verified in the API layer. + # Here we just log for audit purposes. + logger.info( + "Webhook event passed to bus", + provider=event.provider, + event_type=event.event_type_name, + delivery_id=event.delivery_id, + ) diff --git a/src/events/types.py b/src/events/types.py new file mode 100644 index 00000000..54dd2cfe --- /dev/null +++ b/src/events/types.py @@ -0,0 +1,54 @@ +"""Concrete event types for the event bus.""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .bus import Event + + +@dataclass +class UserMessageEvent(Event): + """A message from a Telegram user.""" + + user_id: int = 0 + chat_id: int = 0 + text: str = "" + working_directory: Path = field(default_factory=lambda: Path(".")) + source: str = "telegram" + + +@dataclass +class WebhookEvent(Event): + """An external webhook delivery (GitHub, Notion, etc.).""" + + provider: str = "" + event_type_name: str = "" + payload: Dict[str, Any] = field(default_factory=dict) + delivery_id: str = "" + source: str = "webhook" + + +@dataclass +class ScheduledEvent(Event): + """A cron/scheduled trigger.""" + + job_id: str = "" + job_name: str = "" + prompt: str = "" + working_directory: Path = field(default_factory=lambda: Path(".")) + target_chat_ids: List[int] = field(default_factory=list) + skill_name: Optional[str] = None + source: str = "scheduler" + + +@dataclass +class AgentResponseEvent(Event): + """An agent has produced a response to deliver.""" + + chat_id: int = 0 + text: str = "" + parse_mode: Optional[str] = "Markdown" + reply_to_message_id: Optional[int] = None + source: str = "agent" + originating_event_id: Optional[str] = None diff --git a/src/main.py b/src/main.py index e06f532f..7454c464 100644 --- a/src/main.py +++ b/src/main.py @@ -6,7 +6,7 @@ import signal import sys from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional import structlog @@ -20,9 +20,13 @@ ) from src.claude.sdk_integration import ClaudeSDKManager from src.config.features import FeatureFlags -from src.config.loader import load_config from src.config.settings import Settings +from src.events.bus import EventBus +from src.events.handlers import AgentHandler +from src.events.middleware import EventSecurityMiddleware from src.exceptions import ConfigurationError +from src.notifications.service import NotificationService +from src.scheduler.scheduler import JobScheduler from src.security.audit import AuditLogger, InMemoryAuditStorage from src.security.auth import ( AuthenticationManager, @@ -94,6 +98,8 @@ async def create_application(config: Settings) -> Dict[str, Any]: logger = structlog.get_logger() logger.info("Creating application components") + features = FeatureFlags(config) + # Initialize storage system storage = Storage(config.database_url) await storage.initialize() @@ -113,7 +119,8 @@ async def create_application(config: Settings) -> Dict[str, Any]: # Fall back to allowing all users in development mode if not providers and config.development_mode: logger.warning( - "No auth providers configured - creating development-only allow-all provider" + "No auth providers configured" + " - creating development-only allow-all provider" ) providers.append(WhitelistAuthProvider([], allow_all_dev=True)) elif not providers: @@ -151,6 +158,26 @@ async def create_application(config: Settings) -> Dict[str, Any]: tool_monitor=tool_monitor, ) + # --- Event bus and agentic platform components --- + event_bus = EventBus() + + # Event security middleware + event_security = EventSecurityMiddleware( + event_bus=event_bus, + security_validator=security_validator, + auth_manager=auth_manager, + ) + event_security.register() + + # Agent handler — translates events into Claude executions + agent_handler = AgentHandler( + event_bus=event_bus, + claude_integration=claude_integration, + default_working_directory=config.approved_directory, + default_user_id=config.allowed_users[0] if config.allowed_users else 0, + ) + agent_handler.register() + # Create bot with all dependencies dependencies = { "auth_manager": auth_manager, @@ -159,10 +186,15 @@ async def create_application(config: Settings) -> Dict[str, Any]: "audit_logger": audit_logger, "claude_integration": claude_integration, "storage": storage, + "event_bus": event_bus, } bot = ClaudeCodeBot(config, dependencies) + # Notification service and scheduler need the bot's Telegram Bot instance, + # which is only available after bot.initialize(). We store placeholders + # and wire them up in run_application() after initialization. + logger.info("Application components created successfully") return { @@ -170,6 +202,11 @@ async def create_application(config: Settings) -> Dict[str, Any]: "claude_integration": claude_integration, "storage": storage, "config": config, + "features": features, + "event_bus": event_bus, + "agent_handler": agent_handler, + "auth_manager": auth_manager, + "security_validator": security_validator, } @@ -179,11 +216,17 @@ async def run_application(app: Dict[str, Any]) -> None: bot: ClaudeCodeBot = app["bot"] claude_integration: ClaudeIntegration = app["claude_integration"] storage: Storage = app["storage"] + config: Settings = app["config"] + features: FeatureFlags = app["features"] + event_bus: EventBus = app["event_bus"] + + notification_service: Optional[NotificationService] = None + scheduler: Optional[JobScheduler] = None # Set up signal handlers for graceful shutdown shutdown_event = asyncio.Event() - def signal_handler(signum, frame): + def signal_handler(signum: int, frame: Any) -> None: logger.info("Shutdown signal received", signal=signum) shutdown_event.set() @@ -191,17 +234,72 @@ def signal_handler(signum, frame): signal.signal(signal.SIGTERM, signal_handler) try: - # Start the bot logger.info("Starting Claude Code Telegram Bot") - # Run bot in background task - bot_task = asyncio.create_task(bot.start()) - shutdown_task = asyncio.create_task(shutdown_event.wait()) + # Initialize the bot first (creates the Telegram Application) + await bot.initialize() + + # Now wire up components that need the Telegram Bot instance + telegram_bot = bot.app.bot + + # Start event bus + await event_bus.start() - # Wait for either bot completion or shutdown signal - done, pending = await asyncio.wait( - [bot_task, shutdown_task], return_when=asyncio.FIRST_COMPLETED + # Notification service + notification_service = NotificationService( + event_bus=event_bus, + bot=telegram_bot, + default_chat_ids=config.notification_chat_ids or [], ) + notification_service.register() + await notification_service.start() + + # Collect concurrent tasks + tasks = [] + + # Bot task — use start() which handles its own initialization check + bot_task = asyncio.create_task(bot.start()) + tasks.append(bot_task) + + # API server (if enabled) + if features.api_server_enabled: + from src.api.server import run_api_server + + api_task = asyncio.create_task( + run_api_server(event_bus, config, storage.db_manager) + ) + tasks.append(api_task) + logger.info("API server enabled", port=config.api_server_port) + + # Scheduler (if enabled) + if features.scheduler_enabled: + scheduler = JobScheduler( + event_bus=event_bus, + db_manager=storage.db_manager, + default_working_directory=config.approved_directory, + ) + await scheduler.start() + logger.info("Job scheduler enabled") + + # Shutdown task + shutdown_task = asyncio.create_task(shutdown_event.wait()) + tasks.append(shutdown_task) + + # Wait for any task to complete or shutdown signal + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + # Check completed tasks for exceptions + for task in done: + if task.cancelled(): + continue + exc = task.exception() + if exc is not None: + logger.error( + "Task failed", + task=task.get_name(), + error=str(exc), + error_type=type(exc).__name__, + ) # Cancel remaining tasks for task in pending: @@ -215,10 +313,15 @@ def signal_handler(signum, frame): logger.error("Application error", error=str(e)) raise finally: - # Graceful shutdown + # Ordered shutdown: scheduler -> API -> notification -> bot -> claude -> storage logger.info("Shutting down application") try: + if scheduler: + await scheduler.stop() + if notification_service: + await notification_service.stop() + await event_bus.stop() await bot.stop() await claude_integration.shutdown() await storage.close() diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py new file mode 100644 index 00000000..51892607 --- /dev/null +++ b/src/notifications/__init__.py @@ -0,0 +1,5 @@ +"""Notification service for delivering proactive agent responses.""" + +from .service import NotificationService + +__all__ = ["NotificationService"] diff --git a/src/notifications/service.py b/src/notifications/service.py new file mode 100644 index 00000000..be00c426 --- /dev/null +++ b/src/notifications/service.py @@ -0,0 +1,162 @@ +"""Notification service for delivering proactive agent responses to Telegram. + +Subscribes to AgentResponseEvent on the event bus and delivers messages +through the Telegram bot API with rate limiting (1 msg/sec per chat). +""" + +import asyncio +from typing import List, Optional + +import structlog +from telegram import Bot +from telegram.constants import ParseMode +from telegram.error import TelegramError + +from ..events.bus import Event, EventBus +from ..events.types import AgentResponseEvent + +logger = structlog.get_logger() + +# Telegram rate limit: ~30 msgs/sec globally, ~1 msg/sec per chat +SEND_INTERVAL_SECONDS = 1.1 + + +class NotificationService: + """Delivers agent responses to Telegram chats with rate limiting.""" + + def __init__( + self, + event_bus: EventBus, + bot: Bot, + default_chat_ids: Optional[List[int]] = None, + ) -> None: + self.event_bus = event_bus + self.bot = bot + self.default_chat_ids = default_chat_ids or [] + self._send_queue: asyncio.Queue[AgentResponseEvent] = asyncio.Queue() + self._last_send_per_chat: dict[int, float] = {} + self._running = False + self._sender_task: Optional[asyncio.Task[None]] = None + + def register(self) -> None: + """Subscribe to agent response events.""" + self.event_bus.subscribe(AgentResponseEvent, self.handle_response) + + async def start(self) -> None: + """Start the send queue processor.""" + if self._running: + return + self._running = True + self._sender_task = asyncio.create_task(self._process_send_queue()) + logger.info("Notification service started") + + async def stop(self) -> None: + """Stop the send queue processor.""" + if not self._running: + return + self._running = False + if self._sender_task: + self._sender_task.cancel() + try: + await self._sender_task + except asyncio.CancelledError: + pass + logger.info("Notification service stopped") + + async def handle_response(self, event: Event) -> None: + """Queue an agent response for delivery.""" + if not isinstance(event, AgentResponseEvent): + return + await self._send_queue.put(event) + + async def _process_send_queue(self) -> None: + """Process queued messages with rate limiting.""" + while self._running: + try: + event = await asyncio.wait_for(self._send_queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + + chat_ids = self._resolve_chat_ids(event) + for chat_id in chat_ids: + await self._rate_limited_send(chat_id, event) + + def _resolve_chat_ids(self, event: AgentResponseEvent) -> List[int]: + """Determine which chats to send to.""" + if event.chat_id and event.chat_id != 0: + return [event.chat_id] + return list(self.default_chat_ids) + + async def _rate_limited_send(self, chat_id: int, event: AgentResponseEvent) -> None: + """Send message with per-chat rate limiting.""" + loop = asyncio.get_event_loop() + now = loop.time() + last_send = self._last_send_per_chat.get(chat_id, 0.0) + wait_time = SEND_INTERVAL_SECONDS - (now - last_send) + + if wait_time > 0: + await asyncio.sleep(wait_time) + + try: + # Split long messages (Telegram limit: 4096 chars) + text = event.text + chunks = self._split_message(text) + + for chunk in chunks: + await self.bot.send_message( + chat_id=chat_id, + text=chunk, + parse_mode=( + ParseMode.MARKDOWN if event.parse_mode == "Markdown" else None + ), + ) + self._last_send_per_chat[chat_id] = asyncio.get_event_loop().time() + + # Rate limit between chunks too + if len(chunks) > 1: + await asyncio.sleep(SEND_INTERVAL_SECONDS) + + logger.info( + "Notification sent", + chat_id=chat_id, + text_length=len(text), + chunks=len(chunks), + originating_event=event.originating_event_id, + ) + except TelegramError as e: + logger.error( + "Failed to send notification", + chat_id=chat_id, + error=str(e), + event_id=event.id, + ) + + def _split_message(self, text: str, max_length: int = 4096) -> List[str]: + """Split long messages at paragraph boundaries.""" + if len(text) <= max_length: + return [text] + + chunks: List[str] = [] + while text: + if len(text) <= max_length: + chunks.append(text) + break + + # Try to split at a paragraph boundary + split_pos = text.rfind("\n\n", 0, max_length) + if split_pos == -1: + # Try single newline + split_pos = text.rfind("\n", 0, max_length) + if split_pos == -1: + # Try space + split_pos = text.rfind(" ", 0, max_length) + if split_pos == -1: + # Hard split + split_pos = max_length + + chunks.append(text[:split_pos]) + text = text[split_pos:].lstrip() + + return chunks diff --git a/src/scheduler/__init__.py b/src/scheduler/__init__.py new file mode 100644 index 00000000..9f1c7a52 --- /dev/null +++ b/src/scheduler/__init__.py @@ -0,0 +1,5 @@ +"""Job scheduler for recurring agent tasks.""" + +from .scheduler import JobScheduler + +__all__ = ["JobScheduler"] diff --git a/src/scheduler/scheduler.py b/src/scheduler/scheduler.py new file mode 100644 index 00000000..98d90a55 --- /dev/null +++ b/src/scheduler/scheduler.py @@ -0,0 +1,246 @@ +"""Job scheduler for recurring agent tasks. + +Wraps APScheduler's AsyncIOScheduler and publishes ScheduledEvents +to the event bus when jobs fire. +""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +import structlog +from apscheduler.schedulers.asyncio import ( + AsyncIOScheduler, # type: ignore[import-untyped] +) +from apscheduler.triggers.cron import CronTrigger # type: ignore[import-untyped] + +from ..events.bus import EventBus +from ..events.types import ScheduledEvent +from ..storage.database import DatabaseManager + +logger = structlog.get_logger() + + +class JobScheduler: + """Cron scheduler that publishes ScheduledEvents to the event bus.""" + + def __init__( + self, + event_bus: EventBus, + db_manager: DatabaseManager, + default_working_directory: Path, + ) -> None: + self.event_bus = event_bus + self.db_manager = db_manager + self.default_working_directory = default_working_directory + self._scheduler = AsyncIOScheduler() + + async def start(self) -> None: + """Load persisted jobs and start the scheduler.""" + await self._load_jobs_from_db() + self._scheduler.start() + logger.info("Job scheduler started") + + async def stop(self) -> None: + """Shutdown the scheduler gracefully.""" + self._scheduler.shutdown(wait=False) + logger.info("Job scheduler stopped") + + async def add_job( + self, + job_name: str, + cron_expression: str, + prompt: str, + target_chat_ids: Optional[List[int]] = None, + working_directory: Optional[Path] = None, + skill_name: Optional[str] = None, + created_by: int = 0, + ) -> str: + """Add a new scheduled job. + + Args: + job_name: Human-readable job name. + cron_expression: Cron-style schedule (e.g. "0 9 * * 1-5"). + prompt: The prompt to send to Claude when the job fires. + target_chat_ids: Telegram chat IDs to send the response to. + working_directory: Working directory for Claude execution. + skill_name: Optional skill to invoke. + created_by: Telegram user ID of the creator. + + Returns: + The job ID. + """ + trigger = CronTrigger.from_crontab(cron_expression) + work_dir = working_directory or self.default_working_directory + + job = self._scheduler.add_job( + self._fire_event, + trigger=trigger, + kwargs={ + "job_name": job_name, + "prompt": prompt, + "working_directory": str(work_dir), + "target_chat_ids": target_chat_ids or [], + "skill_name": skill_name, + }, + name=job_name, + ) + + # Persist to database + await self._save_job( + job_id=job.id, + job_name=job_name, + cron_expression=cron_expression, + prompt=prompt, + target_chat_ids=target_chat_ids or [], + working_directory=str(work_dir), + skill_name=skill_name, + created_by=created_by, + ) + + logger.info( + "Scheduled job added", + job_id=job.id, + job_name=job_name, + cron=cron_expression, + ) + return str(job.id) + + async def remove_job(self, job_id: str) -> bool: + """Remove a scheduled job.""" + try: + self._scheduler.remove_job(job_id) + except Exception: + logger.warning("Job not found in scheduler", job_id=job_id) + + await self._delete_job(job_id) + logger.info("Scheduled job removed", job_id=job_id) + return True + + async def list_jobs(self) -> List[Dict[str, Any]]: + """List all scheduled jobs from the database.""" + async with self.db_manager.get_connection() as conn: + cursor = await conn.execute( + "SELECT * FROM scheduled_jobs WHERE is_active = 1 ORDER BY created_at" + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + + async def _fire_event( + self, + job_name: str, + prompt: str, + working_directory: str, + target_chat_ids: List[int], + skill_name: Optional[str], + ) -> None: + """Called by APScheduler when a job triggers. Publishes a ScheduledEvent.""" + event = ScheduledEvent( + job_name=job_name, + prompt=prompt, + working_directory=Path(working_directory), + target_chat_ids=target_chat_ids, + skill_name=skill_name, + ) + + logger.info( + "Scheduled job fired", + job_name=job_name, + event_id=event.id, + ) + + await self.event_bus.publish(event) + + async def _load_jobs_from_db(self) -> None: + """Load persisted jobs and re-register them with APScheduler.""" + try: + async with self.db_manager.get_connection() as conn: + cursor = await conn.execute( + "SELECT * FROM scheduled_jobs WHERE is_active = 1" + ) + rows = list(await cursor.fetchall()) + + for row in rows: + row_dict = dict(row) + try: + trigger = CronTrigger.from_crontab(row_dict["cron_expression"]) + + # Parse target_chat_ids from stored string + chat_ids_str = row_dict.get("target_chat_ids", "") + chat_ids = ( + [int(x) for x in chat_ids_str.split(",") if x.strip()] + if chat_ids_str + else [] + ) + + self._scheduler.add_job( + self._fire_event, + trigger=trigger, + kwargs={ + "job_name": row_dict["job_name"], + "prompt": row_dict["prompt"], + "working_directory": row_dict["working_directory"], + "target_chat_ids": chat_ids, + "skill_name": row_dict.get("skill_name"), + }, + id=row_dict["job_id"], + name=row_dict["job_name"], + replace_existing=True, + ) + logger.debug( + "Loaded scheduled job from DB", + job_id=row_dict["job_id"], + job_name=row_dict["job_name"], + ) + except Exception: + logger.exception( + "Failed to load scheduled job", + job_id=row_dict.get("job_id"), + ) + + logger.info("Loaded scheduled jobs from database", count=len(rows)) + except Exception: + # Table might not exist yet on first run + logger.debug("No scheduled_jobs table found, starting fresh") + + async def _save_job( + self, + job_id: str, + job_name: str, + cron_expression: str, + prompt: str, + target_chat_ids: List[int], + working_directory: str, + skill_name: Optional[str], + created_by: int, + ) -> None: + """Persist a job definition to the database.""" + chat_ids_str = ",".join(str(cid) for cid in target_chat_ids) + async with self.db_manager.get_connection() as conn: + await conn.execute( + """ + INSERT OR REPLACE INTO scheduled_jobs + (job_id, job_name, cron_expression, prompt, target_chat_ids, + working_directory, skill_name, created_by, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1) + """, + ( + job_id, + job_name, + cron_expression, + prompt, + chat_ids_str, + working_directory, + skill_name, + created_by, + ), + ) + await conn.commit() + + async def _delete_job(self, job_id: str) -> None: + """Soft-delete a job from the database.""" + async with self.db_manager.get_connection() as conn: + await conn.execute( + "UPDATE scheduled_jobs SET is_active = 0 WHERE job_id = ?", + (job_id,), + ) + await conn.commit() diff --git a/src/storage/database.py b/src/storage/database.py index 85813bf2..323ff737 100644 --- a/src/storage/database.py +++ b/src/storage/database.py @@ -207,7 +207,7 @@ def _get_migrations(self) -> List[Tuple[int, str]]: """ -- Add analytics views CREATE VIEW IF NOT EXISTS daily_stats AS - SELECT + SELECT date(timestamp) as date, COUNT(DISTINCT user_id) as active_users, COUNT(*) as total_messages, @@ -217,7 +217,7 @@ def _get_migrations(self) -> List[Tuple[int, str]]: GROUP BY date(timestamp); CREATE VIEW IF NOT EXISTS user_stats AS - SELECT + SELECT u.user_id, u.telegram_username, COUNT(DISTINCT s.session_id) as total_sessions, @@ -230,6 +230,49 @@ def _get_migrations(self) -> List[Tuple[int, str]]: GROUP BY u.user_id; """, ), + ( + 3, + """ + -- Agentic platform tables + + -- Scheduled jobs for recurring agent tasks + CREATE TABLE IF NOT EXISTS scheduled_jobs ( + job_id TEXT PRIMARY KEY, + job_name TEXT NOT NULL, + cron_expression TEXT NOT NULL, + prompt TEXT NOT NULL, + target_chat_ids TEXT DEFAULT '', + working_directory TEXT NOT NULL, + skill_name TEXT, + created_by INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Webhook events for deduplication and audit + CREATE TABLE IF NOT EXISTS webhook_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + provider TEXT NOT NULL, + event_type TEXT NOT NULL, + delivery_id TEXT UNIQUE, + payload JSON, + processed BOOLEAN DEFAULT FALSE, + received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_webhook_events_delivery + ON webhook_events(delivery_id); + CREATE INDEX IF NOT EXISTS idx_webhook_events_provider + ON webhook_events(provider, received_at); + CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_active + ON scheduled_jobs(is_active); + + -- Enable WAL mode for better concurrent write performance + PRAGMA journal_mode=WAL; + """, + ), ] async def _init_pool(self): diff --git a/tests/unit/test_api/__init__.py b/tests/unit/test_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/test_api/test_auth.py b/tests/unit/test_api/test_auth.py new file mode 100644 index 00000000..c9f05567 --- /dev/null +++ b/tests/unit/test_api/test_auth.py @@ -0,0 +1,46 @@ +"""Tests for webhook authentication.""" + +from src.api.auth import verify_github_signature, verify_shared_secret + + +class TestGitHubSignatureVerification: + """Tests for GitHub webhook HMAC-SHA256 verification.""" + + def test_valid_signature(self) -> None: + """Valid HMAC-SHA256 signature passes.""" + import hashlib + import hmac + + secret = "test-secret" + payload = b'{"action": "push"}' + sig = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + assert verify_github_signature(payload, sig, secret) is True + + def test_invalid_signature(self) -> None: + """Wrong signature is rejected.""" + assert verify_github_signature(b"payload", "sha256=wrong", "secret") is False + + def test_missing_signature(self) -> None: + """Missing signature header is rejected.""" + assert verify_github_signature(b"payload", None, "secret") is False + + def test_wrong_format(self) -> None: + """Non-sha256 format is rejected.""" + assert verify_github_signature(b"payload", "sha1=abc", "secret") is False + + +class TestSharedSecretVerification: + """Tests for shared secret Bearer token verification.""" + + def test_valid_bearer_token(self) -> None: + assert verify_shared_secret("Bearer my-secret", "my-secret") is True + + def test_invalid_token(self) -> None: + assert verify_shared_secret("Bearer wrong", "my-secret") is False + + def test_missing_header(self) -> None: + assert verify_shared_secret(None, "my-secret") is False + + def test_no_bearer_prefix(self) -> None: + assert verify_shared_secret("my-secret", "my-secret") is False diff --git a/tests/unit/test_api/test_server.py b/tests/unit/test_api/test_server.py new file mode 100644 index 00000000..bd9e1362 --- /dev/null +++ b/tests/unit/test_api/test_server.py @@ -0,0 +1,149 @@ +"""Tests for the webhook API server.""" + +import hashlib +import hmac + +from fastapi.testclient import TestClient + +from src.api.server import create_api_app +from src.events.bus import EventBus + + +def make_settings(**overrides): # type: ignore[no-untyped-def] + """Create a minimal mock settings object.""" + from unittest.mock import MagicMock + + settings = MagicMock() + settings.development_mode = True + settings.github_webhook_secret = overrides.get("github_webhook_secret", "gh-secret") + settings.webhook_api_secret = overrides.get( + "webhook_api_secret", "default-api-secret" + ) + settings.api_server_port = 8080 + settings.debug = False + return settings + + +class TestWebhookAPI: + """Tests for the FastAPI webhook endpoints.""" + + def test_health_check(self) -> None: + bus = EventBus() + app = create_api_app(bus, make_settings()) + client = TestClient(app) + + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + def test_github_webhook_valid_signature(self) -> None: + """Valid GitHub webhook is accepted and event published.""" + bus = EventBus() + secret = "gh-secret" + settings = make_settings(github_webhook_secret=secret) + app = create_api_app(bus, settings) + client = TestClient(app) + + payload = b'{"action": "opened", "number": 1}' + sig = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + response = client.post( + "/webhooks/github", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": sig, + "X-GitHub-Event": "pull_request", + "X-GitHub-Delivery": "del-123", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "accepted" + assert "event_id" in data + + def test_github_webhook_invalid_signature(self) -> None: + """Invalid GitHub signature returns 401.""" + bus = EventBus() + app = create_api_app(bus, make_settings()) + client = TestClient(app) + + response = client.post( + "/webhooks/github", + content=b'{"test": true}', + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": "sha256=invalid", + "X-GitHub-Event": "push", + }, + ) + + assert response.status_code == 401 + + def test_generic_webhook_no_secret_configured_rejected(self) -> None: + """Generic webhooks without configured secret return 500.""" + bus = EventBus() + settings = make_settings(webhook_api_secret=None) + app = create_api_app(bus, settings) + client = TestClient(app) + + response = client.post( + "/webhooks/custom", + json={"event": "test"}, + headers={"X-Event-Type": "test.event"}, + ) + + assert response.status_code == 500 + + def test_generic_webhook_with_auth(self) -> None: + """Generic webhooks with configured secret require Bearer token.""" + bus = EventBus() + settings = make_settings(webhook_api_secret="my-api-secret") + app = create_api_app(bus, settings) + client = TestClient(app) + + # Without auth + response = client.post( + "/webhooks/custom", + json={"data": "test"}, + ) + assert response.status_code == 401 + + # With valid auth + response = client.post( + "/webhooks/custom", + json={"data": "test"}, + headers={"Authorization": "Bearer my-api-secret"}, + ) + assert response.status_code == 200 + + def test_github_webhook_no_secret_configured(self) -> None: + """GitHub webhook without configured secret returns 500.""" + bus = EventBus() + settings = make_settings(github_webhook_secret=None) + app = create_api_app(bus, settings) + client = TestClient(app) + + response = client.post( + "/webhooks/github", + json={"test": True}, + headers={"X-GitHub-Event": "push"}, + ) + + assert response.status_code == 500 + + def test_generic_webhook_wrong_token_rejected(self) -> None: + """Generic webhook with wrong Bearer token returns 401.""" + bus = EventBus() + settings = make_settings(webhook_api_secret="correct-secret") + app = create_api_app(bus, settings) + client = TestClient(app) + + response = client.post( + "/webhooks/custom", + json={"data": "test"}, + headers={"Authorization": "Bearer wrong-secret"}, + ) + + assert response.status_code == 401 diff --git a/tests/unit/test_claude/test_sdk_integration.py b/tests/unit/test_claude/test_sdk_integration.py index 4303b196..3b9e2a6a 100644 --- a/tests/unit/test_claude/test_sdk_integration.py +++ b/tests/unit/test_claude/test_sdk_integration.py @@ -5,7 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, ResultMessage, TextBlock +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + TextBlock, +) from src.claude.sdk_integration import ClaudeResponse, ClaudeSDKManager, StreamUpdate from src.config.settings import Settings @@ -108,6 +113,7 @@ async def test_sdk_manager_initialization_without_api_key(self, config): async def test_execute_command_success(self, sdk_manager): """Test successful command execution.""" + async def mock_query(prompt, options): yield _make_assistant_message("Test response") yield _make_result_message(session_id="test-session", total_cost_usd=0.05) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 6c551189..bf7c7669 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -205,7 +205,9 @@ def test_mcp_config_validation(tmp_path, monkeypatch): # Should succeed with valid MCP config config_file = tmp_path / "mcp_config.json" - config_file.write_text('{"mcpServers": {"my-server": {"command": "npx", "args": ["-y", "my-mcp-server"]}}}') + config_file.write_text( + '{"mcpServers": {"my-server": {"command": "npx", "args": ["-y", "my-mcp-server"]}}}' + ) settings = Settings( telegram_bot_token="test_token", @@ -280,7 +282,9 @@ def test_computed_properties(tmp_path): def test_feature_flags(): """Test feature flag system.""" # Create test MCP config file with valid structure before creating settings - mcp_config = '{"mcpServers": {"test-server": {"command": "echo", "args": ["hello"]}}}' + mcp_config = ( + '{"mcpServers": {"test-server": {"command": "echo", "args": ["hello"]}}}' + ) Path("/tmp/test_mcp.json").write_text(mcp_config) settings = create_test_config( diff --git a/tests/unit/test_events/__init__.py b/tests/unit/test_events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/test_events/test_bus.py b/tests/unit/test_events/test_bus.py new file mode 100644 index 00000000..9f638d7e --- /dev/null +++ b/tests/unit/test_events/test_bus.py @@ -0,0 +1,154 @@ +"""Tests for the event bus.""" + +import asyncio +from dataclasses import dataclass + +import pytest + +from src.events.bus import Event, EventBus + + +@dataclass +class TestEvent(Event): + """Test event subclass.""" + + data: str = "" + source: str = "test" + + +@dataclass +class OtherEvent(Event): + """Another test event subclass.""" + + value: int = 0 + source: str = "test" + + +class TestEventBus: + """Tests for EventBus.""" + + async def test_publish_and_subscribe(self) -> None: + """Events are delivered to matching handlers.""" + bus = EventBus() + received = [] + + async def handler(event: Event) -> None: + received.append(event) + + bus.subscribe(TestEvent, handler) + await bus.start() + + event = TestEvent(data="hello") + await bus.publish(event) + + # Give the processor time to dispatch + await asyncio.sleep(0.1) + await bus.stop() + + assert len(received) == 1 + assert isinstance(received[0], TestEvent) + assert received[0].data == "hello" + + async def test_handler_receives_only_subscribed_type(self) -> None: + """Handler only receives events of the subscribed type.""" + bus = EventBus() + received_test = [] + received_other = [] + + async def test_handler(event: Event) -> None: + received_test.append(event) + + async def other_handler(event: Event) -> None: + received_other.append(event) + + bus.subscribe(TestEvent, test_handler) + bus.subscribe(OtherEvent, other_handler) + await bus.start() + + await bus.publish(TestEvent(data="a")) + await bus.publish(OtherEvent(value=42)) + + await asyncio.sleep(0.1) + await bus.stop() + + assert len(received_test) == 1 + assert len(received_other) == 1 + assert received_test[0].data == "a" + assert received_other[0].value == 42 + + async def test_global_handler_receives_all(self) -> None: + """Global handlers receive every event.""" + bus = EventBus() + received = [] + + async def global_handler(event: Event) -> None: + received.append(event) + + bus.subscribe_all(global_handler) + await bus.start() + + await bus.publish(TestEvent(data="x")) + await bus.publish(OtherEvent(value=1)) + + await asyncio.sleep(0.1) + await bus.stop() + + assert len(received) == 2 + + async def test_handler_error_does_not_crash_bus(self) -> None: + """A failing handler doesn't prevent other handlers from running.""" + bus = EventBus() + received = [] + + async def bad_handler(event: Event) -> None: + raise RuntimeError("boom") + + async def good_handler(event: Event) -> None: + received.append(event) + + bus.subscribe(TestEvent, bad_handler) + bus.subscribe(TestEvent, good_handler) + await bus.start() + + await bus.publish(TestEvent(data="test")) + await asyncio.sleep(0.1) + await bus.stop() + + # Good handler still receives the event + assert len(received) == 1 + + async def test_event_has_id_and_timestamp(self) -> None: + """Events get auto-generated ID and timestamp.""" + event = TestEvent(data="hi") + assert event.id + assert event.timestamp + assert event.event_type == "TestEvent" + + async def test_multiple_handlers_for_same_type(self) -> None: + """Multiple handlers can subscribe to the same event type.""" + bus = EventBus() + results = [] + + async def handler_a(event: Event) -> None: + results.append("a") + + async def handler_b(event: Event) -> None: + results.append("b") + + bus.subscribe(TestEvent, handler_a) + bus.subscribe(TestEvent, handler_b) + await bus.start() + + await bus.publish(TestEvent()) + await asyncio.sleep(0.1) + await bus.stop() + + assert "a" in results + assert "b" in results + + async def test_stop_is_idempotent(self) -> None: + """Stopping an already stopped bus doesn't raise.""" + bus = EventBus() + await bus.start() + await bus.stop() + await bus.stop() # Should not raise diff --git a/tests/unit/test_events/test_handlers.py b/tests/unit/test_events/test_handlers.py new file mode 100644 index 00000000..bec2da90 --- /dev/null +++ b/tests/unit/test_events/test_handlers.py @@ -0,0 +1,161 @@ +"""Tests for event handlers.""" + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.events.bus import EventBus +from src.events.handlers import AgentHandler +from src.events.types import AgentResponseEvent, ScheduledEvent, WebhookEvent + + +@pytest.fixture +def event_bus() -> EventBus: + return EventBus() + + +@pytest.fixture +def mock_claude() -> AsyncMock: + mock = AsyncMock() + mock.run_command = AsyncMock() + return mock + + +@pytest.fixture +def agent_handler(event_bus: EventBus, mock_claude: AsyncMock) -> AgentHandler: + handler = AgentHandler( + event_bus=event_bus, + claude_integration=mock_claude, + default_working_directory=Path("/tmp/test"), + default_user_id=42, + ) + handler.register() + return handler + + +class TestAgentHandler: + """Tests for AgentHandler.""" + + async def test_webhook_event_triggers_claude( + self, event_bus: EventBus, mock_claude: AsyncMock, agent_handler: AgentHandler + ) -> None: + """Webhook events are processed through Claude.""" + mock_response = MagicMock() + mock_response.content = "Analysis complete" + mock_claude.run_command.return_value = mock_response + + published: list = [] + original_publish = event_bus.publish + + async def capture_publish(event): # type: ignore[no-untyped-def] + published.append(event) + await original_publish(event) + + event_bus.publish = capture_publish # type: ignore[assignment] + + event = WebhookEvent( + provider="github", + event_type_name="push", + payload={"ref": "refs/heads/main"}, + delivery_id="del-1", + ) + + await agent_handler.handle_webhook(event) + + mock_claude.run_command.assert_called_once() + call_kwargs = mock_claude.run_command.call_args + assert "github" in call_kwargs.kwargs["prompt"].lower() + + # Should publish an AgentResponseEvent + response_events = [e for e in published if isinstance(e, AgentResponseEvent)] + assert len(response_events) == 1 + assert response_events[0].text == "Analysis complete" + + async def test_scheduled_event_triggers_claude( + self, event_bus: EventBus, mock_claude: AsyncMock, agent_handler: AgentHandler + ) -> None: + """Scheduled events invoke Claude with the job's prompt.""" + mock_response = MagicMock() + mock_response.content = "Standup summary" + mock_claude.run_command.return_value = mock_response + + published: list = [] + original_publish = event_bus.publish + + async def capture_publish(event): # type: ignore[no-untyped-def] + published.append(event) + await original_publish(event) + + event_bus.publish = capture_publish # type: ignore[assignment] + + event = ScheduledEvent( + job_name="standup", + prompt="Generate daily standup", + target_chat_ids=[100], + ) + + await agent_handler.handle_scheduled(event) + + mock_claude.run_command.assert_called_once() + assert "standup" in mock_claude.run_command.call_args.kwargs["prompt"].lower() + + response_events = [e for e in published if isinstance(e, AgentResponseEvent)] + assert len(response_events) == 1 + assert response_events[0].chat_id == 100 + + async def test_scheduled_event_with_skill( + self, event_bus: EventBus, mock_claude: AsyncMock, agent_handler: AgentHandler + ) -> None: + """Scheduled events with skill_name prepend the skill invocation.""" + mock_response = MagicMock() + mock_response.content = "Done" + mock_claude.run_command.return_value = mock_response + + event = ScheduledEvent( + job_name="standup", + prompt="morning report", + skill_name="daily-standup", + target_chat_ids=[100], + ) + + await agent_handler.handle_scheduled(event) + + prompt = mock_claude.run_command.call_args.kwargs["prompt"] + assert prompt.startswith("/daily-standup") + assert "morning report" in prompt + + async def test_claude_error_does_not_propagate( + self, event_bus: EventBus, mock_claude: AsyncMock, agent_handler: AgentHandler + ) -> None: + """Agent errors are logged but don't crash the handler.""" + mock_claude.run_command.side_effect = RuntimeError("SDK error") + + event = WebhookEvent( + provider="github", + event_type_name="push", + payload={}, + ) + + # Should not raise + await agent_handler.handle_webhook(event) + + def test_build_webhook_prompt(self, agent_handler: AgentHandler) -> None: + """Webhook prompt includes provider and event info.""" + event = WebhookEvent( + provider="github", + event_type_name="pull_request", + payload={"action": "opened", "number": 42}, + ) + + prompt = agent_handler._build_webhook_prompt(event) + assert "github" in prompt.lower() + assert "pull_request" in prompt + assert "action: opened" in prompt + + def test_payload_summary_truncation(self, agent_handler: AgentHandler) -> None: + """Large payloads are truncated in the summary.""" + big_payload = {"key": "x" * 3000} + summary = agent_handler._summarize_payload(big_payload) + assert len(summary) <= 2100 # 2000 + truncation message diff --git a/tests/unit/test_events/test_types.py b/tests/unit/test_events/test_types.py new file mode 100644 index 00000000..b1e79a97 --- /dev/null +++ b/tests/unit/test_events/test_types.py @@ -0,0 +1,63 @@ +"""Tests for event types.""" + +from pathlib import Path + +from src.events.types import ( + AgentResponseEvent, + ScheduledEvent, + UserMessageEvent, + WebhookEvent, +) + + +class TestEventTypes: + """Tests for concrete event dataclasses.""" + + def test_user_message_event_defaults(self) -> None: + event = UserMessageEvent(user_id=123, chat_id=456, text="hello") + assert event.source == "telegram" + assert event.user_id == 123 + assert event.chat_id == 456 + assert event.text == "hello" + assert event.event_type == "UserMessageEvent" + + def test_webhook_event_defaults(self) -> None: + event = WebhookEvent( + provider="github", + event_type_name="push", + payload={"ref": "refs/heads/main"}, + delivery_id="abc-123", + ) + assert event.source == "webhook" + assert event.provider == "github" + assert event.payload["ref"] == "refs/heads/main" + + def test_scheduled_event_defaults(self) -> None: + event = ScheduledEvent( + job_id="j1", + job_name="daily-standup", + prompt="Generate standup", + target_chat_ids=[100, 200], + ) + assert event.source == "scheduler" + assert event.target_chat_ids == [100, 200] + + def test_agent_response_event(self) -> None: + event = AgentResponseEvent( + chat_id=789, + text="Here's your summary", + originating_event_id="orig-1", + ) + assert event.source == "agent" + assert event.parse_mode == "Markdown" + assert event.originating_event_id == "orig-1" + + def test_scheduled_event_with_skill(self) -> None: + event = ScheduledEvent( + job_name="standup", + prompt="", + skill_name="daily-standup", + working_directory=Path("/projects/myapp"), + ) + assert event.skill_name == "daily-standup" + assert event.working_directory == Path("/projects/myapp") diff --git a/tests/unit/test_notifications/__init__.py b/tests/unit/test_notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/test_notifications/test_service.py b/tests/unit/test_notifications/test_service.py new file mode 100644 index 00000000..bee577d9 --- /dev/null +++ b/tests/unit/test_notifications/test_service.py @@ -0,0 +1,104 @@ +"""Tests for the notification service.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.events.bus import EventBus +from src.events.types import AgentResponseEvent +from src.notifications.service import NotificationService + + +@pytest.fixture +def event_bus() -> EventBus: + return EventBus() + + +@pytest.fixture +def mock_bot() -> AsyncMock: + bot = AsyncMock() + bot.send_message = AsyncMock() + return bot + + +@pytest.fixture +def service(event_bus: EventBus, mock_bot: AsyncMock) -> NotificationService: + svc = NotificationService( + event_bus=event_bus, + bot=mock_bot, + default_chat_ids=[100, 200], + ) + svc.register() + return svc + + +class TestNotificationService: + """Tests for NotificationService.""" + + async def test_handle_response_queues_event( + self, service: NotificationService + ) -> None: + """Events are queued for delivery.""" + event = AgentResponseEvent(chat_id=100, text="hello") + await service.handle_response(event) + assert service._send_queue.qsize() == 1 + + async def test_resolve_chat_ids_specific( + self, service: NotificationService + ) -> None: + """Specific chat_id takes precedence over defaults.""" + event = AgentResponseEvent(chat_id=999, text="test") + ids = service._resolve_chat_ids(event) + assert ids == [999] + + async def test_resolve_chat_ids_default(self, service: NotificationService) -> None: + """chat_id=0 falls back to default chat IDs.""" + event = AgentResponseEvent(chat_id=0, text="test") + ids = service._resolve_chat_ids(event) + assert ids == [100, 200] + + def test_split_message_short(self, service: NotificationService) -> None: + """Short messages are not split.""" + chunks = service._split_message("short text") + assert len(chunks) == 1 + assert chunks[0] == "short text" + + def test_split_message_long(self, service: NotificationService) -> None: + """Long messages are split at boundaries.""" + text = "A" * 4000 + "\n\n" + "B" * 200 + chunks = service._split_message(text, max_length=4096) + assert len(chunks) >= 1 + # All content preserved + total_len = sum(len(c) for c in chunks) + assert total_len > 0 + + def test_split_message_no_boundary(self, service: NotificationService) -> None: + """Messages without boundaries are hard-split.""" + text = "A" * 5000 # No newlines or spaces + chunks = service._split_message(text, max_length=4096) + assert len(chunks) == 2 + assert len(chunks[0]) == 4096 + assert len(chunks[1]) == 904 + + async def test_send_to_telegram( + self, service: NotificationService, mock_bot: AsyncMock + ) -> None: + """Messages are sent via the Telegram bot.""" + event = AgentResponseEvent(chat_id=123, text="hello world") + await service._rate_limited_send(123, event) + + mock_bot.send_message.assert_called_once() + call_kwargs = mock_bot.send_message.call_args.kwargs + assert call_kwargs["chat_id"] == 123 + assert call_kwargs["text"] == "hello world" + + async def test_ignores_non_response_events( + self, service: NotificationService + ) -> None: + """Non-AgentResponseEvent events are ignored.""" + from src.events.bus import Event + + event = Event(source="test") + await service.handle_response(event) + assert service._send_queue.qsize() == 0 diff --git a/tests/unit/test_scheduler/__init__.py b/tests/unit/test_scheduler/__init__.py new file mode 100644 index 00000000..e69de29b