diff --git a/.env.dev b/.env.dev index e225f8b8..e7257740 100644 --- a/.env.dev +++ b/.env.dev @@ -10,11 +10,18 @@ NICKNAME=["真寻", "小真寻", "绪山真寻", "小寻子"] SESSION_EXPIRE_TIMEOUT=30 +PLATFORM_SUPERUSERS = ' + { + "qq": [""], + "dodo": [""] + } +' + # DRIVER=~fastapi DRIVER=~fastapi+~httpx+~websockets # kook adapter toekn -kaiheila_bots =[{""}] +kaiheila_bots =[{"token": ""}] # discode adapter DISCORD_BOTS=' @@ -41,6 +48,8 @@ DODO_BOTS=' ] ' +# application_commands的{"*": ["*"]}代表将全部应用命令注册为全局应用命令 +# {"admin": ["123", "456"]}则代表将admin命令注册为id是123、456服务器的局部命令,其余命令不注册 LOG_LEVEL=DEBUG # 服务器和端口 diff --git a/.vscode/settings.json b/.vscode/settings.json index 784fa6a0..b23be7bc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,9 +7,15 @@ "Alconna", "arclet", "Arparma", + "displayname", "getbbox", "httpx", + "kaiheila", "nonebot", + "onebot", + "tobytes", + "userinfo", "zhenxun" - ] -} \ No newline at end of file + ], + "python.analysis.autoImportCompletions": true +} diff --git a/poetry.lock b/poetry.lock index f30884aa..58022200 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,21 @@ # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +[[package]] +name = "aiofiles" +version = "23.2.1" +description = "File support for asyncio." +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107"}, + {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, +] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "aiosqlite" version = "0.17.0" @@ -267,6 +283,22 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "cachetools" +version = "5.3.2" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, + {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, +] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "cashews" version = "6.4.0" @@ -534,6 +566,25 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "emoji" +version = "2.10.1" +description = "Emoji for Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "emoji-2.10.1-py2.py3-none-any.whl", hash = "sha256:11fb369ea79d20c14efa4362c732d67126df294a7959a2c98bfd7447c12a218e"}, + {file = "emoji-2.10.1.tar.gz", hash = "sha256:16287283518fb7141bde00198f9ffff4e1c1cb570efb68b2f1ec50975c3a581d"}, +] + +[package.extras] +dev = ["coverage", "coveralls", "pytest"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "exceptiongroup" version = "1.2.0" @@ -553,6 +604,30 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "fastapi" +version = "0.109.2" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.109.2-py3-none-any.whl", hash = "sha256:2c9bab24667293b501cad8dd388c05240c850b58ec5876ee3283c47d6e1e3a4d"}, + {file = "fastapi-0.109.2.tar.gz", hash = "sha256:f3817eac96fe4f65a2ebb4baa000f394e55f5fccdaf7f75250804bc58f354f73"}, +] + +[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.36.3,<0.37.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "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)"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "filelock" version = "3.13.1" @@ -574,21 +649,6 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" -[[package]] -name = "fleep" -version = "1.0.1" -description = "File format determination library" -optional = false -python-versions = ">=3.1" -files = [ - {file = "fleep-1.0.1.tar.gz", hash = "sha256:c8f62b258ee5364d7f6c1ed1f3f278e99020fc3f0a60a24ad1e10846e31d104c"}, -] - -[package.source] -type = "legacy" -url = "https://mirrors.aliyun.com/pypi/simple" -reference = "ali" - [[package]] name = "greenlet" version = "3.0.3" @@ -707,6 +767,59 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "httptools" +version = "0.6.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"}, + {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"}, + {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"}, + {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"}, + {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"}, + {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"}, + {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"}, + {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"}, + {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"}, + {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"}, + {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"}, + {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"}, + {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"}, + {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"}, + {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"}, + {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"}, + {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"}, + {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"}, + {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"}, + {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"}, + {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"}, + {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"}, +] + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "httpx" version = "0.26.0" @@ -813,6 +926,26 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "markdown" +version = "3.5.2" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, + {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, +] + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1223,19 +1356,18 @@ reference = "ali" [[package]] name = "nonebot-plugin-alconna" -version = "0.36.0" +version = "0.36.3" description = "Alconna Adapter for Nonebot" optional = false python-versions = ">=3.8" files = [ - {file = "nonebot_plugin_alconna-0.36.0-py3-none-any.whl", hash = "sha256:37f8afc272924802fe75146df5f68b44e8e5537420cbb983d2d9d65195e625e7"}, - {file = "nonebot_plugin_alconna-0.36.0.tar.gz", hash = "sha256:e524fac76ee0f1a08817007e649c2b491b44094e0262a3d36fcef3e1259edfa2"}, + {file = "nonebot_plugin_alconna-0.36.3-py3-none-any.whl", hash = "sha256:8f26f96c711d3adadc538ebf40d51ba2249c18fe1689bf36baed0e4d1e05246a"}, + {file = "nonebot_plugin_alconna-0.36.3.tar.gz", hash = "sha256:ed8e4f2fd845d0c3d8becdd68678c203ee76109b9104a3b1c18f63525e85c6d4"}, ] [package.dependencies] -arclet-alconna = ">=1.7.38,<2.0.0" -arclet-alconna-tools = ">=0.6.7,<0.7.0" -fleep = ">=1.0.1" +arclet-alconna = ">=1.7.42,<2.0.0" +arclet-alconna-tools = ">=0.6.11,<0.7.0" nepattern = ">=0.5.14,<0.6.0" nonebot2 = ">=2.1.0" @@ -1264,6 +1396,32 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "nonebot-plugin-htmlrender" +version = "0.3.0" +description = "通过浏览器渲染图片" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "nonebot_plugin_htmlrender-0.3.0-py3-none-any.whl", hash = "sha256:c05588bad4738421a49a47a7db974359adeb624c1ed6af49d6237023fa014bcf"}, + {file = "nonebot_plugin_htmlrender-0.3.0.tar.gz", hash = "sha256:34b4ff5b898ea47480d3488a2a0b01c46e0ca3d938ab4b891d1db91a70d83d2d"}, +] + +[package.dependencies] +aiofiles = ">=0.8.0" +jinja2 = ">=3.0.3" +markdown = ">=3.3.6" +nonebot2 = {version = ">=2.2.0", extras = ["fastapi"]} +playwright = ">=1.17.2" +Pygments = ">=2.10.0" +pymdown-extensions = ">=9.1" +python-markdown-math = ">=0.8" + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "nonebot-plugin-send-anything-anywhere" version = "0.5.0" @@ -1306,23 +1464,49 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "nonebot-plugin-userinfo" +version = "0.1.3" +description = "Nonebot2 用户信息获取插件" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "nonebot_plugin_userinfo-0.1.3-py3-none-any.whl", hash = "sha256:e20b22c81e86e81f7953560bd8ce0a54559a87ad615358c613b78cb5a4918191"}, + {file = "nonebot_plugin_userinfo-0.1.3.tar.gz", hash = "sha256:d0a4d64c612486df63cd16950446072f8dfd2063ea28f15d56305a585a6b0b6e"}, +] + +[package.dependencies] +cachetools = ">=5.0.0,<6.0.0" +emoji = ">=2.0.0,<3.0.0" +httpx = ">=0.20.0,<1.0.0" +nonebot2 = {version = ">=2.0.0,<3.0.0", extras = ["fastapi"]} +strenum = ">=0.4.8,<0.5.0" + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "nonebot2" -version = "2.1.3" +version = "2.2.0" description = "An asynchronous python bot framework." optional = false python-versions = ">=3.8,<4.0" files = [ - {file = "nonebot2-2.1.3-py3-none-any.whl", hash = "sha256:c36c1a60ce4355d9777fee431c08619f22ffd60f7060993fbbbd1fe67b6368f7"}, - {file = "nonebot2-2.1.3.tar.gz", hash = "sha256:e750e615f1ad2503721ce055fbe55ec3b061277135d995be112fecd27f7232e5"}, + {file = "nonebot2-2.2.0-py3-none-any.whl", hash = "sha256:447fa63d384414c0e610f4ce6d2b3999db81ac2becd8d86716c4117013dc032f"}, + {file = "nonebot2-2.2.0.tar.gz", hash = "sha256:138800846fa3dc635bda9f2ddc589519ee8d9d3b401013fbb95e47676fc830fb"}, ] [package.dependencies] +fastapi = {version = ">=0.93.0,<1.0.0", optional = true, markers = "extra == \"fastapi\" or extra == \"all\""} loguru = ">=0.6.0,<1.0.0" -pydantic = {version = ">=1.10.0,<2.0.0", extras = ["dotenv"]} +pydantic = ">=1.10.0,<2.5.0 || >2.5.0,<2.5.1 || >2.5.1,<3.0.0" pygtrie = ">=2.4.1,<3.0.0" +python-dotenv = ">=0.21.0,<2.0.0" tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.4.0,<5.0.0" +uvicorn = {version = ">=0.20.0,<1.0.0", extras = ["standard"], optional = true, markers = "extra == \"quart\" or extra == \"fastapi\" or extra == \"all\""} yarl = ">=1.7.2,<2.0.0" [package.extras] @@ -1551,7 +1735,6 @@ files = [ ] [package.dependencies] -python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""} typing-extensions = ">=4.2.0" [package.extras] @@ -1637,6 +1820,29 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "pymdown-extensions" +version = "10.7" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pymdown_extensions-10.7-py3-none-any.whl", hash = "sha256:6ca215bc57bc12bf32b414887a68b810637d039124ed9b2e5bd3325cbb2c050c"}, + {file = "pymdown_extensions-10.7.tar.gz", hash = "sha256:c0d64d5cf62566f59e6b2b690a4095c931107c250a8c8e1351c1de5f6b036deb"}, +] + +[package.dependencies] +markdown = ">=3.5" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "pypika-tortoise" version = "0.1.6" @@ -1691,6 +1897,25 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "python-markdown-math" +version = "0.8" +description = "Math extension for Python-Markdown" +optional = false +python-versions = ">=3.6" +files = [ + {file = "python-markdown-math-0.8.tar.gz", hash = "sha256:8564212af679fc18d53f38681f16080fcd3d186073f23825c7ce86fadd3e3635"}, + {file = "python_markdown_math-0.8-py3-none-any.whl", hash = "sha256:c685249d84b5b697e9114d7beb352bd8ca2e07fd268fd4057ffca888c14641e5"}, +] + +[package.dependencies] +Markdown = ">=3.0" + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "python-slugify" version = "8.0.2" @@ -1820,6 +2045,25 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "retrying" +version = "1.3.4" +description = "Retrying" +optional = false +python-versions = "*" +files = [ + {file = "retrying-1.3.4-py3-none-any.whl", hash = "sha256:8cc4d43cb8e1125e0ff3344e9de678fefd85db3b750b81b2240dc0183af37b35"}, + {file = "retrying-1.3.4.tar.gz", hash = "sha256:345da8c5765bd982b1d1915deb9102fd3d1f7ad16bd84a9700b85f64d24e8f3e"}, +] + +[package.dependencies] +six = ">=1.7.0" + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "rich" version = "13.7.0" @@ -1962,6 +2206,28 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "starlette" +version = "0.36.3" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.36.3-py3-none-any.whl", hash = "sha256:13d429aa93a61dc40bf503e8c801db1f1bca3dc706b10ef2434a36123568f044"}, + {file = "starlette-0.36.3.tar.gz", hash = "sha256:90a671733cfb35771d8cc605e0b679d23b992f8dcfad48cc60b38cb29aeb7080"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "strenum" version = "0.4.15" @@ -2287,6 +2553,86 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "uvicorn" +version = "0.27.1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"}, + {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", 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.14.0,<0.15.0 || >0.15.0,<0.15.1 || >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)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + +[[package]] +name = "uvloop" +version = "0.19.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"}, + {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"}, + {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"}, + {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"}, + {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"}, + {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"}, + {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"}, + {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"}, + {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"}, + {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"}, + {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"}, + {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"}, + {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"}, + {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"}, + {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"}, + {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"}, + {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"}, +] + +[package.extras] +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 = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "virtualenv" version = "20.25.0" @@ -2420,6 +2766,92 @@ type = "legacy" url = "https://mirrors.aliyun.com/pypi/simple" reference = "ali" +[[package]] +name = "websockets" +version = "12.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, + {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, + {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, + {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, + {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, + {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, + {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, + {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, + {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, + {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, + {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, + {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, + {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, + {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, + {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, + {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, + {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, + {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, + {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, + {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, + {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, + {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, + {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, + {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, + {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, + {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, + {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, + {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, + {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, + {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, + {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, + {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, + {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, + {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, + {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, + {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, + {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, + {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, + {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, + {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, + {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, + {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, +] + +[package.source] +type = "legacy" +url = "https://mirrors.aliyun.com/pypi/simple" +reference = "ali" + [[package]] name = "win32-setctime" version = "1.1.0" @@ -2550,4 +2982,4 @@ reference = "ali" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "bc2932cc9955e05badaaf34f0bda8031edd80d3a832ccd05f9c079fadc4c5cdf" +content-hash = "2e5c4963196533949601dff69762b6f5586056a8775419c2ee1aef0df91b016a" diff --git a/pyproject.toml b/pyproject.toml index b79c47c6..41f7f799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ nonebot2 = "^2.1.3" nonebot-adapter-discord = "^0.1.3" nonebot-adapter-dodo = "^0.1.4" pillow = "9.5" +retrying = "^1.3.4" +aiofiles = "^23.2.1" +nonebot-plugin-htmlrender = "^0.3.0" +nonebot-plugin-userinfo = "^0.1.3" [tool.poetry.dev-dependencies] diff --git a/resources/image/sign/sign_res/bar.png b/resources/image/sign/sign_res/bar.png index 4fc99ec3..18b898d1 100644 Binary files a/resources/image/sign/sign_res/bar.png and b/resources/image/sign/sign_res/bar.png differ diff --git a/resources/image/sign/sign_res/bar_white.png b/resources/image/sign/sign_res/bar_white.png index c09c4635..2f3bcae4 100644 Binary files a/resources/image/sign/sign_res/bar_white.png and b/resources/image/sign/sign_res/bar_white.png differ diff --git a/zhenxun/builtin_plugins/__init__.py b/zhenxun/builtin_plugins/__init__.py index 6448d28d..38776d55 100644 --- a/zhenxun/builtin_plugins/__init__.py +++ b/zhenxun/builtin_plugins/__init__.py @@ -1,3 +1,5 @@ +import os + from nonebot import require require("nonebot_plugin_apscheduler") @@ -8,3 +10,10 @@ require("nonebot_plugin_saa") from nonebot_plugin_saa import enable_auto_select_bot enable_auto_select_bot() +from pathlib import Path + +import nonebot + +path = Path(__file__).parent / "platform" +for d in os.listdir(path): + nonebot.load_plugins(str((path / d).resolve())) diff --git a/zhenxun/builtin_plugins/admin/admin_help.py b/zhenxun/builtin_plugins/admin/admin_help.py new file mode 100644 index 00000000..6cad3cc0 --- /dev/null +++ b/zhenxun/builtin_plugins/admin/admin_help.py @@ -0,0 +1,165 @@ +import nonebot +from arclet.alconna import Args, Option +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna +from nonebot_plugin_alconna.matcher import AlconnaMatcher +from nonebot_plugin_saa import Image, Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH +from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.models.task_info import TaskInfo +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.exception import EmptyError +from zhenxun.utils.image_utils import ( + BuildImage, + build_sort_image, + group_image, + text2image, +) +from zhenxun.utils.rules import admin_check, ensure_group + +base_config = Config.get("admin_bot_manage") + +__plugin_meta__ = PluginMetadata( + name="群组管理员帮助", + description="管理员帮助列表", + usage=""" + 管理员帮助 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.ADMIN, + admin_level=1, + ).dict(), +) + +_matcher = on_alconna( + Alconna("管理员帮助"), + rule=admin_check(1) & ensure_group, + priority=5, + block=True, +) + + +ADMIN_HELP_IMAGE = IMAGE_PATH / "ADMIN_HELP.png" +if ADMIN_HELP_IMAGE.exists(): + ADMIN_HELP_IMAGE.unlink() + + +async def build_help() -> BuildImage: + """构造管理员帮助图片 + + 异常: + EmptyError: 管理员帮助为空 + + 返回: + BuildImage: 管理员帮助图片 + """ + plugin_list = await PluginInfo.filter( + plugin_type__in=[PluginType.ADMIN, PluginType.SUPER_AND_ADMIN] + ).all() + data_list = [] + for plugin in plugin_list: + if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): + if _plugin.metadata: + data_list.append({"plugin": plugin, "metadata": _plugin.metadata}) + font = BuildImage.load_font("HYWenHei-85W.ttf", 20) + image_list = [] + for data in data_list: + plugin = data["plugin"] + metadata = data["metadata"] + try: + usage = None + description = None + if metadata.usage: + usage = await text2image( + metadata.usage, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + if metadata.description: + description = await text2image( + metadata.description, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + width = 0 + height = 100 + if usage: + width = usage.width + height += usage.height + if description and description.width > width: + width = description.width + height += description.height + font_width, font_height = BuildImage.get_text_size( + plugin.name + f"[{plugin.level}]", font + ) + if font_width > width: + width = font_width + A = BuildImage(width + 30, height + 120, "#EAEDF2") + await A.text((15, 10), plugin.name + f"[{plugin.level}]") + await A.text((15, 70), "简介:") + if not description: + description = BuildImage(A.width - 30, 30, (255, 255, 255)) + await description.circle_corner(10) + await A.paste(description, (15, 100)) + if not usage: + usage = BuildImage(A.width - 30, 30, (255, 255, 255)) + await usage.circle_corner(10) + await A.text((15, description.height + 115), "用法:") + await A.paste(usage, (15, description.height + 145)) + await A.circle_corner(10) + image_list.append(A) + except Exception as e: + logger.warning( + f"获取群管理员插件 {plugin.module}: {plugin.name} 设置失败...", + "管理员帮助", + e=e, + ) + if task_list := await TaskInfo.all(): + task_str = "\n".join([task.name for task in task_list]) + task_str = "通过 开启/关闭 来控制群被动\n----------\n" + task_str + task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) + await task_image.circle_corner(10) + A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") + await A.text((25, 10), "被动技能") + await A.paste(task_image, (25, 50)) + await A.circle_corner(10) + image_list.append(A) + if not image_list: + raise EmptyError() + image_group, _ = group_image(image_list) + A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) + text = await BuildImage.build_text_image( + "群管理员帮助", + size=40, + ) + tip = await BuildImage.build_text_image( + "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" + ) + await A.paste(text, (50, 30)) + await A.paste(tip, (50, 90)) + await A.save(ADMIN_HELP_IMAGE) + return BuildImage(1, 1) + + +@_matcher.handle() +async def _( + session: EventSession, + matcher: AlconnaMatcher, + arparma: Arparma, +): + if not ADMIN_HELP_IMAGE.exists(): + try: + await build_help() + except EmptyError: + await Text("管理员帮助为空").finish(reply=True) + await Image(ADMIN_HELP_IMAGE).send() + logger.info("查看管理员帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/admin/admin_watch.py b/zhenxun/builtin_plugins/admin/admin_watch.py index 4fc274ea..02fe9417 100644 --- a/zhenxun/builtin_plugins/admin/admin_watch.py +++ b/zhenxun/builtin_plugins/admin/admin_watch.py @@ -9,11 +9,6 @@ from zhenxun.models.level_user import LevelUser from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType -__zx_plugin_name__ = "群管理员变动监测 [Hidden]" -__plugin_version__ = 0.1 -__plugin_author__ = "HibiKier" - - __plugin_meta__ = PluginMetadata( name="群管理员变动监测", description="检测群管理员变动, 添加与删除管理员默认权限, 当配置项 ADMIN_DEFAULT_AUTH 为空时, 不会添加管理员权限", @@ -40,20 +35,25 @@ async def _(event: GroupAdminNoticeEvent): admin_default_auth = base_config.get("ADMIN_DEFAULT_AUTH") if admin_default_auth is not None: await LevelUser.set_level( - event.user_id, - event.group_id, + str(event.user_id), + str(event.group_id), admin_default_auth, ) logger.info( f"成为管理员,添加权限: {admin_default_auth}", "群管理员变动监测", - event.user_id, - event.group_id, + session=event.user_id, + group_id=event.group_id, ) else: logger.warning( f"配置项 MODULE: [admin_bot_manage] | KEY: [ADMIN_DEFAULT_AUTH] 为空" ) elif event.sub_type == "unset": - await LevelUser.delete_level(event.user_id, event.group_id) - logger.info("撤销群管理员, 取消权限等级", "群管理员变动监测", event.user_id, event.group_id) + await LevelUser.delete_level(str(event.user_id), str(event.group_id)) + logger.info( + "撤销群管理员, 取消权限等级", + "群管理员变动监测", + session=event.user_id, + group_id=event.group_id, + ) diff --git a/zhenxun/builtin_plugins/admin/ban/__init__.py b/zhenxun/builtin_plugins/admin/ban/__init__.py new file mode 100644 index 00000000..289ef6b6 --- /dev/null +++ b/zhenxun/builtin_plugins/admin/ban/__init__.py @@ -0,0 +1,196 @@ +from arclet.alconna import Args +from nonebot.adapters import Bot +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import ( + Alconna, + Arparma, + At, + Match, + Option, + Subcommand, + on_alconna, + store_true, +) +from nonebot_plugin_saa import Image, Mention, MessageFactory, Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.rules import admin_check + +from ._data_source import BanManage + +base_config = Config.get("ban") + +__plugin_meta__ = PluginMetadata( + name="封禁用户/群组", + description="你被逮捕了!丢进小黑屋!封禁用户以及群组,屏蔽消息", + usage=""" + .ban [at] ?[小时] ?[分钟] + .unban + 示例:.ban @user + 示例:.ban @user 6 + 示例:.ban @user 3 10 + 示例:.unban @user + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.SUPER_AND_ADMIN, + admin_level=base_config.get("BAN_LEVEL", 5), + configs=[ + RegisterConfig( + key="BAN_LEVEL", + value=5, + help="ban/unban所需要的管理员权限等级", + default_value=5, + type=int, + ) + ], + ).dict(), +) + + +_matcher = on_alconna( + Alconna( + "ban-console", + Subcommand( + "ban", + Args["user?", [str, At]]["duration?", int], + Option("-g|--group", Args["group_id", str]), + ), + Subcommand( + "unban", + Args["user?", [str, At]], + Option("-g|--group", Args["group_id", str]), + ), + ), + rule=admin_check("ban", "BAN_LEVEL"), + priority=5, + block=True, +) + +_status_matcher = on_alconna( + Alconna( + "ban-status", + Option("-u|--user", Args["user_id", str]), + Option("-g|--group", Args["group_id", str]), + ), + permission=SUPERUSER, + priority=1, + block=True, +) +# TODO: shortcut + + +@_status_matcher.handle() +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, + user_id: Match[str], + group_id: Match[str], +): + _user_id = user_id.result if user_id.available else None + _group_id = group_id.result if group_id.available else None + if image := await BanManage.build_ban_image(_user_id, _group_id): + await Image(image.pic2bs4()).finish(reply=True) + else: + await Text("数据为空捏...").finish(reply=True) + + +@_matcher.assign("ban") +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, + user: Match[str | At], + duration: Match[int], + group_id: Match[str], +): + user_id = None + if user.available: + if isinstance(user.result, At): + user_id = user.result.target + else: + user_id = user.result + _duration = duration.result * 60 if duration.available else -1 + if gid := session.id3 or session.id2: + if group_id.available: + gid = group_id.result + await BanManage.ban( + user_id, gid, _duration, session, session.id1 in bot.config.superusers + ) + logger.info( + f"管理员Ban", + arparma.header_result, + session=session, + target=f"{gid}:{user_id}", + ) + await MessageFactory( + [ + Text("对 "), + Mention(user_id), # type: ignore + Text(f" 狠狠惩戒了一番,一脚踢进了小黑屋!"), + ] + ).finish(reply=True) + elif session.id1 in bot.config.superusers: + _group_id = group_id.result if group_id.available else None + await BanManage.ban(user_id, _group_id, _duration, session, True) + logger.info( + f"超级用户Ban", + arparma.header_result, + session=session, + target=f"{_group_id}:{user_id}", + ) + at_msg = user_id if user_id else f"群组:{_group_id}" + await Text(f"对 {at_msg} 狠狠惩戒了一番,一脚踢进了小黑屋!").finish(reply=True) + + +@_matcher.assign("unban") +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, + user: Match[str | At], + group_id: Match[str], +): + user_id = None + if user.available: + if isinstance(user.result, At): + user_id = user.result.target + else: + user_id = user.result + if gid := session.id3 or session.id2: + if group_id.available: + gid = group_id.result + await BanManage.unban( + user_id, gid, session, session.id1 in bot.config.superusers + ) + logger.info( + f"管理员UnBan", + arparma.header_result, + session=session, + target=f"{gid}:{user_id}", + ) + await MessageFactory( + [ + Text("将 "), + Mention(user_id), # type: ignore + Text(f" 从黑屋中拉了出来并急救了一下!"), + ] + ).finish(reply=True) + elif session.id1 in bot.config.superusers: + _group_id = group_id.result if group_id.available else None + await BanManage.unban(user_id, _group_id, session, True) + logger.info( + f"超级用户UnBan", + arparma.header_result, + session=session, + target=f"{_group_id}:{user_id}", + ) + at_msg = user_id if user_id else f"群组:{_group_id}" + await Text(f"对 {at_msg} 从黑屋中拉了出来并急救了一下!").finish(reply=True) diff --git a/zhenxun/builtin_plugins/admin/ban/_data_source.py b/zhenxun/builtin_plugins/admin/ban/_data_source.py new file mode 100644 index 00000000..7418eb47 --- /dev/null +++ b/zhenxun/builtin_plugins/admin/ban/_data_source.py @@ -0,0 +1,120 @@ +import time + +from nonebot_plugin_session import EventSession + +from zhenxun.models.ban_console import BanConsole +from zhenxun.models.level_user import LevelUser +from zhenxun.utils.image_utils import ImageTemplate + + +class BanManage: + + @classmethod + async def build_ban_image(cls, user_id: str | None, group_id: str | None): + data_list = None + if not user_id and not group_id: + data_list = await BanConsole.all() + elif user_id: + if group_id: + data_list = await BanConsole.filter( + user_id=user_id, group_id=group_id + ).all() + else: + data_list = await BanConsole.filter( + user_id=user_id, group_id__isnull=True + ).all() + else: + if group_id: + data_list = await BanConsole.filter( + user_id__isnull=True, group_id=group_id + ).all() + if not data_list: + return None + column_name = [ + "ID", + "用户ID", + "群组ID", + "BAN LEVEL", + "剩余时长(分钟)", + "操作员ID", + ] + row_data = [] + for data in data_list: + duration = int((data.ban_time + data.duration - time.time()) / 60) + if duration < 0: + duration = 0 + row_data.append( + [ + data.id, + data.user_id, + data.group_id, + data.ban_level, + duration, + data.operator, + ] + ) + return await ImageTemplate.table_page( + "Ban / UnBan 列表", "在黑屋中狠狠调教!", column_name, row_data + ) + + @classmethod + async def is_ban(cls, user_id: str, group_id: str | None): + """判断用户是否被ban + + 参数: + user_id: 用户id + + 返回: + bool: 是否被ban + """ + return await BanConsole.is_ban(user_id, group_id) + + @classmethod + async def unban( + cls, + user_id: str | None, + group_id: str | None, + session: EventSession, + is_superuser: bool = False, + ) -> bool: + """ban掉目标用户 + + 参数: + user_id: 用户id + group_id: 群组id + session: Session + is_superuser: 是否为超级用户操作 + + 返回: + bool: 是否unban成功 + """ + user_level = 9999 + if not is_superuser and user_id and session.id1: + user_level = await LevelUser.get_user_level(session.id1, group_id) + if await BanConsole.check_ban_level(user_id, group_id, user_level): + await BanConsole.unban(user_id, group_id) + return True + return False + + @classmethod + async def ban( + cls, + user_id: str | None, + group_id: str | None, + duration: int, + session: EventSession, + is_superuser: bool, + ): + """ban掉目标用户 + + 参数: + user_id: 用户id + group_id: 群组id + duration: 时长,秒 + session: Session + is_superuser: 是否为超级用户操作 + """ + level = 9999 + if not is_superuser and user_id and session.id1: + level = await LevelUser.get_user_level(session.id1, group_id) + await BanConsole.ban(user_id, group_id, level, duration, session.id1) diff --git a/zhenxun/builtin_plugins/admin/group_member_update/__init__.py b/zhenxun/builtin_plugins/admin/group_member_update/__init__.py new file mode 100644 index 00000000..1a7bfe4a --- /dev/null +++ b/zhenxun/builtin_plugins/admin/group_member_update/__init__.py @@ -0,0 +1,63 @@ +from nonebot import on_notice +from nonebot.adapters import Bot +from nonebot.adapters.onebot.v11 import GroupIncreaseNoticeEvent +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna +from nonebot_plugin_saa import Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.utils import PluginExtraData +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.rules import admin_check, ensure_group + +from ._data_source import MemberUpdateManage + +__plugin_meta__ = PluginMetadata( + name="更新群组成员列表", + description="更新群组成员列表", + usage=""" + 更新群组成员的基本信息 + 指令: + 更新群组成员信息 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.SUPER_AND_ADMIN, + admin_level=1, + ).dict(), +) + + +_matcher = on_alconna( + Alconna("更新群组成员信息"), + rule=admin_check(1) & ensure_group, + priority=5, + block=True, +) + + +@_matcher.handle() +async def _(bot: Bot, session: EventSession, arparma: Arparma): + if gid := session.id3 or session.id2: + logger.info("更新群组成员信息", arparma.header_result, session=session) + await MemberUpdateManage.update(bot, gid) + await Text("已经成功更新了群组成员信息!").finish(reply=True) + await Text("群组id为空...").send() + + +_notice = on_notice(priority=1, block=False) + + +@_notice.handle() +async def _(bot: Bot, event: GroupIncreaseNoticeEvent): + # TODO: 其他适配器的加群自动更新群组成员信息 + if str(event.user_id) == bot.self_id: + await MemberUpdateManage.update(bot, str(event.group_id)) + logger.info( + "{NICKNAME}加入群聊更新群组信息", + "更新群组成员列表", + session=event.user_id, + group_id=event.group_id, + ) diff --git a/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py b/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py new file mode 100644 index 00000000..442d337e --- /dev/null +++ b/zhenxun/builtin_plugins/admin/group_member_update/_data_source.py @@ -0,0 +1,177 @@ +import time +from datetime import datetime, timedelta, timezone + +from nonebot.adapters import Bot +from nonebot.adapters.discord import Bot as DiscordBot +from nonebot.adapters.dodo import Bot as DodoBot +from nonebot.adapters.dodo.models import MemberInfo +from nonebot.adapters.kaiheila import Bot as KaiheilaBot +from nonebot.adapters.onebot.v11 import Bot as v11Bot +from nonebot.adapters.onebot.v12 import Bot as v12Bot + +from zhenxun.configs.config import Config +from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.models.level_user import LevelUser +from zhenxun.services.log import logger + + +class MemberUpdateManage: + + @classmethod + async def update(cls, bot: Bot, group_id: str): + if isinstance(bot, v11Bot): + await cls.v11(bot, group_id) + elif isinstance(bot, v12Bot): + await cls.v12(bot, group_id) + elif isinstance(bot, KaiheilaBot): + await cls.kaiheila(bot, group_id) + elif isinstance(bot, DodoBot): + await cls.dodo(bot, group_id) + elif isinstance(bot, DiscordBot): + await cls.discord(bot, group_id) + + @classmethod + async def discord(cls, bot: DiscordBot, group_id: str): + # TODO: discord更新群组成员信息 + pass + + @classmethod + async def dodo(cls, bot: DodoBot, group_id: str): + page_size = 100 + result_size = 100 + max_id = 0 + exist_member_list = [] + group_member_list: list[MemberInfo] = [] + while result_size == page_size: + group_member_data = await bot.get_member_list( + island_source_id=group_id, page_size=page_size + ) + result_size = len(group_member_data.list) + group_member_list += group_member_data.list + max_id = group_member_data.max_id + if group_member_list: + for user in group_member_list: + exist_member_list.append(user.dodo_source_id) + await GroupInfoUser.update_or_create( + user_id=user.dodo_source_id, + group_id=group_id, + defaults={ + "user_name": user.nick_name or user.personal_nick_name, + "user_join_time": user.join_time, + "platform": "dodo", + }, + ) + if delete_member_list := list( + set(exist_member_list).difference( + set(await GroupInfoUser.get_group_member_id_list(group_id)) + ) + ): + await GroupInfoUser.filter( + user_id__in=delete_member_list, group_id=group_id + ).delete() + logger.info( + f"删除已退群用户", + "更新群组成员信息", + group_id=group_id, + platform="dodo", + ) + + @classmethod + async def kaiheila(cls, bot: KaiheilaBot, group_id: str): + # TODO: kaiheila 更新群组成员信息 + pass + + @classmethod + async def v11(cls, bot: v11Bot, group_id: str): + exist_member_list = [] + default_auth = Config.get_config("admin_bot_manage", "ADMIN_DEFAULT_AUTH") + group_member_list = await bot.get_group_member_list(group_id=int(group_id)) + for user_info in group_member_list: + user_id = user_info["user_id"] + nickname = user_info["card"] or user_info["nickname"] + role = user_info["role"] + if default_auth: + if role in ["owner", "admin"] and not LevelUser.is_group_flag( + str(user_id), group_id + ): + await LevelUser.set_level(user_id, group_id, default_auth) + if str(user_id) in bot.config.superusers: + await LevelUser.set_level(str(user_id), group_id, 9) + join_time = datetime.strptime( + time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(user_info["join_time"]) + ), + "%Y-%m-%d %H:%M:%S", + ) + await GroupInfoUser.update_or_create( + user_id=str(user_id), + group_id=group_id, + defaults={ + "user_name": nickname, + "user_join_time": join_time.replace( + tzinfo=timezone(timedelta(hours=8)) + ), + "platform": "qq", + }, + ) + exist_member_list.append(str(user_id)) + logger.debug( + "更新成功", "更新群组成员信息", session=user_id, group_id=group_id + ) + if delete_member_list := list( + set(exist_member_list).difference( + set(await GroupInfoUser.get_group_member_id_list(group_id)) + ) + ): + await GroupInfoUser.filter( + user_id__in=delete_member_list, group_id=group_id + ).delete() + logger.info( + f"删除已退群用户", "更新群组成员信息", group_id=group_id, platform="qq" + ) + + @classmethod + async def v12(cls, bot: v12Bot, group_id: str): + # TODO: v12更新群组成员信息 + pass + # exist_member_list = [] + # default_auth = Config.get_config("admin_bot_manage", "ADMIN_DEFAULT_AUTH") + # group_member_list: list[GetGroupMemberInfoResp] = await bot.get_group_member_list( + # group_id=group_id + # ) + # for user_info in group_member_list: + # user_id = user_info.user_id + # nickname = user_info.user_displayname or user_info.user_name + # role = user_info["role"] + # if default_auth: + # if role in ["owner", "admin"] and not LevelUser.is_group_flag( + # str(user_id), group_id + # ): + # await LevelUser.set_level(user_id, group_id, default_auth) + # if str(user_id) in bot.config.superusers: + # await LevelUser.set_level(str(user_id), group_id, 9) + # join_time = datetime.strptime( + # time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(user_info["join_time"])), + # "%Y-%m-%d %H:%M:%S", + # ) + # await GroupInfoUser.update_or_create( + # user_id=str(user_id), + # group_id=group_id, + # defaults={ + # "user_name": nickname, + # "user_join_time": join_time.replace( + # tzinfo=timezone(timedelta(hours=8)) + # ), + # }, + # ) + # exist_member_list.append(str(user_id)) + # logger.debug("更新成功", "更新群组成员信息", session=user_id, group_id=group_id) + # if delete_member_list := list( + # set(exist_member_list).difference( + # set(await GroupInfoUser.get_group_member_id_list(group_id)) + # ) + # ): + # await GroupInfoUser.filter( + # user_id__in=delete_member_list, group_id=group_id + # ).delete() + # logger.info(f"删除已退群用户", "更新群组成员信息", group_id=group_id) diff --git a/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py b/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py new file mode 100644 index 00000000..42da314f --- /dev/null +++ b/zhenxun/builtin_plugins/admin/plugin_switch/__init__.py @@ -0,0 +1,162 @@ +from nonebot.adapters import Bot +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import ( + Alconna, + Args, + Arparma, + Match, + Option, + Subcommand, + on_alconna, + store_true, +) +from nonebot_plugin_saa import Image, Text +from nonebot_plugin_session import EventSession +from requests import session + +from zhenxun.configs.config import Config +from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.services.log import logger +from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.utils.rules import admin_check, ensure_group + +from ._data_source import PluginManage, build_plugin, build_task + +base_config = Config.get("admin_bot_manage") + + +__plugin_meta__ = PluginMetadata( + name="功能开关", + description="对群组内的功能限制,超级用户可以对群组以及全局的功能被动开关限制", + usage=""" + 开启/关闭[功能] + 群被动状态 + 开启全部被动 + 关闭全部被动 + 醒来/休息吧 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.SUPER_AND_ADMIN, + admin_level=base_config.get("CHANGE_GROUP_SWITCH_LEVEL", 2), + configs=[ + RegisterConfig( + key="CHANGE_GROUP_SWITCH_LEVEL", + value=2, + help="开关群功能权限", + default_value=2, + type=int, + ) + ], + ).dict(), +) + + +_status_matcher = on_alconna( + Alconna( + "switch", + Option("-t|--task", action=store_true, help_text="被动技能"), + Subcommand( + "open", + Args["name", str], + Option( + "-g|--group", + Args["group_id", str], + ), + ), + Subcommand( + "close", + Args["name", str], + Option( + "-t|--type", + Args["block_type", ["all", "a", "private", "p", "group", "g"]], + ), + Option( + "-g|--group", + Args["group_id", str], + ), + ), + ), + rule=admin_check("admin_bot_manage", "CHANGE_GROUP_SWITCH_LEVEL"), + priority=5, + block=True, +) + +# TODO: shortcut + +_group_status_matcher = on_alconna( + Alconna("group-status", Args["status", ["sleep", "wake"]]), + rule=admin_check("admin_bot_manage", "CHANGE_GROUP_SWITCH_LEVEL") & ensure_group, + priority=5, + block=True, +) + + +@_status_matcher.assign("$main") +async def _(bot: Bot, session: EventSession, arparma: Arparma): + image = None + if arparma.find("task"): + image = await build_task(session.id3 or session.id2) + elif session.id1 in bot.config.superusers: + image = await build_plugin() + if image: + await Image(image.pic2bs4()).send(reply=True) + logger.info( + f"查看{'被动' if arparma.find('task') else '功能'}列表", + arparma.header_result, + session=session, + ) + + +@_status_matcher.assign("open") +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, + name: str, + group: Match[str], +): + if gid := session.id3 or session.id2: + result = await PluginManage.block_group_plugin(name, gid) + await Text(result).send(reply=True) + logger.info(f"开启功能 {name}", arparma.header_result, session=session) + elif session.id1 in bot.config.superusers: + result = await PluginManage.superuser_block(name, None, group.result) + await Text(result).send(reply=True) + logger.info( + f"超级用户开启功能 {name}", + arparma.header_result, + session=session, + target=group.result, + ) + + +@_status_matcher.assign("close") +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, + name: str, + block_type: Match[str], + group: Match[str], +): + if gid := session.id3 or session.id2: + result = await PluginManage.unblock_group_plugin(name, gid) + await Text(result).send(reply=True) + logger.info(f"关闭功能 {name}", arparma.header_result, session=session) + elif session.id1 in bot.config.superusers: + _type = BlockType.ALL + if block_type.available: + if block_type.result in ["p", "private"]: + _type = BlockType.FRIEND + elif block_type.result in ["g", "group"]: + _type = BlockType.GROUP + result = await PluginManage.superuser_block(name, _type, group.result) + await Text(result).send(reply=True) + logger.info( + f"超级用户关闭功能 {name}, 禁用类型: {_type}", + arparma.header_result, + session=session, + target=group.result, + ) diff --git a/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py b/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py new file mode 100644 index 00000000..bd7fd937 --- /dev/null +++ b/zhenxun/builtin_plugins/admin/plugin_switch/_data_source.py @@ -0,0 +1,244 @@ +from zhenxun.models.group_console import GroupConsole +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.models.task_info import TaskInfo +from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.utils.exception import GroupInfoNotFound +from zhenxun.utils.image_utils import BuildImage, ImageTemplate, RowStyle + + +def plugin_row_style(column: str, text: str) -> RowStyle: + """被动技能文本风格 + + 参数: + column: 表头 + text: 文本内容 + + 返回: + RowStyle: RowStyle + """ + style = RowStyle() + if column == "全局状态": + if text == "开启": + style.font_color = "#67C23A" + else: + style.font_color = "#F56C6C" + if column == "加载状态": + if text == "SUCCESS": + style.font_color = "#67C23A" + else: + style.font_color = "#F56C6C" + return style + + +async def build_plugin() -> BuildImage: + column_name = [ + "ID", + "模块", + "名称", + "全局状态", + "禁用类型", + "加载状态", + "菜单分类", + "作者", + "版本", + "金币花费", + ] + plugin_list = await PluginInfo.filter(plugin_type__not=PluginType.HIDDEN).all() + column_data = [] + for plugin in plugin_list: + column_data.append( + [ + plugin.id, + plugin.module, + plugin.name, + "开启" if plugin.status else "关闭", + plugin.block_type, + "SUCCESS" if plugin.load_status else "ERROR", + plugin.menu_type, + plugin.author, + plugin.version, + plugin.cost_gold, + ] + ) + return await ImageTemplate.table_page( + "Plugin", + "插件状态", + column_name, + column_data, + text_style=plugin_row_style, + ) + + +def task_row_style(column: str, text: str) -> RowStyle: + """被动技能文本风格 + + 参数: + column: 表头 + text: 文本内容 + + 返回: + RowStyle: RowStyle + """ + style = RowStyle() + if column in ["群组状态", "全局状态"]: + if text == "开启": + style.font_color = "#67C23A" + else: + style.font_color = "#F56C6C" + return style + + +async def build_task(group_id: str | None) -> BuildImage: + """构造被动技能状态图片 + + 参数: + group_id: 群组id + + 异常: + GroupInfoNotFound: 未找到群组 + + 返回: + BuildImage: 被动技能状态图片 + """ + task_list = await TaskInfo.all() + column_name = ["ID", "模块", "名称", "群组状态", "全局状态", "运行时间"] + group = None + if group_id: + group = await GroupConsole.get_or_none(group_id=group_id) + if not group: + raise GroupInfoNotFound() + else: + column_name.remove("群组状态") + column_data = [] + for task in task_list: + if group: + column_data.append( + [ + task.id, + task.module, + task.name, + "开启" if task.module not in group.block_task else "关闭", + "开启" if task.status else "关闭", + task.run_time, + ] + ) + else: + column_data.append( + [ + task.id, + task.module, + task.name, + "开启" if task.status else "关闭", + task.run_time, + ] + ) + return await ImageTemplate.table_page( + "Task", + "被动技能状态", + column_name, + column_data, + text_style=task_row_style, + ) + + +class PluginManage: + + @classmethod + async def block(cls, module: str): + await PluginInfo.filter(module=module).update(status=False) + + @classmethod + async def unblock(cls, module: str): + await PluginInfo.filter(module=module).update(status=True) + + @classmethod + async def block_group_plugin(cls, plugin_name: str, group_id: str) -> str: + """禁用群组插件 + + 参数: + plugin_name: 插件名称 + group_id: 群组id + + 返回: + str: 返回信息 + """ + return await cls._change_group_plugin(plugin_name, group_id, True) + + @classmethod + async def unblock_group_plugin(cls, plugin_name: str, group_id: str) -> str: + """启用群组插件 + + 参数: + plugin_name: 插件名称 + group_id: 群组id + + 返回: + str: 返回信息 + """ + return await cls._change_group_plugin(plugin_name, group_id, False) + + @classmethod + async def _change_group_plugin( + cls, plugin_name: str, group_id: str, status: bool + ) -> str: + """修改群组插件状态 + + 参数: + plugin_name: 插件名称 + group_id: 群组id + status: 插件状态 + + 返回: + str: 返回信息 + """ + status_str = "开启" if status else "关闭" + if plugin := await PluginInfo.get_or_none(name=plugin_name): + group, _ = await GroupConsole.get_or_create(group_id=group_id) + if status: + if plugin.module in group.block_plugin: + group.block_plugin = group.block_plugin.replace( + f"{plugin.module},", "" + ) + await group.save(update_fields=["block_plugin"]) + return f"已成功{status_str} {plugin_name} 功能!" + else: + if plugin.module not in group.block_plugin: + group.block_plugin += f"{plugin.module}," + await group.save(update_fields=["block_plugin"]) + return f"已成功{status_str} {plugin_name} 功能!" + return f"该功能已经{status_str}了喔,不要重复{status_str}..." + return "没有找到这个功能喔..." + + @classmethod + async def superuser_block( + cls, plugin_name: str, block_type: BlockType | None, group_id: str | None + ) -> str: + """超级用户禁用 + + 参数: + plugin_name: 插件名称 + block_type: 禁用类型 + group_id: 群组id + + 返回: + str: 返回信息 + """ + if plugin := await PluginInfo.get_or_none(name=plugin_name): + if group_id: + if group := await GroupConsole.get_or_none(group_id=group_id): + if f"super:{plugin_name}," not in group.block_plugin: + group.block_plugin += f"super:{plugin_name}," + await group.save(update_fields=["block_plugin"]) + return ( + f"已成功关闭群组 {group.group_name} 的 {plugin_name} 功能!" + ) + return "此群组该功能已被超级用户关闭,不要重复关闭..." + return "群组信息未更新,请先更新群组信息..." + plugin.block_type = block_type + plugin.status = not bool(block_type) + await plugin.save(update_fields=["status", "block_type"]) + if not block_type: + return f"已成功将 {plugin_name} 全局启用!" + else: + return f"已成功将 {plugin_name} 全局关闭!" + return "没有找到这个功能喔..." diff --git a/zhenxun/builtin_plugins/admin/welcome_message.py b/zhenxun/builtin_plugins/admin/welcome_message.py index 96c47949..7909a036 100644 --- a/zhenxun/builtin_plugins/admin/welcome_message.py +++ b/zhenxun/builtin_plugins/admin/welcome_message.py @@ -1,26 +1,22 @@ +import os import shutil -from typing import Dict +from typing import Annotated, Dict import ujson as json -from arclet.alconna import Args, Option +from nonebot import on_command +from nonebot.params import Command from nonebot.plugin import PluginMetadata -from nonebot_plugin_alconna import ( - Alconna, - AlconnaMatch, - Arparma, - Match, - on_alconna, - store_true, -) -from nonebot_plugin_alconna.matcher import AlconnaMatcher -from nonebot_plugin_saa import Text +from nonebot_plugin_alconna import Image +from nonebot_plugin_alconna import Text as alcText +from nonebot_plugin_alconna import UniMsg from nonebot_plugin_session import EventSession from zhenxun.configs.config import Config from zhenxun.configs.path_config import DATA_PATH -from zhenxun.configs.utils import PluginCdBlock, PluginExtraData, RegisterConfig +from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType +from zhenxun.utils.http_utils import AsyncHttpx from zhenxun.utils.rules import admin_check, ensure_group base_config = Config.get("admin_bot_manage") @@ -48,18 +44,15 @@ __plugin_meta__ = PluginMetadata( ).dict(), ) -_matcher = on_alconna( - Alconna( - "设置欢迎消息", - Args["message", str], - Option("-at", action=store_true, help_text="是否at新入群用户"), - ), +_matcher = on_command( + "设置欢迎消息", rule=admin_check("admin_bot_manage", "SET_GROUP_WELCOME_MESSAGE_LEVEL") & ensure_group, priority=5, block=True, ) + BASE_PATH = DATA_PATH / "welcome_message" BASE_PATH.mkdir(parents=True, exist_ok=True) @@ -86,31 +79,43 @@ if old_file.exists(): @_matcher.handle() async def _( session: EventSession, - matcher: AlconnaMatcher, - arparma: Arparma, - message: str, + message: UniMsg, + command: Annotated[tuple[str, ...], Command()], ): - file = ( - BASE_PATH - / f"{session.platform or session.bot_type}" - / f"{session.id2}" - / "text.json" - ) + path = BASE_PATH / f"{session.platform or session.bot_type}" / f"{session.id2}" if session.id3: - file = ( + path = ( BASE_PATH / f"{session.platform or session.bot_type}" / f"{session.id3}" / f"{session.id2}" - / "text.json" ) + file = path / "text.json" + idx = 0 + text = "" + for f in os.listdir(path): + (path / f).unlink() + message[0].text = message[0].text.replace(command[0], "").strip() + for msg in message: + if isinstance(msg, alcText): + text += msg.text + elif isinstance(msg, Image): + if msg.url: + text += f"[image:{idx}]" + await AsyncHttpx.download_file(msg.url, path / f"{idx}.png") + idx += 1 + else: + logger.debug("图片 URL 为空...", command[0]) if not file.exists(): file.parent.mkdir(exist_ok=True, parents=True) + is_at = "-at" in message + text = text.replace("-at", "") json.dump( - {"at": arparma.find("at"), "message": message}, + {"at": is_at, "message": text}, file.open("w"), ensure_ascii=False, indent=4, ) - logger.info(f"设置群欢迎消息成功: {message}", arparma.header_result, session=session) - await Text(f"设置欢迎消息成功: \n{message}").send() + uni_msg = alcText("设置欢迎消息成功: \n") + message + await uni_msg.send() + logger.info(f"设置群欢迎消息成功: {text}", command[0], session=session) diff --git a/zhenxun/builtin_plugins/chat_history/__init__.py b/zhenxun/builtin_plugins/chat_history/__init__.py new file mode 100644 index 00000000..838488cf --- /dev/null +++ b/zhenxun/builtin_plugins/chat_history/__init__.py @@ -0,0 +1,4 @@ +import nonebot +from pathlib import Path + +nonebot.load_plugins(str(Path(__file__).parent.resolve())) diff --git a/zhenxun/builtin_plugins/chat_history/chat_message.py b/zhenxun/builtin_plugins/chat_history/chat_message.py new file mode 100644 index 00000000..602a0199 --- /dev/null +++ b/zhenxun/builtin_plugins/chat_history/chat_message.py @@ -0,0 +1,83 @@ +from nonebot import on_message +from nonebot.adapters import Bot +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import UniMsg +from nonebot_plugin_apscheduler import scheduler +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.models.chat_history import ChatHistory +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType + +__plugin_meta__ = PluginMetadata( + name="消息存储", + description="消息存储,被动存储群消息", + usage="", + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.HIDDEN, + configs=[ + RegisterConfig( + module="chat_history", + key="FLAG", + value=True, + help="是否开启消息自从存储", + default_value=True, + type=bool, + ) + ], + ).dict(), +) + + +def rule(message: UniMsg) -> bool: + return bool(Config.get_config("chat_history", "FLAG") and message) + + +chat_history = on_message(rule=rule, priority=1, block=False) + + +TEMP_LIST = [] + + +@chat_history.handle() +async def _(message: UniMsg, session: EventSession): + group_id = session.id3 or session.id2 + TEMP_LIST.append( + ChatHistory( + user_id=session.id1, + group_id=group_id, + text=str(message), + plain_text=message.extract_plain_text(), + bot_id=session.bot_id, + platform=session.platform, + ) + ) + + +@scheduler.scheduled_job( + "interval", + minutes=1, +) +async def _(): + try: + message_list = TEMP_LIST.copy() + TEMP_LIST.clear() + if message_list: + await ChatHistory.bulk_create(message_list) + logger.debug(f"批量添加聊天记录 {len(message_list)} 条", "定时任务") + except Exception as e: + logger.error(f"定时批量添加聊天记录", "定时任务", e=e) + + +# @test.handle() +# async def _(event: MessageEvent): +# print(await ChatHistory.get_user_msg(event.user_id, "private")) +# print(await ChatHistory.get_user_msg_count(event.user_id, "private")) +# print(await ChatHistory.get_user_msg(event.user_id, "group")) +# print(await ChatHistory.get_user_msg_count(event.user_id, "group")) +# print(await ChatHistory.get_group_msg(event.group_id)) +# print(await ChatHistory.get_group_msg_count(event.group_id)) diff --git a/zhenxun/builtin_plugins/chat_history/chat_message_handle.py b/zhenxun/builtin_plugins/chat_history/chat_message_handle.py new file mode 100644 index 00000000..8b8cb697 --- /dev/null +++ b/zhenxun/builtin_plugins/chat_history/chat_message_handle.py @@ -0,0 +1,116 @@ +from datetime import datetime, timedelta + +import pytz +from nonebot.adapters import Bot +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import ( + Alconna, + Args, + Arparma, + Match, + Option, + on_alconna, + store_true, +) +from nonebot_plugin_saa import Image, Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.utils import PluginExtraData +from zhenxun.models.chat_history import ChatHistory +from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.image_utils import ImageTemplate + +__plugin_meta__ = PluginMetadata( + name="消息统计查询", + description="消息统计查询", + usage="", + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.NORMAL, + menu_type="数据统计", + ).dict(), +) + +# TODO: shortcut + +_matcher = on_alconna( + Alconna( + "消息排行", + Option("--des", default=False, action=store_true), + Args["type?", ["日", "周", "月", "年"]]["count?", int, 10], + ), + priority=5, + block=True, +) + + +@_matcher.handle() +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, + type: Match[str], + count: Match[int], +): + group_id = session.id3 or session.id2 + if not group_id: + await Text("群组id为空...").finish() + time_now = datetime.now() + date_scope = None + zero_today = time_now - timedelta( + hours=time_now.hour, minutes=time_now.minute, seconds=time_now.second + ) + date = type.result if type.available else None + if date: + if date in ["日"]: + date_scope = (zero_today, time_now) + elif date in ["周"]: + date_scope = (time_now - timedelta(days=7), time_now) + elif date in ["月"]: + date_scope = (time_now - timedelta(days=30), time_now) + column_name = ["名次", "昵称", "发言次数"] + if rank_data := await ChatHistory.get_group_msg_rank( + group_id, count.result, "DES" if arparma.find("des") else "DESC", date_scope + ): + idx = 1 + data_list = [] + for uid, num in rank_data: + if user := await GroupInfoUser.filter( + user_id=uid, group_id=group_id + ).first(): + user_name = user.user_name + else: + user_name = uid + data_list.append([idx, user_name, num]) + idx += 1 + if not date_scope: + if date_scope := await ChatHistory.get_group_first_msg_datetime(group_id): + date_scope = date_scope.astimezone( + pytz.timezone("Asia/Shanghai") + ).replace(microsecond=0) + else: + date_scope = time_now.replace(microsecond=0) + date_str = f"{date_scope} - 至今" + else: + date_str = f"{date_scope[0].replace(microsecond=0)} - {date_scope[1].replace(microsecond=0)}" + A = await ImageTemplate.table_page( + f"消息排行({count.result})", date_str, column_name, data_list + ) + logger.info( + f"查看消息排行 数量={count.result}", arparma.header_result, session=session + ) + await Image(A.pic2bs4()).finish(reply=True) + await Text("群组消息记录为空...").finish() + + +# # @test.handle() +# # async def _(event: MessageEvent): +# # print(await ChatHistory.get_user_msg(event.user_id, "private")) +# # print(await ChatHistory.get_user_msg_count(event.user_id, "private")) +# # print(await ChatHistory.get_user_msg(event.user_id, "group")) +# # print(await ChatHistory.get_user_msg_count(event.user_id, "group")) +# # print(await ChatHistory.get_group_msg(event.group_id)) +# # print(await ChatHistory.get_group_msg_count(event.group_id)) diff --git a/zhenxun/builtin_plugins/help/__init__.py b/zhenxun/builtin_plugins/help/__init__.py new file mode 100644 index 00000000..41c190b2 --- /dev/null +++ b/zhenxun/builtin_plugins/help/__init__.py @@ -0,0 +1,80 @@ +from nonebot.plugin import PluginMetadata +from nonebot.rule import to_me +from nonebot_plugin_alconna import Alconna, Args, Match, on_alconna +from nonebot_plugin_saa import Image, Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH +from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType + +from ._data_source import create_help_img, get_plugin_help +from ._utils import GROUP_HELP_PATH + +__plugin_meta__ = PluginMetadata( + name="帮助", + description="帮助", + usage="", + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.HIDDEN, + configs=[ + RegisterConfig( + key="type", + value="normal", + help="帮助图片样式 ['normal', 'HTML']", + default_value="normal", + ) + ], + ).dict(), +) + + +SIMPLE_HELP_IMAGE = IMAGE_PATH / "SIMPLE_HELP.png" +if SIMPLE_HELP_IMAGE.exists(): + SIMPLE_HELP_IMAGE.unlink() + +_matcher = on_alconna( + Alconna( + "功能", + Args["name?", str], + ), + aliases={"help", "帮助"}, + rule=to_me(), + priority=1, + block=True, +) + +# TODO: 插件使用详情 图片形式的帮助回复 + + +@_matcher.handle() +async def _( + name: Match[str], + session: EventSession, +): + + if name.available: + if text := await get_plugin_help(name.result): + await Text(text).send(reply=True) + else: + await Text("没有此功能的帮助信息...").send() + logger.info( + f"查看帮助详情: {name.result}", + "帮助", + session=session, + ) + else: + if gid := session.id3 or session.id2: + _image_path = GROUP_HELP_PATH / f"{gid}.png" + if not _image_path.exists(): + await create_help_img(gid) + await Image(_image_path).finish() + else: + if not SIMPLE_HELP_IMAGE.exists(): + if SIMPLE_HELP_IMAGE.exists(): + SIMPLE_HELP_IMAGE.unlink() + await create_help_img(None) + await Image(SIMPLE_HELP_IMAGE).finish() diff --git a/zhenxun/builtin_plugins/help/_config.py b/zhenxun/builtin_plugins/help/_config.py new file mode 100644 index 00000000..b38bf066 --- /dev/null +++ b/zhenxun/builtin_plugins/help/_config.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class Item(BaseModel): + plugin_name: str + sta: int + + +class PluginList(BaseModel): + plugin_type: str + icon: str + logo: str + items: list[Item] diff --git a/zhenxun/builtin_plugins/help/_data_source.py b/zhenxun/builtin_plugins/help/_data_source.py new file mode 100644 index 00000000..75d8b66e --- /dev/null +++ b/zhenxun/builtin_plugins/help/_data_source.py @@ -0,0 +1,35 @@ +import nonebot + +from zhenxun.configs.path_config import IMAGE_PATH +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.utils.image_utils import BuildImage + +from ._utils import HelpImageBuild + +random_bk_path = IMAGE_PATH / "background" / "help" / "simple_help" + +background = IMAGE_PATH / "background" / "0.png" + + +async def create_help_img(group_id: int | None): + """ + 说明: + 生成帮助图片 + 参数: + :param group_id: 群号 + """ + await HelpImageBuild().build_image(group_id) + + +async def get_plugin_help(name: str) -> str: + """获取功能的帮助信息 + + 参数: + name: 插件名称 + """ + if plugin := await PluginInfo.get_or_none(name=name): + _plugin = nonebot.get_plugin_by_module_name(plugin.module_path) + if _plugin and _plugin.metadata: + return _plugin.metadata.usage + return "糟糕! 该功能没有帮助喔..." + return "没有查找到这个功能噢..." diff --git a/zhenxun/builtin_plugins/help/_utils.py b/zhenxun/builtin_plugins/help/_utils.py new file mode 100644 index 00000000..b3f8d3a4 --- /dev/null +++ b/zhenxun/builtin_plugins/help/_utils.py @@ -0,0 +1,242 @@ +import os +import random +from typing import Dict + +from zhenxun.configs.config import Config +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH, TEMPLATE_PATH +from zhenxun.models.group_console import GroupConsole +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.utils.enum import BlockType, PluginType +from zhenxun.utils.image_utils import BuildImage, build_sort_image, group_image + +from ._config import Item + +GROUP_HELP_PATH = DATA_PATH / "group_help" +GROUP_HELP_PATH.mkdir(exist_ok=True, parents=True) +for f in os.listdir(GROUP_HELP_PATH): + group_help_image = GROUP_HELP_PATH / f + group_help_image.unlink() + +BACKGROUND_PATH = IMAGE_PATH / "background" / "help" / "simple_help" + +LOGO_PATH = TEMPLATE_PATH / "menu" / "res" / "logo" + + +class HelpImageBuild: + def __init__(self): + self._data: list[PluginInfo] = [] + self._sort_data: Dict[str, list[PluginInfo]] = {} + self._image_list = [] + self.icon2str = { + "normal": "fa fa-cog", + "原神相关": "fa fa-circle-o", + "常规插件": "fa fa-cubes", + "联系管理员": "fa fa-envelope-o", + "抽卡相关": "fa fa-credit-card-alt", + "来点好康的": "fa fa-picture-o", + "数据统计": "fa fa-bar-chart", + "一些工具": "fa fa-shopping-cart", + "商店": "fa fa-shopping-cart", + "其它": "fa fa-tags", + "群内小游戏": "fa fa-gamepad", + } + + async def sort_type(self): + """ + 对插件按照菜单类型分类 + """ + if not self._data: + self._data = await PluginInfo.filter(plugin_type=PluginType.NORMAL) + if not self._sort_data: + for plugin in self._data: + menu_type = plugin.menu_type or "normal" + if not self._sort_data.get(menu_type): + self._sort_data[menu_type] = [] + self._sort_data[menu_type].append(plugin) + + async def build_image(self, group_id: int | None): + if group_id: + help_image = GROUP_HELP_PATH / f"{group_id}.png" + else: + help_image = IMAGE_PATH / f"SIMPLE_HELP.png" + build_type = Config.get_config("help", "TYPE") + if build_type == "HTML": + byt = await self.build_html_image(group_id) + with open(help_image, "wb") as f: + f.write(byt) + else: + img = await self.build_pil_image(group_id) + await img.save(help_image) + + async def build_html_image(self, group_id: int | None) -> bytes: + from nonebot_plugin_htmlrender import template_to_pic + + await self.sort_type() + classify = {} + for menu in self._sort_data: + for plugin in self._sort_data[menu]: + sta = 0 + if not plugin.status: + if group_id and plugin.block_type in [ + BlockType.ALL, + BlockType.GROUP, + ]: + sta = 2 + if not group_id and plugin.block_type in [ + BlockType.ALL, + BlockType.FRIEND, + ]: + sta = 2 + if group_id and ( + group := await GroupConsole.get_or_none(group_id=group_id) + ): + if f"{plugin.module}:super," in group.block_plugin: + sta = 2 + if f"{plugin.module}," in group.block_plugin: + sta = 1 + if classify.get(menu): + classify[menu].append(Item(plugin_name=plugin.name, sta=sta)) + else: + classify[menu] = [Item(plugin_name=plugin.name, sta=sta)] + max_len = 0 + flag_index = -1 + max_data = None + plugin_list = [] + for index, plu in enumerate(classify.keys()): + if plu in self.icon2str.keys(): + icon = self.icon2str[plu] + else: + icon = "fa fa-pencil-square-o" + logo = LOGO_PATH / random.choice(os.listdir(LOGO_PATH)) + data = { + "name": plu if plu != "normal" else "功能", + "items": classify[plu], + "icon": icon, + "logo": str(logo.absolute()), + } + if len(classify[plu]) > max_len: + max_len = len(classify[plu]) + flag_index = index + max_data = data + plugin_list.append(data) + del plugin_list[flag_index] + plugin_list.insert(0, max_data) + pic = await template_to_pic( + template_path=str((TEMPLATE_PATH / "menu").absolute()), + template_name="zhenxun_menu.html", + templates={"plugin_list": plugin_list}, + pages={ + "viewport": {"width": 1903, "height": 975}, + "base_url": f"file://{TEMPLATE_PATH}", + }, + wait=2, + ) + return pic + + async def build_pil_image(self, group_id: int | None) -> BuildImage: + """构造帮助图片 + + 参数: + group_id: 群号 + """ + self._image_list = [] + await self.sort_type() + font_size = 24 + build_type = Config.get_config("help", "TYPE") + _image = BuildImage.build_text_image("1", size=font_size) + font = BuildImage.load_font("HYWenHei-85W.ttf", 20) + for idx, menu_type in enumerate(self._sort_data.keys()): + plugin_list = self._sort_data[menu_type] + wh_list = [BuildImage.get_text_size(x.name, font) for x in plugin_list] + wh_list.append(BuildImage.get_text_size(menu_type, font)) + # sum_height = sum([x[1] for x in wh_list]) + if build_type == "VV": + sum_height = 50 * len(plugin_list) + 10 + else: + sum_height = (font_size + 6) * len(plugin_list) + 10 + max_width = max([x[0] for x in wh_list]) + 20 + bk = BuildImage( + max_width + 40, + sum_height + 50, + font_size=30, + color="#a7d1fc", + font="CJGaoDeGuo.otf", + ) + title_size = bk.getsize(menu_type) + max_width = max_width if max_width > title_size[0] else title_size[0] + B = BuildImage( + max_width + 40, + sum_height, + font_size=font_size, + color="white" if not idx % 2 else "black", + ) + curr_h = 10 + if group := await GroupConsole.get_or_none(group_id=group_id): + for i, plugin in enumerate(plugin_list): + text_color = (255, 255, 255) if idx % 2 else (0, 0, 0) + if f"{plugin.module}," in group.block_plugin: + text_color = (252, 75, 13) + pos = None + # 禁用状态划线 + if ( + plugin.block_type in [BlockType.ALL, BlockType.GROUP] + or f"{plugin.module}:super," in group.block_plugin + ): + w = curr_h + int(B.getsize(plugin.name)[1] / 2) + 2 + pos = ( + 7, + w, + B.getsize(plugin.name)[0] + 35, + w, + ) + if build_type == "VV": + name_image = await self.build_name_image( # type: ignore + max_width, + plugin.name, + "black" if not idx % 2 else "white", + text_color, + pos, + ) + await B.paste(name_image, (0, curr_h), center_type="width") + curr_h += name_image.h + 5 + else: + await B.text((10, curr_h), f"{i + 1}.{plugin.name}", text_color) + if pos: + await B.line(pos, (236, 66, 7), 3) + curr_h += font_size + 5 + if menu_type == "normal": + menu_type = "功能" + await bk.text((0, 14), menu_type, center_type="width") + await bk.paste(B, (0, 50)) + await bk.transparent(2) + # await bk.acircle_corner(point_list=['lt', 'rt']) + self._image_list.append(bk) + image_group, h = group_image(self._image_list) + B = await build_sort_image( + image_group, + h, + background_path=BACKGROUND_PATH, + background_handle=lambda image: image.filter("GaussianBlur", 5), + ) + w = 10 + h = 10 + for msg in [ + "目前支持的功能列表:", + "可以通过 ‘帮助[功能名称]’ 来获取对应功能的使用方法", + ]: + text = await BuildImage.build_text_image(msg, "HYWenHei-85W.ttf", 24) + await B.paste(text, (w, h)) + h += 50 + if msg == "目前支持的功能列表:": + w += 50 + text = await BuildImage.build_text_image( + "注: 红字代表功能被群管理员禁用,红线代表功能正在维护", + "HYWenHei-85W.ttf", + 24, + (231, 74, 57), + ) + await B.paste( + text, + (300, 10), + ) + return B diff --git a/zhenxun/builtin_plugins/hooks/__init__.py b/zhenxun/builtin_plugins/hooks/__init__.py new file mode 100644 index 00000000..80aa7181 --- /dev/null +++ b/zhenxun/builtin_plugins/hooks/__init__.py @@ -0,0 +1,43 @@ +from pathlib import Path + +import nonebot + +from zhenxun.configs.config import Config + +Config.add_plugin_config( + "hook", + "CHECK_NOTICE_INFO_CD", + 300, + help="群检测,个人权限检测等各种检测提示信息cd", + default_value=300, + type=int, +) + +Config.add_plugin_config( + "hook", + "MALICIOUS_BAN_TIME", + 30, + help="恶意命令触发检测触发后ban的时长(分钟)", + default_value=30, + type=int, +) + +Config.add_plugin_config( + "hook", + "MALICIOUS_CHECK_TIME", + 5, + help="恶意命令触发检测规定时间内(秒)", + default_value=5, + type=int, +) + +Config.add_plugin_config( + "hook", + "MALICIOUS_BAN_COUNT", + 6, + help="恶意命令触发检测最大触发次数", + default_value=6, + type=int, +) + +nonebot.load_plugins(str(Path(__file__).parent.resolve())) diff --git a/zhenxun/builtin_plugins/hooks/ban_hook.py b/zhenxun/builtin_plugins/hooks/ban_hook.py new file mode 100644 index 00000000..3f10e078 --- /dev/null +++ b/zhenxun/builtin_plugins/hooks/ban_hook.py @@ -0,0 +1,61 @@ +from nonebot.adapters import Bot, Event +from nonebot.exception import IgnoredException +from nonebot.matcher import Matcher +from nonebot.message import run_preprocessor +from nonebot.typing import T_State +from nonebot_plugin_saa import Mention, MessageFactory, Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.models.ban_console import BanConsole +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.utils import FreqLimiter + +Config.add_plugin_config( + "hook", + "BAN_RESULT", + "才不会给你发消息.", + help="对被ban用户发送的消息", +) + +_flmt = FreqLimiter(300) + + +# 检查是否被ban +@run_preprocessor +async def _( + matcher: Matcher, bot: Bot, event: Event, state: T_State, session: EventSession +): + if plugin := matcher.plugin: + if metadata := plugin.metadata: + extra = metadata.extra + if extra.get("plugin_type") == PluginType.HIDDEN: + return + user_id = session.id1 + group_id = session.id3 or session.id2 + if user_id: + ban_result = Config.get_config("hook", "BAN_RESULT") + if user_id in bot.config.superusers: + return + if await BanConsole.is_ban(user_id) or await BanConsole.is_ban( + user_id, group_id + ): + time = await BanConsole.check_ban_time(user_id) + if time == -1: + time_str = "∞" + else: + time = abs(int(time)) + if time < 60: + time_str = str(time) + " 秒" + else: + time_str = str(int(time / 60)) + " 分钟" + if ban_result and _flmt.check(user_id): + _flmt.start_cd(user_id) + await MessageFactory( + [ + Mention(user_id), + Text(f"{ban_result}\n在..在 {time_str} 后才会理你喔"), + ] + ).send() + raise IgnoredException("用户处于黑名单中") diff --git a/zhenxun/builtin_plugins/hooks/chkdsk_hook.py b/zhenxun/builtin_plugins/hooks/chkdsk_hook.py new file mode 100644 index 00000000..fffc1b80 --- /dev/null +++ b/zhenxun/builtin_plugins/hooks/chkdsk_hook.py @@ -0,0 +1,104 @@ +import time +from collections import defaultdict + +from click import command +from nonebot.adapters.onebot.v11 import ActionFailed, Bot, GroupMessageEvent +from nonebot.exception import IgnoredException +from nonebot.matcher import Matcher +from nonebot.message import run_preprocessor +from nonebot.typing import T_State +from nonebot_plugin_alconna import Arparma +from nonebot_plugin_saa import Mention, MessageFactory, Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.models.ban_console import BanConsole +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType + +malicious_check_time = Config.get_config("hook", "MALICIOUS_CHECK_TIME") +malicious_ban_count = Config.get_config("hook", "MALICIOUS_BAN_COUNT") + +if not malicious_check_time: + raise ValueError("模块: [hook], 配置项: [MALICIOUS_CHECK_TIME] 为空或小于0") +if not malicious_ban_count: + raise ValueError("模块: [hook], 配置项: [MALICIOUS_BAN_COUNT] 为空或小于0") + + +class BanCheckLimiter: + """ + 恶意命令触发检测 + """ + + def __init__(self, default_check_time: float = 5, default_count: int = 4): + self.mint = defaultdict(int) + self.mtime = defaultdict(float) + self.default_check_time = default_check_time + self.default_count = default_count + + def add(self, key: str | int | float): + if self.mint[key] == 1: + self.mtime[key] = time.time() + self.mint[key] += 1 + + def check(self, key: str | int | float) -> bool: + if time.time() - self.mtime[key] > self.default_check_time: + self.mtime[key] = time.time() + self.mint[key] = 0 + return False + if ( + self.mint[key] >= self.default_count + and time.time() - self.mtime[key] < self.default_check_time + ): + self.mtime[key] = time.time() + self.mint[key] = 0 + return True + return False + + +_blmt = BanCheckLimiter( + malicious_check_time, + malicious_ban_count, +) + + +# 恶意触发命令检测 +@run_preprocessor +async def _(matcher: Matcher, bot: Bot, session: EventSession, state: T_State): + if plugin := matcher.plugin: + if metadata := plugin.metadata: + extra = metadata.extra + if extra.get("plugin_type") == PluginType.HIDDEN: + return + user_id = session.id1 + group_id = session.id3 or session.id2 + malicious_ban_time = Config.get_config("hook", "MALICIOUS_BAN_TIME") + if not malicious_ban_time: + raise ValueError("模块: [hook], 配置项: [MALICIOUS_BAN_TIME] 为空或小于0") + if user_id: + command = state["_prefix"]["raw_command"] + if state["_alc_result"]: + command = state["_alc_result"].source.command + if command: + if _blmt.check(f"{user_id}__{command}"): + await BanConsole.ban( + user_id, group_id, 9, malicious_ban_time * 60, bot.self_id + ) + logger.info( + f"触发了恶意触发检测: {matcher.plugin_name}", + "HOOK", + session=session, + ) + await MessageFactory( + [ + Mention(user_id), + Text(f"检测到恶意触发命令,您将被封禁 30 分钟"), + ] + ).send() + logger.debug( + f"触发了恶意触发检测: {matcher.plugin_name}", + "HOOK", + session=session, + ) + raise IgnoredException("检测到恶意触发命令") + _blmt.add(f"{user_id}__{command}") diff --git a/zhenxun/builtin_plugins/hooks/withdraw_hook.py b/zhenxun/builtin_plugins/hooks/withdraw_hook.py new file mode 100644 index 00000000..eab5267a --- /dev/null +++ b/zhenxun/builtin_plugins/hooks/withdraw_hook.py @@ -0,0 +1,46 @@ +import asyncio +from typing import Optional + +from nonebot.adapters import Bot +from nonebot.adapters.discord import Bot as DiscordBot +from nonebot.adapters.dodo import Bot as DodoBot +from nonebot.adapters.kaiheila import Bot as KaiheilaBot +from nonebot.adapters.onebot.v11 import Bot as v11Bot +from nonebot.adapters.onebot.v12 import Bot as v12Bot +from nonebot.matcher import Matcher +from nonebot.message import run_postprocessor + +from zhenxun.services.log import logger +from zhenxun.utils.utils import WithdrawManager + +# TODO: 其他平台撤回消息 + + +# 消息撤回 +@run_postprocessor +async def _( + matcher: Matcher, + exception: Optional[Exception], + bot: Bot, +): + tasks = [] + for message_id in WithdrawManager._data: + second = WithdrawManager._data[message_id] + tasks.append(asyncio.ensure_future(_withdraw_message(bot, message_id, second))) + WithdrawManager.remove(message_id) + await asyncio.gather(*tasks) + + +async def _withdraw_message(bot: Bot, message_id: str, time: int): + await asyncio.sleep(time) + logger.debug(f"撤回消息ID: {message_id}", "HOOK") + if isinstance(bot, v11Bot): + await bot.delete_msg(message_id=int(message_id)) + elif isinstance(bot, v12Bot): + await bot.delete_message(message_id=message_id) + elif isinstance(bot, DodoBot): + pass + elif isinstance(bot, KaiheilaBot): + pass + elif isinstance(bot, DiscordBot): + pass diff --git a/zhenxun/builtin_plugins/init/init_plugin.py b/zhenxun/builtin_plugins/init/init_plugin.py index 2a19cebc..e80fc8f6 100644 --- a/zhenxun/builtin_plugins/init/init_plugin.py +++ b/zhenxun/builtin_plugins/init/init_plugin.py @@ -1,26 +1,15 @@ -from pathlib import Path -from typing import List - import nonebot from nonebot import get_loaded_plugins from nonebot.drivers import Driver from nonebot.plugin import Plugin -from ruamel import yaml -from ruamel.yaml import YAML, round_trip_dump, round_trip_load -from ruamel.yaml.comments import CommentedMap +from ruamel.yaml import YAML -from zhenxun.configs.config import Config -from zhenxun.configs.path_config import DATA_PATH -from zhenxun.configs.utils import ( - BaseBlock, - PluginExtraData, - PluginSetting, - RegisterConfig, -) +from zhenxun.configs.utils import PluginExtraData, PluginSetting from zhenxun.models.plugin_info import PluginInfo from zhenxun.models.plugin_limit import PluginLimit +from zhenxun.models.task_info import TaskInfo from zhenxun.services.log import logger -from zhenxun.utils.enum import PluginLimitType +from zhenxun.utils.enum import PluginType _yaml = YAML(pure=True) _yaml.allow_unicode = True @@ -29,8 +18,11 @@ _yaml.indent = 2 driver: Driver = nonebot.get_driver() -def _handle_setting( - plugin: Plugin, plugin_list: List[PluginInfo], limit_list: List[PluginLimit] +async def _handle_setting( + plugin: Plugin, + plugin_list: list[PluginInfo], + limit_list: list[PluginLimit], + task_list: list[TaskInfo], ): """处理插件设置 @@ -45,6 +37,8 @@ def _handle_setting( extra_data = PluginExtraData(**extra) logger.debug(f"{metadata.name}:{plugin.name} -> {extra}", "初始化插件数据") setting = extra_data.setting or PluginSetting() + if metadata.type == "library": + extra_data.plugin_type = PluginType.HIDDEN plugin_list.append( PluginInfo( module=plugin.name, @@ -76,6 +70,16 @@ def _handle_setting( max_count=getattr(limit, "max_count", None), ) ) + if extra_data.tasks: + for task in extra_data.tasks: + task_list.append( + TaskInfo( + module=task.module, + name=task.name, + status=task.status, + run_time=task.run_time, + ) + ) @driver.on_startup @@ -83,14 +87,15 @@ async def _(): """ 初始化插件数据配置 """ - plugin_list: List[PluginInfo] = [] - limit_list: List[PluginLimit] = [] + plugin_list: list[PluginInfo] = [] + limit_list: list[PluginLimit] = [] + task_list: list[TaskInfo] = [] module2id = {} if module_list := await PluginInfo.all().values("id", "module_path"): module2id = {m["module_path"]: m["id"] for m in module_list} for plugin in get_loaded_plugins(): if plugin.metadata: - _handle_setting(plugin, plugin_list, limit_list) + await _handle_setting(plugin, plugin_list, limit_list, task_list) create_list = [] update_list = [] for plugin in plugin_list: @@ -124,3 +129,23 @@ async def _(): limit_create.append(limit) if limit_create: await PluginLimit.bulk_create(limit_create, 10) + if task_list: + module_dict = { + t[1]: t[0] for t in await TaskInfo.all().values_list("id", "module") + } + create_list = [] + update_list = [] + for task in task_list: + if task.module not in module_list: + create_list.append(task) + else: + task.id = module_dict[task.module] + update_list.append(task) + if create_list: + await TaskInfo.bulk_create(create_list, 10) + if update_list: + await TaskInfo.bulk_update( + update_list, + ["run_time", "status", "name"], + 10, + ) diff --git a/zhenxun/builtin_plugins/nickname.py b/zhenxun/builtin_plugins/nickname.py new file mode 100644 index 00000000..d672db69 --- /dev/null +++ b/zhenxun/builtin_plugins/nickname.py @@ -0,0 +1,240 @@ +import random +from typing import Any, List + +from nonebot import on_regex +from nonebot.adapters import Bot +from nonebot.matcher import Matcher +from nonebot.params import Depends, RegexGroup +from nonebot.plugin import PluginMetadata +from nonebot.rule import to_me +from nonebot_plugin_alconna import Alconna, Option, UniMsg, on_alconna, store_true +from nonebot_plugin_saa import Text +from nonebot_plugin_session import EventSession +from nonebot_plugin_userinfo import EventUserInfo, UserInfo + +from zhenxun.configs.config import NICKNAME, Config +from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.models.ban_console import BanConsole +from zhenxun.models.friend_user import FriendUser +from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType + +__plugin_meta__ = PluginMetadata( + name="昵称系统", + description="区区昵称,才不想叫呢!", + usage=f""" + 个人昵称,将替换{NICKNAME}称呼你的名称,群聊 与 私聊 昵称相互独立,全局昵称设置将更改您目前所有群聊中及私聊的昵称 + 指令: + 以后叫我 [昵称]: 设置当前群聊/私聊的昵称 + 全局昵称设置 [昵称]: 设置当前所有群聊和私聊的昵称 + {NICKNAME}我是谁 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.NORMAL, + menu_type="商店", + configs=[ + RegisterConfig( + key="BLACK_WORD", + value=["爸", "爹", "爷", "父"], + help="昵称所屏蔽的关键词,已设置的昵称会被替换为 *,未设置的昵称会在设置时提示", + default_value=None, + type=List[str], + ) + ], + ).dict(), +) + +_nickname_matcher = on_regex( + "(?:以后)?(?:叫我|请叫我|称呼我)(.*)", + rule=to_me(), + priority=5, + block=True, +) + +_global_nickname_matcher = on_regex( + "设置全局昵称(.*)", rule=to_me(), priority=5, block=True +) + +_matcher = on_alconna( + Alconna( + "nickname", + Option("--name", action=store_true, help_text="用户昵称"), + Option("--cancel", action=store_true, help_text="取消昵称"), + ), + rule=to_me(), + priority=5, + block=True, +) + + +CALL_NAME = [ + "好啦好啦,我知道啦,{},以后就这么叫你吧", + f"嗯嗯,{NICKNAME}" + "记住你的昵称了哦,{}", + "好突然,突然要叫你昵称什么的...{}..", + f"{NICKNAME}" + "会好好记住{}的,放心吧", + "好..好.,那窝以后就叫你{}了.", +] + +REMIND = [ + "我肯定记得你啊,你是{}啊", + "我不会忘记你的,你也不要忘记我!{}", + f"哼哼,{NICKNAME}" + "记忆力可是很好的,{}", + "嗯?你是失忆了嘛...{}..", + f"不要小看{NICKNAME}" + "的记忆力啊!笨蛋{}!QAQ", + "哎?{}..怎么了吗..突然这样问..", +] + +CANCEL = [ + f"呜..{NICKNAME}" + "睡一觉就会忘记的..和梦一样..{}", + "窝知道了..{}..", + f"是{NICKNAME}" + "哪里做的不好嘛..好吧..晚安{}", + "呃,{},下次我绝对绝对绝对不会再忘记你!", + "可..可恶!{}!太可恶了!呜", +] + + +def CheckNickname(): + """ + 检查名称是否合法 + """ + + async def dependency( + bot: Bot, + matcher: Matcher, + session: EventSession, + message: UniMsg, + reg_group: tuple[Any, ...] = RegexGroup(), + ): + black_word = Config.get_config("nickname", "BLACK_WORD") + (name,) = reg_group + logger.debug(f"昵称检查: {name}", "昵称设置", session=session) + if not name: + await Text("叫你空白?叫你虚空?叫你无名??").finish(at_sender=True) + if session.id1 in bot.config.superusers: + logger.debug( + f"超级用户设置昵称, 跳过合法检测: {name}", "昵称设置", session=session + ) + return + if len(name) > 20: + await Text("昵称可不能超过20个字!").finish(at_sender=True) + if name in bot.config.nickname: + await Text("笨蛋!休想占用我的名字! #").finish(at_sender=True) + if black_word: + for x in name: + if x in black_word: + logger.debug("昵称设置禁止字符: [{x}]", "昵称设置", session=session) + await Text(f"字符 [{x}] 为禁止字符!").finish(at_sender=True) + for word in black_word: + if word in name: + logger.debug( + "昵称设置禁止字符: [{word}]", "昵称设置", session=session + ) + await Text(f"字符 [{x}] 为禁止字符!").finish(at_sender=True) + + return Depends(dependency) + + +@_nickname_matcher.handle(parameterless=[CheckNickname()]) +async def _( + session: EventSession, + user_info: UserInfo = EventUserInfo(), + reg_group: tuple[Any, ...] = RegexGroup(), +): + if session.id1: + (name,) = reg_group + if len(name) < 5: + if random.random() < 0.3: + name = "~".join(name) + if gid := session.id3 or session.id2: + await GroupInfoUser.set_user_nickname( + session.id1, + gid, + name, + user_info.user_displayname + or user_info.user_remark + or user_info.user_name, + session.platform, + ) + logger.info(f"设置群昵称成功: {name}", "昵称设置", session=session) + await Text(random.choice(CALL_NAME).format(name)).finish(reply=True) + else: + await FriendUser.set_user_nickname( + session.id1, + name, + user_info.user_displayname + or user_info.user_remark + or user_info.user_name, + session.platform, + ) + logger.info(f"设置私聊昵称成功: {name}", "昵称设置", session=session) + await Text(random.choice(CALL_NAME).format(name)).finish(reply=True) + await Text("用户id为空...").send() + + +@_global_nickname_matcher.handle(parameterless=[CheckNickname()]) +async def _( + session: EventSession, + user_info: UserInfo = EventUserInfo(), + reg_group: tuple[Any, ...] = RegexGroup(), +): + if session.id1: + (name,) = reg_group + await FriendUser.set_user_nickname( + session.id1, + name, + user_info.user_displayname or user_info.user_remark or user_info.user_name, + session.platform, + ) + await GroupInfoUser.filter(user_id=session.id1).update(nickname=name) + logger.info(f"设置全局昵称成功: {name}", "设置全局昵称", session=session) + await Text(random.choice(CALL_NAME).format(name)).finish(reply=True) + await Text("用户id为空...").send() + + +@_matcher.assign("name") +async def _(session: EventSession, user_info: UserInfo = EventUserInfo()): + if session.id1: + if gid := session.id3 or session.id2: + nickname = await GroupInfoUser.get_user_nickname(session.id1, gid) + card = user_info.user_displayname or user_info.user_name + else: + nickname = await FriendUser.get_user_nickname(session.id1) + card = user_info.user_name + if nickname: + await Text(random.choice(REMIND).format(nickname)).finish(reply=True) + else: + await Text( + random.choice( + [ + "没..没有昵称嘛,{}", + "啊,你是{}啊,我想叫你的昵称!", + "是{}啊,有什么事吗?", + "你是{}?", + ] + ).format(card) + ).finish(reply=True) + await Text("用户id为空...").send() + + +@_matcher.assign("cancel") +async def _(bot: Bot, session: EventSession, user_info: UserInfo = EventUserInfo()): + if session.id1: + gid = session.id3 or session.id2 + if gid: + nickname = await GroupInfoUser.get_user_nickname(session.id1, gid) + else: + nickname = await FriendUser.get_user_nickname(session.id1) + if nickname: + await Text(random.choice(CANCEL).format(nickname)).send(reply=True) + if gid: + await GroupInfoUser.set_user_nickname(session.id1, gid, "") + else: + await FriendUser.set_user_nickname(session.id1, "") + await BanConsole.ban(session.id1, gid, 9, 60, bot.self_id) + return + else: + await Text("你在做梦吗?你没有昵称啊").finish(reply=True) + await Text("用户id为空...").send() diff --git a/zhenxun/builtin_plugins/platform/qq/group_handle.py b/zhenxun/builtin_plugins/platform/qq/group_handle.py new file mode 100644 index 00000000..141b58bc --- /dev/null +++ b/zhenxun/builtin_plugins/platform/qq/group_handle.py @@ -0,0 +1,297 @@ +import os +import random +import re +from datetime import datetime + +import nonebot +import ujson as json +from nonebot import on_notice, on_request +from nonebot.adapters import Bot +from nonebot.adapters.onebot.v11 import ( + GroupDecreaseNoticeEvent, + GroupIncreaseNoticeEvent, +) +from nonebot.adapters.onebot.v12 import ( + GroupMemberDecreaseEvent, + GroupMemberIncreaseEvent, +) +from nonebot.plugin import PluginMetadata +from nonebot_plugin_saa import Image, Mention, MessageFactory, Text + +from zhenxun.configs.config import NICKNAME, Config +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH +from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task +from zhenxun.models.fg_request import FgRequest +from zhenxun.models.group_console import GroupConsole +from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.models.level_user import LevelUser +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType, RequestHandleType, RequestType +from zhenxun.utils.utils import FreqLimiter + +__plugin_meta__ = PluginMetadata( + name="QQ群事件处理", + description="群事件处理", + usage="", + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.HIDDEN, + configs=[ + RegisterConfig( + module="invite_manager", + key="message", + value=f"请不要未经同意就拉{NICKNAME}入群!告辞!", + help="强制拉群后进群回复的内容", + ), + RegisterConfig( + module="invite_manager", + key="flag", + value=True, + help="强制拉群后进群回复的内容", + default_value=True, + type=bool, + ), + RegisterConfig( + module="invite_manager", + key="welcome_msg_cd", + value=5, + help="群欢迎消息cd", + default_value=5, + type=int, + ), + RegisterConfig( + module="_task", + key="DEFAULT_GROUP_WELCOME", + value=True, + help="被动 进群欢迎 进群默认开关状态", + default_value=True, + type=bool, + ), + RegisterConfig( + module="_task", + key="DEFAULT_REFUND_GROUP_REMIND", + value=True, + help="被动 退群提醒 进群默认开关状态", + default_value=True, + type=bool, + ), + ], + tasks=[ + Task(module="group_welcome", name="进群欢迎"), + Task(module="refund_group_remind", name="退群提醒"), + ], + ).dict(), +) + + +superuser = nonebot.get_driver().config.platform_superusers["qq"][0] + +base_config = Config.get("invite_manager") + + +limit_cd = base_config.get("welcome_msg_cd") + +_flmt = FreqLimiter(limit_cd) + + +group_increase_handle = on_notice(priority=1, block=False) +"""群员增加处理""" +group_decrease_handle = on_notice(priority=1, block=False) +"""群员减少处理""" +add_group = on_request(priority=1, block=False) +"""加群同意请求""" + + +@group_increase_handle.handle() +async def _(bot: Bot, event: GroupIncreaseNoticeEvent | GroupMemberIncreaseEvent): + user_id = str(event.user_id) + group_id = str(event.group_id) + if user_id == bot.self_id: + """新成员为bot本身""" + group = await GroupConsole.get_or_none(group_id=group_id) + if (not group or group.group_flag == 0) and base_config.get("flag"): + """群聊不存在或被强制拉群,退出该群""" + try: + if result_msg := base_config.get("message"): + await bot.send_group_msg( + group_id=event.group_id, message=result_msg + ) + await bot.set_group_leave(group_id=event.group_id) + await bot.send_private_msg( + user_id=int(superuser), + message=f"触发强制入群保护,已成功退出群聊 {group_id}...", + ) + logger.info( + f"强制拉群或未有群信息,退出群聊成功", + "入群检测", + group_id=event.group_id, + ) + if req := await FgRequest.get_or_none(group_id=group_id): + req.handle_type = RequestHandleType.IGNORE + await req.save(update_fields=["handle_type"]) + except Exception as e: + logger.error( + f"强制拉群或未有群信息,退出群聊失败", + "入群检测", + group_id=event.group_id, + e=e, + ) + await bot.send_private_msg( + user_id=int(superuser), + message=f"触发强制入群保护,退出群聊 {event.group_id} 失败...", + ) + elif group_id not in await GroupConsole.all().values_list( + "group_id", flat=True + ): + """默认群功能开关""" + block_plugin = "" + if plugin_list := await PluginInfo.filter(default_status=False).all(): + for plugin in plugin_list: + block_plugin += f"{plugin.module}," + group_info = await bot.get_group_info(group_id=event.group_id) + await GroupConsole.create( + group_id=group_info["group_id"], + group_name=group_info["group_name"], + max_member_count=group_info["max_member_count"], + member_count=group_info["member_count"], + group_flag=1, + block_plugin=block_plugin, + platform="qq", + ) + admin_default_auth = Config.get_config( + "admin_bot_manage", "ADMIN_DEFAULT_AUTH" + ) + # 即刻刷新权限 + for user_info in await bot.get_group_member_list(group_id=event.group_id): + """即刻刷新权限""" + if ( + user_info["role"] + in [ + "owner", + "admin", + ] + and not await LevelUser.is_group_flag( + user_info["user_id"], group_id + ) + and admin_default_auth is not None + ): + await LevelUser.set_level( + user_info["user_id"], + user_info["group_id"], + admin_default_auth, + ) + logger.debug( + f"添加默认群管理员权限: {admin_default_auth}", + "入群检测", + session=user_info["user_id"], + group_id=user_info["group_id"], + ) + if str(user_info["user_id"]) in bot.config.superusers: + await LevelUser.set_level( + user_info["user_id"], user_info["group_id"], 9 + ) + logger.debug( + f"添加超级用户权限: 9", + "入群检测", + session=user_info["user_id"], + group_id=user_info["group_id"], + ) + else: + join_time = datetime.now() + user_info = await bot.get_group_member_info( + group_id=event.group_id, user_id=event.user_id + ) + await GroupInfoUser.update_or_create( + user_id=str(user_info["user_id"]), + group_id=str(user_info["group_id"]), + defaults={"user_name": user_info["nickname"], "user_join_time": join_time}, + ) + logger.info(f"用户{user_info['user_id']} 所属{user_info['group_id']} 更新成功") + + if _flmt.check(group_id): + """群欢迎消息""" + _flmt.start_cd(group_id) + path = DATA_PATH / "welcome_message" / "qq" / f"{group_id}" + data = json.load((path / "text.json").open()) + message = data["message"] + msg_split = re.split(r"\[image:\d+\]", message) + msg_list = [] + if data["at"]: + msg_list.append(Mention(user_id)) + for i, text in enumerate(msg_split): + msg_list.append(Text(text)) + img_file = path / f"{i}.png" + if img_file.exists(): + msg_list.append(Image(img_file)) + if GroupConsole.is_block_task(group_id, "group_welcome"): + logger.info(f"发送群欢迎消息...", "入群检测", group_id=group_id) + if msg_list: + await MessageFactory(msg_list).send() + else: + await MessageFactory( + [ + Text("新人快跑啊!!本群现状↓(快使用自定义!)"), + Image( + IMAGE_PATH + / "qxz" + / random.choice(os.listdir(IMAGE_PATH / "qxz")) + ), + ] + ).send() + + +@group_decrease_handle.handle() +async def _(bot: Bot, event: GroupDecreaseNoticeEvent | GroupMemberDecreaseEvent): + if event.sub_type == "kick_me": + """踢出Bot""" + group_id = event.group_id + operator_id = event.operator_id + if user := await GroupInfoUser.get_or_none( + user_id=str(event.operator_id), group_id=str(event.group_id) + ): + operator_name = user.user_name + else: + operator_name = "None" + group = await GroupConsole.filter(group_id=str(group_id)).first() + group_name = group.group_name if group else "" + coffee = int(list(bot.config.superusers)[0]) + await bot.send_private_msg( + user_id=coffee, + message=f"****呜..一份踢出报告****\n" + f"我被 {operator_name}({operator_id})\n" + f"踢出了 {group_name}({group_id})\n" + f"日期:{str(datetime.now()).split('.')[0]}", + ) + return + if str(event.user_id) == bot.self_id: + """踢出Bot""" + await GroupConsole.filter(group_id=str(event.group_id)).delete() + return + if user := await GroupInfoUser.get_or_none( + user_id=str(event.user_id), group_id=str(event.group_id) + ): + user_name = user.user_name + else: + user_name = f"{event.user_id}" + await GroupInfoUser.filter( + user_id=str(event.user_id), group_id=str(event.group_id) + ).delete() + logger.info( + f"名称: {user_name} 退出群聊", + "group_decrease_handle", + session=event.user_id, + group_id=event.group_id, + ) + result = "" + if event.sub_type == "leave": + result = f"{user_name}离开了我们..." + if event.sub_type == "kick": + operator = await bot.get_group_member_info( + user_id=event.operator_id, group_id=event.group_id + ) + operator_name = operator["card"] if operator["card"] else operator["nickname"] + result = f"{user_name} 被 {operator_name} 送走了." + if GroupConsole.is_block_task(str(event.group_id), "refund_group_remind"): + await group_decrease_handle.send(f"{result}") diff --git a/zhenxun/builtin_plugins/record_request.py b/zhenxun/builtin_plugins/record_request.py index 2743e662..367031fc 100644 --- a/zhenxun/builtin_plugins/record_request.py +++ b/zhenxun/builtin_plugins/record_request.py @@ -2,7 +2,8 @@ import time from datetime import datetime from typing import Dict -from nonebot import on_message, on_request +import nonebot +from nonebot import drivers, on_message, on_request from nonebot.adapters.onebot.v11 import ( ActionFailed, Bot, @@ -18,7 +19,7 @@ from zhenxun.configs.config import NICKNAME, Config from zhenxun.configs.utils import PluginExtraData, RegisterConfig from zhenxun.models.fg_request import FgRequest from zhenxun.models.friend_user import FriendUser -from zhenxun.models.group_info import GroupInfo +from zhenxun.models.group_console import GroupConsole from zhenxun.services.log import logger from zhenxun.utils.enum import PluginType, RequestType @@ -26,7 +27,7 @@ base_config = Config.get("invite_manager") __plugin_meta__ = PluginMetadata( name="记录请求", - description="自定义群欢迎消息", + description="记录 好友/群组 请求", usage="", extra=PluginExtraData( author="HibiKier", @@ -61,6 +62,8 @@ class Timer: cls.data = {k: v for k, v in cls.data.items() if v - now < 5 * 60} +# TODO: 其他平台请求 + friend_req = on_request(priority=5, block=True) group_req = on_request(priority=5, block=True) _t = on_message(priority=999, block=False, rule=lambda: False) @@ -69,13 +72,14 @@ _t = on_message(priority=999, block=False, rule=lambda: False) @friend_req.handle() async def _(bot: Bot, event: FriendRequestEvent, session: EventSession): if event.user_id and Timer.check(event.user_id): + superuser = nonebot.get_driver().config.platform_superusers["qq"][0] logger.debug(f"收录好友请求...", "好友请求", target=event.user_id) user = await bot.get_stranger_info(user_id=event.user_id) nickname = user["nickname"] # sex = user["sex"] # age = str(user["age"]) comment = event.comment - superuser = int(list(bot.config.superusers)[0]) + superuser = int(superuser) await Text( f"*****一份好友申请*****\n" f"昵称:{nickname}({event.user_id})\n" @@ -84,7 +88,11 @@ async def _(bot: Bot, event: FriendRequestEvent, session: EventSession): f"备注:{event.comment}" ).send_to(target=TargetQQPrivate(user_id=superuser), bot=bot) if base_config.get("AUTO_ADD_FRIEND"): - logger.debug(f"已开启好友请求自动同意,成功通过该请求", "好友请求", target=event.user_id) + logger.debug( + f"已开启好友请求自动同意,成功通过该请求", + "好友请求", + target=event.user_id, + ) await bot.set_friend_add_request(flag=event.flag, approve=True) await FriendUser.create( user_id=str(user["user_id"]), user_name=user["nickname"] @@ -119,7 +127,7 @@ async def _(bot: Bot, event: GroupRequestEvent, session: EventSession): flag=event.flag, sub_type="invite", approve=True ) group_info = await bot.get_group_info(group_id=event.group_id) - await GroupInfo.update_or_create( + await GroupConsole.update_or_create( group_id=str(group_info["group_id"]), defaults={ "group_name": group_info["group_name"], diff --git a/zhenxun/builtin_plugins/scheduler/__init__.py b/zhenxun/builtin_plugins/scheduler/__init__.py new file mode 100644 index 00000000..eb35e275 --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +import nonebot + +nonebot.load_plugins(str(Path(__file__).parent.resolve())) diff --git a/zhenxun/builtin_plugins/scheduler/auto_backup.py b/zhenxun/builtin_plugins/scheduler/auto_backup.py new file mode 100644 index 00000000..92f1119b --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler/auto_backup.py @@ -0,0 +1,62 @@ +import shutil +from pathlib import Path + +from nonebot_plugin_apscheduler import scheduler + +from zhenxun.configs.config import Config +from zhenxun.services.log import logger + +Config.add_plugin_config( + "_backup", + "BACKUP_FLAG", + True, + help="是否开启文件备份", + default_value=True, + type=bool, +) + +Config.add_plugin_config( + "_backup", + "BACKUP_DIR_OR_FILE", + [ + "data/black_word", + "data/configs", + "data/statistics", + "data/word_bank", + "data/manager", + "configs", + ], + help="备份的文件夹或文件", + default_value=[], + type=list[str], +) + + +# 自动备份 +@scheduler.scheduled_job( + "cron", + hour=3, + minute=25, +) +async def _(): + if Config.get_config("_backup", "BACKUP_FLAG"): + _backup_path = Path() / "backup" + _backup_path.mkdir(exist_ok=True, parents=True) + if backup_dir_or_file := Config.get_config("_backup", "BACKUP_DIR_OR_FILE"): + for path_file in backup_dir_or_file: + try: + path = Path(path_file) + _p = _backup_path / path_file + if path.exists(): + if path.is_dir(): + if _p.exists(): + shutil.rmtree(_p, ignore_errors=True) + shutil.copytree(path_file, _p) + else: + if _p.exists(): + _p.unlink() + shutil.copy(path_file, _p) + logger.debug(f"已完成自动备份:{path_file}", "自动备份") + except Exception as e: + logger.error(f"自动备份文件 {path_file} 发生错误", "自动备份", e=e) + logger.info("自动备份成功...", "自动备份") diff --git a/zhenxun/builtin_plugins/scheduler/auto_update_group.py b/zhenxun/builtin_plugins/scheduler/auto_update_group.py new file mode 100644 index 00000000..1cfdacb3 --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler/auto_update_group.py @@ -0,0 +1,68 @@ +import nonebot +from nonebot_plugin_apscheduler import scheduler + +from zhenxun.models.friend_user import FriendUser +from zhenxun.models.group_console import GroupConsole +from zhenxun.services.log import logger + +# TODO: 其他平台更新 + + +# 自动更新群组信息 +@scheduler.scheduled_job( + "cron", + hour=3, + minute=1, +) +async def _(): + bots = nonebot.get_bots() + _used_group = [] + for bot in bots.values(): + try: + group_list = await bot.get_group_list() + gl = [g["group_id"] for g in group_list if g["group_id"] not in _used_group] + for g in gl: + _used_group.append(g) + group_info = await bot.get_group_info(group_id=g) + await GroupConsole.update_or_create( + group_id=str(group_info["group_id"]), + defaults={ + "group_name": group_info["group_name"], + "max_member_count": group_info["max_member_count"], + "member_count": group_info["member_count"], + "group_flag": 1, + }, + ) + logger.debug("自动更新群组信息成功", "自动更新群组", group_id=g) + except Exception as e: + logger.error(f"Bot: {bot.self_id} 自动更新群组信息", e=e) + logger.info("自动更新群组成员信息成功...") + + +# 自动更新好友信息 +@scheduler.scheduled_job( + "cron", + hour=3, + minute=1, +) +async def _(): + bots = nonebot.get_bots() + for key in bots: + try: + bot = bots[key] + fl = await bot.get_friend_list() + for f in fl: + if FriendUser.exists(user_id=str(f["user_id"])): + await FriendUser.create( + user_id=str(f["user_id"]), user_name=f["nickname"] + ) + logger.debug( + f"更新好友信息成功", "自动更新好友", session=f["user_id"] + ) + else: + logger.debug( + f"好友信息已存在", "自动更新好友", session=f["user_id"] + ) + except Exception as e: + logger.error(f"自动更新好友信息错误", "自动更新好友", e=e) + logger.info("自动更新好友信息成功...") diff --git a/zhenxun/builtin_plugins/scheduler/morning.py b/zhenxun/builtin_plugins/scheduler/morning.py new file mode 100644 index 00000000..62964462 --- /dev/null +++ b/zhenxun/builtin_plugins/scheduler/morning.py @@ -0,0 +1,28 @@ +from nonebot_plugin_apscheduler import scheduler + +# TODO: 消息发送 + +# # 早上好 +# @scheduler.scheduled_job( +# "cron", +# hour=6, +# minute=1, +# ) +# async def _(): +# img = image(IMAGE_PATH / "zhenxun" / "zao.jpg") +# await broadcast_group("[[_task|zwa]]早上好" + img, log_cmd="被动早晚安") +# logger.info("每日早安发送...") + + +# # 睡觉了 +# @scheduler.scheduled_job( +# "cron", +# hour=23, +# minute=59, +# ) +# async def _(): +# img = image(IMAGE_PATH / "zhenxun" / "sleep.jpg") +# await broadcast_group( +# f"[[_task|zwa]]{NICKNAME}要睡觉了,你们也要早点睡呀" + img, log_cmd="被动早晚安" +# ) +# logger.info("每日晚安发送...") diff --git a/zhenxun/builtin_plugins/scripts.py b/zhenxun/builtin_plugins/scripts.py new file mode 100644 index 00000000..13589454 --- /dev/null +++ b/zhenxun/builtin_plugins/scripts.py @@ -0,0 +1,59 @@ +from asyncio.exceptions import TimeoutError + +import nonebot +import ujson as json +from nonebot.drivers import Driver +from nonebot_plugin_apscheduler import scheduler + +from zhenxun.configs.path_config import TEXT_PATH +from zhenxun.services.log import logger +from zhenxun.utils.http_utils import AsyncHttpx + +driver: Driver = nonebot.get_driver() + + +@driver.on_startup +async def update_city(): + """ + 部分插件需要中国省份城市 + 这里直接更新,避免插件内代码重复 + """ + china_city = TEXT_PATH / "china_city.json" + data = {} + if not china_city.exists(): + try: + logger.debug("开始更新城市列表...") + res = await AsyncHttpx.get( + "http://www.weather.com.cn/data/city3jdata/china.html", timeout=5 + ) + res.encoding = "utf8" + provinces_data = json.loads(res.text) + for province in provinces_data.keys(): + data[provinces_data[province]] = [] + res = await AsyncHttpx.get( + f"http://www.weather.com.cn/data/city3jdata/provshi/{province}.html", + timeout=5, + ) + res.encoding = "utf8" + city_data = json.loads(res.text) + for city in city_data.keys(): + data[provinces_data[province]].append(city_data[city]) + with open(china_city, "w", encoding="utf8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + logger.info("自动更新城市列表完成...") + except TimeoutError as e: + logger.warning("自动更新城市列表超时...", e=e) + except ValueError as e: + logger.warning("自动城市列表失败...", e=e) + except Exception as e: + logger.error(f"自动城市列表未知错误...", e=e) + + +# 自动更新城市列表 +@scheduler.scheduled_job( + "cron", + hour=6, + minute=1, +) +async def _(): + await update_city() diff --git a/zhenxun/builtin_plugins/shop/__init__.py b/zhenxun/builtin_plugins/shop/__init__.py new file mode 100644 index 00000000..9ea6a6cb --- /dev/null +++ b/zhenxun/builtin_plugins/shop/__init__.py @@ -0,0 +1,102 @@ +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import Alconna, Args, Arparma, Subcommand, on_alconna +from nonebot_plugin_saa import Image, Text +from nonebot_plugin_session import EventSession +from nonebot_plugin_userinfo import EventUserInfo, UserInfo + +from zhenxun.configs.utils import BaseBlock, PluginExtraData +from zhenxun.services.log import logger +from zhenxun.utils.enum import BlockType, PluginType + +from ._data_source import ShopManage + +__plugin_meta__ = PluginMetadata( + name="商店", + description="商店系统[金币回收计划]", + usage=""" + 商品操作 + 指令: + 添加商品 name:[名称] price:[价格] des:[描述] ?discount:[折扣](小数) ?limit_time:[限时时间](小时) + 删除商品 [名称或序号] + 修改商品 name:[名称或序号] price:[价格] des:[描述] discount:[折扣] limit_time:[限时] + 示例:添加商品 name:萝莉酒杯 price:9999 des:普通的酒杯,但是里面.. discount:0.4 limit_time:90 + 示例:添加商品 name:可疑的药 price:5 des:效果未知 + 示例:删除商品 2 + 示例:修改商品 name:1 price:900 修改序号为1的商品的价格为900 + * 修改商品只需添加需要值即可 * + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.NORMAL, + menu_type="商店", + limits=[BaseBlock(check_type=BlockType.GROUP)], + ).dict(), +) + +# TODO: 修改操作,shortcut + +_matcher = on_alconna( + Alconna( + "shop", + Subcommand("my-cost", help_text="我的金币"), + Subcommand("my-props", help_text="我的道具"), + Subcommand("buy", Args["name", str]["num", int, 1], help_text="购买道具"), + Subcommand("use", Args["name", str]["num?", int, 1], help_text="使用道具"), + ), + priority=5, + block=True, +) + + +@_matcher.assign("$main") +async def _(session: EventSession, arparma: Arparma): + image = await ShopManage.build_shop_image() + logger.info("查看商店", arparma.header_result, session=session) + await Image(image.pic2bs4()).send() + + +@_matcher.assign("my-cost") +async def _(session: EventSession, arparma: Arparma): + if session.id1: + logger.info("查看金币", arparma.header_result, session=session) + gold = await ShopManage.my_cost(session.id1, session.platform) + await Text(f"你的当前余额: {gold}").send(reply=True) + else: + await Text(f"用户id为空...").send(reply=True) + + +@_matcher.assign("my-props") +async def _( + session: EventSession, arparma: Arparma, user_info: UserInfo = EventUserInfo() +): + if session.id1: + logger.info("查看道具", arparma.header_result, session=session) + if image := await ShopManage.my_props( + session.id1, + user_info.user_displayname or user_info.user_name, + session.platform, + ): + await Image(image.pic2bs4()).finish(reply=True) + return await Text(f"你的道具为空捏...").send(reply=True) + else: + await Text(f"用户id为空...").send(reply=True) + + +@_matcher.assign("buy") +async def _(session: EventSession, arparma: Arparma, name: str, num: int): + if session.id1: + logger.info( + f"购买道具 {name}, 数量: {num}", + arparma.header_result, + session=session, + ) + result = await ShopManage.buy_prop(session.id1, name, num, session.platform) + await Text(result).send(reply=True) + else: + await Text(f"用户id为空...").send(reply=True) + + +@_matcher.assign("use") +async def _(session: EventSession, arparma: Arparma, name: str, num: int): + pass diff --git a/zhenxun/builtin_plugins/shop/_data_source.py b/zhenxun/builtin_plugins/shop/_data_source.py new file mode 100644 index 00000000..96bed3f0 --- /dev/null +++ b/zhenxun/builtin_plugins/shop/_data_source.py @@ -0,0 +1,319 @@ +import time +from typing import Dict + +from zhenxun.configs.path_config import IMAGE_PATH +from zhenxun.models.goods_info import GoodsInfo +from zhenxun.models.user_console import UserConsole +from zhenxun.models.user_gold_log import UserGoldLog +from zhenxun.models.user_props_log import UserPropsLog +from zhenxun.services.log import logger +from zhenxun.utils.enum import GoldHandle, PropHandle +from zhenxun.utils.image_utils import BuildImage, ImageTemplate, text2image + +ICON_PATH = IMAGE_PATH / "shop_icon" + + +class ShopManage: + + @classmethod + async def buy_prop( + cls, user_id: str, name: str, num: int = 1, platform: str | None = None + ) -> str: + if name == "神秘药水": + return "你们看看就好啦,这是不可能卖给你们的~" + if num < 0: + return "购买的数量要大于0!" + goods_list = await GoodsInfo.annotate().order_by("-id").all() + goods_list = [ + goods + for goods in goods_list + if goods.goods_limit_time > time.time() or goods.goods_limit_time == 0 + ] + if name.isdigit(): + goods = goods_list[int(name) - 1] + else: + if filter_goods := [g for g in goods_list if g.goods_name == name]: + goods = filter_goods[0] + else: + return "道具名称不存在..." + user, _ = await UserConsole.get_or_create( + user_id=user_id, defaults={"platform": platform} + ) + price = goods.goods_price * num * goods.goods_discount + if user.gold < price: + return "糟糕! 您的金币好像不太够哦..." + count = await UserPropsLog.filter( + user_id=user_id, handle=PropHandle.BUY + ).count() + if goods.daily_limit and count >= goods.daily_limit: + return "今天的购买已达限制了喔!" + await UserGoldLog.create(user_id=user_id, gold=price, handle=GoldHandle.BUY) + await UserPropsLog.create( + user_id=user_id, uuid=goods.uuid, gold=price, num=num, handle=PropHandle.BUY + ) + logger.info( + f"花费 {price} 金币购买 {goods.goods_name} ×{num} 成功!", + "购买道具", + session=user_id, + ) + user.gold -= int(price) + if goods.uuid not in user.props: + user.props[goods.uuid] = 0 + user.props[goods.uuid] += num + await user.save(update_fields=["gold", "props"]) + return f"花费 {price} 金币购买 {goods.goods_name} ×{num} 成功!" + + @classmethod + async def my_props( + cls, user_id: str, name: str, platform: str | None = None + ) -> BuildImage | None: + """获取道具背包 + + 参数: + user_id: 用户id + name: 用户昵称 + platform: 平台. + + 返回: + BuildImage | None: 道具背包图片 + """ + user, _ = await UserConsole.get_or_create( + user_id=user_id, defaults={"platform": platform} + ) + if not user.props: + return None + result = await GoodsInfo.filter(uuid__in=user.props.keys()).all() + data_list = [] + uuid2goods = {item.uuid: item for item in result} + column_name = ["-", "使用ID", "名称", "数量", "简介"] + for i, p in enumerate(user.props): + prop = uuid2goods[p] + data_list.append( + [ + (ICON_PATH / prop.icon, 33, 33) if prop.icon else "", + i, + prop.goods_name, + user.props[p], + prop.goods_description, + ] + ) + + return await ImageTemplate.table_page( + f"{name}的道具仓库", "", column_name, data_list + ) + + @classmethod + async def my_cost(cls, user_id: str, platform: str | None = None) -> int: + """用户金币 + + 参数: + user_id: 用户id + platform: 平台. + + 返回: + int: 金币数量 + """ + user, _ = await UserConsole.get_or_create( + user_id=user_id, defaults={"platform": platform} + ) + return user.gold + + @classmethod + async def build_shop_image(cls) -> BuildImage: + """制作商店图片 + + 返回: + BuildImage: 商店图片 + """ + goods_lst = await GoodsInfo.get_all_goods() + _dc = {} + font_h = BuildImage.get_text_size("正")[1] + h = 10 + _list: list[GoodsInfo] = [] + for goods in goods_lst: + if goods.goods_limit_time == 0 or time.time() < goods.goods_limit_time: + _list.append(goods) + # A = BuildImage(1100, h, color="#f9f6f2") + total_n = 0 + image_list = [] + for idx, goods in enumerate(_list): + name_image = BuildImage( + 580, 40, font_size=25, color="#e67b6b", font="CJGaoDeGuo.otf" + ) + await name_image.text( + (15, 0), f"{idx + 1}.{goods.goods_name}", center_type="height" + ) + await name_image.line((380, -5, 280, 45), "#a29ad6", 5) + await name_image.text((390, 0), "售价:", center_type="height") + if goods.goods_discount != 1: + discount_price = int(goods.goods_discount * goods.goods_price) + old_price_image = await BuildImage.build_text_image( + str(goods.goods_price), font_color=(194, 194, 194), size=15 + ) + await old_price_image.line( + ( + 0, + int(old_price_image.height / 2), + old_price_image.width + 1, + int(old_price_image.height / 2), + ), + (0, 0, 0), + ) + await name_image.paste(old_price_image, (440, 0)) + await name_image.text((440, 15), str(discount_price), (255, 255, 255)) + else: + await name_image.text( + (440, 0), + str(goods.goods_price), + (255, 255, 255), + center_type="height", + ) + _tmp = await BuildImage.build_text_image(str(goods.goods_price), size=25) + await name_image.text( + ( + 440 + _tmp.width, + 0, + ), + f" 金币", + center_type="height", + ) + des_image = None + font_img = BuildImage(600, 80, font_size=20, color="#a29ad6") + p = font_img.getsize("简介:")[0] + 20 + if goods.goods_description: + des_list = goods.goods_description.split("\n") + desc = "" + for des in des_list: + if font_img.getsize(des)[0] > font_img.width - p - 20: + msg = "" + tmp = "" + for i in range(len(des)): + if font_img.getsize(tmp)[0] < font_img.width - p - 20: + tmp += des[i] + else: + msg += tmp + "\n" + tmp = des[i] + desc += msg + if tmp: + desc += tmp + else: + desc += des + "\n" + if desc[-1] == "\n": + desc = desc[:-1] + des_image = await text2image(desc, color="#a29ad6") + goods_image = BuildImage( + 600, + (50 + des_image.height) if des_image else 50, + font_size=20, + color="#a29ad6", + font="CJGaoDeGuo.otf", + ) + if des_image: + await goods_image.text((15, 50), "简介:") + await goods_image.paste(des_image, (p, 50)) + await name_image.circle_corner(5) + await goods_image.paste(name_image, (0, 5), center_type="width") + await goods_image.circle_corner(20) + bk = BuildImage( + 1180, + (50 + des_image.height) if des_image else 50, + font_size=15, + color="#f9f6f2", + font="CJGaoDeGuo.otf", + ) + if goods.icon and (ICON_PATH / goods.icon).exists(): + icon = BuildImage(70, 70, background=ICON_PATH / goods.icon) + await bk.paste(icon) + await bk.paste(goods_image, (70, 0)) + n = 0 + _w = 650 + # 添加限时图标和时间 + if goods.goods_limit_time > 0: + n += 140 + _limit_time_logo = BuildImage( + 40, 40, background=f"{IMAGE_PATH}/other/time.png" + ) + await bk.paste(_limit_time_logo, (_w + 50, 0)) + _time_img = await BuildImage.build_text_image("限时!", size=23) + await bk.paste( + _time_img, + (_w + 90, 10), + ) + limit_time = time.strftime( + "%Y-%m-%d %H:%M", time.localtime(goods.goods_limit_time) + ).split() + y_m_d = limit_time[0] + _h_m = limit_time[1].split(":") + h_m = _h_m[0] + "时 " + _h_m[1] + "分" + await bk.text((_w + 55, 38), str(y_m_d)) + await bk.text((_w + 65, 57), str(h_m)) + _w += 140 + if goods.goods_discount != 1: + n += 140 + _discount_logo = BuildImage( + 30, 30, background=f"{IMAGE_PATH}/other/discount.png" + ) + await bk.paste(_discount_logo, (_w + 50, 10)) + _tmp = await BuildImage.build_text_image("折扣!", size=23) + await bk.paste(_tmp, (_w + 90, 15)) + _tmp = await BuildImage.build_text_image( + f"{10 * goods.goods_discount:.1f} 折", + size=30, + font_color=(85, 156, 75), + ) + await bk.paste(_tmp, (_w + 50, 44)) + _w += 140 + if goods.daily_limit != 0: + n += 140 + _daily_limit_logo = BuildImage( + 35, 35, background=f"{IMAGE_PATH}/other/daily_limit.png" + ) + await bk.paste(_daily_limit_logo, (_w + 50, 10)) + _tmp = await BuildImage.build_text_image( + "限购!", + size=23, + ) + await bk.paste(_tmp, (_w + 90, 20)) + _tmp = await BuildImage.build_text_image( + f"{goods.daily_limit}", size=30 + ) + await bk.paste(_tmp, (_w + 72, 45)) + if total_n < n: + total_n = n + if n: + await bk.line((650, -1, 650 + n, -1), "#a29ad6", 5) + # await bk.aline((650, 80, 650 + n, 80), "#a29ad6", 5) + + # 添加限时图标和时间 + image_list.append(bk) + # await A.apaste(bk, (0, current_h), True) + # current_h += 90 + h = 0 + current_h = 0 + for img in image_list: + h += img.height + 10 + A = BuildImage(1100, h, color="#f9f6f2") + for img in image_list: + await A.paste(img, (0, current_h)) + current_h += img.height + 10 + w = 950 + if total_n: + w += total_n + h = A.height + 230 + 100 + h = 1000 if h < 1000 else h + shop_logo = BuildImage(100, 100, background=f"{IMAGE_PATH}/other/shop_text.png") + shop = BuildImage(w, h, font_size=20, color="#f9f6f2") + await shop.paste(A, (20, 230)) + await shop.paste(shop_logo, (450, 30)) + await shop.text( + ( + int((1000 - shop.getsize("注【通过 序号 或者 商品名称 购买】")[0]) / 2), + 170, + ), + "注【通过 序号 或者 商品名称 购买】", + ) + await shop.text( + (20, h - 100), + "神秘药水\t\t售价:9999999金币\n\t\t鬼知道会有什么效果~", + ) + return shop diff --git a/zhenxun/builtin_plugins/sign_in/__init__.py b/zhenxun/builtin_plugins/sign_in/__init__.py new file mode 100644 index 00000000..cb1155f2 --- /dev/null +++ b/zhenxun/builtin_plugins/sign_in/__init__.py @@ -0,0 +1,148 @@ +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import ( + Alconna, + Args, + Arparma, + Option, + on_alconna, + store_true, +) +from nonebot_plugin_apscheduler import scheduler +from nonebot_plugin_saa import Image, Text +from nonebot_plugin_session import EventSession +from nonebot_plugin_userinfo import EventUserInfo, UserInfo + +from zhenxun.configs.utils import PluginCdBlock, PluginExtraData, RegisterConfig +from zhenxun.services.log import logger + +from ._data_source import SignManage +from .goods_register import driver +from .utils import clear_sign_data_pic + +__plugin_meta__ = PluginMetadata( + name="签到", + description="每日签到,证明你在这里", + usage=""" + 每日签到 + 会影响色图概率和开箱次数,以及签到的随机道具获取 + 指令: + 我的签到 + 好感度排行 + * 签到时有 3% 概率 * 2 * + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + configs=[ + RegisterConfig( + module="send_setu", + key="INITIAL_SETU_PROBABILITY", + value=0.7, + help="初始色图概率,总概率 = 初始色图概率 + 好感度", + default_value=0.7, + type=float, + ), + RegisterConfig( + key="MAX_SIGN_GOLD", + value=200, + help="签到好感度加成额外获得的最大金币数", + default_value=200, + type=int, + ), + RegisterConfig( + key="SIGN_CARD1_PROB", + value=0.2, + help="签到好感度双倍加持卡Ⅰ掉落概率", + default_value=0.2, + type=float, + ), + RegisterConfig( + key="SIGN_CARD2_PROB", + value=0.09, + help="签到好感度双倍加持卡Ⅲ掉落概率", + default_value=0.09, + type=float, + ), + RegisterConfig( + key="SIGN_CARD3_PROB", + value=0.05, + help="签到好感度双倍加持卡Ⅲ掉落概率", + default_value=0.05, + type=float, + ), + ], + limits=[PluginCdBlock()], + ).dict(), +) + + +_sign_matcher = on_alconna( + Alconna( + "签到", + Option("--my", action=store_true, help_text="我的签到"), + Option( + "-l|--list", Args["num", int, 10], action=store_true, help_text="好感度排行" + ), + ), + priority=5, + block=True, +) + +# TODO: shortcut + + +@_sign_matcher.assign("$main") +async def _( + session: EventSession, arparma: Arparma, user_info: UserInfo = EventUserInfo() +): + nickname = ( + user_info.user_displayname or user_info.user_remark or user_info.user_name + ) + if session.id1: + if path := await SignManage.sign(session, nickname): + logger.info("签到成功", arparma.header_result, session=session) + await Image(path).finish(reply=True) + return Text("用户id为空...").send() + + +@_sign_matcher.assign("my") +async def _( + session: EventSession, arparma: Arparma, user_info: UserInfo = EventUserInfo() +): + nickname = ( + user_info.user_displayname or user_info.user_remark or user_info.user_name + ) + if session.id1: + if image := await SignManage.sign(session, nickname, True): + logger.info("查看我的签到", arparma.header_result, session=session) + await Image(image).finish(reply=True) + return Text("用户id为空...").send() + + +@_sign_matcher.assign("list") +async def _( + session: EventSession, + arparma: Arparma, + num: int, + user_info: UserInfo = EventUserInfo(), +): + nickname = ( + user_info.user_displayname or user_info.user_remark or user_info.user_name + ) + if session.id1: + if image := await SignManage.rank(session.id1, num): + logger.info("查看签到排行", arparma.header_result, session=session) + await Image(image.pic2bs4()).finish() + return Text("用户id为空...").send() + + +@scheduler.scheduled_job( + "interval", + hours=1, +) +async def _(): + try: + clear_sign_data_pic() + logger.info("清理日常签到图片数据数据完成...", "签到") + except Exception as e: + logger.error(f"清理日常签到图片数据数据失败...", e=e) diff --git a/zhenxun/builtin_plugins/sign_in/_data_source.py b/zhenxun/builtin_plugins/sign_in/_data_source.py new file mode 100644 index 00000000..07f3fcbe --- /dev/null +++ b/zhenxun/builtin_plugins/sign_in/_data_source.py @@ -0,0 +1,175 @@ +import os +import random +import secrets +from datetime import datetime +from pathlib import Path + +import pytz +from nonebot_plugin_session import EventSession +from tortoise.functions import Count + +from zhenxun.configs.path_config import IMAGE_PATH +from zhenxun.models.friend_user import FriendUser +from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.models.sign_log import SignLog +from zhenxun.models.sign_user import SignUser +from zhenxun.models.user_console import UserConsole +from zhenxun.services.log import logger +from zhenxun.utils.image_utils import BuildImage, ImageTemplate +from zhenxun.utils.utils import get_user_avatar + +from ._random_event import random_event +from .utils import SIGN_TODAY_CARD_PATH, get_card + +ICON_PATH = IMAGE_PATH / "_icon" + +PLATFORM_PATH = { + "dodo": ICON_PATH / "dodo.png", + "discord": ICON_PATH / "discord.png", + "kaiheila": ICON_PATH / "kook.png", + "qq": ICON_PATH / "qq.png", +} + + +class SignManage: + + @classmethod + async def rank(cls, user_id: str, num: int) -> BuildImage: + all_list = ( + await SignUser.annotate() + .order_by("impression") + .values_list("user_id", flat=True) + ) + index = all_list.index(user_id) + 1 # type: ignore + user_list = await SignUser.annotate().order_by("impression").limit(num).all() + user_id_list = [u.user_id for u in user_list] + log_list = ( + await SignLog.filter(user_id__in=user_id_list) + .annotate(count=Count("id")) + .group_by("user_id") + .values_list("user_id", "count") + ) + uid2cnt = {l[0]: l[1] for l in log_list} + column_name = ["排名", "-", "名称", "好感度", "签到次数", "平台"] + friend_list = await FriendUser.filter(user_id__in=user_id_list).values_list( + "user_id", "user_name" + ) + uid2name = {f[0]: f[1] for f in friend_list} + group_member_list = await GroupInfoUser.filter( + user_id__in=user_id_list + ).values_list("user_id", "user_name") + for gm in group_member_list: + uid2name[gm[0]] = gm[1] + data_list = [] + for i, user in enumerate(user_list): + bytes = await get_user_avatar(user.user_id) + data_list.append( + [ + f"{i+1}", + (bytes, 30, 30) if user.platform == "qq" else "", + uid2name.get(user.user_id), + user.impression, + uid2cnt.get(user.user_id) or 0, + (PLATFORM_PATH.get(user.platform), 30, 30), + ] + ) + return await ImageTemplate.table_page( + "好感度排行", f"你的排名在第 {index} 位哦!", column_name, data_list + ) + + @classmethod + async def sign( + cls, session: EventSession, nickname: str, is_view_card: bool = False + ) -> Path | None: + """签到 + + 参数: + session: Session + nickname: 用户昵称 + is_view_card: 是否展示卡片 + + 返回: + Path: 卡片路径 + """ + if not session.id1: + return None + now = datetime.now(pytz.timezone("Asia/Shanghai")) + user_console, _ = await UserConsole.get_or_create( + user_id=session.id1, + defaults={ + "uid": await UserConsole.get_new_uid(), + "platform": session.platform, + }, + ) + user, _ = await SignUser.get_or_create( + user_id=session.id1, + defaults={"user_console": user_console, "platform": session.platform}, + ) + new_log = await SignLog.filter(user_id=session.id1).first() + file_name = f"{user}_sign_{datetime.now().date()}.png" + if ( + user.sign_count != 0 + or (new_log and now > new_log.create_time) + or file_name in os.listdir(SIGN_TODAY_CARD_PATH) + ): + user_console, _ = await UserConsole.get_or_create(user_id=session.id1) + path = await get_card(user, nickname, -1, user_console.gold, "") + else: + path = await cls._handle_sign_in(user, nickname, session, is_view_card) + return path + + @classmethod + async def _handle_sign_in( + cls, + user: SignUser, + nickname: str, + session: EventSession, + is_view_card: bool, + ) -> Path: + """签到处理 + + 参数: + user: SignUser + nickname: 用户昵称 + session: Session + is_view_card: 是否展示卡片 + + 返回: + Path: 卡片路径 + """ + impression_added = (secrets.randbelow(99) + 1) / 100 + rand = random.random() + add_probability = float(user.add_probability) + specify_probability = user.specify_probability + if rand + add_probability > 0.97: + impression_added *= 2 + elif rand < specify_probability: + impression_added *= 2 + await SignUser.sign(user, impression_added, session.bot_id, session.platform) + gold = random.randint(1, 100) + gift = random_event(float(user.impression)) + if isinstance(gift, int): + gold += gift + await UserConsole.add_gold( + user.user_id, gold + gift, "sign_in", session.platform + ) + gift = f"额外金币 +{gift}" + else: + await UserConsole.add_gold(user.user_id, gold, "sign_in", session.platform) + await UserConsole.add_props(user.user_id, gift, 1, session.platform) + gift += " + 1" + logger.info( + f"签到成功. score: {user.impression:.2f} " + f"(+{impression_added:.2f}).获取金币/道具: {gold}", + "签到", + session=session, + ) + return await get_card( + user, + nickname, + impression_added, + gold, + gift, + rand + add_probability > 0.97 or rand < specify_probability, + is_view_card, + ) diff --git a/zhenxun/builtin_plugins/sign_in/_random_event.py b/zhenxun/builtin_plugins/sign_in/_random_event.py new file mode 100644 index 00000000..32d133c1 --- /dev/null +++ b/zhenxun/builtin_plugins/sign_in/_random_event.py @@ -0,0 +1,33 @@ +import random + +from zhenxun.configs.config import Config + +PROB_DATA = None + + +def random_event(impression: float) -> str | int: + """签到随机事件 + + 参数: + impression: 好感度 + + 返回: + 额外奖励 和 类型 + """ + global PROB_DATA + if not PROB_DATA: + PROB_DATA = { + Config.get_config("sign_in", "SIGN_CARD3_PROB"): "好感度双倍加持卡Ⅲ", + Config.get_config("sign_in", "SIGN_CARD2_PROB"): "好感度双倍加持卡Ⅱ", + Config.get_config("sign_in", "SIGN_CARD1_PROB"): "好感度双倍加持卡Ⅰ", + } + rand = random.random() - impression / 1000 + for prob in PROB_DATA.keys(): + if rand <= prob: + return PROB_DATA[prob] + gold = random.randint( + 1, random.randint(1, int(1 if impression < 1 else impression)) + ) + max_sign_gold = Config.get_config("sign_in", "MAX_SIGN_GOLD") + gold = max_sign_gold if gold > max_sign_gold else gold + return gold diff --git a/zhenxun/builtin_plugins/sign_in/config.py b/zhenxun/builtin_plugins/sign_in/config.py new file mode 100644 index 00000000..e2bfdbc6 --- /dev/null +++ b/zhenxun/builtin_plugins/sign_in/config.py @@ -0,0 +1,49 @@ +from zhenxun.configs.path_config import IMAGE_PATH + +SIGN_RESOURCE_PATH = IMAGE_PATH / "sign" / "sign_res" +SIGN_TODAY_CARD_PATH = IMAGE_PATH / "sign" / "today_card" +SIGN_BORDER_PATH = SIGN_RESOURCE_PATH / "border" +SIGN_BACKGROUND_PATH = SIGN_RESOURCE_PATH / "background" + +SIGN_BORDER_PATH.mkdir(exist_ok=True, parents=True) +SIGN_BACKGROUND_PATH.mkdir(exist_ok=True, parents=True) + + +lik2relation = { + "0": "路人", + "1": "陌生", + "2": "初识", + "3": "普通", + "4": "熟悉", + "5": "信赖", + "6": "相知", + "7": "厚谊", + "8": "亲密", +} + +level2attitude = { + "0": "排斥", + "1": "警惕", + "2": "可以交流", + "3": "一般", + "4": "是个好人", + "5": "好朋友", + "6": "可以分享小秘密", + "7": "喜欢", + "8": "恋人", +} + +weekdays = {1: "Mon", 2: "Tue", 3: "Wed", 4: "Thu", 5: "Fri", 6: "Sat", 7: "Sun"} + +lik2level = { + 9999: "9", + 400: "8", + 270: "7", + 200: "6", + 140: "5", + 90: "4", + 50: "3", + 25: "2", + 10: "1", + 0: "0", +} diff --git a/zhenxun/builtin_plugins/sign_in/goods_register.py b/zhenxun/builtin_plugins/sign_in/goods_register.py new file mode 100644 index 00000000..3af6514f --- /dev/null +++ b/zhenxun/builtin_plugins/sign_in/goods_register.py @@ -0,0 +1,75 @@ +from decimal import Decimal + +import nonebot +from nonebot.drivers import Driver +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.models.sign_user import SignUser +from zhenxun.models.user_console import UserConsole +from zhenxun.utils.decorator.shop import NotMeetUseConditionsException, shop_register + +driver: Driver = nonebot.get_driver() + + +@driver.on_startup +async def _(): + """ + 导入内置的三个商品 + """ + + @shop_register( + name=("好感度双倍加持卡Ⅰ", "好感度双倍加持卡Ⅱ", "好感度双倍加持卡Ⅲ"), + price=(30, 150, 250), + des=( + "下次签到双倍好感度概率 + 10%(谁才是真命天子?)(同类商品将覆盖)", + "下次签到双倍好感度概率 + 20%(平平庸庸)(同类商品将覆盖)", + "下次签到双倍好感度概率 + 30%(金币才是真命天子!)(同类商品将覆盖)", + ), + load_status=bool(Config.get_config("shop", "IMPORT_DEFAULT_SHOP_GOODS")), + icon=( + "favorability_card_1.png", + "favorability_card_2.png", + "favorability_card_3.png", + ), + **{"好感度双倍加持卡Ⅰ_prob": 0.1, "好感度双倍加持卡Ⅱ_prob": 0.2, "好感度双倍加持卡Ⅲ_prob": 0.3}, # type: ignore + ) + async def _(session: EventSession, user_id: int, group_id: int, prob: float): + user_console, _ = await UserConsole.get_or_create( + user_id=session.id1, + defaults={ + "uid": await UserConsole.get_new_uid(), + "platform": session.platform, + }, + ) + user, _ = await SignUser.get_or_create( + user_id=user_id, + defaults={"platform": session.platform, "user_console": user_console}, + ) + user.add_probability = Decimal(prob) + await user.save(update_fields=["add_probability"]) + + @shop_register( + name="测试道具A", + price=99, + des="随便侧而出", + load_status=False, + icon="sword.png", + ) + async def _(user_id: int, group_id: int): + print(user_id, group_id, "使用测试道具") + + @shop_register.before_handle(name="测试道具A", load_status=False) + async def _(user_id: int, group_id: int): + print(user_id, group_id, "第一个使用前函数(before handle)") + + @shop_register.before_handle(name="测试道具A", load_status=False) + async def _(user_id: int, group_id: int): + print(user_id, group_id, "第二个使用前函数(before handle)222") + raise NotMeetUseConditionsException( + "太笨了!" + ) # 抛出异常,阻断使用,并返回信息 + + @shop_register.after_handle(name="测试道具A", load_status=False) + async def _(user_id: int, group_id: int): + print(user_id, group_id, "第一个使用后函数(after handle)") diff --git a/zhenxun/builtin_plugins/sign_in/utils.py b/zhenxun/builtin_plugins/sign_in/utils.py new file mode 100644 index 00000000..950a7aab --- /dev/null +++ b/zhenxun/builtin_plugins/sign_in/utils.py @@ -0,0 +1,326 @@ +import os +import random +from datetime import datetime +from io import BytesIO +from pathlib import Path + +import nonebot +import pytz +from nonebot.drivers import Driver + +from zhenxun.configs.config import NICKNAME, Config +from zhenxun.configs.path_config import IMAGE_PATH +from zhenxun.models.sign_log import SignLog +from zhenxun.models.sign_user import SignUser +from zhenxun.utils.image_utils import BuildImage +from zhenxun.utils.utils import get_user_avatar + +from .config import ( + SIGN_BACKGROUND_PATH, + SIGN_BORDER_PATH, + SIGN_RESOURCE_PATH, + SIGN_TODAY_CARD_PATH, + level2attitude, + lik2level, + lik2relation, +) + +driver: Driver = nonebot.get_driver() + + +@driver.on_startup +async def init_image(): + SIGN_RESOURCE_PATH.mkdir(parents=True, exist_ok=True) + SIGN_TODAY_CARD_PATH.mkdir(exist_ok=True, parents=True) + await generate_progress_bar_pic() + clear_sign_data_pic() + + +async def get_card( + user: SignUser, + nickname: str, + add_impression: float, + gold: int | None, + gift: str, + is_double: bool = False, + is_card_view: bool = False, +) -> Path: + """获取好感度卡片 + + 参数: + user: SignUser + nickname: 用户昵称 + impression: 新增的好感度 + gold: 金币 + gift: 礼物 + is_double: 是否触发双倍. + is_card_view: 是否展示好感度卡片. + + 返回: + Path: 卡片路径 + """ + user_id = user.user_id + date = datetime.now().date() + _type = "view" if is_card_view else "sign" + file_name = f"{user_id}_{_type}_{date}.png" + view_name = f"{user_id}_view_{date}.png" + card_file = Path(SIGN_TODAY_CARD_PATH) / file_name + if card_file.exists(): + return IMAGE_PATH / "sign" / "today_card" / file_name + else: + if add_impression == -1: + card_file = Path(SIGN_TODAY_CARD_PATH) / view_name + if card_file.exists(): + return card_file + is_card_view = True + return await _generate_card( + user, nickname, add_impression, gold, gift, is_double, is_card_view + ) + + +async def _generate_card( + user: SignUser, + nickname: str, + impression: float, + gold: int | None, + gift: str, + is_double: bool = False, + is_card_view: bool = False, +) -> Path: + """生成签到卡片 + + 参数: + user: SignUser + nickname: 用户昵称 + impression: 新增的好感度 + gold: 金币 + gift: 礼物 + is_double: 是否触发双倍. + is_card_view: 是否展示好感度卡片. + + 返回: + Path: 卡片路径 + """ + ava_bk = BuildImage(140, 140, (255, 255, 255, 0)) + ava_border = BuildImage( + 140, + 140, + background=SIGN_BORDER_PATH / "ava_border_01.png", + ) + if user.platform == "qq" and (byt := await get_user_avatar(user.user_id)): + ava = BuildImage(107, 107, background=BytesIO(byt)) + else: + ava = BuildImage(107, 107, (0, 0, 0)) + await ava.circle() + await ava_bk.paste(ava, (19, 18)) + await ava_bk.paste(ava_border, center_type="center") + add_impression = impression + impression = float(user.impression) + info_img = BuildImage(250, 150, color=(255, 255, 255, 0), font_size=15) + level, next_impression, previous_impression = get_level_and_next_impression( + impression + ) + interpolation = next_impression - impression + if level == "9": + level = "8" + interpolation = 0 + await info_img.text((0, 0), f"· 好感度等级:{level} [{lik2relation[level]}]") + await info_img.text((0, 20), f"· {NICKNAME}对你的态度:{level2attitude[level]}") + await info_img.text((0, 40), f"· 距离升级还差 {interpolation:.2f} 好感度") + + bar_bk = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar_white.png") + bar = BuildImage(220, 20, background=SIGN_RESOURCE_PATH / "bar.png") + ratio = 1 - (next_impression - user.impression) / ( + next_impression - previous_impression + ) + if next_impression == 0: + ratio = 0 + await bar.resize(width=int(bar.width * ratio) or bar.width, height=bar.height) + await bar_bk.paste(bar) + font_size = 30 + if "好感度双倍加持卡" in gift: + font_size = 20 + gift_border = BuildImage( + 270, + 100, + background=SIGN_BORDER_PATH / "gift_border_02.png", + font_size=font_size, + ) + await gift_border.text((0, 0), gift, center_type="center") + + bk = BuildImage( + 876, + 424, + background=SIGN_BACKGROUND_PATH + / random.choice(os.listdir(SIGN_BACKGROUND_PATH)), + font_size=25, + ) + A = BuildImage(876, 274, background=SIGN_RESOURCE_PATH / "white.png") + line = BuildImage(2, 180, color="black") + await A.transparent(2) + await A.paste(ava_bk, (25, 80)) + await A.paste(line, (200, 70)) + nickname_img = await BuildImage.build_text_image( + nickname, size=50, font_color=(255, 255, 255) + ) + user_console = await user.user_console.first() + if user_console and user_console.uid: + uid = f"{user_console.uid}".rjust(12, "0") + uid = uid[:4] + " " + uid[4:8] + " " + uid[8:] + else: + uid = "XXXX XXXX XXXX" + uid_img = await BuildImage.build_text_image( + f"UID: {uid}", size=30, font_color=(255, 255, 255) + ) + sign_count = await SignLog.filter(user_id=user.user_id).count() + sign_day_img = await BuildImage.build_text_image( + f"{sign_count}", size=40, font_color=(211, 64, 33) + ) + lik_text1_img = await BuildImage.build_text_image("当前", size=20) + lik_text2_img = await BuildImage.build_text_image( + f"好感度:{user.impression:.2f}", size=30 + ) + watermark = await BuildImage.build_text_image( + f"{NICKNAME}@{datetime.now().year}", size=15, font_color=(155, 155, 155) + ) + today_data = BuildImage(300, 300, color=(255, 255, 255, 0), font_size=20) + if is_card_view: + today_sign_text_img = await BuildImage.build_text_image("", size=30) + value_list = ( + await SignUser.annotate() + .order_by("impression") + .values_list("user_id", flat=True) + ) + index = value_list.index(user.user_id) + 1 # type: ignore + rank_img = await BuildImage.build_text_image( + f"* 好感度排名第 {index} 位", size=30 + ) + await A.paste(rank_img, ((A.width - rank_img.width - 32), 20)) + last_log = ( + await SignLog.filter(user_id=user.user_id).order_by("create_time").first() + ) + last_date = "从未" + if last_log: + last_date = last_log.create_time.astimezone( + pytz.timezone("Asia/Shanghai") + ).date() + await today_data.text( + (0, 0), + f"上次签到日期:{last_date}", + ) + await today_data.text((0, 25), f"总金币:{gold}") + default_setu_prob = ( + Config.get_config("send_setu", "INITIAL_SETU_PROBABILITY") * 100 # type: ignore + ) + await today_data.text( + (0, 50), + f"色图概率:{(default_setu_prob + float(user.impression) if user.impression < 100 else 100):.2f}%", + ) + await today_data.text((0, 75), f"开箱次数:{(20 + int(user.impression / 3))}") + _type = "view" + else: + await A.paste(gift_border, (570, 140)) + today_sign_text_img = await BuildImage.build_text_image("今日签到", size=30) + if is_double: + await today_data.text((0, 0), f"好感度 + {add_impression / 2:.2f} × 2") + else: + await today_data.text((0, 0), f"好感度 + {add_impression:.2f}") + await today_data.text((0, 25), f"金币 + {gold}") + _type = "sign" + current_date = datetime.now() + current_datetime_str = current_date.strftime("%Y-%m-%d %a %H:%M:%S") + data = current_date.date() + data_img = await BuildImage.build_text_image( + f"时间:{current_datetime_str}", size=20 + ) + await bk.paste(nickname_img, (30, 15)) + await bk.paste(uid_img, (30, 85)) + await bk.paste(A, (0, 150)) + await bk.text((30, 167), "Accumulative check-in for") + _x = bk.getsize("Accumulative check-in for")[0] + sign_day_img.width + 45 + await bk.paste(sign_day_img, (380, 158)) + await bk.text((_x, 167), "days") + await bk.paste(data_img, (220, 370)) + await bk.paste(lik_text1_img, (220, 240)) + await bk.paste(lik_text2_img, (262, 234)) + await bk.paste(bar_bk, (225, 275)) + await bk.paste(info_img, (220, 305)) + await bk.paste(today_sign_text_img, (550, 180)) + await bk.paste(today_data, (580, 220)) + await bk.paste(watermark, (15, 400)) + await bk.save(SIGN_TODAY_CARD_PATH / f"{user.user_id}_{_type}_{data}.png") + return IMAGE_PATH / "sign" / "today_card" / f"{user.user_id}_{_type}_{data}.png" + + +async def generate_progress_bar_pic(): + """ + 初始化进度条图片 + """ + bg_2 = (254, 1, 254) + bg_1 = (0, 245, 246) + + bk = BuildImage(1000, 50) + img_x = BuildImage(50, 50, color=bg_2) + await img_x.circle() + await img_x.crop((25, 0, 50, 50)) + img_y = BuildImage(50, 50, color=bg_1) + await img_y.circle() + await img_y.crop((0, 0, 25, 50)) + A = BuildImage(950, 50) + width, height = A.size + + step_r = (bg_2[0] - bg_1[0]) / width + step_g = (bg_2[1] - bg_1[1]) / width + step_b = (bg_2[2] - bg_1[2]) / width + + for y in range(0, width): + bg_r = round(bg_1[0] + step_r * y) + bg_g = round(bg_1[1] + step_g * y) + bg_b = round(bg_1[2] + step_b * y) + for x in range(0, height): + await A.point((y, x), fill=(bg_r, bg_g, bg_b)) + await bk.paste(img_y, (0, 0)) + await bk.paste(A, (25, 0)) + await bk.paste(img_x, (975, 0)) + await bk.save(SIGN_RESOURCE_PATH / "bar.png") + + A = BuildImage(950, 50) + bk = BuildImage(1000, 50) + img_x = BuildImage(50, 50) + await img_x.circle() + await img_x.crop((25, 0, 50, 50)) + img_y = BuildImage(50, 50) + await img_y.circle() + await img_y.crop((0, 0, 25, 50)) + await bk.paste(img_y, (0, 0)) + await bk.paste(A, (25, 0)) + await bk.paste(img_x, (975, 0)) + await bk.save(SIGN_RESOURCE_PATH / "bar_white.png") + + +def get_level_and_next_impression(impression: float) -> tuple[str, int, int]: + """获取当前好感等级与下一等级的差距 + + 参数: + impression: 好感度 + + 返回: + tuple[str, int, int]: 好感度等级中文,好感度等级,下一等级好感差距 + """ + if impression == 0: + return lik2level[10], 10, 0 + keys = list(lik2level.keys()) + for i in range(len(keys)): + if impression > keys[i]: + return lik2level[keys[i]], keys[i - 1], keys[i] + return lik2level[10], 10, 0 + + +def clear_sign_data_pic(): + """ + 清空当前签到图片数据 + """ + date = datetime.now().date() + for file in os.listdir(SIGN_TODAY_CARD_PATH): + if str(date) not in file: + os.remove(SIGN_TODAY_CARD_PATH / file) diff --git a/zhenxun/builtin_plugins/superuser/broadcast/__init__.py b/zhenxun/builtin_plugins/superuser/broadcast/__init__.py new file mode 100644 index 00000000..271652a7 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/broadcast/__init__.py @@ -0,0 +1,61 @@ +from typing import Annotated + +from nonebot import on_command +from nonebot.adapters import Bot +from nonebot.params import Command +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import UniMsg +from nonebot_plugin_saa import Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.configs.utils import PluginExtraData, RegisterConfig, Task +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType + +from ._data_source import BroadcastManage + +__plugin_meta__ = PluginMetadata( + name="广播", + description="昭告天下!", + usage=""" + 广播 [消息] [图片] + 示例:广播 你们好! + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.SUPERUSER, + configs=[ + RegisterConfig( + module="_task", + key="DEFAULT_BROADCAST", + value=True, + help="被动 广播 进群默认开关状态", + default_value=True, + type=bool, + ) + ], + tasks=[Task(module="broadcast", name="广播")], + ).dict(), +) + +_matcher = on_command("广播", priority=1, permission=SUPERUSER, block=True) + + +@_matcher.handle() +async def _( + bot: Bot, + session: EventSession, + message: UniMsg, + command: Annotated[tuple[str, ...], Command()], +): + message[0].text = message[0].text.replace(command[0], "").strip() + # await Text("正在发送..请等一下哦!").send() + count, error_count = await BroadcastManage.send(bot, message, session) + result = f"成功广播 {count} 个群组" + if error_count: + result += f"\n广播失败 {error_count} 个群组" + await Text(f"发送广播完成!\n{result}").send(reply=True) + logger.info(f"发送广播信息: {message}", "广播", session=session) diff --git a/zhenxun/builtin_plugins/superuser/broadcast/_data_source.py b/zhenxun/builtin_plugins/superuser/broadcast/_data_source.py new file mode 100644 index 00000000..967fee82 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/broadcast/_data_source.py @@ -0,0 +1,117 @@ +import nonebot_plugin_alconna as alc +from nonebot.adapters import Bot +from nonebot.adapters.discord import Bot as DiscordBot +from nonebot.adapters.dodo import Bot as DodoBot +from nonebot.adapters.kaiheila import Bot as KaiheilaBot +from nonebot.adapters.onebot.v11 import Bot as v11Bot +from nonebot.adapters.onebot.v12 import Bot as v12Bot +from nonebot_plugin_alconna import UniMsg +from nonebot_plugin_saa import ( + Image, + MessageFactory, + TargetDoDoChannel, + TargetQQGroup, + Text, +) +from nonebot_plugin_session import EventSession +from pydantic import BaseModel + +from zhenxun.models.group_console import GroupConsole +from zhenxun.services.log import logger + + +class GroupChannel(BaseModel): + + group_id: str + """群组id""" + channel_id: str | None = None + """频道id""" + + +class BroadcastManage: + + @classmethod + async def send( + cls, bot: Bot, message: UniMsg, session: EventSession + ) -> tuple[int, int]: + """发送广播消息 + + 参数: + bot: Bot + message: 消息内容 + session: Session + + 返回: + tuple[int, int]: 发送成功的群组数量, 发送失败的群组数量 + """ + message_list = [] + for msg in message: + if isinstance(msg, alc.Image) and msg.url: + message_list.append(Image(msg.url)) + elif isinstance(msg, alc.Text): + message_list.append(Text(msg.text)) + if group_list := await cls.__get_group_list(bot): + error_count = 0 + for group in group_list: + try: + if not await GroupConsole.is_block_task( + group.group_id, "broadcast", group.channel_id + ): + if isinstance(bot, (v11Bot, v12Bot)): + target = TargetQQGroup(group_id=int(group.group_id)) + elif isinstance(bot, DodoBot): + target = TargetDoDoChannel(channel_id=group.channel_id) # type: ignore + await MessageFactory(message_list).send_to(target, bot) + logger.debug( + "发送成功", + "广播", + session=session, + target=f"{group.group_id}:{group.channel_id}", + ) + except Exception as e: + error_count += 1 + logger.error( + "发送失败", + "广播", + session=session, + target=f"{group.group_id}:{group.channel_id}", + e=e, + ) + return len(group_list) - error_count, error_count + return 0, 0 + + @classmethod + async def __get_group_list(cls, bot: Bot) -> list[GroupChannel]: + """获取群组id列表 + + 参数: + bot: Bot + + 返回: + list[str]: 群组id列表 + """ + if isinstance(bot, (v11Bot, v12Bot)): + group_list = await bot.get_group_list() + return [GroupChannel(group_id=str(g["group_id"])) for g in group_list] + if isinstance(bot, DodoBot): + island_list = await bot.get_island_list() + source_id_list = [ + g.island_source_id for g in island_list if g.island_source_id + ] + channel_id_list = [] + for id in source_id_list: + channel_list = await bot.get_channel_list(island_source_id=id) + channel_id_list += [ + GroupChannel(group_id=id, channel_id=c.channel_id) + for c in channel_list + ] + return channel_id_list + if isinstance(bot, KaiheilaBot): + pass + # group_list = await bot.guild_list() + # if group_list.guilds: + # return [g.open_id for g in group_list.guilds if g.open_id] + if isinstance(bot, DiscordBot): + # TODO: discord获取群组列表 + pass + return [] diff --git a/zhenxun/builtin_plugins/superuser/fg_manage.py b/zhenxun/builtin_plugins/superuser/fg_manage.py index 9e6e79d1..fe17f8f3 100644 --- a/zhenxun/builtin_plugins/superuser/fg_manage.py +++ b/zhenxun/builtin_plugins/superuser/fg_manage.py @@ -1,21 +1,9 @@ - - from nonebot.adapters import Bot from nonebot.adapters.kaiheila.exception import ApiNotAvailable from nonebot.permission import SUPERUSER from nonebot.plugin import PluginMetadata from nonebot.rule import to_me -from nonebot_plugin_alconna import ( - Alconna, - AlconnaMatch, - Arparma, - Match, - Query, - Subcommand, - UniMessage, - on_alconna, - store_true, -) +from nonebot_plugin_alconna import Alconna, on_alconna from nonebot_plugin_alconna.matcher import AlconnaMatcher from nonebot_plugin_saa import Text from nonebot_plugin_session import EventSession diff --git a/zhenxun/builtin_plugins/superuser/super_help.py b/zhenxun/builtin_plugins/superuser/super_help.py new file mode 100644 index 00000000..a47943d7 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/super_help.py @@ -0,0 +1,161 @@ +import nonebot +from arclet.alconna import Args, Option +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna +from nonebot_plugin_alconna.matcher import AlconnaMatcher +from nonebot_plugin_saa import Image, Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.config import Config +from zhenxun.configs.path_config import DATA_PATH, IMAGE_PATH +from zhenxun.configs.utils import PluginExtraData, RegisterConfig +from zhenxun.models.plugin_info import PluginInfo +from zhenxun.models.task_info import TaskInfo +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType +from zhenxun.utils.exception import EmptyError +from zhenxun.utils.image_utils import ( + BuildImage, + build_sort_image, + group_image, + text2image, +) +from zhenxun.utils.rules import admin_check, ensure_group + +__plugin_meta__ = PluginMetadata( + name="超级用户帮助", + description="超级用户帮助", + usage=""" + 超级用户帮助 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.SUPERUSER, + ).dict(), +) + +_matcher = on_alconna( + Alconna("超级用户帮助"), + permission=SUPERUSER, + priority=5, + block=True, +) + + +SUPERUSER_HELP_IMAGE = IMAGE_PATH / "SUPERUSER_HELP.png" +if SUPERUSER_HELP_IMAGE.exists(): + SUPERUSER_HELP_IMAGE.unlink() + + +async def build_help() -> BuildImage: + """构造超级用户帮助图片 + + 异常: + EmptyError: 超级用户帮助为空 + + 返回: + BuildImage: 超级用户帮助图片 + """ + plugin_list = await PluginInfo.filter(plugin_type=PluginType.SUPERUSER).all() + data_list = [] + for plugin in plugin_list: + if _plugin := nonebot.get_plugin_by_module_name(plugin.module_path): + if _plugin.metadata: + data_list.append({"plugin": plugin, "metadata": _plugin.metadata}) + font = BuildImage.load_font("HYWenHei-85W.ttf", 20) + image_list = [] + for data in data_list: + plugin = data["plugin"] + metadata = data["metadata"] + try: + usage = None + description = None + if metadata.usage: + usage = await text2image( + metadata.usage, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + if metadata.description: + description = await text2image( + metadata.description, + padding=5, + color=(255, 255, 255), + font_color=(0, 0, 0), + ) + width = 0 + height = 100 + if usage: + width = usage.width + height += usage.height + if description and description.width > width: + width = description.width + height += description.height + font_width, font_height = BuildImage.get_text_size( + plugin.name + f"[{plugin.level}]", font + ) + if font_width > width: + width = font_width + A = BuildImage(width + 30, height + 120, "#EAEDF2") + await A.text((15, 10), plugin.name + f"[{plugin.level}]") + await A.text((15, 70), "简介:") + if not description: + description = BuildImage(A.width - 30, 30, (255, 255, 255)) + await description.circle_corner(10) + await A.paste(description, (15, 100)) + if not usage: + usage = BuildImage(A.width - 30, 30, (255, 255, 255)) + await usage.circle_corner(10) + await A.text((15, description.height + 115), "用法:") + await A.paste(usage, (15, description.height + 145)) + await A.circle_corner(10) + image_list.append(A) + except Exception as e: + logger.warning( + f"获取超级用户管理员插件 {plugin.module}: {plugin.name} 设置失败...", + "超级用户帮助", + e=e, + ) + if task_list := await TaskInfo.all(): + task_str = "\n".join([task.name for task in task_list]) + task_str = "通过 开启/关闭 来控制群被动\n----------\n" + task_str + task_image = await text2image(task_str, padding=5, color=(255, 255, 255)) + await task_image.circle_corner(10) + A = BuildImage(task_image.width + 50, task_image.height + 85, "#EAEDF2") + await A.text((25, 10), "被动技能") + await A.paste(task_image, (25, 50)) + await A.circle_corner(10) + image_list.append(A) + if not image_list: + raise EmptyError() + image_group, _ = group_image(image_list) + A = await build_sort_image(image_group, color=(255, 255, 255), padding_top=160) + text = await BuildImage.build_text_image( + "超级用户帮助", + size=40, + ) + tip = await BuildImage.build_text_image( + "注: ‘*’ 代表可有多个相同参数 ‘?’ 代表可省略该参数", size=25, font_color="red" + ) + await A.paste(text, (50, 30)) + await A.paste(tip, (50, 90)) + await A.save(SUPERUSER_HELP_IMAGE) + return BuildImage(1, 1) + + +@_matcher.handle() +async def _( + session: EventSession, + matcher: AlconnaMatcher, + arparma: Arparma, +): + if not SUPERUSER_HELP_IMAGE.exists(): + try: + await build_help() + except EmptyError: + await Text("超级用户帮助为空").finish(reply=True) + await Image(SUPERUSER_HELP_IMAGE).send() + logger.info("查看超级用户帮助", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/superuser/update_fg_info.py b/zhenxun/builtin_plugins/superuser/update_fg_info.py deleted file mode 100644 index 18cf31cb..00000000 --- a/zhenxun/builtin_plugins/superuser/update_fg_info.py +++ /dev/null @@ -1,119 +0,0 @@ -from nonebot.adapters import Bot -from nonebot.adapters.kaiheila.exception import ApiNotAvailable -from nonebot.permission import SUPERUSER -from nonebot.plugin import PluginMetadata -from nonebot.rule import to_me -from nonebot_plugin_alconna import Alconna, Arparma, At, Match, on_alconna -from nonebot_plugin_saa import Mention, MessageFactory, Text -from nonebot_plugin_session import EventSession, SessionLevel - -from zhenxun.configs.config import Config -from zhenxun.configs.utils import PluginExtraData -from zhenxun.models.friend_user import FriendUser -from zhenxun.models.group_info import GroupInfo -from zhenxun.models.level_user import LevelUser -from zhenxun.services.log import logger -from zhenxun.utils.enum import PluginType - -__plugin_meta__ = PluginMetadata( - name="更新群组/好友信息", - description="更新群组/好友信息", - usage=""" - 更新群组信息 - 更新好友信息 - """.strip(), - extra=PluginExtraData( - author="HibiKier", - version="0.1", - plugin_type=PluginType.SUPERUSER, - ).dict(), -) - - -_group_matcher = on_alconna( - Alconna( - "更新群组信息", - ), - permission=SUPERUSER, - rule=to_me(), - priority=1, - block=True, -) - -_friend_matcher = on_alconna( - Alconna( - "更新好友信息", - ), - permission=SUPERUSER, - rule=to_me(), - priority=1, - block=True, -) - -# TODO: 其他adapter的更新操作 - -@_group_matcher.handle() -async def _( - bot: Bot, - session: EventSession, - arparma: Arparma, -): - try: - gl = await bot.get_group_list() - gl = [g["group_id"] for g in gl] - num = 0 - for g in gl: - try: - group_info = await bot.get_group_info(group_id=g) - await GroupInfo.update_or_create( - group_id=str(group_info["group_id"]), - defaults={ - "group_name": group_info["group_name"], - "max_member_count": group_info["max_member_count"], - "member_count": group_info["member_count"], - }, - ) - num += 1 - logger.debug( - "群聊信息更新成功", "更新群信息", session=session, target=group_info["group_id"] - ) - except Exception as e: - logger.error( - f"更新群聊信息失败", - arparma.header_result, - session=session, - target=g, - ) - await Text(f"成功更新了 {len(gl)} 个群的信息").send() - logger.info( - f"更新群聊信息完成,共更新了 {len(gl)} 个群的信息", arparma.header_result, session=session - ) - except (ApiNotAvailable, AttributeError) as e: - await Text("Api未实现...").send() - except Exception as e: - logger.error("更新好友信息发生错误", arparma.header_result, session=session, e=e) - await Text("其他未知错误...").send() - - -@_friend_matcher.assign("delete") -async def _( - bot: Bot, - session: EventSession, - arparma: Arparma, -): - num = 0 - error_list = [] - fl = await bot.get_friend_list() - for f in fl: - try: - await FriendUser.update_or_create( - user_id=str(f["user_id"]), defaults={"nickname": f["nickname"]} - ) - logger.debug(f"更新好友信息成功", "更新好友信息", session=session, target=f["user_id"]) - num += 1 - except Exception as e: - logger.error(f"更新好友信息失败", "更新好友信息", session=session, target=f["user_id"], e=e) - await Text(f"成功更新了 {num} 个好友的信息!").send() - if error_list: - await Text(f"以下好友更新失败:\n" + "\n".join(error_list)).send() - logger.info(f"更新好友信息完成,共更新了 {num} 个群的信息", arparma.header_result, session=session) diff --git a/zhenxun/builtin_plugins/superuser/update_fg_info/__init__.py b/zhenxun/builtin_plugins/superuser/update_fg_info/__init__.py new file mode 100644 index 00000000..2c8bcca0 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/update_fg_info/__init__.py @@ -0,0 +1,92 @@ +from nonebot.adapters import Bot +from nonebot.adapters.kaiheila.exception import ApiNotAvailable +from nonebot.permission import SUPERUSER +from nonebot.plugin import PluginMetadata +from nonebot.rule import to_me +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna +from nonebot_plugin_saa import Text +from nonebot_plugin_session import EventSession + +from zhenxun.configs.utils import PluginExtraData +from zhenxun.models.friend_user import FriendUser +from zhenxun.services.log import logger +from zhenxun.utils.enum import PluginType + +from ._data_source import FgUpdateManage + +__plugin_meta__ = PluginMetadata( + name="更新群组/好友信息", + description="更新群组/好友信息", + usage=""" + 更新群组信息 + 更新好友信息 + """.strip(), + extra=PluginExtraData( + author="HibiKier", + version="0.1", + plugin_type=PluginType.SUPERUSER, + ).dict(), +) + + +_group_matcher = on_alconna( + Alconna( + "更新群组信息", + ), + permission=SUPERUSER, + rule=to_me(), + priority=1, + block=True, +) + +_friend_matcher = on_alconna( + Alconna( + "更新好友信息", + ), + permission=SUPERUSER, + rule=to_me(), + priority=1, + block=True, +) + + +@_group_matcher.handle() +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, +): + try: + num = await FgUpdateManage.update_group(bot, session.platform) + logger.info( + f"更新群聊信息完成,共更新了 {num} 个群组的信息!", + arparma.header_result, + session=session, + ) + await Text(f"成功更新了 {num} 个群组的信息").send() + except Exception as e: + logger.error( + "更新群组信息发生错误", arparma.header_result, session=session, e=e + ) + await Text("其他未知错误...").send() + + +@_friend_matcher.handle() +async def _( + bot: Bot, + session: EventSession, + arparma: Arparma, +): + try: + num = await FgUpdateManage.update_friend(bot, session.platform) + logger.info( + f"更新好友信息完成,共更新了 {num} 个好友的信息!", + arparma.header_result, + session=session, + ) + await Text(f"成功更新了 {num} 个好友的信息").send() + except Exception as e: + logger.error( + "更新好友信息发生错误", arparma.header_result, session=session, e=e + ) + await Text("其他未知错误...").send() diff --git a/zhenxun/builtin_plugins/superuser/update_fg_info/_data_source.py b/zhenxun/builtin_plugins/superuser/update_fg_info/_data_source.py new file mode 100644 index 00000000..646a1136 --- /dev/null +++ b/zhenxun/builtin_plugins/superuser/update_fg_info/_data_source.py @@ -0,0 +1,156 @@ +from nonebot.adapters import Bot +from nonebot.adapters.discord import Bot as DiscordBot +from nonebot.adapters.dodo import Bot as DodoBot +from nonebot.adapters.kaiheila import Bot as KaiheilaBot +from nonebot.adapters.onebot.v11 import Bot as v11Bot +from nonebot.adapters.onebot.v12 import Bot as v12Bot + +from zhenxun.models.friend_user import FriendUser +from zhenxun.models.group_console import GroupConsole +from zhenxun.services.log import logger + + +class FgUpdateManage: + + @classmethod + async def update_group(cls, bot: Bot, platform: str) -> int: + """更新群组信息 + + 参数: + bot: Bot + platform: 平台 + + 返回: + int: 更新个数 + """ + create_list = [] + if group_list := await cls.__get_group_list(bot): + exists_group_list = await GroupConsole.all().values_list( + "group_id", "channel_id" + ) + for group in group_list: + group.platform = platform + if (group.group_id, group.channel_id) not in exists_group_list: + create_list.append(group) + logger.debug( + "群聊信息更新成功", + "更新群信息", + target=f"{group.group_id}:{group.channel_id}", + ) + if create_list: + await GroupConsole.bulk_create(create_list, 10) + return len(create_list) + + @classmethod + async def __get_group_list(cls, bot: Bot) -> list[GroupConsole]: + """获取群组列表 + + 参数: + bot: Bot + + 返回: + list[GroupConsole]: 群组列表 + """ + if isinstance(bot, v11Bot): + group_list = await bot.get_group_list() + return [ + GroupConsole( + group_id=str(g["group_id"]), + group_name=g["group_name"], + max_member_count=g["max_member_count"], + member_count=g["member_count"], + ) + for g in group_list + ] + if isinstance(bot, v12Bot): + group_list = await bot.get_group_list() + return [ + GroupConsole( + group_id=g.group_id, # type: ignore + user_name=g.group_name, # type: ignore + ) + for g in group_list + ] + if isinstance(bot, DodoBot): + island_list = await bot.get_island_list() + source_id_list = [ + (g.island_source_id, g.island_name) + for g in island_list + if g.island_source_id + ] + group_list = [] + for id, name in source_id_list: + channel_list = await bot.get_channel_list(island_source_id=id) + group_list.append(GroupConsole(group_id=id, group_name=name)) + group_list += [ + GroupConsole( + group_id=id, group_name=c.channel_name, channel_id=c.channel_id + ) + for c in channel_list + ] + return group_list + if isinstance(bot, KaiheilaBot): + # TODO: kaiheila群组列表 + pass + if isinstance(bot, DiscordBot): + # TODO: discord群组列表 + pass + return [] + + @classmethod + async def update_friend(cls, bot: Bot, platform: str) -> int: + """更新好友信息 + + 参数: + bot: Bot + platform: 平台 + + 返回: + int: 更新个数 + """ + create_list = [] + if friend_list := await cls.__get_friend_list(bot): + user_id_list = await FriendUser.all().values_list("user_id", flat=True) + for friend in friend_list: + friend.platform = platform + if friend.user_id not in user_id_list: + create_list.append(friend) + if create_list: + await FriendUser.bulk_create(create_list, 10) + return len(create_list) + + @classmethod + async def __get_friend_list(cls, bot: Bot) -> list[FriendUser]: + """获取好友列表 + + 参数: + bot: Bot + + 返回: + list[FriendUser]: 好友列表 + """ + if isinstance(bot, v11Bot): + friend_list = await bot.get_friend_list() + return [ + FriendUser(user_id=str(f["user_id"]), user_name=f["nickname"]) + for f in friend_list + ] + if isinstance(bot, v12Bot): + friend_list = await bot.get_friend_list() + return [ + FriendUser( + user_id=f.user_id, # type: ignore + user_name=f.user_displayname or f.user_remark or f.user_name, # type: ignore + ) + for f in friend_list + ] + if isinstance(bot, DodoBot): + # TODO: dodo好友列表 + pass + if isinstance(bot, KaiheilaBot): + # TODO: kaiheila好友列表 + pass + if isinstance(bot, DiscordBot): + # TODO: discord好友列表 + pass + return [] diff --git a/zhenxun/configs/utils/__init__.py b/zhenxun/configs/utils/__init__.py index 8ed7cd1e..7ef9e0f4 100644 --- a/zhenxun/configs/utils/__init__.py +++ b/zhenxun/configs/utils/__init__.py @@ -1,6 +1,6 @@ import copy from pathlib import Path -from typing import Any, Callable, Dict, List, Type, Union +from typing import Any, Callable, Dict, Type import cattrs from pydantic import BaseModel @@ -17,7 +17,6 @@ _yaml.allow_unicode = True class RegisterConfig(BaseModel): - """ 注册配置项 """ @@ -39,7 +38,6 @@ class RegisterConfig(BaseModel): class ConfigModel(BaseModel): - """ 配置项 """ @@ -57,7 +55,6 @@ class ConfigModel(BaseModel): class ConfigGroup(BaseModel): - """ 配置组 """ @@ -72,7 +69,7 @@ class ConfigGroup(BaseModel): def get(self, c: str, default: Any = None) -> Any: cfg = self.configs.get(c) if cfg is not None: - return cfg + return cfg.value return default @@ -130,6 +127,17 @@ class PluginSetting(BaseModel): """调用插件花费金币""" +class Task(BaseBlock): + module: str + """被动技能模块名""" + name: str + """被动技能名称""" + status: bool = True + """全局开关状态""" + run_time: str | None = None + """运行时间""" + + class PluginExtraData(BaseModel): """ 插件扩展信息 @@ -139,18 +147,20 @@ class PluginExtraData(BaseModel): """作者""" version: str | None = None """版本""" - plugin_type: PluginType | None = None + plugin_type: PluginType = PluginType.NORMAL """插件类型""" menu_type: str = "功能" """菜单类型""" admin_level: int | None = None """管理员插件所需权限等级""" - configs: List[RegisterConfig] | None = None + configs: list[RegisterConfig] | None = None """插件配置""" setting: PluginSetting | None = None """插件基本配置""" - limits: List[BaseBlock | PluginCdBlock | PluginCountBlock] | None = None + limits: list[BaseBlock | PluginCdBlock | PluginCountBlock] | None = None """插件限制""" + tasks: list[Task] | None = None + """技能被动""" class NoSuchConfig(Exception): @@ -287,7 +297,9 @@ class ConfigsManager: if not config: config = self._data[module].configs.get(f"{key} [LEVEL]") if not config: - raise NoSuchConfig(f"未查询到配置项 MODULE: [ {module} ] | KEY: [ {key} ]") + raise NoSuchConfig( + f"未查询到配置项 MODULE: [ {module} ] | KEY: [ {key} ]" + ) if config.arg_parser: value = config.arg_parser(value or config.default_value) else: diff --git a/zhenxun/models/bag_user.py b/zhenxun/models/bag_user.py new file mode 100644 index 00000000..7f3b0322 --- /dev/null +++ b/zhenxun/models/bag_user.py @@ -0,0 +1,160 @@ +# from typing import Dict + +# from services.db_context import Model +# from tortoise import fields + +# from .goods_info import GoodsInfo + + +# class BagUser(Model): + +# id = fields.IntField(pk=True, generated=True, auto_increment=True) +# """自增id""" +# user_id = fields.CharField(255) +# """用户id""" +# group_id = fields.CharField(255) +# """群聊id""" +# gold = fields.IntField(default=100) +# """金币数量""" +# spend_total_gold = fields.IntField(default=0) +# """花费金币总数""" +# get_total_gold = fields.IntField(default=0) +# """获取金币总数""" +# get_today_gold = fields.IntField(default=0) +# """今日获取金币""" +# spend_today_gold = fields.IntField(default=0) +# """今日获取金币""" +# property: Dict[str, int] = fields.JSONField(default={}) # type: ignore +# """道具""" + +# class Meta: +# table = "bag_users" +# table_description = "用户道具数据表" +# unique_together = ("user_id", "group_id") + +# @classmethod +# async def get_gold(cls, user_id: str, group_id: str) -> int: +# """获取当前金币 + +# 参数: +# user_id: 用户id +# group_id: 所在群组id + +# 返回: +# int: 金币数量 +# """ +# user, _ = await cls.get_or_create(user_id=user_id, group_id=group_id) +# return user.gold + +# @classmethod +# async def get_property( +# cls, user_id: str, group_id: str, only_active: bool = False +# ) -> Dict[str, int]: +# """获取当前道具 + +# 参数: +# user_id: 用户id +# group_id: 所在群组id +# only_active: 仅仅获取主动使用的道具 + +# 返回: +# Dict[str, int]: 道具名称与数量 +# """ +# user, _ = await cls.get_or_create(user_id=user_id, group_id=group_id) +# if only_active and user.property: +# data = {} +# name_list = [ +# x.goods_name +# for x in await GoodsInfo.get_all_goods() +# if not x.is_passive +# ] +# for key in [x for x in user.property if x in name_list]: +# data[key] = user.property[key] +# return data +# return user.property + +# @classmethod +# async def add_gold(cls, user_id: str, group_id: str, num: int): +# """增加金币 + +# 参数: +# user_id: 用户id +# group_id: 所在群组id +# num: 金币数量 +# """ +# user, _ = await cls.get_or_create(user_id=user_id, group_id=group_id) +# user.gold = user.gold + num +# user.get_total_gold = user.get_total_gold + num +# user.get_today_gold = user.get_today_gold + num +# await user.save(update_fields=["gold", "get_today_gold", "get_total_gold"]) + +# @classmethod +# async def spend_gold(cls, user_id: str, group_id: str, num: int): +# """花费金币 + +# 参数: +# user_id: 用户id +# group_id: 所在群组id +# num: 金币数量 +# """ +# user, _ = await cls.get_or_create(user_id=str(user_id), group_id=str(group_id)) +# user.gold = user.gold - num +# user.spend_total_gold = user.spend_total_gold + num +# user.spend_today_gold = user.spend_today_gold + num +# await user.save(update_fields=["gold", "spend_total_gold", "spend_today_gold"]) + +# @classmethod +# async def add_property(cls, user_id: str, group_id: str, name: str, num: int = 1): +# """增加道具 + +# 参数: +# user_id: 用户id +# group_id: 所在群组id +# name: 道具名称 +# num: 道具数量 +# """ +# user, _ = await cls.get_or_create(user_id=str(user_id), group_id=str(group_id)) +# property_ = user.property +# if property_.get(name) is None: +# property_[name] = 0 +# property_[name] += num +# user.property = property_ +# await user.save(update_fields=["property"]) + +# @classmethod +# async def delete_property( +# cls, user_id: str, group_id: str, name: str, num: int = 1 +# ) -> bool: +# """使用/删除 道具 + +# 参数: +# user_id: 用户id +# group_id: 所在群组id +# name: 道具名称 +# num: 使用个数 + +# 返回: +# bool: 是否使用/删除成功 +# """ +# user, _ = await cls.get_or_create(user_id=str(user_id), group_id=str(group_id)) +# property_ = user.property +# if name in property_: +# if (n := property_.get(name, 0)) < num: +# return False +# if n == num: +# del property_[name] +# else: +# property_[name] -= num +# await user.save(update_fields=["property"]) +# return True +# return False + +# @classmethod +# async def _run_script(cls): +# return [ +# "ALTER TABLE bag_users DROP props;", # 删除 props 字段 +# "ALTER TABLE bag_users RENAME COLUMN user_qq TO user_id;", # 将user_qq改为user_id +# "ALTER TABLE bag_users ALTER COLUMN user_id TYPE character varying(255);", +# # 将user_id字段类型改为character varying(255) +# "ALTER TABLE bag_users ALTER COLUMN group_id TYPE character varying(255);", +# ] diff --git a/zhenxun/models/ban_console.py b/zhenxun/models/ban_console.py new file mode 100644 index 00000000..5996e55f --- /dev/null +++ b/zhenxun/models/ban_console.py @@ -0,0 +1,172 @@ +import time + +from tortoise import fields +from typing_extensions import Self + +from zhenxun.services.db_context import Model +from zhenxun.services.log import logger +from zhenxun.utils.exception import UserAndGroupIsNone + + +class BanConsole(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255, null=True) + """用户id""" + group_id = fields.CharField(255, null=True) + """群组id""" + ban_level = fields.IntField() + """使用ban命令的用户等级""" + ban_time = fields.BigIntField() + """ban开始的时间""" + duration = fields.BigIntField() + """ban时长""" + operator = fields.CharField(255) + """使用Ban命令的用户""" + + class Meta: + table = "ban_console" + table_description = ".ban/b了 封禁人员/群组数据表" + + @classmethod + async def _get_data(cls, user_id: str | None, group_id: str | None) -> Self | None: + """获取数据 + + 参数: + user_id: 用户id + group_id: 群组id + + 异常: + UserAndGroupIsNone: 用户id和群组id都为空 + + 返回: + Self | None: Self + """ + if not user_id and not group_id: + raise UserAndGroupIsNone() + user = None + if user_id: + if group_id: + user = await cls.get_or_none(user_id=user_id, group_id=group_id) + else: + user = await cls.get_or_none(user_id=user_id, group_id__isnull=True) + else: + if group_id: + user = await cls.get_or_none(user_id__isnull=True, group_id=group_id) + return user + + @classmethod + async def check_ban_level( + cls, user_id: str | None, group_id: str | None, level: int + ) -> bool: + """检测ban掉目标的用户与unban用户的权限等级大小 + + 参数: + user_id: 用户id + group_id: 群组id + level: 权限等级 + + 返回: + bool: 权限判断 + """ + user = await cls._get_data(user_id, group_id) + if user: + logger.debug( + f"检测用户被ban等级,user_level: {user.ban_level},level: {level}", + target=f"{group_id}:{user_id}", + ) + return bool(user and user.ban_level >= level) + return False + + @classmethod + async def check_ban_time( + cls, user_id: str | None, group_id: str | None = None + ) -> int: + """检测用户被ban时长 + + 参数: + user_id: 用户id + + 返回: + int: ban剩余时长,-1时为永久ban,0表示未被ban + """ + logger.debug(f"获取用户ban时长", target=f"{group_id}:{user_id}") + user = await cls._get_data(user_id, group_id) + if user: + if user.duration == -1: + return -1 + _time = time.time() - (user.ban_time + user.duration) + if _time > 0: + return 0 + return int(time.time() - user.ban_time - user.duration) + return 0 + + @classmethod + async def is_ban(cls, user_id: str | None, group_id: str | None = None) -> bool: + """判断用户是否被ban + + 参数: + user_id: 用户id + + 返回: + bool: 是否被ban + """ + logger.debug(f"检测是否被ban", target=f"{group_id}:{user_id}") + if await cls.check_ban_time(user_id, group_id): + return True + else: + await cls.unban(user_id, group_id) + return False + + @classmethod + async def ban( + cls, + user_id: str | None, + group_id: str | None, + ban_level: int, + duration: int, + operator: str | None, + ): + """ban掉目标用户 + + 参数: + user_id: 用户id + group_id: 群组id + ban_level: 使用命令者的权限等级 + duration: 时长,秒 + operator: 操作者id + """ + logger.debug( + f"封禁用户,等级:{ban_level},时长: {duration}", + target=f"{group_id}:{user_id}", + ) + user = await cls._get_data(user_id, group_id) + if user: + await cls.unban(user_id, group_id) + await cls.create( + user_id=user_id, + group_id=group_id, + ban_level=ban_level, + ban_time=int(time.time()), + duration=duration, + operator=operator or 0, + ) + + @classmethod + async def unban(cls, user_id: str | None, group_id: str | None = None) -> bool: + """unban用户 + + 参数: + user_id: 用户id + group_id: 群组id + + 返回: + bool: 是否被ban + """ + user = await cls._get_data(user_id, group_id) + if user: + logger.debug("解除封禁", target=f"{group_id}:{user_id}") + await user.delete() + return True + return False diff --git a/zhenxun/models/chat_history.py b/zhenxun/models/chat_history.py new file mode 100644 index 00000000..117397ce --- /dev/null +++ b/zhenxun/models/chat_history.py @@ -0,0 +1,125 @@ +from datetime import datetime, timedelta +from typing import Literal, Tuple + +from tortoise import fields +from tortoise.functions import Count +from typing_extensions import Self + +from zhenxun.services.db_context import Model + + +class ChatHistory(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255) + """用户id""" + group_id = fields.CharField(255, null=True) + """群聊id""" + text = fields.TextField(null=True) + """文本内容""" + plain_text = fields.TextField(null=True) + """纯文本""" + create_time = fields.DatetimeField(auto_now_add=True) + """创建时间""" + bot_id = fields.CharField(255, null=True) + """bot记录id""" + platform = fields.CharField(255, null=True) + """平台""" + + class Meta: + table = "chat_history" + table_description = "聊天记录数据表" + + @classmethod + async def get_group_msg_rank( + cls, + gid: str, + limit: int = 10, + order: str = "DESC", + date_scope: tuple[datetime, datetime] | None = None, + ) -> list[Self]: + """获取排行数据 + + 参数: + gid: 群号 + limit: 获取数量 + order: 排序类型,desc,des + date_scope: 日期范围 + """ + o = "-" if order == "DESC" else "" + query = cls.filter(group_id=gid) + if date_scope: + query = query.filter(create_time__range=date_scope) + return list( + await query.annotate(count=Count("user_id")) + .order_by(o + "count") + .group_by("user_id") + .limit(limit) + .values_list("user_id", "count") + ) # type: ignore + + @classmethod + async def get_group_first_msg_datetime(cls, group_id: str) -> datetime | None: + """获取群第一条记录消息时间 + + 参数: + group_id: 群组id + """ + if ( + message := await cls.filter(group_id=group_id) + .order_by("create_time") + .first() + ): + return message.create_time + + @classmethod + async def get_message( + cls, + uid: str, + gid: str, + type_: Literal["user", "group"], + msg_type: Literal["private", "group"] | None = None, + days: int | Tuple[datetime, datetime] | None = None, + ) -> list[Self]: + """获取消息查询query + + 参数: + uid: 用户id + gid: 群聊id + type_: 类型,私聊或群聊 + msg_type: 消息类型,用户或群聊 + days: 限制日期 + """ + if type_ == "user": + query = cls.filter(user_id=uid) + if msg_type == "private": + query = query.filter(group_id__isnull=True) + elif msg_type == "group": + query = query.filter(group_id__not_isnull=True) + else: + query = cls.filter(group_id=gid) + if uid: + query = query.filter(user_id=uid) + if days: + if isinstance(days, int): + query = query.filter( + create_time__gte=datetime.now() - timedelta(days=days) + ) + elif isinstance(days, tuple): + query = query.filter(create_time__range=days) + return await query.all() # type: ignore + + @classmethod + async def _run_script(cls): + return [ + "alter table chat_history alter group_id drop not null;", # 允许 group_id 为空 + "alter table chat_history alter text drop not null;", # 允许 text 为空 + "alter table chat_history alter plain_text drop not null;", # 允许 plain_text 为空 + "ALTER TABLE chat_history RENAME COLUMN user_qq TO user_id;", # 将user_id改为user_id + "ALTER TABLE chat_history ALTER COLUMN user_id TYPE character varying(255);", + "ALTER TABLE chat_history ALTER COLUMN group_id TYPE character varying(255);", + "ALTER TABLE chat_history ADD bot_id VARCHAR(255);", # 添加bot_id字段 + "ALTER TABLE chat_history ALTER COLUMN bot_id TYPE character varying(255);", + "ALTER TABLE chat_history ADD COLUMN platform character varying(255);", + ] diff --git a/zhenxun/models/friend_user.py b/zhenxun/models/friend_user.py index f871e389..597ce4f1 100644 --- a/zhenxun/models/friend_user.py +++ b/zhenxun/models/friend_user.py @@ -15,32 +15,32 @@ class FriendUser(Model): """用户名称""" nickname = fields.CharField(max_length=255, null=True, description="用户自定义昵称") """私聊下自定义昵称""" + platform = fields.CharField(255, null=True, description="平台") + """平台""" class Meta: table = "friend_users" table_description = "好友信息数据表" @classmethod - async def get_user_name(cls, user_id: Union[int, str]) -> str: - """ - 说明: - 获取好友用户名称 + async def get_user_name(cls, user_id: str) -> str: + """获取好友用户名称 + 参数: - :param user_id: 用户id + user_id: 用户id """ - if user := await cls.get_or_none(user_id=str(user_id)): + if user := await cls.get_or_none(user_id=user_id): return user.user_name return "" @classmethod - async def get_user_nickname(cls, user_id: Union[int, str]) -> str: - """ - 说明: - 获取用户昵称 + async def get_user_nickname(cls, user_id: str) -> str: + """获取用户昵称 + 参数: - :param user_id: 用户id + user_id: 用户id """ - if user := await cls.get_or_none(user_id=str(user_id)): + if user := await cls.get_or_none(user_id=user_id): if user.nickname: _tmp = "" if black_word := Config.get_config("nickname", "BLACK_WORD"): @@ -50,21 +50,34 @@ class FriendUser(Model): return "" @classmethod - async def set_user_nickname(cls, user_id: Union[int, str], nickname: str): - """ - 说明: - 设置用户昵称 + async def set_user_nickname( + cls, + user_id: str, + nickname: str, + uname: str | None = None, + platform: str | None = None, + ): + """设置用户昵称 + 参数: - :param user_id: 用户id - :param nickname: 昵称 + user_id: 用户id + nickname: 昵称 + uname: 用户昵称 + platform: 平台 """ + defaults = {"nickname": nickname} + if uname is not None: + defaults["user_name"] = uname + if platform is not None: + defaults["platform"] = platform await cls.update_or_create( - user_id=str(user_id), defaults={"nickname": nickname} + user_id=user_id, + defaults=defaults, ) @classmethod - async def _run_script(cls): - await cls.raw( - "ALTER TABLE friend_users ALTER COLUMN user_id TYPE character varying(255);" - ) - # 将user_id字段类型改为character varying(255)) + def _run_script(cls): + return [ + "ALTER TABLE friend_users ALTER COLUMN user_id TYPE character varying(255);", + "ALTER TABLE friend_users ADD COLUMN platform character varying(255) default 'qq';", + ] diff --git a/zhenxun/models/goods_info.py b/zhenxun/models/goods_info.py new file mode 100644 index 00000000..60f64c4b --- /dev/null +++ b/zhenxun/models/goods_info.py @@ -0,0 +1,162 @@ +import uuid +from typing import Dict + +from tortoise import fields +from typing_extensions import Self + +from zhenxun.services.db_context import Model + + +class GoodsInfo(Model): + __tablename__ = "goods_info" + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + uuid = fields.CharField(255, null=True) + """uuid""" + goods_name = fields.CharField(255, unique=True) + """商品名称""" + goods_price = fields.IntField() + """价格""" + goods_description = fields.TextField() + """描述""" + goods_discount = fields.FloatField(default=1) + """折扣""" + goods_limit_time = fields.BigIntField(default=0) + """限时""" + daily_limit = fields.IntField(default=0) + """每日限购""" + is_passive = fields.BooleanField(default=False) + """是否为被动道具""" + icon = fields.TextField(null=True) + """图标路径""" + + class Meta: + table = "goods_info" + table_description = "商品数据表" + + @classmethod + async def add_goods( + cls, + goods_name: str, + goods_price: int, + goods_description: str, + goods_discount: float = 1, + goods_limit_time: int = 0, + daily_limit: int = 0, + is_passive: bool = False, + icon: str | None = None, + ): + """添加商品 + + 参数: + goods_name: 商品名称 + goods_price: 商品价格 + goods_description: 商品简介 + goods_discount: 商品折扣 + goods_limit_time: 商品限时 + daily_limit: 每日购买限制 + is_passive: 是否为被动道具 + icon: 图标 + """ + if not await cls.exists(goods_name=goods_name): + await cls.create( + uuid=uuid.uuid1(), + goods_name=goods_name, + goods_price=goods_price, + goods_description=goods_description, + goods_discount=goods_discount, + goods_limit_time=goods_limit_time, + daily_limit=daily_limit, + is_passive=is_passive, + icon=icon, + ) + + @classmethod + async def delete_goods(cls, goods_name: str) -> bool: + """删除商品 + + 参数: + goods_name: 商品名称 + + 返回: + bool: 是否删除成功 + """ + if goods := await cls.get_or_none(goods_name=goods_name): + await goods.delete() + return True + return False + + @classmethod + async def update_goods( + cls, + goods_name: str, + goods_price: int | None = None, + goods_description: str | None = None, + goods_discount: float | None = None, + goods_limit_time: int | None = None, + daily_limit: int | None = None, + is_passive: bool | None = None, + icon: str | None = None, + ): + """更新商品信息 + + 参数: + goods_name: 商品名称 + goods_price: 商品价格 + goods_description: 商品简介 + goods_discount: 商品折扣 + goods_limit_time: 商品限时时间 + daily_limit: 每日次数限制 + is_passive: 是否为被动 + icon: 图标 + """ + if goods := await cls.get_or_none(goods_name=goods_name): + await cls.update_or_create( + goods_name=goods_name, + defaults={ + "goods_price": goods_price or goods.goods_price, + "goods_description": goods_description or goods.goods_description, + "goods_discount": goods_discount or goods.goods_discount, + "goods_limit_time": ( + goods_limit_time + if goods_limit_time is not None + else goods.goods_limit_time + ), + "daily_limit": ( + daily_limit if daily_limit is not None else goods.daily_limit + ), + "is_passive": ( + is_passive if is_passive is not None else goods.is_passive + ), + "icon": icon or goods.icon, + }, + ) + + @classmethod + async def get_all_goods(cls) -> list[Self]: + """ + 获得全部有序商品对象 + """ + query = await cls.all() + id_lst = [x.id for x in query] + goods_lst = [] + for _ in range(len(query)): + min_id = min(id_lst) + goods_lst.append([x for x in query if x.id == min_id][0]) + id_lst.remove(min_id) + return goods_lst + + @classmethod + async def _run_script(cls): + if goods_list := await cls.filter(uuid__isnull=True).all(): + for goods in goods_list: + goods.uuid = uuid.uuid1() + await cls.bulk_update(goods_list, ["uuid"], 10) + return [ + "ALTER TABLE goods_info ADD uuid VARCHAR(255);", + "ALTER TABLE goods_info ADD daily_limit Integer DEFAULT 0;", + "ALTER TABLE goods_info ADD is_passive boolean DEFAULT False;", + "ALTER TABLE goods_info ADD icon VARCHAR(255);", + "ALTER TABLE goods_info DROP daily_purchase_limit;", # 删除 daily_purchase_limit 字段 + ] diff --git a/zhenxun/models/group_console.py b/zhenxun/models/group_console.py new file mode 100644 index 00000000..a0ff26fe --- /dev/null +++ b/zhenxun/models/group_console.py @@ -0,0 +1,54 @@ +from tortoise import fields + +from zhenxun.services.db_context import Model + + +class GroupConsole(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + group_id = fields.CharField(255, description="群组id") + """群聊id""" + channel_id = fields.CharField(255, null=True, description="频道id") + """频道id""" + group_name = fields.TextField(default="", description="群组名称") + """群聊名称""" + max_member_count = fields.IntField(default=0, description="最大人数") + """最大人数""" + member_count = fields.IntField(default=0, description="当前人数") + """当前人数""" + group_flag = fields.IntField(default=0, description="群认证标记") + """群认证标记""" + block_plugin = fields.TextField(default="", description="禁用插件") + """禁用插件""" + block_task = fields.TextField(default="", description="禁用插件") + """禁用插件""" + platform = fields.CharField(255, default="qq", description="所属平台") + """所属平台""" + + class Meta: + table = "group_console" + table_description = "群组信息表" + unique_together = ("group_id", "channel_id") + + @classmethod + async def is_block_task( + cls, group_id: str, task: str, channel_id: str | None = None + ) -> bool: + """查看群组是否禁用被动 + + 参数: + group_id: 群组id + task: 任务模块 + channel_id: 频道id + + 返回: + bool: 是否禁用被动 + """ + return await cls.exists( + group_id=group_id, channel_id=channel_id, block_task__contains=f"{task}," + ) + + @classmethod + def _run_script(cls): + return [] diff --git a/zhenxun/models/group_info copy.py b/zhenxun/models/group_info copy.py deleted file mode 100644 index 64df8c5a..00000000 --- a/zhenxun/models/group_info copy.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import List, Optional - -from tortoise import fields - -from zhenxun.services.db_context import Model -from zhenxun.services.log import logger - - -class GroupInfo(Model): - group_id = fields.CharField(255, pk=True) - """群聊id""" - group_name = fields.TextField(default="") - """群聊名称""" - max_member_count = fields.IntField(default=0) - """最大人数""" - member_count = fields.IntField(default=0) - """当前人数""" - group_flag = fields.IntField(default=0) - """群认证标记""" - - class Meta: - table = "group_info" - table_description = "群聊信息表" - - @classmethod - def _run_script(cls): - return [ - "ALTER TABLE group_info ADD group_flag Integer NOT NULL DEFAULT 0;", # group_info表添加一个group_flag - "ALTER TABLE group_info ALTER COLUMN group_id TYPE character varying(255);" - # 将group_id字段类型改为character varying(255) - ] diff --git a/zhenxun/models/group_info.py b/zhenxun/models/group_info.py index e8249c44..8183bf16 100644 --- a/zhenxun/models/group_info.py +++ b/zhenxun/models/group_info.py @@ -1,5 +1,3 @@ -from typing import List, Optional - from tortoise import fields from zhenxun.services.db_context import Model @@ -8,6 +6,8 @@ from zhenxun.services.db_context import Model class GroupInfo(Model): group_id = fields.CharField(255, pk=True, description="群组id") """群聊id""" + # channel_id = fields.CharField(255, description="群组id") + # """频道id""" group_name = fields.TextField(default="", description="群组名称") """群聊名称""" max_member_count = fields.IntField(default=0, description="最大人数") @@ -16,15 +16,36 @@ class GroupInfo(Model): """当前人数""" group_flag = fields.IntField(default=0, description="群认证标记") """群认证标记""" + block_plugin = fields.TextField(default="", description="禁用插件") + """禁用插件""" + block_task = fields.TextField(default="", description="禁用插件") + """禁用插件""" + platform = fields.CharField(255, default="qq", description="所属平台") + """所属平台""" class Meta: table = "group_info" table_description = "群聊信息表" + @classmethod + async def is_block_task(cls, group_id: str, task: str) -> bool: + """查看群组是否禁用被动 + + 参数: + group_id: 群组id + task: 任务模块 + + 返回: + bool: 是否禁用被动 + """ + return await cls.exists(group_id=group_id, block_task__contains=f"{task},") + @classmethod def _run_script(cls): return [ "ALTER TABLE group_info ADD group_flag Integer NOT NULL DEFAULT 0;", # group_info表添加一个group_flag - "ALTER TABLE group_info ALTER COLUMN group_id TYPE character varying(255);" - # 将group_id字段类型改为character varying(255) + "ALTER TABLE group_info ALTER COLUMN group_id TYPE character varying(255);", + "ALTER TABLE group_info ADD block_plugin Text NOT NULL DEFAULT '';", + "ALTER TABLE group_info ADD block_task Text NOT NULL DEFAULT '';", + "ALTER TABLE group_info ADD platform character varying(255) NOT NULL DEFAULT 'qq';", ] diff --git a/zhenxun/models/group_member_info.py b/zhenxun/models/group_member_info.py index e7bbe586..cf73f14d 100644 --- a/zhenxun/models/group_member_info.py +++ b/zhenxun/models/group_member_info.py @@ -1,9 +1,8 @@ -from datetime import datetime -from typing import List, Optional, Set, Union +from typing import Set from tortoise import fields - from zhenxun.configs.config import Config + from zhenxun.services.db_context import Model from zhenxun.services.log import logger @@ -23,6 +22,8 @@ class GroupInfoUser(Model): """群聊昵称""" uid = fields.BigIntField(null=True) """用户uid""" + platform = fields.CharField(255, null=True, description="平台") + """平台""" class Meta: table = "group_info_users" @@ -30,59 +31,65 @@ class GroupInfoUser(Model): unique_together = ("user_id", "group_id") @classmethod - async def get_group_member_id_list(cls, group_id: Union[int, str]) -> Set[int]: - """ - 说明: - 获取该群所有用户id + async def get_group_member_id_list(cls, group_id: str) -> Set[int]: + """获取该群所有用户id + 参数: - :param group_id: 群号 + group_id: 群号 """ return set( - await cls.filter(group_id=str(group_id)).values_list("user_id", flat=True) + await cls.filter(group_id=group_id).values_list("user_id", flat=True) ) # type: ignore @classmethod async def set_user_nickname( - cls, user_id: Union[int, str], group_id: Union[int, str], nickname: str + cls, + user_id: str, + group_id: str, + nickname: str, + uname: str | None = None, + platform: str | None = None, ): - """ - 说明: - 设置群员在该群内的昵称 + """设置群员在该群内的昵称 + 参数: - :param user_id: 用户id - :param group_id: 群号 - :param nickname: 昵称 + user_id: 用户id + group_id: 群号 + nickname: 昵称 + uname: 用户昵称 + platform: 平台 """ + defaults = {"nickname": nickname} + if uname is not None: + defaults["user_name"] = uname + if platform is not None: + defaults["platform"] = platform await cls.update_or_create( - user_id=str(user_id), - group_id=str(group_id), - defaults={"nickname": nickname}, + user_id=user_id, + group_id=group_id, + defaults=defaults, ) @classmethod - async def get_user_all_group(cls, user_id: Union[int, str]) -> List[int]: - """ - 说明: - 获取该用户所在的所有群聊 + async def get_user_all_group(cls, user_id: str) -> list[int]: + """获取该用户所在的所有群聊 + 参数: - :param user_id: 用户id + user_id: 用户id """ return list( await cls.filter(user_id=str(user_id)).values_list("group_id", flat=True) ) # type: ignore @classmethod - async def get_user_nickname( - cls, user_id: Union[int, str], group_id: Union[int, str] - ) -> str: - """ - 说明: - 获取用户在该群的昵称 + async def get_user_nickname(cls, user_id: str, group_id: str) -> str: + """获取用户在该群的昵称 + 参数: - :param user_id: 用户id - :param group_id: 群号 + user_id: 用户id + group_id: 群号 """ - if user := await cls.get_or_none(user_id=str(user_id), group_id=str(group_id)): + if user := await cls.get_or_none(user_id=user_id, group_id=group_id): if user.nickname: nickname = "" if black_word := Config.get_config("nickname", "BLACK_WORD"): @@ -92,28 +99,6 @@ class GroupInfoUser(Model): return user.nickname return "" - @classmethod - async def get_group_member_uid( - cls, user_id: Union[int, str], group_id: Union[int, str] - ) -> Optional[int]: - logger.debug( - f"GroupInfoUser 尝试获取 用户[{user_id}] 群聊[{group_id}] UID" - ) - user, _ = await cls.get_or_create(user_id=str(user_id), group_id=str(group_id)) - _max_uid_user, _ = await cls.get_or_create(user_id="114514", group_id="114514") - _max_uid = _max_uid_user.uid - if not user.uid: - all_user = await cls.filter(user_id=str(user_id)).all() - for x in all_user: - if x.uid: - return x.uid - user.uid = _max_uid + 1 - _max_uid_user.uid = _max_uid + 1 - await cls.bulk_update([user, _max_uid_user], ["uid"]) - logger.debug( - f"GroupInfoUser 获取 用户[{user_id}] 群聊[{group_id}] UID: {user.uid}" - ) - return user.uid @classmethod async def _run_script(cls): @@ -124,4 +109,5 @@ class GroupInfoUser(Model): "ALTER TABLE group_info_users ALTER COLUMN user_id TYPE character varying(255);", # 将user_id字段类型改为character varying(255) "ALTER TABLE group_info_users ALTER COLUMN group_id TYPE character varying(255);", + "ALTER TABLE group_info_users ADD COLUMN platform character varying(255) default 'qq';", ] diff --git a/zhenxun/models/level_user.py b/zhenxun/models/level_user.py index 1495a315..da229216 100644 --- a/zhenxun/models/level_user.py +++ b/zhenxun/models/level_user.py @@ -24,9 +24,8 @@ class LevelUser(Model): unique_together = ("user_id", "group_id") @classmethod - async def get_user_level(cls, user_id: int | str, group_id: int | str) -> int: - """ - 获取用户在群内的等级 + async def get_user_level(cls, user_id: str, group_id: str | None) -> int: + """获取用户在群内的等级 参数: user_id: 用户id @@ -35,20 +34,21 @@ class LevelUser(Model): 返回: int: 权限等级 """ - if user := await cls.get_or_none(user_id=str(user_id), group_id=str(group_id)): + if not group_id: + return 0 + if user := await cls.get_or_none(user_id=user_id, group_id=group_id): return user.user_level return 0 @classmethod async def set_level( cls, - user_id: int | str, - group_id: int | str, + user_id: str, + group_id: str, level: int, group_flag: int = 0, ): - """ - 设置用户在群内的权限 + """设置用户在群内的权限 参数: user_id: 用户id @@ -57,8 +57,8 @@ class LevelUser(Model): group_flag: 是否被自动更新刷新权限 0:是, 1:否. """ await cls.update_or_create( - user_id=str(user_id), - group_id=str(group_id), + user_id=user_id, + group_id=group_id, defaults={ "user_level": level, "group_flag": group_flag, @@ -66,9 +66,8 @@ class LevelUser(Model): ) @classmethod - async def delete_level(cls, user_id: int | str, group_id: int | str) -> bool: - """ - 删除用户权限 + async def delete_level(cls, user_id: str, group_id: str) -> bool: + """删除用户权限 参数: user_id: 用户id @@ -77,17 +76,14 @@ class LevelUser(Model): 返回: bool: 是否含有用户权限 """ - if user := await cls.get_or_none(user_id=str(user_id), group_id=str(group_id)): + if user := await cls.get_or_none(user_id=user_id, group_id=group_id): await user.delete() return True return False @classmethod - async def check_level( - cls, user_id: int | str, group_id: int | str, level: int - ) -> bool: - """ - 检查用户权限等级是否大于 level + async def check_level(cls, user_id: str, group_id: str, level: int) -> bool: + """检查用户权限等级是否大于 level 参数: user_id: 用户id @@ -98,20 +94,17 @@ class LevelUser(Model): bool: 是否大于level """ if group_id: - if user := await cls.get_or_none( - user_id=str(user_id), group_id=str(group_id) - ): + if user := await cls.get_or_none(user_id=user_id, group_id=group_id): return user.user_level >= level else: - user_list = await cls.filter(user_id=str(user_id)).all() + user_list = await cls.filter(user_id=user_id).all() user = max(user_list, key=lambda x: x.user_level) return user.user_level >= level return False @classmethod - async def is_group_flag(cls, user_id: int | str, group_id: int | str) -> bool: - """ - 检测是否会被自动更新刷新权限 + async def is_group_flag(cls, user_id: str, group_id: str) -> bool: + """检测是否会被自动更新刷新权限 参数: user_id: 用户id @@ -120,7 +113,7 @@ class LevelUser(Model): 返回: bool: 是否会被自动更新权限刷新 """ - if user := await cls.get_or_none(user_id=str(user_id), group_id=str(group_id)): + if user := await cls.get_or_none(user_id=user_id, group_id=group_id): return user.group_flag == 1 return False diff --git a/zhenxun/models/plugin_info.py b/zhenxun/models/plugin_info.py index 15eb2a0a..0eaea950 100644 --- a/zhenxun/models/plugin_info.py +++ b/zhenxun/models/plugin_info.py @@ -17,7 +17,7 @@ class PluginInfo(Model): """插件名称""" status = fields.BooleanField(default=True, description="全局开关状态") """全局开关状态""" - block_type = fields.CharEnumField( + block_type: BlockType | None = fields.CharEnumField( BlockType, default=None, null=True, description="禁用类型" ) """禁用类型""" diff --git a/zhenxun/models/plugin_limit.py b/zhenxun/models/plugin_limit.py index 89247886..9bf4259f 100644 --- a/zhenxun/models/plugin_limit.py +++ b/zhenxun/models/plugin_limit.py @@ -22,6 +22,7 @@ class PluginLimit(Model): on_delete=fields.CASCADE, description="所属插件", ) + """所属插件""" limit_type = fields.CharEnumField(PluginLimitType, description="限制类型") """限制类型""" watch_type = fields.CharEnumField(LimitWatchType, description="监听类型") diff --git a/zhenxun/models/sign_log.py b/zhenxun/models/sign_log.py new file mode 100644 index 00000000..fffdee2c --- /dev/null +++ b/zhenxun/models/sign_log.py @@ -0,0 +1,26 @@ +from datetime import datetime +from typing import List, Literal, Optional, Tuple, Union + +from tortoise import fields + +from zhenxun.services.db_context import Model + + +class SignLog(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255, unique=True, description="用户id") + """用户id""" + impression = fields.DecimalField(10, 3, default=0, description="好感度") + """好感度""" + create_time = fields.DatetimeField(auto_now_add=True, description="创建时间") + """创建时间""" + bot_id = fields.CharField(255, null=True, description="botId") + """bot记录id""" + platform = fields.CharField(255, null=True, description="平台") + """平台""" + + class Meta: + table = "sign_log" + table_description = "用户签到记录表" diff --git a/zhenxun/models/sign_user.py b/zhenxun/models/sign_user.py new file mode 100644 index 00000000..eb25b6cb --- /dev/null +++ b/zhenxun/models/sign_user.py @@ -0,0 +1,72 @@ +from tortoise import fields +from typing_extensions import Self + +from zhenxun.services.db_context import Model + +from .sign_log import SignLog +from .user_console import UserConsole + + +class SignUser(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255, unique=True, description="用户id") + """用户id""" + sign_count = fields.IntField(default=0, description="签到次数") + """签到次数""" + impression = fields.DecimalField(10, 3, default=0, description="好感度") + """好感度""" + user_console: fields.OneToOneRelation[UserConsole] = fields.OneToOneField( + "models.UserConsole", related_name="user_console", description="用户数据" + ) + """用户数据""" + add_probability = fields.DecimalField( + 10, 3, default=0, description="双倍签到增加概率" + ) + """双倍签到增加概率""" + specify_probability = fields.DecimalField( + 10, 3, default=0, description="指定双倍概率" + ) + """使用指定双倍概率""" + platform = fields.CharField(255, null=True, description="平台") + """平台""" + + class Meta: + table = "sign_users" + table_description = "用户签到数据表" + + @classmethod + async def sign( + cls, + user_id: str | Self, + impression: float, + bot_id: str | None = None, + platform: str | None = None, + ) -> Self: + """签到 + + 参数: + user_id: 用户id + impression: 好感度 + bot_id: bot Id + platform: 平台 + """ + if isinstance(user_id, SignUser): + user = user_id + else: + user, _ = await cls.get_or_create( + user_id=user_id, defaults={"platform": platform} + ) + user.impression = float(user.impression) + impression + user.add_probability = 0 + user.specify_probability = 0 + user.sign_count += 1 + await user.save() + await SignLog.create( + user_id=user.user_id, + impression=impression, + bot_id=bot_id, + platform=platform, + ) + return user diff --git a/zhenxun/models/task_info.py b/zhenxun/models/task_info.py new file mode 100644 index 00000000..7e02a3d0 --- /dev/null +++ b/zhenxun/models/task_info.py @@ -0,0 +1,22 @@ +from tortoise import fields + +from zhenxun.services.db_context import Model + + +class TaskInfo(Model): + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + module = fields.CharField(255, description="被动技能模块名") + """被动技能模块名""" + name = fields.CharField(255, description="被动技能名称") + """被动技能名称""" + status = fields.BooleanField(default=True, description="全局开关状态") + """全局开关状态""" + run_time = fields.CharField(255, null=True, description="运行时间") + """运行时间""" + run_count = fields.IntField(default=0, description="运行次数") + """运行次数""" + + class Meta: + table = "task_info" + table_description = "被动技能基本信息" diff --git a/zhenxun/models/user_console.py b/zhenxun/models/user_console.py new file mode 100644 index 00000000..59b643f4 --- /dev/null +++ b/zhenxun/models/user_console.py @@ -0,0 +1,79 @@ +from typing import Dict + +from tortoise import fields + +from zhenxun.services.db_context import Model +from zhenxun.utils.enum import GoldHandle + +from .user_gold_log import UserGoldLog + + +class UserConsole(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255, unique=True, description="用户id") + """用户id""" + uid = fields.IntField(description="UID") + """UID""" + gold = fields.IntField(default=100, description="金币数量") + """金币数量""" + sign = fields.ReverseRelation["SignUser"] # type: ignore + """好感度""" + props: Dict[str, int] = fields.JSONField(default={}) # type: ignore + """道具""" + platform = fields.CharField(255, null=True, description="平台") + """平台""" + create_time = fields.DatetimeField(auto_now_add=True, description="创建时间") + """创建时间""" + + class Meta: + table = "user_console" + table_description = "用户数据表" + + @classmethod + async def get_new_uid(cls): + if user := await cls.annotate().order_by("uid").first(): + return user.uid + 1 + return 1 + + @classmethod + async def add_gold( + cls, user_id: str, gold: int, source: str, platform: str | None = None + ): + """添加金币 + + 参数: + user_id: 用户id + gold: 金币 + source: 来源 + platform: 平台. + """ + user, _ = await cls.get_or_create( + user_id=user_id, defaults={"platform": platform, "uid": cls.get_new_uid()} + ) + user.gold += gold + await user.save(update_fields=["gold"]) + await UserGoldLog.create( + user_id=user_id, gold=gold, handle=GoldHandle.GET, source=source + ) + + @classmethod + async def add_props( + cls, user_id: str, goods_uuid: str, num: int = 1, platform: str | None = None + ): + """添加道具 + + 参数: + user_id: 用户id + goods_uuid: 道具uuid + num: 道具数量. + platform: 平台. + """ + user, _ = await cls.get_or_create( + user_id=user_id, defaults={"platform": platform, "uid": cls.get_new_uid()} + ) + if goods_uuid not in user.props: + user.props[goods_uuid] = 0 + user.props[goods_uuid] += num + await user.save(update_fields=["props"]) diff --git a/zhenxun/models/user_gold_log.py b/zhenxun/models/user_gold_log.py new file mode 100644 index 00000000..30e83242 --- /dev/null +++ b/zhenxun/models/user_gold_log.py @@ -0,0 +1,24 @@ +from tortoise import fields + +from zhenxun.services.db_context import Model +from zhenxun.utils.enum import GoldHandle + + +class UserGoldLog(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255, description="用户id") + """用户id""" + gold = fields.IntField(description="金币") + """金币""" + handle = fields.CharEnumField(GoldHandle, default=None, description="道具处理类型") + """金币处理类型""" + source = fields.CharField(255, null=True, description="来源插件") + """来源插件""" + create_time = fields.DatetimeField(auto_now_add=True, description="创建时间") + """创建时间""" + + class Meta: + table = "user_gold_log" + table_description = "用户金币记录表" diff --git a/zhenxun/models/user_props.py b/zhenxun/models/user_props.py new file mode 100644 index 00000000..3e3a5e2b --- /dev/null +++ b/zhenxun/models/user_props.py @@ -0,0 +1,25 @@ +from typing import Dict + +from tortoise import fields + +from zhenxun.services.db_context import Model + +from .sign_user import SignUser + + +class UserProps(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255, unique=True, description="用户id") + """用户id""" + name = fields.CharField(255, description="道具名称") + """道具名称""" + property: Dict[str, int] = fields.JSONField(default={}) # type: ignore + """道具""" + platform = fields.CharField(255, null=True) + """平台""" + + class Meta: + table = "user_props" + table_description = "用户道具表" diff --git a/zhenxun/models/user_props_log.py b/zhenxun/models/user_props_log.py new file mode 100644 index 00000000..16ae405c --- /dev/null +++ b/zhenxun/models/user_props_log.py @@ -0,0 +1,30 @@ +from typing import Dict + +from tortoise import fields + +from zhenxun.services.db_context import Model +from zhenxun.utils.enum import PropHandle + +from .sign_user import SignUser + + +class UserPropsLog(Model): + + id = fields.IntField(pk=True, generated=True, auto_increment=True) + """自增id""" + user_id = fields.CharField(255, description="用户id") + """用户id""" + uuid = fields.CharField(255, description="道具uuid") + """道具uuid""" + num = fields.IntField(null=True, description="道具金币") + """数量""" + gold = fields.IntField(null=True, description="道具金币") + """道具金币""" + handle = fields.CharEnumField(PropHandle, default=None, description="道具处理类型") + """道具处理类型""" + create_time = fields.DatetimeField(auto_now_add=True, description="创建时间") + """创建时间""" + + class Meta: + table = "user_props_log" + table_description = "用户道具记录表" diff --git a/zhenxun/services/db_context.py b/zhenxun/services/db_context.py index a0f9c7dc..3e2e4649 100644 --- a/zhenxun/services/db_context.py +++ b/zhenxun/services/db_context.py @@ -1,10 +1,7 @@ -from typing import List - from nonebot.utils import is_coroutine_callable from tortoise import Tortoise, fields from tortoise.connection import connections from tortoise.models import Model as Model_ -from tortoise.queryset import RawSQLQuery from zhenxun.configs.config import ( address, @@ -18,7 +15,7 @@ from zhenxun.configs.config import ( from .log import logger -MODELS: List[str] = [] +MODELS: list[str] = [] SCRIPT_METHOD = [] @@ -60,12 +57,14 @@ async def init(): modules={"models": MODELS}, # timezone="Asia/Shanghai" ) - await Tortoise.generate_schemas() logger.info(f"Database loaded successfully!") # except Exception as e: # raise Exception(f"数据库连接错误... {type(e)}: {e}") if SCRIPT_METHOD: - logger.debug(f"即将运行SCRIPT_METHOD方法, 合计 {len(SCRIPT_METHOD)} 个...") + db = Tortoise.get_connection("default") + logger.debug( + f"即将运行SCRIPT_METHOD方法, 合计 {len(SCRIPT_METHOD)} 个..." + ) sql_list = [] for module, func in SCRIPT_METHOD: try: @@ -75,15 +74,16 @@ async def init(): sql = func() if sql: sql_list += sql - except Exception as e: logger.debug(f"{module} 执行SCRIPT_METHOD方法出错...", e=e) for sql in sql_list: logger.debug(f"执行SQL: {sql}") try: - await TestSQL.raw(sql) + await db.execute_query_dict(sql) + # await TestSQL.raw(sql) except Exception as e: logger.debug(f"执行SQL: {sql} 错误...", e=e) + await Tortoise.generate_schemas() async def disconnect(): diff --git a/zhenxun/services/log.py b/zhenxun/services/log.py index 8220ca01..e7bfed4a 100644 --- a/zhenxun/services/log.py +++ b/zhenxun/services/log.py @@ -40,6 +40,7 @@ class logger: TEMPLATE_USER = "用户[{}] " TEMPLATE_GROUP = "群聊[{}] " TEMPLATE_COMMAND = "CMD[{}] " + TEMPLATE_PLATFORM = "平台[{}] " TEMPLATE_TARGET = "[Target]([{}]) " SUCCESS_TEMPLATE = "[{}]: {} | 参数[{}] 返回: [{}]" @@ -59,8 +60,8 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, - ): - ... + platform: str | None = None, + ): ... @overload @classmethod @@ -71,8 +72,8 @@ class logger: *, session: Session | None = None, target: Any = None, - ): - ... + platform: str | None = None, + ): ... @classmethod def info( @@ -84,16 +85,20 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, ): user_id: str | None = session # type: ignore group_id = None if type(session) == Session: user_id = session.id1 adapter = session.bot_type - if session.id3 or session.id2: + if session.id3: group_id = f"{session.id3}:{session.id2}" + elif session.id2: + group_id = f"{session.id2}" + platform = platform or session.platform template = cls.__parser_template( - info, command, user_id, group_id, adapter, target + info, command, user_id, group_id, adapter, target, platform ) logger_.opt(colors=True).info(template) @@ -123,9 +128,9 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, - ): - ... + ): ... @overload @classmethod @@ -137,9 +142,9 @@ class logger: session: Session | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, - ): - ... + ): ... @classmethod def warning( @@ -151,6 +156,7 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, ): user_id: str | None = session # type: ignore @@ -158,10 +164,13 @@ class logger: if type(session) == Session: user_id = session.id1 adapter = session.bot_type - if session.id3 or session.id2: + if session.id3: group_id = f"{session.id3}:{session.id2}" + elif session.id2: + group_id = f"{session.id2}" + platform = platform or session.platform template = cls.__parser_template( - info, command, user_id, group_id, adapter, target + info, command, user_id, group_id, adapter, target, platform ) if e: template += f" || 错误{type(e)}: {e}" @@ -178,9 +187,9 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, - ): - ... + ): ... @overload @classmethod @@ -191,9 +200,9 @@ class logger: *, session: Session | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, - ): - ... + ): ... @classmethod def error( @@ -205,6 +214,7 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, ): user_id: str | None = session # type: ignore @@ -212,10 +222,13 @@ class logger: if type(session) == Session: user_id = session.id1 adapter = session.bot_type - if session.id3 or session.id2: + if session.id3: group_id = f"{session.id3}:{session.id2}" + elif session.id2: + group_id = f"{session.id2}" + platform = platform or session.platform template = cls.__parser_template( - info, command, user_id, group_id, adapter, target + info, command, user_id, group_id, adapter, target, platform ) if e: template += f" || 错误 {type(e)}: {e}" @@ -232,9 +245,9 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, - ): - ... + ): ... @overload @classmethod @@ -245,9 +258,9 @@ class logger: *, session: Session | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, - ): - ... + ): ... @classmethod def debug( @@ -259,6 +272,7 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, e: Exception | None = None, ): user_id: str | None = session # type: ignore @@ -266,10 +280,13 @@ class logger: if type(session) == Session: user_id = session.id1 adapter = session.bot_type - if session.id3 or session.id2: + if session.id3: group_id = f"{session.id3}:{session.id2}" + elif session.id2: + group_id = f"{session.id2}" + platform = platform or session.platform template = cls.__parser_template( - info, command, user_id, group_id, adapter, target + info, command, user_id, group_id, adapter, target, platform ) if e: template += f" || 错误 {type(e)}: {e}" @@ -284,12 +301,16 @@ class logger: group_id: int | str | None = None, adapter: str | None = None, target: Any = None, + platform: str | None = None, ) -> str: arg_list = [] template = "" if adapter is not None: template += cls.TEMPLATE_ADAPTER arg_list.append(adapter) + if platform is not None: + template += cls.TEMPLATE_PLATFORM + arg_list.append(platform) if group_id is not None: template += cls.TEMPLATE_GROUP arg_list.append(group_id) diff --git a/zhenxun/utils/_build_image.py b/zhenxun/utils/_build_image.py index 6649e542..bcb83dc7 100644 --- a/zhenxun/utils/_build_image.py +++ b/zhenxun/utils/_build_image.py @@ -1,8 +1,9 @@ import base64 import math +import uuid from io import BytesIO from pathlib import Path -from typing import List, Literal, Tuple, TypeAlias, overload +from typing import Literal, Tuple, TypeAlias, overload from nonebot.utils import run_sync from PIL import Image, ImageDraw, ImageFilter, ImageFont @@ -40,12 +41,13 @@ class BuildImage: self, width: int = 0, height: int = 0, - color: ColorAlias = None, + color: ColorAlias = (255, 255, 255), mode: ModeType = "RGBA", font: str | Path | FreeTypeFont = "HYWenHei-85W.ttf", font_size: int = 20, background: str | BytesIO | Path | None = None, ) -> None: + self.uid = uuid.uuid1() self.width = width self.height = height self.color = color @@ -72,7 +74,7 @@ class BuildImage: async def build_text_image( cls, text: str, - font: str | Path = "HYWenHei-85W.ttf", + font: str | FreeTypeFont | Path = "HYWenHei-85W.ttf", size: int = 10, font_color: str | Tuple[int, int, int] = (0, 0, 0), color: ColorAlias = None, @@ -91,12 +93,16 @@ class BuildImage: 返回: Self: Self """ - _font = cls.load_font(font, size) - width, height = cls.get_text_size(text, _font) - if type(padding) == int: + _font = None + if isinstance(font, FreeTypeFont): + _font = font + elif isinstance(font, (str, Path)): + _font = cls.load_font(font, size) + width, height = cls.get_text_size(text or "A", _font) + if isinstance(padding, int): width += padding * 2 height += padding * 2 - elif type(padding) == tuple: + elif isinstance(padding, tuple): width += padding[1] + padding[3] height += padding[0] + padding[2] markImg = cls(width, height, color) @@ -112,9 +118,9 @@ class BuildImage: row: int, space: int = 10, padding: int = 50, - color: ColorAlias = (255, 255, 255, 0), + color: ColorAlias = (255, 255, 255), background: str | BytesIO | Path | None = None, - ) -> Self | None: + ) -> Self: """自动贴图 参数: @@ -129,11 +135,15 @@ class BuildImage: Self: Self """ if not img_list: - return None + raise ValueError("贴图类别为空...") width, height = img_list[0].size background_width = width * row + space * (row - 1) + padding * 2 - column = math.ceil(len(img_list) / row) - background_height = height * column + space * (column - 1) + padding * 2 + row_count = math.ceil(len(img_list) / row) + if row_count == 1: + background_width = ( + sum([img.width for img in img_list]) + space * (row - 1) + padding * 2 + ) + background_height = height * row_count + space * (row_count - 1) + padding * 2 background_image = cls( background_width, background_height, color=color, background=background ) @@ -141,15 +151,16 @@ class BuildImage: for img in img_list: await background_image.paste(img, (_cur_width, _cur_height)) _cur_width += space + img.width - _cur_height += space + img.height if _cur_width + padding >= background_image.width: + _cur_height += space + img.height _cur_width = padding return background_image @classmethod - def load_font(cls, font: str | Path, font_size: int) -> FreeTypeFont: - """ - 加载字体 + def load_font( + cls, font: str | Path = "HYWenHei-85W.ttf", font_size: int = 10 + ) -> FreeTypeFont: + """加载字体 参数: font: 字体名称 @@ -165,19 +176,20 @@ class BuildImage: @classmethod def get_text_size( cls, text: str, font: FreeTypeFont | None = None - ) -> Tuple[int, int]: - ... + ) -> Tuple[int, int]: ... @overload @classmethod def get_text_size( cls, text: str, font: str | None = None, font_size: int = 10 - ) -> Tuple[int, int]: - ... + ) -> Tuple[int, int]: ... @classmethod def get_text_size( - cls, text: str, font: str | FreeTypeFont | None = None, font_size: int = 10 + cls, + text: str, + font: str | FreeTypeFont | None = "HYWenHei-85W.ttf", + font_size: int = 10, ) -> Tuple[int, int]: """获取该字体下文本需要的长宽 @@ -192,7 +204,7 @@ class BuildImage: _font = font if font and type(font) == str: _font = cls.load_font(font, font_size) - return _font.getsize(text) # type: ignore + return _font.getsize(str(text)) # type: ignore def getsize(self, msg: str) -> Tuple[int, int]: """ @@ -265,7 +277,10 @@ class BuildImage: _image = image.markImg if _image.width and _image.height and center_type: pos = self.__center_xy(pos, _image.width, _image.height, center_type) - self.markImg.paste(_image, pos, _image) # type: ignore + try: + self.markImg.paste(_image, pos, _image) # type: ignore + except ValueError: + self.markImg.paste(_image, pos) # type: ignore return self @run_sync @@ -335,6 +350,7 @@ class BuildImage: 异常: ValueError: 居中类型错误 """ + text = str(text) if center_type and center_type not in ["center", "height", "width"]: raise ValueError("center_type must be 'center', 'width' or 'height'") width, height = 0, 0 @@ -484,7 +500,7 @@ class BuildImage: @run_sync def polygon( self, - xy: List[Tuple[int, int]], + xy: list[Tuple[int, int]], fill: Tuple[int, int, int] = (0, 0, 0), outline: int = 1, ) -> Self: @@ -560,7 +576,7 @@ class BuildImage: def circle_corner( self, radii: int = 30, - point_list: List[Literal["lt", "rt", "lb", "rb"]] = ["lt", "rt", "lb", "rb"], + point_list: list[Literal["lt", "rt", "lb", "rb"]] = ["lt", "rt", "lb", "rb"], ) -> Self: """ 矩形四角变圆 @@ -654,3 +670,11 @@ class BuildImage: self.markImg = self.markImg.filter(_type) self.draw = ImageDraw.Draw(self.markImg) return self + + def tobytes(self) -> bytes: + """转换为bytes + + 返回: + bytes: bytes + """ + return self.markImg.tobytes() diff --git a/zhenxun/utils/_image_template.py b/zhenxun/utils/_image_template.py new file mode 100644 index 00000000..3c894cab --- /dev/null +++ b/zhenxun/utils/_image_template.py @@ -0,0 +1,156 @@ +from email.mime import image +from io import BytesIO +from pathlib import Path +from typing import Any, Callable + +from nonebot.plugin import PluginMetadata +from PIL.ImageFont import FreeTypeFont +from pydantic import BaseModel + +from ._build_image import BuildImage + + +class RowStyle(BaseModel): + + font: FreeTypeFont | str | Path | None = "HYWenHei-85W.ttf" + """字体""" + font_size: int = 20 + """字体大小""" + font_color: str | tuple[int, int, int] = (0, 0, 0) + """字体颜色""" + + class Config: + arbitrary_types_allowed = True + + +class ImageTemplate: + + @classmethod + async def table_page( + cls, + head_text: str, + tip_text: str | None, + column_name: list[str], + data_list: list[list[str]], + row_space: int = 35, + column_space: int = 30, + padding: int = 5, + text_style: Callable[[str, str], RowStyle] | None = None, + ) -> BuildImage: + """表格页 + + 参数: + head_text: 标题文本. + tip_text: 标题注释. + column_name: 表头列表. + data_list: 数据列表. + row_space: 行间距. + column_space: 列间距. + padding: 文本内间距. + text_style: 文本样式. + + 返回: + BuildImage: 表格图片 + """ + table = await cls.table( + column_name, data_list, row_space, column_space, padding, text_style + ) + await table.circle_corner() + table_bk = BuildImage(table.width + 100, table.height + 50, "#EAEDF2") + await table_bk.paste(table, center_type="center") + height = table_bk.height + 200 + background = BuildImage(table_bk.width, height, (255, 255, 255), font_size=50) + await background.paste(table_bk, (0, 200)) + await background.text((0, 50), head_text, "#334762", center_type="width") + if tip_text: + text_image = await BuildImage.build_text_image(tip_text, size=22) + await background.paste(text_image, (0, 110), center_type="width") + return background + + @classmethod + async def table( + cls, + column_name: list[str], + data_list: list[list[str | tuple[Path, int, int]]], + row_space: int = 25, + column_space: int = 10, + padding: int = 5, + text_style: Callable[[str, str], RowStyle] | None = None, + ) -> BuildImage: + """表格 + + 参数: + column_name: 表头列表 + data_list: 数据列表 + row_space: 行间距. + column_space: 列间距. + padding: 文本内间距. + text_style: 文本样式. + + 返回: + BuildImage: 表格图片 + """ + font = BuildImage.load_font("HYWenHei-85W.ttf", 20) + column_num = max([len(l) for l in data_list]) + list_data = [] + column_data = [] + for i in range(len(column_name)): + c = [] + for l in data_list: + if len(l) > i: + c.append(l[i]) + else: + c.append("") + column_data.append(c) + build_data_list = [] + _, base_h = BuildImage.get_text_size("A", font) + for i, column_list in enumerate(column_data): + name_width, name_height = BuildImage.get_text_size(column_name[i], font) + _temp = {"width": name_width, "data": column_list} + for s in column_list: + if isinstance(s, tuple): + w = s[1] + else: + w, _ = BuildImage.get_text_size(s, font) + if w > _temp["width"]: + _temp["width"] = w + build_data_list.append(_temp) + column_image_list = [] + for i, data in enumerate(build_data_list): + width = data["width"] + padding * 2 + height = (base_h + row_space) * (len(data["data"]) + 1) + padding * 2 + background = BuildImage(width, height, (255, 255, 255)) + column_name_image = await BuildImage.build_text_image( + column_name[i], font, 12, "#C8CCCF" + ) + await background.paste(column_name_image, (0, 20), center_type="width") + cur_h = column_name_image.height + row_space + 20 + for item in data["data"]: + style = RowStyle(font=font) + if text_style: + style = text_style(column_name[i], item) + if isinstance(item, tuple): + """图片""" + data, width, height = item + if isinstance(data, Path): + image_ = BuildImage(width, height, background=data) + elif isinstance(data, bytes): + image_ = BuildImage(width, height, background=BytesIO(data)) + elif isinstance(data, BuildImage): + image_ = data + await background.paste(image_, (padding, cur_h)) + else: + await background.text( + (padding, cur_h), + item if item is not None else "", + style.font_color, + font=style.font, + font_size=style.font_size, + ) + cur_h += base_h + row_space + column_image_list.append(background) + height = max([bk.height for bk in column_image_list]) + width = sum([bk.width for bk in column_image_list]) + return await BuildImage.auto_paste( + column_image_list, len(column_image_list), column_space + ) diff --git a/zhenxun/utils/browser.py b/zhenxun/utils/browser.py index 88e252b0..c7d89727 100644 --- a/zhenxun/utils/browser.py +++ b/zhenxun/utils/browser.py @@ -1,14 +1,12 @@ -from typing import Optional - from nonebot import get_driver from playwright.async_api import Browser, Playwright, async_playwright -from services.log import logger +from zhenxun.services.log import logger driver = get_driver() -_playwright: Optional[Playwright] = None -_browser: Optional[Browser] = None +_playwright: Playwright | None = None +_browser: Browser | None = None @driver.on_startup diff --git a/zhenxun/utils/decorator/shop.py b/zhenxun/utils/decorator/shop.py new file mode 100644 index 00000000..5105e4c2 --- /dev/null +++ b/zhenxun/utils/decorator/shop.py @@ -0,0 +1,204 @@ +from typing import Callable, Union, Tuple, Optional +from nonebot.adapters.onebot.v11 import MessageSegment, Message +from nonebot.plugin import require + + +class ShopRegister(dict): + def __init__(self, *args, **kwargs): + super(ShopRegister, self).__init__(*args, **kwargs) + self._data = {} + self._flag = True + + def before_handle(self, name: Union[str, Tuple[str, ...]], load_status: bool = True): + """ + 说明: + 使用前检查方法 + 参数: + :param name: 道具名称 + :param load_status: 加载状态 + """ + def register_before_handle(name_list: Tuple[str, ...], func: Callable): + if load_status: + for name_ in name_list: + if not self._data[name_]: + self._data[name_] = {} + if not self._data[name_].get('before_handle'): + self._data[name_]['before_handle'] = [] + self._data[name]['before_handle'].append(func) + _name = (name,) if isinstance(name, str) else name + return lambda func: register_before_handle(_name, func) + + def after_handle(self, name: Union[str, Tuple[str, ...]], load_status: bool = True): + """ + 说明: + 使用后执行方法 + 参数: + :param name: 道具名称 + :param load_status: 加载状态 + """ + def register_after_handle(name_list: Tuple[str, ...], func: Callable): + if load_status: + for name_ in name_list: + if not self._data[name_]: + self._data[name_] = {} + if not self._data[name_].get('after_handle'): + self._data[name_]['after_handle'] = [] + self._data[name_]['after_handle'].append(func) + _name = (name,) if isinstance(name, str) else name + return lambda func: register_after_handle(_name, func) + + def register( + self, + name: Tuple[str, ...], + price: Tuple[float, ...], + des: Tuple[str, ...], + discount: Tuple[float, ...], + limit_time: Tuple[int, ...], + load_status: Tuple[bool, ...], + daily_limit: Tuple[int, ...], + is_passive: Tuple[bool, ...], + icon: Tuple[str, ...], + **kwargs, + ): + def add_register_item(func: Callable): + if name in self._data.keys(): + raise ValueError("该商品已注册,请替换其他名称!") + for n, p, d, dd, l, s, dl, pa, i in zip( + name, price, des, discount, limit_time, load_status, daily_limit, is_passive, icon + ): + if s: + _temp_kwargs = {} + for key, value in kwargs.items(): + if key.startswith(f"{n}_"): + _temp_kwargs[key.split("_", maxsplit=1)[-1]] = value + else: + _temp_kwargs[key] = value + temp = self._data.get(n, {}) + temp.update({ + "price": p, + "des": d, + "discount": dd, + "limit_time": l, + "daily_limit": dl, + "icon": i, + "is_passive": pa, + "func": func, + "kwargs": _temp_kwargs, + }) + self._data[n] = temp + return func + + return lambda func: add_register_item(func) + + async def load_register(self): + require("use") + require("shop_handle") + from basic_plugins.shop.use.data_source import register_use, func_manager + from basic_plugins.shop.shop_handle.data_source import register_goods + # 统一进行注册 + if self._flag: + # 只进行一次注册 + self._flag = False + for name in self._data.keys(): + await register_goods( + name, + self._data[name]["price"], + self._data[name]["des"], + self._data[name]["discount"], + self._data[name]["limit_time"], + self._data[name]["daily_limit"], + self._data[name]["is_passive"], + self._data[name]["icon"], + ) + register_use( + name, self._data[name]["func"], **self._data[name]["kwargs"] + ) + func_manager.register_use_before_handle(name, self._data[name].get('before_handle', [])) + func_manager.register_use_after_handle(name, self._data[name].get('after_handle', [])) + + def __call__( + self, + name: Union[str, Tuple[str, ...]], # 名称 + price: Union[float, Tuple[float, ...]], # 价格 + des: Union[str, Tuple[str, ...]], # 简介 + discount: Union[float, Tuple[float, ...]] = 1, # 折扣 + limit_time: Union[int, Tuple[int, ...]] = 0, # 限时 + load_status: Union[bool, Tuple[bool, ...]] = True, # 加载状态 + daily_limit: Union[int, Tuple[int, ...]] = 0, # 每日限购 + is_passive: Union[bool, Tuple[bool, ...]] = False, # 被动道具(无法被'使用道具'命令消耗) + icon: Union[str, Tuple[str, ...]] = False, # 图标 + **kwargs, + ): + _tuple_list = [] + _current_len = -1 + for x in [name, price, des, discount, limit_time, load_status]: + if isinstance(x, tuple): + if _current_len == -1: + _current_len = len(x) + if _current_len != len(x): + raise ValueError( + f"注册商品 {name} 中 name,price,des,discount,limit_time,load_status,daily_limit 数量不符!" + ) + _current_len = _current_len if _current_len > -1 else 1 + _name = self.__get(name, _current_len) + _price = self.__get(price, _current_len) + _discount = self.__get(discount, _current_len) + _limit_time = self.__get(limit_time, _current_len) + _des = self.__get(des, _current_len) + _load_status = self.__get(load_status, _current_len) + _daily_limit = self.__get(daily_limit, _current_len) + _is_passive = self.__get(is_passive, _current_len) + _icon = self.__get(icon, _current_len) + return self.register( + _name, + _price, + _des, + _discount, + _limit_time, + _load_status, + _daily_limit, + _is_passive, + _icon, + **kwargs, + ) + + def __get(self, value, _current_len): + return value if isinstance(value, tuple) else tuple([value for _ in range(_current_len)]) + + def __setitem__(self, key, value): + self._data[key] = value + + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def __str__(self): + return str(self._data) + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + + +class NotMeetUseConditionsException(Exception): + + """ + 不满足条件异常类 + """ + + def __init__(self, info: Optional[Union[str, MessageSegment, Message]]): + super().__init__(self) + self._info = info + + def get_info(self): + return self._info + + +shop_register = ShopRegister() diff --git a/zhenxun/utils/enum.py b/zhenxun/utils/enum.py index 5de75bf4..32270316 100644 --- a/zhenxun/utils/enum.py +++ b/zhenxun/utils/enum.py @@ -1,15 +1,38 @@ from strenum import StrEnum +class GoldHandle(StrEnum): + """ + 金币处理 + """ + + BUY = "BUY" + """购买""" + GET = "GET" + """获取""" + + +class PropHandle(StrEnum): + """ + 道具处理 + """ + + BUY = "BUY" + """购买""" + USE = "USE" + """使用""" + + class PluginType(StrEnum): """ 插件类型 """ - SUPERUSER = "超级管理员插件" - ADMIN = "管理员插件" - NORMAL = "普通插件" - HIDDEN = "被动插件" + SUPERUSER = "SUPERUSER" + ADMIN = "ADMIN" + SUPER_AND_ADMIN = "ADMIN_SUPER" + NORMAL = "NORMAL" + HIDDEN = "HIDDEN" class BlockType(StrEnum): diff --git a/zhenxun/utils/exception.py b/zhenxun/utils/exception.py index c901a5c9..0998e776 100644 --- a/zhenxun/utils/exception.py +++ b/zhenxun/utils/exception.py @@ -1,2 +1,14 @@ class NotFoundError(Exception): pass + + +class GroupInfoNotFound(Exception): + pass + + +class EmptyError(Exception): + pass + + +class UserAndGroupIsNone(Exception): + pass diff --git a/zhenxun/utils/http_utils.py b/zhenxun/utils/http_utils.py new file mode 100644 index 00000000..ea9516ef --- /dev/null +++ b/zhenxun/utils/http_utils.py @@ -0,0 +1,379 @@ +import asyncio +from asyncio.exceptions import TimeoutError +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, AsyncGenerator, Dict, Literal + +import aiofiles +import httpx +import rich +from httpx import ConnectTimeout, Response +from nonebot import require +from playwright.async_api import Page +from retrying import retry + +from zhenxun.configs.config import SYSTEM_PROXY +from zhenxun.services.log import logger +from zhenxun.utils.user_agent import get_user_agent + +from .browser import get_browser + +require("nonebot_plugin_saa") + +from nonebot_plugin_saa import Image + + +class AsyncHttpx: + + proxy = {"http://": SYSTEM_PROXY, "https://": SYSTEM_PROXY} + + @classmethod + @retry(stop_max_attempt_number=3) + async def get( + cls, + url: str, + *, + params: Dict[str, Any] | None = None, + headers: Dict[str, str] | None = None, + cookies: Dict[str, str] | None = None, + verify: bool = True, + use_proxy: bool = True, + proxy: Dict[str, str] | None = None, + timeout: int = 30, + **kwargs, + ) -> Response: + """Get + + 参数: + url: url + params: params + headers: 请求头 + cookies: cookies + verify: verify + use_proxy: 使用默认代理 + proxy: 指定代理 + timeout: 超时时间 + """ + if not headers: + headers = get_user_agent() + _proxy = proxy if proxy else cls.proxy if use_proxy else None + async with httpx.AsyncClient(proxies=_proxy, verify=verify) as client: # type: ignore + return await client.get( + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + **kwargs, + ) + + @classmethod + async def post( + cls, + url: str, + *, + data: Dict[str, str] | None = None, + content: Any = None, + files: Any = None, + verify: bool = True, + use_proxy: bool = True, + proxy: Dict[str, str] | None = None, + json: Dict[str, Any] | None = None, + params: Dict[str, str] | None = None, + headers: Dict[str, str] | None = None, + cookies: Dict[str, str] | None = None, + timeout: int = 30, + **kwargs, + ) -> Response: + """ + 说明: + Post + 参数: + url: url + data: data + content: content + files: files + use_proxy: 是否默认代理 + proxy: 指定代理 + json: json + params: params + headers: 请求头 + cookies: cookies + timeout: 超时时间 + """ + if not headers: + headers = get_user_agent() + _proxy = proxy if proxy else cls.proxy if use_proxy else None + async with httpx.AsyncClient(proxies=_proxy, verify=verify) as client: # type: ignore + return await client.post( + url, + content=content, + data=data, + files=files, + json=json, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + **kwargs, + ) + + @classmethod + async def download_file( + cls, + url: str, + path: str | Path, + *, + params: Dict[str, str] | None = None, + verify: bool = True, + use_proxy: bool = True, + proxy: Dict[str, str] | None = None, + headers: Dict[str, str] | None = None, + cookies: Dict[str, str] | None = None, + timeout: int = 30, + stream: bool = False, + **kwargs, + ) -> bool: + """下载文件 + + 参数: + url: url + path: 存储路径 + params: params + verify: verify + use_proxy: 使用代理 + proxy: 指定代理 + headers: 请求头 + cookies: cookies + timeout: 超时时间 + stream: 是否使用流式下载(流式写入+进度条,适用于下载大文件) + """ + if isinstance(path, str): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + try: + for _ in range(3): + if not stream: + try: + content = ( + await cls.get( + url, + params=params, + headers=headers, + cookies=cookies, + use_proxy=use_proxy, + proxy=proxy, + timeout=timeout, + **kwargs, + ) + ).content + async with aiofiles.open(path, "wb") as wf: + await wf.write(content) + logger.info(f"下载 {url} 成功.. Path:{path.absolute()}") + return True + except (TimeoutError, ConnectTimeout): + pass + else: + if not headers: + headers = get_user_agent() + _proxy = proxy if proxy else cls.proxy if use_proxy else None + try: + async with httpx.AsyncClient( + proxies=_proxy, verify=verify # type: ignore + ) as client: + async with client.stream( + "GET", + url, + params=params, + headers=headers, + cookies=cookies, + timeout=timeout, + **kwargs, + ) as response: + logger.info( + f"开始下载 {path.name}.. Path: {path.absolute()}" + ) + async with aiofiles.open(path, "wb") as wf: + total = int(response.headers["Content-Length"]) + with rich.progress.Progress( # type: ignore + rich.progress.TextColumn(path.name), # type: ignore + "[progress.percentage]{task.percentage:>3.0f}%", # type: ignore + rich.progress.BarColumn(bar_width=None), # type: ignore + rich.progress.DownloadColumn(), # type: ignore + rich.progress.TransferSpeedColumn(), # type: ignore + ) as progress: + download_task = progress.add_task( + "Download", total=total + ) + async for chunk in response.aiter_bytes(): + await wf.write(chunk) + await wf.flush() + progress.update( + download_task, + completed=response.num_bytes_downloaded, + ) + logger.info( + f"下载 {url} 成功.. Path:{path.absolute()}" + ) + return True + except (TimeoutError, ConnectTimeout): + pass + else: + logger.error(f"下载 {url} 下载超时.. Path:{path.absolute()}") + except Exception as e: + logger.error(f"下载 {url} 错误 Path:{path.absolute()}", e=e) + return False + + @classmethod + async def gather_download_file( + cls, + url_list: list[str], + path_list: list[str | Path], + *, + limit_async_number: int | None = None, + params: Dict[str, str] | None = None, + use_proxy: bool = True, + proxy: Dict[str, str] | None = None, + headers: Dict[str, str] | None = None, + cookies: Dict[str, str] | None = None, + timeout: int = 30, + **kwargs, + ) -> list[bool]: + """分组同时下载文件 + + 参数: + url_list: url列表 + path_list: 存储路径列表 + limit_async_number: 限制同时请求数量 + params: params + use_proxy: 使用代理 + proxy: 指定代理 + headers: 请求头 + cookies: cookies + timeout: 超时时间 + """ + if n := len(url_list) != len(path_list): + raise UrlPathNumberNotEqual( + f"Url数量与Path数量不对等,Url:{len(url_list)},Path:{len(path_list)}" + ) + if limit_async_number and n > limit_async_number: + m = float(n) / limit_async_number + x = 0 + j = limit_async_number + _split_url_list = [] + _split_path_list = [] + for _ in range(int(m)): + _split_url_list.append(url_list[x:j]) + _split_path_list.append(path_list[x:j]) + x += limit_async_number + j += limit_async_number + if int(m) < m: + _split_url_list.append(url_list[j:]) + _split_path_list.append(path_list[j:]) + else: + _split_url_list = [url_list] + _split_path_list = [path_list] + tasks = [] + result_ = [] + for x, y in zip(_split_url_list, _split_path_list): + for url, path in zip(x, y): + tasks.append( + asyncio.create_task( + cls.download_file( + url, + path, + params=params, + headers=headers, + cookies=cookies, + use_proxy=use_proxy, + timeout=timeout, + proxy=proxy, + **kwargs, + ) + ) + ) + _x = await asyncio.gather(*tasks) + result_ = result_ + list(_x) + tasks.clear() + return result_ + + +class AsyncPlaywright: + @classmethod + @asynccontextmanager + async def new_page(cls, **kwargs) -> AsyncGenerator[Page, None]: + """获取一个新页面 + + 参数: + user_agent: 请求头 + """ + browser = get_browser() + ctx = await browser.new_context(**kwargs) + page = await ctx.new_page() + try: + yield page + finally: + await page.close() + await ctx.close() + + @classmethod + async def screenshot( + cls, + url: str, + path: Path | str, + element: str | list[str], + *, + wait_time: int | None = None, + viewport_size: Dict[str, int] | None = None, + wait_until: ( + Literal["domcontentloaded", "load", "networkidle"] | None + ) = "networkidle", + timeout: float | None = None, + type_: Literal["jpeg", "png"] | None = None, + user_agent: str | None = None, + **kwargs, + ) -> Image | None: + """截图,该方法仅用于简单快捷截图,复杂截图请操作 page + + 参数: + url: 网址 + path: 存储路径 + element: 元素选择 + wait_time: 等待截取超时时间 + viewport_size: 窗口大小 + wait_until: 等待类型 + timeout: 超时限制 + type_: 保存类型 + """ + if viewport_size is None: + viewport_size = dict(width=2560, height=1080) + if isinstance(path, str): + path = Path(path) + wait_time = wait_time * 1000 if wait_time else None + if isinstance(element, str): + element_list = [element] + else: + element_list = element + async with cls.new_page( + viewport=viewport_size, + user_agent=user_agent, + **kwargs, + ) as page: + await page.goto(url, timeout=timeout, wait_until=wait_until) + card = page + for e in element_list: + if not card: + return None + card = await card.wait_for_selector(e, timeout=wait_time) + if card: + await card.screenshot(path=path, timeout=timeout, type=type_) + return Image(path) + return None + + +class UrlPathNumberNotEqual(Exception): + pass + + +class BrowserIsNone(Exception): + pass diff --git a/zhenxun/utils/image_utils.py b/zhenxun/utils/image_utils.py index fe09e627..b09e9d92 100644 --- a/zhenxun/utils/image_utils.py +++ b/zhenxun/utils/image_utils.py @@ -1,2 +1,339 @@ -from ._build_image import BuildImage +import os +import random +import re +from pathlib import Path +from typing import Awaitable, Callable + +from nonebot.utils import is_coroutine_callable + +from ._build_image import BuildImage, ColorAlias from ._build_mat import BuildMat +from ._image_template import ImageTemplate, RowStyle + +# TODO: text2image 长度错误 + + +async def text2image( + text: str, + auto_parse: bool = True, + font_size: int = 20, + color: str | tuple[int, int, int] = (255, 255, 255), + font: str = "HYWenHei-85W.ttf", + font_color: str | tuple[int, int, int] = (0, 0, 0), + padding: int | tuple[int, int, int, int] = 0, + _add_height: float = 0, +) -> BuildImage: + """解析文本并转为图片 + 使用标签 + + 可选配置项 + font: str -> 特殊文本字体 + fs / font_size: int -> 特殊文本大小 + fc / font_color: Union[str, Tuple[int, int, int]] -> 特殊文本颜色 + 示例 + 在不在,HibiKi小姐, + 你最近还好吗,我非常想你,这段时间我非常不好过, + 抽卡抽不到金色,这让我很痛苦 + 参数: + text: 文本 + auto_parse: 是否自动解析,否则原样发送 + font_size: 普通字体大小 + color: 背景颜色 + font: 普通字体 + font_color: 普通字体颜色 + padding: 文本外边距,元组类型时为 (上,左,下,右) + _add_height: 由于get_size无法返回正确的高度,采用手动方式额外添加高度 + """ + if not text: + raise ValueError("文本转图片 text 不能为空...") + pw = ph = top_padding = left_padding = 0 + if padding: + if isinstance(padding, int): + pw = padding * 2 + ph = padding * 2 + top_padding = left_padding = padding + elif isinstance(padding, tuple): + pw = padding[0] + padding[2] + ph = padding[1] + padding[3] + top_padding = padding[0] + left_padding = padding[1] + _font = BuildImage.load_font(font, font_size) + if auto_parse and re.search(r"(.*)", text): + _data = [] + new_text = "" + placeholder_index = 0 + for s in text.split(""): + r = re.search(r"(.*)", s) + if r: + start, end = r.span() + if start != 0 and (t := s[:start]): + new_text += t + _data.append( + [ + (start, end), + f"[placeholder_{placeholder_index}]", + r.group(1).strip(), + r.group(2), + ] + ) + new_text += f"[placeholder_{placeholder_index}]" + placeholder_index += 1 + new_text += text.split("")[-1] + image_list = [] + current_placeholder_index = 0 + # 切分换行,每行为单张图片 + for s in new_text.split("\n"): + _tmp_text = s + img_width = 0 + img_height = BuildImage.get_text_size("正", _font)[1] + _tmp_index = current_placeholder_index + for _ in range(s.count("[placeholder_")): + placeholder = _data[_tmp_index] + if "font_size" in placeholder[2]: + r = re.search(r"font_size=['\"]?(\d+)", placeholder[2]) + if r: + w, h = BuildImage.get_text_size( + placeholder[3], font, int(r.group(1)) + ) + img_height = img_height if img_height > h else h + img_width += w + else: + img_width += BuildImage.get_text_size(placeholder[3], _font)[0] + _tmp_text = _tmp_text.replace(f"[placeholder_{_tmp_index}]", "") + _tmp_index += 1 + img_width += BuildImage.get_text_size(_tmp_text, _font)[0] + # 开始画图 + A = BuildImage( + img_width, img_height, color=color, font=font, font_size=font_size + ) + basic_font_h = A.getsize("正")[1] + current_width = 0 + # 遍历占位符 + for _ in range(s.count("[placeholder_")): + if not s.startswith(f"[placeholder_{current_placeholder_index}]"): + slice_ = s.split(f"[placeholder_{current_placeholder_index}]") + await A.text( + (current_width, A.height - basic_font_h - 1), + slice_[0], + font_color, + ) + current_width += A.getsize(slice_[0])[0] + placeholder = _data[current_placeholder_index] + # 解析配置 + _font = font + _font_size = font_size + _font_color = font_color + for e in placeholder[2].split(): + if e.startswith("font="): + _font = e.split("=")[-1] + if e.startswith("font_size=") or e.startswith("fs="): + _font_size = int(e.split("=")[-1]) + if _font_size > 1000: + _font_size = 1000 + if _font_size < 1: + _font_size = 1 + if e.startswith("font_color") or e.startswith("fc="): + _font_color = e.split("=")[-1] + text_img = await BuildImage.build_text_image( + placeholder[3], font=_font, size=_font_size, font_color=_font_color + ) + _img_h = ( + int(A.height / 2 - text_img.height / 2) + if new_text == "[placeholder_0]" + else A.height - text_img.height + ) + await A.paste(text_img, (current_width, _img_h - 1)) + current_width += text_img.width + s = s[ + s.index(f"[placeholder_{current_placeholder_index}]") + + len(f"[placeholder_{current_placeholder_index}]") : + ] + current_placeholder_index += 1 + if s: + slice_ = s.split(f"[placeholder_{current_placeholder_index}]") + await A.text((current_width, A.height - basic_font_h), slice_[0]) + current_width += A.getsize(slice_[0])[0] + await A.crop((0, 0, current_width, A.height)) + # A.show() + image_list.append(A) + height = 0 + width = 0 + for img in image_list: + height += img.h + width = width if width > img.w else img.w + width += pw + height += ph + A = BuildImage(width + left_padding, height + top_padding, color=color) + current_height = top_padding + for img in image_list: + await A.paste(img, (left_padding, current_height)) + current_height += img.h + else: + width = 0 + height = 0 + _, h = BuildImage.get_text_size("正", _font) + line_height = int(font_size / 3) + image_list = [] + for s in text.split("\n"): + w, _ = BuildImage.get_text_size(s.strip() or "正", _font) + height += h + line_height + width = width if width > w else w + image_list.append( + await BuildImage.build_text_image( + s.strip(), font, font_size, font_color + ) + ) + width += pw + height += ph + A = BuildImage( + width + left_padding, + height + top_padding + 2, + color=color, + ) + cur_h = ph + for img in image_list: + await A.paste(img, (pw, cur_h)) + cur_h += img.height + line_height + return A + + +def group_image(image_list: list[BuildImage]) -> tuple[list[list[BuildImage]], int]: + """ + 说明: + 根据图片大小进行分组 + 参数: + image_list: 排序图片列表 + """ + image_list.sort(key=lambda x: x.height, reverse=True) + max_image = max(image_list, key=lambda x: x.height) + + image_list.remove(max_image) + max_h = max_image.height + total_w = 0 + + # 图片分组 + image_group = [[max_image]] + is_use = [] + surplus_list = image_list[:] + + for image in image_list: + if image.uid not in is_use: + group = [image] + is_use.append(image.uid) + curr_h = image.height + while True: + surplus_list = [x for x in surplus_list if x.uid not in is_use] + for tmp in surplus_list: + temp_h = curr_h + tmp.height + 10 + if temp_h < max_h or abs(max_h - temp_h) < 100: + curr_h += tmp.height + 15 + is_use.append(tmp.uid) + group.append(tmp) + break + else: + break + total_w += max([x.width for x in group]) + 15 + image_group.append(group) + while surplus_list: + surplus_list = [x for x in surplus_list if x.uid not in is_use] + if not surplus_list: + break + surplus_list.sort(key=lambda x: x.height, reverse=True) + for img in surplus_list: + if img.uid not in is_use: + _w = 0 + index = -1 + for i, ig in enumerate(image_group): + if s := sum([x.height for x in ig]) > _w: + _w = s + index = i + if index != -1: + image_group[index].append(img) + is_use.append(img.uid) + + max_h = 0 + max_w = 0 + for ig in image_group: + if (_h := sum([x.height + 15 for x in ig])) > max_h: + max_h = _h + max_w += max([x.width for x in ig]) + 30 + is_use.clear() + while abs(max_h - max_w) > 200 and len(image_group) - 1 >= len(image_group[-1]): + for img in image_group[-1]: + _min_h = 999999 + _min_index = -1 + for i, ig in enumerate(image_group): + # if i not in is_use and (_h := sum([x.h for x in ig]) + img.h) > _min_h: + if (_h := sum([x.height for x in ig]) + img.height) < _min_h: + _min_h = _h + _min_index = i + is_use.append(_min_index) + image_group[_min_index].append(img) + max_w -= max([x.width for x in image_group[-1]]) - 30 + image_group.pop(-1) + max_h = max([sum([x.height + 15 for x in ig]) for ig in image_group]) + return image_group, max(max_h + 250, max_w + 70) + + +async def build_sort_image( + image_group: list[list[BuildImage]], + h: int | None = None, + padding_top: int = 200, + color: ColorAlias = ( + 255, + 255, + 255, + ), + background_path: Path | None = None, + background_handle: Callable[[BuildImage], Awaitable] | None = None, +) -> BuildImage: + """ + 说明: + 对group_image的图片进行组装 + 参数: + image_group: 分组图片列表 + h: max(宽,高),一般为group_image的返回值,有值时,图片必定为正方形 + padding_top: 图像列表与最顶层间距 + color: 背景颜色 + background_path: 背景图片文件夹路径(随机) + background_handle: 背景图额外操作 + """ + bk_file = None + if background_path: + random_bk = os.listdir(background_path) + if random_bk: + bk_file = random.choice(random_bk) + image_w = 0 + image_h = 0 + if not h: + for ig in image_group: + _w = max([x.width + 30 for x in ig]) + image_w += _w + 30 + _h = sum([x.height + 10 for x in ig]) + if _h > image_h: + image_h = _h + image_h += padding_top + else: + image_w = h + image_h = h + A = BuildImage( + image_w, + image_h, + font_size=24, + font="CJGaoDeGuo.otf", + color=color, + background=(background_path / bk_file) if background_path and bk_file else None, + ) + if background_handle: + if is_coroutine_callable(background_handle): + await background_handle(A) + else: + background_handle(A) + curr_w = 50 + for ig in image_group: + curr_h = padding_top - 20 + for img in ig: + await A.paste(img, (curr_w, curr_h)) + curr_h += img.height + 10 + curr_w += max([x.width for x in ig]) + 30 + return A diff --git a/zhenxun/utils/user_agent.py b/zhenxun/utils/user_agent.py new file mode 100644 index 00000000..3047da33 --- /dev/null +++ b/zhenxun/utils/user_agent.py @@ -0,0 +1,50 @@ +import random + +user_agent = [ + "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50", + "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50", + "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:38.0) Gecko/20100101 Firefox/38.0", + "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; InfoPath.3; rv:11.0) like Gecko", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)", + "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0.1) Gecko/20100101 Firefox/4.0.1", + "Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1", + "Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11", + "Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0; SE 2.X MetaSr 1.0; SE 2.X MetaSr 1.0; .NET CLR 2.0.50727; SE 2.X MetaSr 1.0)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", + "MQQBrowser/26 Mozilla/5.0 (Linux; U; Android 2.3.7; zh-cn; MB200 Build/GRJ22; CyanogenMod-7) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", + "Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10", + "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13", + "Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+", + "Mozilla/5.0 (hp-tablet; Linux; hpwOS/3.0.0; U; en-US) AppleWebKit/534.6 (KHTML, like Gecko) wOSBrowser/233.70 Safari/534.6 TouchPad/1.0", + "Mozilla/5.0 (SymbianOS/9.4; Series60/5.0 NokiaN97-1/20.0.019; Profile/MIDP-2.1 Configuration/CLDC-1.1) AppleWebKit/525 (KHTML, like Gecko) BrowserNG/7.1.18124", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)", + "UCWEB7.0.2.37/28/999", + "NOKIA5700/ UCWEB7.0.2.37/28/999", + "Openwave/ UCWEB7.0.2.37/28/999", + "Mozilla/4.0 (compatible; MSIE 6.0; ) Opera/UCWEB7.0.2.37/28/999", + # iPhone 6: + "Mozilla/6.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/8.0 Mobile/10A5376e Safari/8536.25", +] + + +def get_user_agent(): + return {"User-Agent": random.choice(user_agent)} + + +def get_user_agent_str(): + return random.choice(user_agent) diff --git a/zhenxun/utils/utils.py b/zhenxun/utils/utils.py index 660b0327..86e8c4e4 100644 --- a/zhenxun/utils/utils.py +++ b/zhenxun/utils/utils.py @@ -1,11 +1,42 @@ import os +import time +from collections import defaultdict from pathlib import Path +from typing import Any import httpx from zhenxun.services.log import logger +class WithdrawManager: + """ + 消息撤回 + """ + + _data = {} + + @classmethod + def append(cls, message_id: str, second: int): + """添加一个撤回消息id和时间 + + 参数: + message_id: 撤回消息id + time: 延迟时间 + """ + cls._data[message_id] = second + + @classmethod + def remove(cls, message_id: str): + """删除一个数据 + + 参数: + message_id: 撤回消息id + """ + if message_id in cls._data: + del cls._data[message_id] + + class ResourceDirManager: """ 临时文件管理器 @@ -45,6 +76,69 @@ class ResourceDirManager: cls.__tree_append(path) +class CountLimiter: + """ + 次数检测工具,检测调用次数是否超过设定值 + """ + + def __init__(self, max_count: int): + self.count = defaultdict(int) + self.max_count = max_count + + def add(self, key: Any): + self.count[key] += 1 + + def check(self, key: Any) -> bool: + if self.count[key] >= self.max_count: + self.count[key] = 0 + return True + return False + + +class UserBlockLimiter: + """ + 检测用户是否正在调用命令 + """ + + def __init__(self): + self.flag_data = defaultdict(bool) + self.time = time.time() + + def set_true(self, key: Any): + self.time = time.time() + self.flag_data[key] = True + + def set_false(self, key: Any): + self.flag_data[key] = False + + def check(self, key: Any) -> bool: + if time.time() - self.time > 30: + self.set_false(key) + return False + return self.flag_data[key] + + +class FreqLimiter: + """ + 命令冷却,检测用户是否处于冷却状态 + """ + + def __init__(self, default_cd_seconds: int): + self.next_time = defaultdict(float) + self.default_cd = default_cd_seconds + + def check(self, key: Any) -> bool: + return time.time() >= self.next_time[key] + + def start_cd(self, key: Any, cd_time: int = 0): + self.next_time[key] = time.time() + ( + cd_time if cd_time > 0 else self.default_cd + ) + + def left_time(self, key: Any) -> float: + return self.next_time[key] - time.time() + + async def get_user_avatar(uid: int | str) -> bytes | None: """快捷获取用户头像