├── .python-version ├── .prettierrc ├── nonebot_plugin_setu_now ├── aioutils │ ├── __init__.py │ └── _main.py ├── cooldown.py ├── perf_timer.py ├── models.py ├── config.py ├── data_source.py ├── utils.py ├── img_utils.py ├── database.py └── __init__.py ├── .vscode ├── settings.json └── docstring.mustache ├── LICENSE ├── migrations └── versions │ ├── 4d3ca10a3be8_add_cooldown_record.py │ ├── 4f9463d0ecb0_change_groupwhitelistrecord.py │ └── dc8d05807856_first_revision.py ├── .github └── workflows │ └── publish.yml ├── pyproject.toml ├── .gitignore └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "endOfLine": "lf", 5 | "arrowParens": "always", 6 | "singleQuote": false, 7 | "trailingComma": "es5", 8 | "semi": true 9 | } 10 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/aioutils/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1" 2 | 3 | from ._main import PendingValueException as PendingValueException 4 | from ._main import SoonValue as SoonValue 5 | from ._main import asyncify as asyncify 6 | from ._main import create_task_group as create_task_group 7 | from ._main import runnify as runnify 8 | from ._main import syncify as syncify 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "files.watcherExclude": { 4 | "**/.git/objects/**": true, 5 | "**/.git/subtree-cache/**": true, 6 | "**/node_modules/**": true, 7 | "**/.hg/store/**": true, 8 | "**/.venv/**": true, 9 | "**/.mypy_cache/**": true 10 | }, 11 | "autoDocstring.customTemplatePath": ".vscode/docstring.mustache", 12 | "files.encoding": "utf8", 13 | "git.autofetch": true, 14 | "git.enableSmartCommit": true, 15 | "files.exclude": { 16 | "**/__pycache__": true, 17 | ".venv": true, 18 | ".mypy_cache": true, 19 | "LICENSE": true 20 | }, 21 | "ruff.enable": true 22 | } -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/cooldown.py: -------------------------------------------------------------------------------- 1 | """ 2 | CD 管理 3 | """ 4 | 5 | from nonebot import Bot, logger 6 | from nonebot_plugin_uninfo import Uninfo 7 | 8 | from .config import CDTIME 9 | from .database import CooldownRecord 10 | 11 | 12 | async def Cooldown(bot: Bot, session: Uninfo) -> bool: 13 | """检查用户是否在冷却中 14 | 15 | 超级管理员可以忽略冷却时间 16 | 17 | Args: 18 | session: 会话信息 19 | 20 | Returns: 21 | bool: 是否在冷却中(True表示冷却完成,False表示还在冷却中) 22 | """ 23 | user_id = session.user.id 24 | 25 | if session.user.id in bot.config.superusers: 26 | logger.debug("Superuser ignore cooldown") 27 | return True 28 | 29 | return await CooldownRecord.check_is_cooldown(user_id, cd=CDTIME) 30 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/perf_timer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from typing import ClassVar 5 | 6 | from nonebot.log import logger 7 | 8 | 9 | class PerfTimer: 10 | timer_list: ClassVar[dict[str, float]] = {} 11 | 12 | def __init__(self, name: str) -> None: 13 | self.name: str = name 14 | self.start_time: float = time.time() 15 | 16 | @classmethod 17 | def start(cls, name: str, output: bool = False): 18 | if output: 19 | logger.debug(f"{name} started") 20 | return cls(name) 21 | 22 | def stop(self): 23 | timer_count = time.time() - self.start_time 24 | timer_count = round(timer_count, 2) 25 | logger.debug(f"{self.name} took {timer_count}s") 26 | -------------------------------------------------------------------------------- /.vscode/docstring.mustache: -------------------------------------------------------------------------------- 1 | {{! Takker Automatic docstring}} 2 | 3 | :说明: `{{name}}` 4 | > {{summaryPlaceholder}} {{extendedSummaryPlaceholder}} 5 | {{#argsExist}} 6 | 7 | :参数: 8 | {{#args}} 9 | * `{{var}}: {{typePlaceholder}}`: {{descriptionPlaceholder}} 10 | {{/args}} 11 | {{/argsExist}} 12 | {{#kwargsExist}} 13 | 14 | :可选参数: 15 | {{#kwargs}} 16 | * `{{var}}: {{typePlaceholder}} = {{default}}`: {{descriptionPlaceholder}} 17 | {{/kwargs}} 18 | {{/kwargsExist}} 19 | {{#exceptionsExist}} 20 | 21 | :Exceptions: 22 | {{#exceptions}} 23 | * `{{var}}`: {{descriptionPlaceholder}} 24 | {{/exceptions}} 25 | {{/exceptionsExist}} 26 | {{#yieldsExist}} 27 | 28 | :生成: 29 | {{#yields}} 30 | * `{{typePlaceholder}}`: {{descriptionPlaceholder}} 31 | {{/yields}} 32 | {{/yieldsExist}} 33 | {{#returnsExist}} 34 | 35 | :返回: 36 | {{#returns}} 37 | - `{{typePlaceholder}}`: {{descriptionPlaceholder}} 38 | {{/returns}} 39 | {{/returnsExist}} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 kexue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/models.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class SetuData(BaseModel): 7 | pid: int 8 | p: int 9 | uid: int 10 | title: str 11 | author: str 12 | r18: bool 13 | width: int 14 | height: int 15 | tags: list[str] 16 | ext: str 17 | aiType: int 18 | uploadDate: int 19 | urls: dict[str, str] 20 | 21 | 22 | class SetuApiData(BaseModel): 23 | error: str | None 24 | data: list[SetuData] 25 | 26 | 27 | class Setu: 28 | def __init__(self, data: SetuData): 29 | self.title: str = data.title 30 | self.urls: dict[str, str] = data.urls 31 | self.author: str = data.author 32 | self.tags: list[str] = data.tags 33 | self.pid: int = data.pid 34 | self.p: int = data.p 35 | self.r18: bool = data.r18 36 | self.ext: str = data.ext 37 | self.img: Path | None = None 38 | self.msg: str | None = None 39 | 40 | 41 | class SetuMessage(BaseModel): 42 | send: list[str] 43 | cd: list[str] 44 | 45 | 46 | class SetuNotFindError(Exception): 47 | pass 48 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/config.py: -------------------------------------------------------------------------------- 1 | from nonebot import get_plugin_config 2 | from pydantic import BaseModel 3 | 4 | 5 | class Config(BaseModel): 6 | superusers: set[str] 7 | debug: bool = False 8 | setu_cd: int = 60 9 | setu_path: str | None = None 10 | setu_proxy: str | None = None 11 | setu_withdraw: int | None = None 12 | setu_reverse_proxy: str = "i.pixiv.re" 13 | setu_size: str = "regular" 14 | setu_api_url: str = "https://api.lolicon.app/setu/v2" 15 | setu_max: int = 30 16 | setu_add_random_effect: bool = True 17 | setu_minimum_send_interval: int = 3 18 | setu_excludeAI: bool = False 19 | setu_r18: bool = False 20 | 21 | 22 | plugin_config = get_plugin_config(Config) 23 | CDTIME = plugin_config.setu_cd 24 | SETU_PATH = plugin_config.setu_path 25 | PROXY = plugin_config.setu_proxy 26 | WITHDRAW_TIME = plugin_config.setu_withdraw 27 | REVERSE_PROXY = plugin_config.setu_reverse_proxy 28 | SETU_SIZE = plugin_config.setu_size 29 | API_URL = plugin_config.setu_api_url 30 | MAX = plugin_config.setu_max 31 | EFFECT = plugin_config.setu_add_random_effect 32 | SEND_INTERVAL = plugin_config.setu_minimum_send_interval 33 | EXCLUDEAI = plugin_config.setu_excludeAI 34 | SETU_R18 = plugin_config.setu_r18 35 | -------------------------------------------------------------------------------- /migrations/versions/4d3ca10a3be8_add_cooldown_record.py: -------------------------------------------------------------------------------- 1 | """add cooldown record 2 | 3 | 迁移 ID: 4d3ca10a3be8 4 | 父迁移: dc8d05807856 5 | 创建时间: 2025-10-23 22:14:01.393201 6 | 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from collections.abc import Sequence 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | revision: str = "4d3ca10a3be8" 17 | down_revision: str | Sequence[str] | None = "dc8d05807856" 18 | branch_labels: str | Sequence[str] | None = None 19 | depends_on: str | Sequence[str] | None = None 20 | 21 | 22 | def upgrade(name: str = "") -> None: 23 | if name: 24 | return 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.create_table( 27 | "nonebot_plugin_setu_now_cooldownrecord", 28 | sa.Column("user_id", sa.String(), nullable=False), 29 | sa.Column("last_use_time", sa.DateTime(), nullable=False), 30 | sa.PrimaryKeyConstraint( 31 | "user_id", name=op.f("pk_nonebot_plugin_setu_now_cooldownrecord") 32 | ), 33 | info={"bind_key": "nonebot_plugin_setu_now"}, 34 | ) 35 | # ### end Alembic commands ### 36 | 37 | 38 | def downgrade(name: str = "") -> None: 39 | if name: 40 | return 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_table("nonebot_plugin_setu_now_cooldownrecord") 43 | # ### end Alembic commands ### 44 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "*" 5 | workflow_dispatch: 6 | 7 | 8 | jobs: 9 | pypi-publish: 10 | name: Upload release to PyPI 11 | runs-on: ubuntu-latest 12 | environment: release 13 | permissions: 14 | # IMPORTANT: this permission is mandatory for trusted publishing 15 | id-token: write 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install the latest version of uv 21 | uses: astral-sh/setup-uv@v3 22 | with: 23 | enable-cache: true 24 | 25 | - name: "Set up Python" 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version-file: ".python-version" 29 | 30 | - run: uv sync 31 | shell: bash 32 | 33 | # - name: Get Version 34 | # id: version 35 | # run: | 36 | # echo "VERSION=$(uvx pdm show --version)" >> $GITHUB_OUTPUT 37 | # echo "TAG_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 38 | # echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 39 | 40 | # - name: Check Version 41 | # if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION 42 | # run: exit 1 43 | 44 | - name: Build Package 45 | run: uv build 46 | 47 | - uses: actions/upload-artifact@v4 48 | with: 49 | name: artifact 50 | path: dist/ # or path/to/artifact 51 | 52 | - name: pypi-publish 53 | uses: pypa/gh-action-pypi-publish@v1.12.3 54 | # - name: Publish Package to PyPI 55 | # run: uv publish 56 | 57 | # - name: Publish Package to GitHub Release 58 | # run: gh release create ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl -t "🔖 ${{ steps.version.outputs.TAG_NAME }}" --generate-notes 59 | # env: 60 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /migrations/versions/4f9463d0ecb0_change_groupwhitelistrecord.py: -------------------------------------------------------------------------------- 1 | """change GroupWhiteListRecord 2 | 3 | 迁移 ID: 4f9463d0ecb0 4 | 父迁移: 4d3ca10a3be8 5 | 创建时间: 2025-10-23 23:09:35.517294 6 | 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from collections.abc import Sequence 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | revision: str = "4f9463d0ecb0" 17 | down_revision: str | Sequence[str] | None = "4d3ca10a3be8" 18 | branch_labels: str | Sequence[str] | None = None 19 | depends_on: str | Sequence[str] | None = None 20 | 21 | 22 | def upgrade(name: str = "") -> None: 23 | if name: 24 | return 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | with op.batch_alter_table( 27 | "nonebot_plugin_setu_now_groupwhitelistrecord", schema=None 28 | ) as batch_op: 29 | batch_op.alter_column( 30 | "group_id", 31 | existing_type=sa.INTEGER(), 32 | type_=sa.String(), 33 | existing_nullable=False, 34 | ) 35 | batch_op.alter_column( 36 | "operator_user_id", 37 | existing_type=sa.INTEGER(), 38 | type_=sa.String(), 39 | existing_nullable=False, 40 | ) 41 | 42 | # ### end Alembic commands ### 43 | 44 | 45 | def downgrade(name: str = "") -> None: 46 | if name: 47 | return 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | with op.batch_alter_table( 50 | "nonebot_plugin_setu_now_groupwhitelistrecord", schema=None 51 | ) as batch_op: 52 | batch_op.alter_column( 53 | "operator_user_id", 54 | existing_type=sa.String(), 55 | type_=sa.INTEGER(), 56 | existing_nullable=False, 57 | ) 58 | batch_op.alter_column( 59 | "group_id", 60 | existing_type=sa.String(), 61 | type_=sa.INTEGER(), 62 | existing_nullable=False, 63 | ) 64 | 65 | # ### end Alembic commands ### 66 | -------------------------------------------------------------------------------- /migrations/versions/dc8d05807856_first_revision.py: -------------------------------------------------------------------------------- 1 | """first revision 2 | 3 | 迁移 ID: dc8d05807856 4 | 父迁移: 5 | 创建时间: 2025-10-23 19:04:52.135908 6 | 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from collections.abc import Sequence 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | revision: str = "dc8d05807856" 17 | down_revision: str | Sequence[str] | None = None 18 | branch_labels: str | Sequence[str] | None = None 19 | depends_on: str | Sequence[str] | None = None 20 | 21 | 22 | def upgrade(name: str = "") -> None: 23 | if name: 24 | return 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.create_table( 27 | "nonebot_plugin_setu_now_groupwhitelistrecord", 28 | sa.Column("group_id", sa.Integer(), nullable=False), 29 | sa.Column("operator_user_id", sa.Integer(), nullable=False), 30 | sa.PrimaryKeyConstraint( 31 | "group_id", name=op.f("pk_nonebot_plugin_setu_now_groupwhitelistrecord") 32 | ), 33 | info={"bind_key": "nonebot_plugin_setu_now"}, 34 | ) 35 | op.create_table( 36 | "nonebot_plugin_setu_now_messageinfo", 37 | sa.Column("message_id", sa.Integer(), nullable=False), 38 | sa.Column("pid", sa.Integer(), nullable=False), 39 | sa.PrimaryKeyConstraint( 40 | "message_id", name=op.f("pk_nonebot_plugin_setu_now_messageinfo") 41 | ), 42 | info={"bind_key": "nonebot_plugin_setu_now"}, 43 | ) 44 | op.create_table( 45 | "nonebot_plugin_setu_now_setuinfo", 46 | sa.Column("pid", sa.Integer(), nullable=False), 47 | sa.Column("author", sa.String(), nullable=False), 48 | sa.Column("title", sa.String(), nullable=False), 49 | sa.Column("url", sa.String(), nullable=False), 50 | sa.PrimaryKeyConstraint( 51 | "pid", name=op.f("pk_nonebot_plugin_setu_now_setuinfo") 52 | ), 53 | info={"bind_key": "nonebot_plugin_setu_now"}, 54 | ) 55 | # ### end Alembic commands ### 56 | 57 | 58 | def downgrade(name: str = "") -> None: 59 | if name: 60 | return 61 | # ### commands auto generated by Alembic - please adjust! ### 62 | op.drop_table("nonebot_plugin_setu_now_setuinfo") 63 | op.drop_table("nonebot_plugin_setu_now_messageinfo") 64 | op.drop_table("nonebot_plugin_setu_now_groupwhitelistrecord") 65 | # ### end Alembic commands ### 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot-plugin-setu-now" 3 | version = "1.0.0.dev2" 4 | description = "另一个色图插件" 5 | authors = [{ name = "kexue", email = "xana278@foxmail.com" }] 6 | dependencies = [ 7 | "httpx>=0.28.0", 8 | "nonebot2>=2.4.0", 9 | "pydantic>=2.10.3", 10 | "pillow>=11.0.0", 11 | "nonebot-plugin-localstore>=0.7.2", 12 | "nonebot-plugin-alconna>=0.60.1", 13 | "nonebot-plugin-uninfo>=0.10.0", 14 | "nonebot-plugin-orm>=0.8.2", 15 | ] 16 | requires-python = ">=3.11,<4.0" 17 | readme = "README.md" 18 | license = { file = "LICENSE" } 19 | keywords = ["nonebot2"] 20 | 21 | 22 | [project.urls] 23 | homepage = "https://github.com/kexue-z/nonebot-plugin-setu-now" 24 | 25 | [dependency-groups] 26 | dev = [ 27 | "greenlet>=3.2.4", 28 | "nb-cli>=1.4.2", 29 | "nonebot-adapter-onebot>=2.4.6", 30 | "nonebot-plugin-orm[default]>=0.8.2", 31 | "nonebot2[fastapi]>=2.4.3", 32 | ] 33 | 34 | 35 | [build-system] 36 | requires = ["hatchling"] 37 | build-backend = "hatchling.build" 38 | 39 | 40 | [tool.ruff] 41 | line-length = 88 42 | target-version = "py39" 43 | 44 | 45 | [tool.ruff.format] 46 | line-ending = "lf" 47 | 48 | 49 | [tool.ruff.lint] 50 | select = [ 51 | "F", # Pyflakes 52 | "W", # pycodestyle warnings 53 | "E", # pycodestyle errors 54 | "I", # isort 55 | "UP", # pyupgrade 56 | "ASYNC", # flake8-async 57 | "C4", # flake8-comprehensions 58 | "T10", # flake8-debugger 59 | "T20", # flake8-print 60 | "PYI", # flake8-pyi 61 | "PT", # flake8-pytest-style 62 | "Q", # flake8-quotes 63 | "TID", # flake8-tidy-imports 64 | "RUF", # Ruff-specific rules 65 | ] 66 | ignore = [ 67 | "E402", # module-import-not-at-top-of-file 68 | "UP037", # quoted-annotation 69 | "RUF001", # ambiguous-unicode-character-string 70 | "RUF002", # ambiguous-unicode-character-docstring 71 | "RUF003", # ambiguous-unicode-character-comment 72 | ] 73 | 74 | [tool.ruff.lint.isort] 75 | force-sort-within-sections = true 76 | known-first-party = ["nonebot_plugin_setu_now"] 77 | extra-standard-library = ["typing_extensions"] 78 | 79 | 80 | [tool.ruff.lint.flake8-pytest-style] 81 | fixture-parentheses = false 82 | mark-parentheses = false 83 | 84 | 85 | [tool.ruff.lint.pyupgrade] 86 | keep-runtime-typing = true 87 | 88 | 89 | [tool.nonebot] 90 | plugins = ["nonebot_plugin_setu_now"] 91 | adapters = [ 92 | { name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11" }, 93 | ] 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *DS_Store* 2 | test 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | .pdm-python 133 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/data_source.py: -------------------------------------------------------------------------------- 1 | from asyncio import gather 2 | from pathlib import Path 3 | from typing import Callable 4 | 5 | from httpx import AsyncClient 6 | from nonebot.log import logger 7 | import nonebot_plugin_localstore as store 8 | 9 | from .config import API_URL, PROXY, REVERSE_PROXY, SETU_SIZE 10 | from .models import Setu, SetuApiData, SetuNotFindError 11 | from .utils import download_pic 12 | 13 | CACHE_PATH = Path(store.get_cache_dir("nonebot_plugin_setu_now")) 14 | if not CACHE_PATH.exists(): 15 | logger.info("Creating setu cache floder") 16 | CACHE_PATH.mkdir(parents=True, exist_ok=True) 17 | 18 | 19 | class SetuHandler: 20 | def __init__( 21 | self, 22 | key: str, 23 | tags: list[str], 24 | r18: bool, 25 | num: int, 26 | handler: Callable, 27 | excludeAI: bool = False, 28 | ) -> None: 29 | self.key = key 30 | self.tags = tags 31 | self.r18 = r18 32 | self.num = num 33 | self.api_url = API_URL 34 | self.size = SETU_SIZE 35 | self.proxy = PROXY 36 | self.reverse_proxy_url = REVERSE_PROXY 37 | self.handler = handler 38 | self.setu_instance_list: list[Setu] = [] 39 | self.excludeAI = excludeAI 40 | 41 | async def refresh_api_info(self): 42 | data = { 43 | "keyword": self.key, 44 | "tag": self.tags, 45 | "r18": self.r18, 46 | "proxy": self.reverse_proxy_url, 47 | "num": self.num, 48 | "size": self.size, 49 | "excludeAI": self.excludeAI, 50 | } 51 | headers = {"Content-Type": "application/json"} 52 | 53 | async with AsyncClient(proxy=self.proxy) as client: 54 | res = await client.post( 55 | self.api_url, json=data, headers=headers, timeout=60 56 | ) 57 | data = res.json() 58 | setu_api_data_instance = SetuApiData(**data) 59 | if len(setu_api_data_instance.data) == 0: 60 | raise SetuNotFindError() 61 | logger.debug(f"API Responsed {len(setu_api_data_instance.data)} image") 62 | for i in setu_api_data_instance.data: 63 | self.setu_instance_list.append(Setu(data=i)) 64 | 65 | async def prep_handler(self, setu: Setu): 66 | setu.img = await download_pic( 67 | url=setu.urls[SETU_SIZE], 68 | proxy=self.proxy, 69 | file_mode=True, 70 | file_name=f"{setu.pid}.{setu.ext}", 71 | ) 72 | await self.handler(setu) 73 | 74 | async def process_request(self): 75 | await self.refresh_api_info() 76 | task_list = [] 77 | for i in self.setu_instance_list: 78 | task_list.append(self.prep_handler(i)) 79 | await gather(*task_list) 80 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | import time 4 | from typing import Optional 5 | 6 | import anyio 7 | from httpx import AsyncClient 8 | from nonebot.log import logger 9 | import nonebot_plugin_localstore as store 10 | 11 | from .config import SEND_INTERVAL, SETU_PATH 12 | from .perf_timer import PerfTimer 13 | 14 | 15 | async def download_pic( 16 | url: str, proxy: Optional[str] = None, file_mode=False, file_name="" 17 | ) -> Optional[Path]: 18 | headers = { 19 | "Referer": "https://accounts.pixiv.net/login?lang=zh&source=pc&view_type=page&ref=wwwtop_accounts_index", 20 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) " 21 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36", 22 | } 23 | download_timer = PerfTimer.start("Image download") 24 | image_path = ( 25 | store.get_cache_file("nonebot_plugin_setu_now", file_name) 26 | if SETU_PATH is None 27 | else Path(SETU_PATH, file_name) 28 | ) 29 | client = AsyncClient(proxy=proxy, timeout=5) 30 | try: 31 | async with client.stream( 32 | method="GET", url=url, headers=headers, timeout=15 33 | ) as response: 34 | if response.status_code != 200: 35 | logger.warning( 36 | f"Image respond status code error: {response.status_code}" 37 | ) 38 | raise ValueError( 39 | f"Image respond status code error: {response.status_code}" 40 | ) 41 | async with await anyio.open_file(image_path, "wb") as f: 42 | async for chunk in response.aiter_bytes(): 43 | await f.write(chunk) 44 | except Exception: 45 | logger.warning(f"Image download failed: {url}") 46 | return None 47 | finally: 48 | await client.aclose() 49 | download_timer.stop() 50 | logger.debug(f"Image download success: {image_path}") 51 | return image_path 52 | 53 | 54 | # async def send_forward_msg( 55 | # bot: Bot, 56 | # event: GroupMessageEvent, 57 | # name: str, 58 | # uin: str, 59 | # msgs: list[Message], 60 | # ): 61 | # """ 62 | # :说明: `send_forward_msg` 63 | # > 发送合并转发消息 64 | 65 | # :参数: 66 | # * `bot: Bot`: bot 实例 67 | # * `event: GroupMessageEvent`: 群聊事件 68 | # * `name: str`: 名字 69 | # * `uin: str`: qq号 70 | # * `msgs: List[Message]`: 消息列表 71 | # """ 72 | 73 | # def to_json(msg: Message): 74 | # return {"type": "node", "data": {"name": name, "uin": uin, "content": msg}} 75 | 76 | # messages = [to_json(msg) for msg in msgs] 77 | # await bot.call_api( 78 | # "send_group_forward_msg", group_id=event.group_id, messages=messages 79 | # ) 80 | 81 | 82 | class SpeedLimiter: 83 | def __init__(self) -> None: 84 | self.send_success_time = 0 85 | 86 | def send_success(self) -> None: 87 | self.send_success_time = time.time() 88 | 89 | async def async_speedlimit(self): 90 | if (delay_time := time.time() - self.send_success_time) < SEND_INTERVAL: 91 | delay_time = round(delay_time, 2) 92 | logger.debug(f"Speed limit: Asyncio sleep {delay_time}s") 93 | await asyncio.sleep(delay_time) 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | NoneBotPluginLogo 3 |

4 | 5 |
6 | 7 | # nonebot-plugin-setu-now 8 | 9 | _✨ NoneBot2 涩图插件 ✨_ 10 | 11 |
12 | 13 |

14 | 15 | license 16 | 17 | 18 | pypi 19 | 20 | python 21 |

22 | 23 | 24 | ## 简介 25 | 26 | 可通过群聊或私聊获取 Pixiv 涩图的 NoneBot2 插件 27 | 28 | 29 | ## 特色 30 | 31 | - **极高的涩图发送成功率** 32 | - 支持多种参数选项的命令式交互 33 | - 自动撤回涩图 34 | - 私聊R18支持 35 | - 独立的下载发送任务结构,速度更快 36 | 37 | 38 | ## 安装 39 | 40 | **使用 `nb-cli` 安装(推荐):** 41 | ``` 42 | nb plugin install nonebot-plugin-setu-now 43 | ``` 44 | 45 | 使用 `pip` 安装: 46 | ``` 47 | pip install nonebot-plugin-setu-now 48 | ``` 49 | 50 | 使用 `git clone` 安装(不推荐喵): 51 | ``` 52 | git clone https://github.com/kexue-z/nonebot-plugin-setu-now.git 53 | ``` 54 | 55 | 56 | ### .env 默认配置 57 | 58 | > 只有要用到的才填写,如果用不到或者不知道怎么设置,就不用写配置也能用 59 | 60 | ```ini 61 | setu_cd= 62 | setu_path= 63 | setu_proxy= 64 | setu_withdraw= 65 | setu_reverse_proxy= 66 | setu_size= 67 | setu_api_url= 68 | setu_max= 69 | setu_add_random_effect= 70 | setu_minimum_send_interval= 71 | setu_excludeAI= 72 | setu_r18= 73 | ``` 74 | 75 | 76 | - `setu_cd` CD(单位秒) 可选 默认`60`秒 77 | - `setu_path` 保存路径 可选 可不填使用默认 78 | - `setu_proxy` 代理地址 可选 当 pixiv 反向代理不能使用时可自定义 79 | - `setu_reverse_proxy` pixiv 反向代理 可选 默认 `i.pixiv.re` 80 | - `setu_withdraw` 撤回发送的色图消息的时间, 单位: 秒 可选 默认 `关闭` 填入数字来启用, 建议 `10` ~ `120` 81 | - `setu_size` 色图质量 默认 `regular` 可选 `original` `regular` `small` `thumb` `mini` 82 | - `setu_api_url` 色图信息 api 地址 默认`https://api.lolicon.app/setu/v2` 如果有 api 符合类型也能用 83 | - `setu_max` 一次获取色图的数量 默认 `30` 如果你的服务器/主机内存吃紧 建议调小 84 | - `setu_add_random_effect` 在发送失败时,添加特效以尝试重新发送 默认 `True` 85 | - `setu_minimum_send_interval` 连续发送最小间隔(秒) 可选 默认 `3` 86 | - `setu_excludeAI` 排除 AI 生成的图片 默认 `False` 87 | - `True` :排除 AI 生成的图片 88 | - `False` : 不排除 AI 生成的图片 89 | - `setu_r18` 开启 R18 功能 默认 `False` 90 | 91 | 92 | > 有配置均可选,按需填写 93 | 94 | 95 | ## 使用 96 | 97 | ### 获取色图 98 | 99 | 使用命令式交互获取色图 100 | 101 | ``` 102 | setu [-r|--r18] [-t|--tag 标签] [--switch] [数量] [关键词] 103 | ``` 104 | 105 | **解释:** 106 | 107 | - 支持的命令头:`setu`、`色图`、`涩图`、`来点色色`、`色色`、`涩涩`、`来点色图` 108 | - 参数说明: 109 | - `-r` 或 `--r18`:获取R18内容,仅在私聊可用 110 | - `-t` 或 `--tag`:指定标签,多个标签需要连续使用 `-t 标签1 -t 标签2` 111 | - `--switch` 或 `--switch`:切换R18白名单,仅超级用户可用 112 | - `数量`:获取的图片数量,默认1张,最多不超过配置的`setu_max`值 113 | - `关键词`:搜索关键词,匹配标题、作者或标签 114 | - 注意: 115 | - 如果同时指定了标签和关键词,将优先使用标签搜索 116 | - R18模式下强制只发送1张图片 117 | - 例子 118 | - `色图 妹妹` - 获取1张包含"妹妹"关键词的图片 119 | - `setu --r18` - 私聊中获取1张R18图片 120 | - `涩图 -t 碧蓝航线 5` - 获取5张带有"碧蓝航线"标签的图片 121 | - `来点色图 -t 可爱 -t 猫耳 3` - 获取3张同时带有"可爱"和"猫耳"标签的图片 122 | 123 | ### 获取图片信息 124 | 125 | > 注:当前版本暂未实现此功能,敬请期待后续更新 126 | 127 | 128 | ### R18 设置 129 | 130 | - 如果要求发送 R18 图片时,则数量会被忽略,仅发送单张 131 | - 私聊中可以正常使用 R18 功能 132 | - 当前版本群聊中暂不支持 R18 内容 133 | 134 | > 注:白名单相关功能正在开发中,敬请期待后续更新 135 | 136 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/aioutils/_main.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Coroutine 2 | import functools 3 | import sys 4 | from typing import ( 5 | Any, 6 | Callable, 7 | Generic, 8 | Optional, 9 | TypeVar, 10 | Union, 11 | ) 12 | 13 | if sys.version_info >= (3, 10): 14 | from typing import ParamSpec 15 | else: 16 | from typing_extensions import ParamSpec 17 | 18 | import anyio 19 | from anyio._core._eventloop import get_asynclib, threadlocals 20 | from anyio.abc import TaskGroup as _TaskGroup 21 | 22 | T_Retval = TypeVar("T_Retval") 23 | T_ParamSpec = ParamSpec("T_ParamSpec") 24 | T = TypeVar("T") 25 | 26 | 27 | class PendingType: 28 | def __repr__(self) -> str: 29 | return "AsyncerPending" 30 | 31 | 32 | Pending = PendingType() 33 | 34 | 35 | class PendingValueException(Exception): 36 | pass 37 | 38 | 39 | class SoonValue(Generic[T]): 40 | def __init__(self) -> None: 41 | self._stored_value: Union[T, PendingType] = Pending 42 | 43 | @property 44 | def value(self) -> T: 45 | if isinstance(self._stored_value, PendingType): 46 | raise PendingValueException( 47 | "The return value of this task is still pending!" 48 | ) 49 | return self._stored_value 50 | 51 | @property 52 | def ready(self) -> bool: 53 | return not isinstance(self._stored_value, PendingType) 54 | 55 | 56 | class TaskGroup(_TaskGroup): 57 | def soonify( 58 | self, async_function: Callable[T_ParamSpec, Awaitable[T]], name: object = None 59 | ) -> Callable[T_ParamSpec, SoonValue[T]]: 60 | @functools.wraps(async_function) 61 | def wrapper( 62 | *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs 63 | ) -> SoonValue[T]: 64 | partial_f = functools.partial(async_function, *args, **kwargs) 65 | soon_value: SoonValue[T] = SoonValue() 66 | 67 | @functools.wraps(partial_f) 68 | async def value_wrapper() -> None: 69 | value = await partial_f() 70 | soon_value._stored_value = value 71 | 72 | self.start_soon(value_wrapper, name=name) 73 | return soon_value 74 | 75 | return wrapper 76 | 77 | async def __aenter__(self) -> "TaskGroup": # pragma: nocover 78 | """Enter the task group context and allow starting new tasks.""" 79 | return await super().__aenter__() # type: ignore 80 | 81 | 82 | def create_task_group() -> "TaskGroup": 83 | LibTaskGroup = get_asynclib().TaskGroup 84 | 85 | class ExtendedTaskGroup(LibTaskGroup, TaskGroup): # type: ignore 86 | pass 87 | 88 | return ExtendedTaskGroup() 89 | 90 | 91 | def runnify( 92 | async_function: Callable[T_ParamSpec, Coroutine[Any, Any, T_Retval]], 93 | backend: str = "asyncio", 94 | backend_options: dict[str, Any] | None = None, 95 | ) -> Callable[T_ParamSpec, T_Retval]: 96 | @functools.wraps(async_function) 97 | def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: 98 | partial_f = functools.partial(async_function, *args, **kwargs) 99 | 100 | return anyio.run(partial_f, backend=backend, backend_options=backend_options) 101 | 102 | return wrapper 103 | 104 | 105 | def syncify( 106 | async_function: Callable[T_ParamSpec, Coroutine[Any, Any, T_Retval]], 107 | raise_sync_error: bool = True, 108 | ) -> Callable[T_ParamSpec, T_Retval]: 109 | @functools.wraps(async_function) 110 | def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval: 111 | current_async_module = getattr(threadlocals, "current_async_module", None) 112 | partial_f = functools.partial(async_function, *args, **kwargs) 113 | if current_async_module is None and raise_sync_error is False: 114 | return anyio.run(partial_f) 115 | return anyio.from_thread.run(partial_f) 116 | 117 | return wrapper 118 | 119 | 120 | def asyncify( 121 | function: Callable[T_ParamSpec, T_Retval], 122 | *, 123 | cancellable: bool = False, 124 | limiter: Optional[anyio.CapacityLimiter] = None, 125 | ) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: 126 | async def wrapper( 127 | *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs 128 | ) -> T_Retval: 129 | partial_f = functools.partial(function, *args, **kwargs) 130 | return await anyio.to_thread.run_sync( 131 | partial_f, cancellable=cancellable, limiter=limiter 132 | ) 133 | 134 | return wrapper 135 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/img_utils.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from pathlib import Path 3 | from random import choice, randint 4 | from typing import Callable, Union 5 | 6 | from nonebot.log import logger 7 | from PIL import Image, ImageFilter 8 | 9 | # from .perf_timer import PerfTimer 10 | 11 | 12 | def image_param_converter(source: Union[Path, Image.Image, bytes]) -> Image.Image: 13 | FORCE_RESIZE = True 14 | IMAGE_RESIZE_RES = 1080 # 限制被处理图片最大为1080P 15 | 16 | def resize_converter(img: Image.Image): 17 | if not FORCE_RESIZE: 18 | return img 19 | image_landscape = img.width >= img.height 20 | if min(*img.size) <= IMAGE_RESIZE_RES: 21 | return img 22 | if image_landscape: 23 | resize_res = ( 24 | int(IMAGE_RESIZE_RES / img.height * img.width), 25 | IMAGE_RESIZE_RES, 26 | ) 27 | else: 28 | resize_res = ( 29 | IMAGE_RESIZE_RES, 30 | int(IMAGE_RESIZE_RES / img.width * img.height), 31 | ) 32 | logger.debug(f"Effect force resize: {img.size} -> {resize_res}") 33 | return img.resize(resize_res) 34 | 35 | if isinstance(source, Path): 36 | return resize_converter(Image.open(source)) 37 | if isinstance(source, Image.Image): 38 | return resize_converter(source) 39 | if isinstance(source, bytes): 40 | return resize_converter(Image.open(BytesIO(source))) 41 | raise ValueError(f"Unsopported image type: {type(source)}") 42 | 43 | 44 | def draw_frame(img: Union[Path, Image.Image, bytes]) -> Image.Image: 45 | """画边框""" 46 | img = image_param_converter(img) 47 | BLUR_HEIGHT_QUALITY = 128 48 | FRAME_RATIO = 1.5 49 | resize_resoluation = ( 50 | int(img.width * (BLUR_HEIGHT_QUALITY / img.height)), 51 | BLUR_HEIGHT_QUALITY, 52 | ) 53 | background = img 54 | background = background.resize(resize_resoluation) 55 | background = background.filter(ImageFilter.GaussianBlur(6)) 56 | background = background.resize( 57 | (int(img.width * FRAME_RATIO), int(img.height * FRAME_RATIO)) 58 | ) 59 | background.paste( 60 | img, 61 | ( 62 | int((background.width - img.width) / 2), 63 | int((background.height - img.height) / 2), 64 | ), 65 | ) 66 | return background 67 | 68 | 69 | def random_rotate(img: Union[Path, Image.Image, bytes]) -> Image.Image: 70 | """随机旋转角度""" 71 | img = image_param_converter(img) 72 | a = float(randint(0, 360)) 73 | img = img.rotate(angle=a, expand=True) 74 | return img 75 | 76 | 77 | def random_flip(img: Union[Path, Image.Image, bytes]) -> Image.Image: 78 | """随机翻转""" 79 | img = image_param_converter(img) 80 | t = [Image.Transpose.FLIP_TOP_BOTTOM, Image.Transpose.FLIP_LEFT_RIGHT] 81 | img = img.transpose(choice(t)) 82 | return img 83 | 84 | 85 | def random_lines(img: Union[Path, Image.Image, bytes]) -> Image.Image: 86 | """随机画黑线""" 87 | img = image_param_converter(img) 88 | from PIL import ImageDraw 89 | 90 | x, y = img.size 91 | draw = ImageDraw.Draw(img) 92 | line_width = round(min(x, y) * 0.001) 93 | 94 | def random_line(): 95 | start_point = end_point = (0, 0) 96 | x_y = randint(0, 1) 97 | if x_y: 98 | # 横 99 | start_point = (0, randint(0, y)) 100 | end_point = (y, randint(0, y)) 101 | else: 102 | # 竖 103 | start_point = (randint(0, x), 0) 104 | end_point = (randint(0, x), y) 105 | 106 | draw.line((start_point, end_point), fill=0, width=line_width) 107 | 108 | for _ in range(randint(0, 10)): 109 | random_line() 110 | return img 111 | 112 | 113 | def do_nothing(img: Path | Image.Image | bytes) -> Image.Image: 114 | return image_param_converter(img) 115 | 116 | 117 | # def image_segment_convert(img: Union[Path, Image.Image, bytes]) -> MessageSegment: 118 | # if isinstance(img, Path): 119 | # # 将图片读取 120 | # if SEND_AS_BYTES: 121 | # img = Image.open(img) 122 | # else: 123 | # return MessageSegment.image(img) 124 | # elif isinstance(img, bytes): 125 | # img = Image.open(BytesIO(img)) 126 | # elif isinstance(img, Image.Image): 127 | # pass 128 | # else: 129 | # raise ValueError(f"Unsopported image type: {type(img)}") 130 | # image_bytesio = BytesIO() 131 | # save_timer = PerfTimer.start(f"Save bytes {img.width} x {img.height}") 132 | # if img.mode != "RGB": 133 | # img = img.convert("RGB") 134 | # img.save( 135 | # image_bytesio, 136 | # format="JPEG", 137 | # quality="keep" if img.format in ("JPEG", "JPG") else 95, 138 | # ) 139 | # save_timer.stop() 140 | # return MessageSegment.image(image_bytesio) # type: ignore 141 | 142 | 143 | def pil2bytes(img: Image.Image) -> BytesIO: 144 | buf = BytesIO() 145 | if img.mode != "RGB": 146 | img = img.convert("RGB") 147 | img.save(buf, format="JPEG", quality=95) 148 | return buf 149 | 150 | 151 | EFFECT_FUNC_LIST: list[Callable[[Union[Path, Image.Image, bytes]], Image.Image]] = [ 152 | do_nothing, 153 | draw_frame, 154 | random_flip, 155 | random_lines, 156 | random_rotate, 157 | ] 158 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/database.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from nonebot_plugin_orm import Model, get_session 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from .data_source import SETU_SIZE, Setu 8 | 9 | 10 | class SetuInfo(Model): 11 | pid: Mapped[int] = mapped_column(primary_key=True) 12 | author: Mapped[str] 13 | title: Mapped[str] 14 | url: Mapped[str] 15 | 16 | 17 | async def auto_upgrade_setuinfo(setu_instance: Setu): 18 | async with get_session() as session: 19 | session: AsyncSession 20 | result = await session.get(SetuInfo, int(setu_instance.pid)) 21 | created = False 22 | if not result: 23 | result = SetuInfo( 24 | pid=int(setu_instance.pid), 25 | author=setu_instance.author, 26 | title=setu_instance.title, 27 | url=setu_instance.urls[SETU_SIZE], 28 | ) 29 | session.add(result) 30 | await session.commit() 31 | await session.refresh(result) 32 | created = True 33 | return result, created 34 | 35 | 36 | class MessageInfo(Model): 37 | message_id: Mapped[int] = mapped_column(primary_key=True) 38 | pid: Mapped[int] 39 | 40 | 41 | async def bind_message_data(message_id: int, pid: int): 42 | async with get_session() as session: 43 | session: AsyncSession 44 | result = await session.get(MessageInfo, message_id) 45 | created = False 46 | if not result: 47 | result = MessageInfo( 48 | message_id=message_id, 49 | pid=pid, 50 | ) 51 | session.add(result) 52 | await session.commit() 53 | await session.refresh(result) 54 | created = True 55 | return result, created 56 | 57 | 58 | class GroupWhiteListRecord(Model): 59 | group_id: Mapped[str] = mapped_column(primary_key=True) 60 | operator_user_id: Mapped[str] 61 | 62 | @classmethod 63 | async def activate(cls, group_id: str, operator_user_id: str) -> bool: 64 | """激活群白名单 65 | 66 | Args: 67 | group_id: 群号 68 | operator_user_id: 操作者用户ID 69 | 70 | Returns: 71 | bool: 是否成功激活(True表示激活成功,False表示已存在) 72 | """ 73 | async with get_session() as session: 74 | session: AsyncSession 75 | record = await session.get(cls, group_id) 76 | if record: 77 | return False 78 | record = cls(group_id=group_id, operator_user_id=operator_user_id) 79 | session.add(record) 80 | await session.commit() 81 | return True 82 | 83 | @classmethod 84 | async def deactivate(cls, group_id: str) -> bool: 85 | """取消群白名单 86 | 87 | Args: 88 | group_id: 群号 89 | 90 | Returns: 91 | bool: 是否成功取消(True表示取消成功,False表示记录不存在) 92 | """ 93 | async with get_session() as session: 94 | session: AsyncSession 95 | record = await session.get(cls, group_id) 96 | if not record: 97 | return False 98 | await session.delete(record) 99 | await session.commit() 100 | return True 101 | 102 | @classmethod 103 | async def get_record(cls, group_id: str) -> "GroupWhiteListRecord | None": 104 | """获取群白名单记录 105 | 106 | Args: 107 | group_id: 群号 108 | 109 | Returns: 110 | GroupWhiteListRecord | None: 白名单记录(存在返回记录,不存在返回None) 111 | """ 112 | async with get_session() as session: 113 | session: AsyncSession 114 | return await session.get(cls, group_id) 115 | 116 | 117 | class CooldownRecord(Model): 118 | """冷却时间记录表""" 119 | 120 | user_id: Mapped[str] = mapped_column(primary_key=True) 121 | last_use_time: Mapped[datetime] 122 | 123 | @classmethod 124 | async def check_is_cooldown(cls, user_id: str, cd: int) -> bool: 125 | """检测用户是否冷却完成 126 | 127 | Args: 128 | user_id: 用户ID 129 | cd: 冷却时间(秒) 130 | 131 | Returns: 132 | bool: 是否冷却完成(True表示冷却完成,False表示还在冷却中) 133 | """ 134 | async with get_session() as session: 135 | session: AsyncSession 136 | record = await session.get(cls, user_id) 137 | 138 | if not record: 139 | return True 140 | 141 | # 检查是否超过冷却时间 142 | time_diff = datetime.now() - record.last_use_time 143 | return time_diff.total_seconds() >= cd 144 | 145 | @classmethod 146 | async def set_last_use_time(cls, user_id: str) -> None: 147 | """设置用户的上次使用时间 148 | 149 | Args: 150 | user_id: 用户ID 151 | """ 152 | async with get_session() as session: 153 | session: AsyncSession 154 | record = await session.get(cls, user_id) 155 | 156 | if not record: 157 | # 创建新记录 158 | record = cls(user_id=user_id, last_use_time=datetime.now()) 159 | session.add(record) 160 | else: 161 | # 更新现有记录 162 | record.last_use_time = datetime.now() 163 | 164 | await session.commit() 165 | 166 | @classmethod 167 | async def delete_record(cls, user_id: str) -> bool: 168 | """删除用户的冷却记录 169 | 170 | Args: 171 | user_id: 用户ID 172 | 173 | Returns: 174 | bool: 是否成功删除记录(True表示删除成功,False表示记录不存在) 175 | """ 176 | async with get_session() as session: 177 | session: AsyncSession 178 | record = await session.get(cls, user_id) 179 | 180 | if not record: 181 | return False 182 | 183 | await session.delete(record) 184 | await session.commit() 185 | return True 186 | -------------------------------------------------------------------------------- /nonebot_plugin_setu_now/__init__.py: -------------------------------------------------------------------------------- 1 | from nonebot import require 2 | 3 | require("nonebot_plugin_localstore") 4 | require("nonebot_plugin_orm") 5 | 6 | import asyncio 7 | from pathlib import Path 8 | 9 | from arclet.alconna import Alconna, Args, Option, action 10 | from nonebot import Bot 11 | from nonebot.exception import ActionFailed 12 | from nonebot.log import logger 13 | from nonebot.params import Depends 14 | from nonebot.plugin import PluginMetadata 15 | from nonebot_plugin_alconna import Arparma, CommandMeta, UniMessage, on_alconna 16 | from nonebot_plugin_uninfo import Uninfo 17 | from PIL import UnidentifiedImageError 18 | 19 | from .config import EFFECT, EXCLUDEAI, MAX, SETU_PATH, SETU_R18, WITHDRAW_TIME, Config 20 | from .cooldown import Cooldown 21 | from .data_source import SetuHandler 22 | from .database import ( 23 | CooldownRecord, 24 | GroupWhiteListRecord, 25 | auto_upgrade_setuinfo, 26 | bind_message_data, 27 | ) 28 | from .img_utils import EFFECT_FUNC_LIST, pil2bytes 29 | from .models import Setu, SetuNotFindError 30 | from .perf_timer import PerfTimer 31 | 32 | # from .r18_whitelist import check_group_r18_whitelist 33 | from .utils import SpeedLimiter 34 | 35 | usage_msg = """TL;DR: 色图 或 看文档""" 36 | 37 | __plugin_meta__ = PluginMetadata( 38 | name="nonebot-plugin-setu-now", 39 | description="另一个色图插件", 40 | usage=usage_msg, 41 | type="application", 42 | homepage="https://github.com/kexue-z/nonebot-plugin-setu-now", 43 | config=Config, 44 | extra={}, 45 | ) 46 | 47 | global_speedlimiter = SpeedLimiter() 48 | 49 | setu_matcher = on_alconna( 50 | Alconna( 51 | ["setu", "色图", "来点色图", "色色", "涩涩", "来点色色"], 52 | Args["num", int, 1], 53 | Args["key", str, ""], 54 | Option( 55 | "-r|--r18", 56 | action=action.store_true, 57 | default=False, 58 | help_text="是否获取R18内容", 59 | ), 60 | Option( 61 | "-t|--tag", 62 | Args["tag", str, ""], 63 | action=action.append, 64 | help_text="标签, 多个标签需要连续使用 -t aaa -t bbb 分隔", 65 | ), 66 | Option( 67 | "--switch", 68 | action=action.store_true, 69 | default=False, 70 | help_text="切换R18白名单", 71 | ), 72 | meta=CommandMeta( 73 | description=( 74 | "获取色图, 格式:\n" 75 | "setu [-r|--r18] [-t|--tag 标签] [--switch] [数量] [关键词]\n" 76 | "建议使用 -t 标签 来获取指定标签的色图,不建议使用关键词来获取色图。" 77 | ) 78 | ), 79 | ) 80 | ) 81 | 82 | 83 | @setu_matcher.handle() 84 | async def _(bot: Bot, session: Uninfo, result: Arparma): 85 | if not result.options.get("switch").value: 86 | return 87 | 88 | if session.user.id not in bot.config.superusers: 89 | await setu_matcher.finish("仅超级用户可以使用该功能") 90 | 91 | if not session.scene.is_group: 92 | await setu_matcher.finish("仅支持在群组中使用") 93 | 94 | has_r18_access = await GroupWhiteListRecord.get_record(session.group.id) 95 | if has_r18_access: 96 | await GroupWhiteListRecord.deactivate(session.group.id) 97 | await setu_matcher.finish("已关闭R18白名单") 98 | else: 99 | await GroupWhiteListRecord.activate(session.group.id, session.user.id) 100 | await setu_matcher.finish("已开启R18白名单") 101 | 102 | 103 | @setu_matcher.handle() 104 | async def handle_setu_command( 105 | session: Uninfo, 106 | result: Arparma, 107 | is_cooldown: bool = Depends(Cooldown), 108 | ): 109 | # is_cooldown: True 表示冷却完成 110 | if not is_cooldown: 111 | await UniMessage.text("你冲得太快啦,请稍后再试").send(reply_to=True) 112 | await setu_matcher.finish() 113 | 114 | await CooldownRecord.set_last_use_time(session.user.id) 115 | # 解析参数 116 | num = min(result.main_args.get("num", 1), MAX) 117 | key = result.main_args.get("key", "") 118 | r18 = result.options.get("r18").value if "r18" in result.options else False 119 | tag = ( 120 | result.options.get("tag").args.get("tag", []) if "tag" in result.options else [] 121 | ) 122 | 123 | logger.debug(f"Setu: r18:{r18}, tag:{tag}, key:{key}, num:{num}") 124 | setu_total_timer = PerfTimer("Image request total") 125 | 126 | # 如果存在 tag 关键字, 则不用 key 127 | if tag: 128 | key = "" 129 | 130 | # R18内容控制逻辑 131 | if r18: 132 | has_r18_access = await _validate_r18_access(session) 133 | if not has_r18_access: 134 | await CooldownRecord.delete_record(session.user.id) 135 | await setu_matcher.finish( 136 | "不可以涩涩!\n本群未启用R18支持\n请移除R18标签或联系维护组" 137 | ) 138 | num = 1 # R18模式下强制单张图片 139 | 140 | failure_msg = 0 141 | 142 | async def nb_send_handler(setu: Setu) -> None: 143 | nonlocal failure_msg 144 | if setu.img is None: 145 | logger.warning("Invalid image type, skipped") 146 | failure_msg += 1 147 | return 148 | 149 | for process_func in EFFECT_FUNC_LIST: 150 | if r18 and process_func == EFFECT_FUNC_LIST[0]: 151 | continue 152 | 153 | logger.debug(f"Using effect {process_func}") 154 | effert_timer = PerfTimer.start("Effect process") 155 | 156 | try: 157 | image = process_func(setu.img) # type: ignore 158 | except UnidentifiedImageError: 159 | logger.warning(f"Unidentified image: {type(setu.img)}") 160 | failure_msg += 1 161 | return 162 | 163 | effert_timer.stop() 164 | 165 | msg = UniMessage.image(raw=pil2bytes(image)) 166 | 167 | try: 168 | await global_speedlimiter.async_speedlimit() 169 | send_timer = PerfTimer("Image send") 170 | message_id = 0 171 | receipt = await msg.send() 172 | 173 | message_id = receipt.msg_ids[0]["message_id"] 174 | 175 | await auto_upgrade_setuinfo(setu) 176 | await bind_message_data(message_id, setu.pid) 177 | logger.debug(f"Message ID: {message_id}") 178 | 179 | if WITHDRAW_TIME: 180 | logger.debug( 181 | f"Recall message {message_id} in {WITHDRAW_TIME} seconds" 182 | ) 183 | await receipt.recall(delay=WITHDRAW_TIME) 184 | 185 | send_timer.stop() 186 | global_speedlimiter.send_success() 187 | if SETU_PATH is None: # 未设置缓存路径,删除缓存 188 | Path(setu.img).unlink() 189 | return 190 | except ActionFailed: 191 | if not EFFECT: # 设置不允许添加特效 192 | failure_msg += 1 193 | return 194 | await asyncio.sleep(0) 195 | logger.warning("Image send failed, retrying another effect") 196 | failure_msg += 1 197 | logger.warning("Image send failed after tried all effects") 198 | if SETU_PATH is None: # 未设置缓存路径,删除缓存 199 | Path(setu.img).unlink() 200 | 201 | setu_handler = SetuHandler(key, tag, r18, num, nb_send_handler, EXCLUDEAI) 202 | try: 203 | await setu_handler.process_request() 204 | except SetuNotFindError: 205 | await setu_matcher.finish(f"没有找到关于 {tag or key} 的色图喵") 206 | if failure_msg: 207 | await setu_matcher.send( 208 | message=UniMessage.text(f"{failure_msg} 张图片消失了喵"), 209 | ) 210 | 211 | if failure_msg == num: 212 | await CooldownRecord.delete_record(session.user.id) 213 | 214 | setu_total_timer.stop() 215 | 216 | 217 | async def _validate_r18_access(session: Uninfo) -> bool: 218 | """验证R18内容访问权限""" 219 | # 如果是私聊,检查是否开启了R18权限 220 | if session.scene.is_private: 221 | return SETU_R18 222 | 223 | # 检查群是否在 R18 白名单中 224 | if session.scene.is_group: 225 | has_r18_access = await GroupWhiteListRecord.get_record(session.group.id) 226 | return True if has_r18_access else False 227 | 228 | return False 229 | 230 | 231 | # TODO: 没有查询功能了,到时候再写 232 | """ 233 | setuinfo_matcher = on_command("信息") 234 | 235 | 236 | @setuinfo_matcher.handle() 237 | async def _( 238 | event: MessageEvent, 239 | ): 240 | logger.debug("Running setu info handler") 241 | event_message = event.original_message 242 | reply_segment = event_message["reply"] 243 | 244 | if reply_segment == []: 245 | logger.debug("Command invalid: Not specified setu info to get!") 246 | await setuinfo_matcher.finish("请直接回复需要作品信息的插画") 247 | 248 | reply_segment = reply_segment[0] 249 | reply_message_id = reply_segment.data["id"] 250 | 251 | logger.debug(f"Get setu info for message id: {reply_message_id}") 252 | 253 | if message_info := await MessageInfo.get_or_none(message_id=reply_message_id): 254 | message_pid = message_info.pid 255 | else: 256 | await setuinfo_matcher.finish("未找到该插画相关信息") 257 | 258 | if setu_info := await SetuInfo.get_or_none(pid=message_pid): 259 | info_message = MessageSegment.text(f"标题:{setu_info.title}\n") 260 | info_message += MessageSegment.text(f"画师:{setu_info.author}\n") 261 | info_message += MessageSegment.text(f"PID:{setu_info.pid}") 262 | 263 | await setu_matcher.finish(MessageSegment.reply(reply_message_id) + info_message) 264 | else: 265 | await setuinfo_matcher.finish("该插画相关信息已被移除") 266 | """ 267 | --------------------------------------------------------------------------------