Repository: bot-ssttkkl/nonebot-plugin-pixivbot (language: python)

Issue:
主动加载协议特化组件



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml
index 032b254..dd634eb 100644
--- a/.github/workflows/python-build.yml
+++ b/.github/workflows/python-build.yml
@@ -5,16 +5,15 @@ name: Python package
 
 on:
   push:
-    branches: [ "main" ]
+    branches: [ "release" ]
   pull_request:
-    branches: [ "main" ]
+    branches: [ "main", "release" ]
 
 permissions:
   contents: read
 
 jobs:
-  deploy:
-
+  package:
     runs-on: ubuntu-latest
     strategy:
       fail-fast: false
@@ -35,11 +34,6 @@ jobs:
       #----------------------------------------------
       #  -----  install & configure poetry  -----
       #----------------------------------------------
-      - name: Load cached Poetry installation
-        uses: actions/cache@v2
-        with:
-          path: ~/.local  # the path depends on the OS
-          key: poetry-2  # increment to reset cache
       - name: Install Poetry
         uses: snok/install-poetry@v1
 
@@ -71,7 +65,7 @@ jobs:
         run: |
           poetry run pytest
       #----------------------------------------------
-      #               build & publish
+      #               build & upload
       #----------------------------------------------
       - name: Build package
         run: poetry build
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index b3c9d17..e530912 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -16,10 +16,8 @@ permissions:
   contents: read
 
 jobs:
-  deploy:
-
+  upload:
     runs-on: ubuntu-latest
-
     steps:
       #----------------------------------------------
       #       check-out repo and set-up python
@@ -34,17 +32,8 @@ jobs:
       #----------------------------------------------
       #  -----  install & configure poetry  -----
       #----------------------------------------------
-      - name: Load cached Poetry installation
-        uses: actions/cache@v2
-        with:
-          path: ~/.local  # the path depends on the OS
-          key: poetry-0  # increment to reset cache
       - name: Install Poetry
         uses: snok/install-poetry@v1
-        with:
-          virtualenvs-create: true
-          virtualenvs-in-project: true
-          installer-parallel: true
 
       #----------------------------------------------
       #       load cached venv if cache exists
diff --git a/poetry.lock b/poetry.lock
index 64cfbb7..4259dcf 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -31,7 +31,7 @@ name = "aiohttp-socks"
 version = "0.7.1"
 description = "Proxy connector for aiohttp"
 category = "main"
-optional = true
+optional = false
 python-versions = "*"
 
 [package.dependencies]
@@ -39,18 +39,6 @@ aiohttp = ">=2.3.2"
 attrs = ">=19.2.0"
 python-socks = {version = ">=2.0.0,<3.0.0", extras = ["asyncio"]}
 
-[[package]]
-name = "aioreactive"
-version = "0.16.0"
-description = "sync/await Reactive Tools for Python 3.9+"
-category = "main"
-optional = false
-python-versions = ">=3.9,<3.11"
-
-[package.dependencies]
-Expression = ">=2.0.0,<3.0.0"
-typing-extensions = ">=4.1.1,<5.0.0"
-
 [[package]]
 name = "aiosignal"
 version = "1.2.0"
@@ -136,6 +124,17 @@ category = "main"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "asyncache"
+version = "0.1.1"
+description = "Helpers to use cachetools with async functions"
+category = "main"
+optional = true
+python-versions = "*"
+
+[package.dependencies]
+cachetools = ">=2.1"
+
 [[package]]
 name = "atomicwrites"
 version = "1.4.1"
@@ -174,11 +173,19 @@ webencodings = "*"
 css = ["tinycss2 (>=1.1.0,<1.2)"]
 dev = ["build (==0.8.0)", "flake8 (==4.0.1)", "hashin (==0.17.0)", "pip-tools (==6.6.2)", "pytest (==7.1.2)", "Sphinx (==4.3.2)", "tox (==3.25.0)", "twine (==4.0.1)", "wheel (==0.37.1)", "black (==22.3.0)", "mypy (==0.961)"]
 
+[[package]]
+name = "cachetools"
+version = "5.2.0"
+description = "Extensible memoizing collections and decorators"
+category = "main"
+optional = true
+python-versions = "~=3.7"
+
 [[package]]
 name = "certifi"
 version = "2022.6.15"
 description = "Python package for providing Mozilla's CA Bundle."
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=3.6"
 
@@ -195,7 +202,7 @@ pycparser = "*"
 
 [[package]]
 name = "charset-normalizer"
-version = "2.1.0"
+version = "2.1.1"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = false
@@ -275,20 +282,9 @@ category = "dev"
 optional = false
 python-versions = ">=3.7"
 
-[[package]]
-name = "expression"
-version = "2.2.0"
-description = "Practical functional programming for Python 3.9+"
-category = "main"
-optional = false
-python-versions = ">=3.9,<3.11"
-
-[package.dependencies]
-typing-extensions = ">=4.1.1,<5.0.0"
-
 [[package]]
 name = "fastapi"
-version = "0.79.0"
+version = "0.79.1"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 category = "main"
 optional = false
@@ -299,10 +295,10 @@ pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.
 starlette = "0.19.1"
 
 [package.extras]
-all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)"]
-dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)", "pre-commit (>=2.17.0,<3.0.0)"]
-doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<7.0.0)"]
-test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==4.2.1)", "types-orjson (==3.6.2)", "types-dataclasses (==0.6.5)"]
+test = ["types-dataclasses (==0.6.5)", "types-orjson (==3.6.2)", "types-ujson (==4.2.1)", "anyio[trio] (>=3.2.1,<4.0.0)", "flask (>=1.1.2,<3.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "orjson (>=3.2.1,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "peewee (>=3.13.3,<4.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "email_validator (>=1.1.1,<2.0.0)", "httpx (>=0.14.0,<0.19.0)", "requests (>=2.24.0,<3.0.0)", "isort (>=5.0.6,<6.0.0)", "black (==22.3.0)", "flake8 (>=3.8.3,<4.0.0)", "mypy (==0.910)", "pytest-cov (>=2.12.0,<4.0.0)", "pytest (>=6.2.4,<7.0.0)"]
+doc = ["pyyaml (>=5.3.1,<7.0.0)", "typer (>=0.4.1,<0.5.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mkdocs (>=1.1.2,<2.0.0)"]
+dev = ["pre-commit (>=2.17.0,<3.0.0)", "uvicorn[standard] (>=0.12.0,<0.18.0)", "flake8 (>=3.8.3,<4.0.0)", "autoflake (>=1.4.0,<2.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)"]
+all = ["uvicorn[standard] (>=0.12.0,<0.18.0)", "email_validator (>=1.1.1,<2.0.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "itsdangerous (>=1.1.0,<3.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "jinja2 (>=2.11.2,<4.0.0)", "requests (>=2.24.0,<3.0.0)"]
 
 [[package]]
 name = "flake8"
@@ -317,6 +313,14 @@ mccabe = ">=0.7.0,<0.8.0"
 pycodestyle = ">=2.9.0,<2.10.0"
 pyflakes = ">=2.5.0,<2.6.0"
 
+[[package]]
+name = "frozendict"
+version = "2.3.4"
+description = "A simple immutable dictionary"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
 [[package]]
 name = "frozenlist"
 version = "1.3.1"
@@ -327,12 +331,30 @@ python-versions = ">=3.7"
 
 [[package]]
 name = "h11"
-version = "0.13.0"
+version = "0.12.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 category = "main"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "httpcore"
+version = "0.14.7"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = true
+python-versions = ">=3.6"
+
+[package.dependencies]
+anyio = ">=3.0.0,<4.0.0"
+certifi = "*"
+h11 = ">=0.11,<0.13"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
 [[package]]
 name = "httptools"
 version = "0.4.0"
@@ -344,6 +366,26 @@ python-versions = ">=3.5.0"
 [package.extras]
 test = ["Cython (>=0.29.24,<0.30.0)"]
 
+[[package]]
+name = "httpx"
+version = "0.21.3"
+description = "The next generation HTTP client."
+category = "main"
+optional = true
+python-versions = ">=3.6"
+
+[package.dependencies]
+certifi = "*"
+charset-normalizer = "*"
+httpcore = ">=0.14.0,<0.15.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotlicffi", "brotli"]
+cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"]
+http2 = ["h2 (>=3,<5)"]
+
 [[package]]
 name = "idna"
 version = "3.3"
@@ -390,7 +432,7 @@ test = ["async-timeout", "trio", "testpath", "pytest-asyncio (>=0.17)", "pytest-
 
 [[package]]
 name = "keyring"
-version = "23.8.1"
+version = "23.8.2"
 description = "Store and access your passwords safely."
 category = "dev"
 optional = false
@@ -403,8 +445,8 @@ pywin32-ctypes = {version = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1", markers = "sys_
 SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
+testing = ["pytest-mypy (>=0.9.1)", "pytest-black (>=0.3.7)", "pytest-enabler (>=1.3)", "pytest-cov", "flake8 (<5)", "pytest-flake8", "pytest-checkdocs (>=2.4)", "pytest (>=6)"]
+docs = ["jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "jaraco.packaging (>=9)", "sphinx"]
 
 [[package]]
 name = "lazy"
@@ -457,6 +499,14 @@ snappy = ["pymongo[snappy] (>=4.1,<5)"]
 srv = ["pymongo[srv] (>=4.1,<5)"]
 zstd = ["pymongo[zstd] (>=4.1,<5)"]
 
+[[package]]
+name = "msgpack"
+version = "1.0.4"
+description = "MessagePack serializer"
+category = "main"
+optional = true
+python-versions = "*"
+
 [[package]]
 name = "multidict"
 version = "6.0.2"
@@ -465,6 +515,33 @@ category = "main"
 optional = false
 python-versions = ">=3.7"
 
+[[package]]
+name = "nonebot-adapter-kaiheila"
+version = "0.0.5"
+description = "kaiheila adapter for nonebot2"
+category = "main"
+optional = true
+python-versions = ">=3.7.3,<4.0.0"
+
+[package.dependencies]
+aiohttp = ">=3.8.1,<4.0.0"
+deprecated = ">=1.2.13,<2.0.0"
+httpx = ">=0.21.1,<0.22.0"
+lazy = ">=1.4,<2.0"
+nonebot2 = ">=2.0.0-beta.1,<3.0.0"
+
+[[package]]
+name = "nonebot-adapter-onebot"
+version = "2.1.3"
+description = "OneBot(CQHTTP) adapter for nonebot2"
+category = "main"
+optional = true
+python-versions = ">=3.7.3,<4.0.0"
+
+[package.dependencies]
+msgpack = ">=1.0.3,<2.0.0"
+nonebot2 = ">=2.0.0-beta.3,<3.0.0"
+
 [[package]]
 name = "nonebot-plugin-apscheduler"
 version = "0.1.4"
@@ -477,6 +554,32 @@ python-versions = ">=3.7.3,<4.0.0"
 apscheduler = ">=3.7.0,<4.0.0"
 nonebot2 = ">=2.0.0-alpha.8,<3.0.0"
 
+[[package]]
+name = "nonebot-plugin-pixivbot-kook"
+version = "1.3.0.post1"
+description = "Nonebot Plugin Pixivbot (KOOK)"
+category = "main"
+optional = true
+python-versions = ">=3.9,<4.0"
+
+[package.dependencies]
+asyncache = ">=0.1.1,<0.2.0"
+cachetools = ">=5.2.0,<6.0.0"
+nonebot-adapter-kaiheila = ">=0.0.5"
+nonebot2 = ">=2.0.0-beta.4,<3.0.0"
+
+[[package]]
+name = "nonebot-plugin-pixivbot-onebot-v11"
+version = "1.3.0"
+description = "Nonebot Plugin PixivBot (Onebot V11)"
+category = "main"
+optional = true
+python-versions = ">=3.9,<4.0"
+
+[package.dependencies]
+nonebot-adapter-onebot = ">=2.1.1,<3.0.0"
+nonebot2 = ">=2.0.0-beta.4,<3.0.0"
+
 [[package]]
 name = "nonebot2"
 version = "2.0.0b5"
@@ -520,7 +623,7 @@ typing-extensions = ">=4.0.0,<5.0.0"
 
 [[package]]
 name = "numpy"
-version = "1.23.1"
+version = "1.23.2"
 description = "NumPy is the fundamental package for array computing with Python."
 category = "main"
 optional = false
@@ -611,7 +714,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "pydantic"
-version = "1.9.1"
+version = "1.9.2"
 description = "Data validation and settings management using python type hints"
 category = "main"
 optional = false
@@ -635,12 +738,15 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "pygments"
-version = "2.12.0"
+version = "2.13.0"
 description = "Pygments is a syntax highlighting package written in Python."
 category = "dev"
 optional = false
 python-versions = ">=3.6"
 
+[package.extras]
+plugins = ["importlib-metadata"]
+
 [[package]]
 name = "pygtrie"
 version = "2.5.0"
@@ -742,7 +848,7 @@ name = "python-socks"
 version = "2.0.3"
 description = "Core proxy (SOCKS4, SOCKS5, HTTP tunneling) functionality for Python"
 category = "main"
-optional = true
+optional = false
 python-versions = "*"
 
 [package.dependencies]
@@ -756,7 +862,7 @@ trio = ["trio (>=0.16.0)"]
 
 [[package]]
 name = "pytz"
-version = "2022.1"
+version = "2022.2.1"
 description = "World timezone definitions, modern and historical"
 category = "main"
 optional = false
@@ -836,11 +942,14 @@ requests = ">=2.0.1,<3.0.0"
 
 [[package]]
 name = "rfc3986"
-version = "2.0.0"
+version = "1.5.0"
 description = "Validating URI References per RFC 3986"
-category = "dev"
+category = "main"
 optional = false
-python-versions = ">=3.7"
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
 
 [package.extras]
 idna2008 = ["idna"]
@@ -862,7 +971,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
 
 [[package]]
 name = "secretstorage"
-version = "3.3.2"
+version = "3.3.3"
 description = "Python bindings to FreeDesktop.org Secret Service API"
 category = "dev"
 optional = false
@@ -913,7 +1022,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "tomlkit"
-version = "0.11.2"
+version = "0.11.4"
 description = "Style preserving TOML library"
 category = "main"
 optional = false
@@ -948,7 +1057,7 @@ python-versions = ">=3.7"
 
 [[package]]
 name = "tzdata"
-version = "2022.1"
+version = "2022.2"
 description = "Provider of IANA time zone data"
 category = "main"
 optional = false
@@ -1089,12 +1198,13 @@ docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tideli
 testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
 
 [extras]
-socks = ["aiohttp-socks"]
+kook = ["nonebot-plugin-pixivbot-kook"]
+onebot = ["nonebot-plugin-pixivbot-onebot-v11"]
 
 [metadata]
 lock-version = "1.1"
-python-versions = ">=3.9,<3.11"
-content-hash = "76b40dbd768174f2560d34946a634498f0061e899b3adfd8b4fabae0fdeca464"
+python-versions = "^3.9"
+content-hash = "599b09e3081587bac908ae592ee505f27f121ef1aed1a54758194e1541596d4c"
 
 [metadata.files]
 aiofiles = [
@@ -1179,10 +1289,6 @@ aiohttp-socks = [
     {file = "aiohttp_socks-0.7.1-py3-none-any.whl", hash = "sha256:94bcff5ef73611c6c6231c2ffc1be4af1599abec90dbd2fdbbd63233ec2fb0ff"},
     {file = "aiohttp_socks-0.7.1.tar.gz", hash = "sha256:2215cac4891ef3fa14b7d600ed343ed0f0a670c23b10e4142aa862b3db20341a"},
 ]
-aioreactive = [
-    {file = "aioreactive-0.16.0-py3-none-any.whl", hash = "sha256:b98f055e902dcc585a22511cdf6c88ba313ebb38d7387a9e0350b65b14ccfe67"},
-    {file = "aioreactive-0.16.0.tar.gz", hash = "sha256:7010dab2d87f4b26cb38000880c91ee9cf0c0e7c60807066beda62c94f2a7f3a"},
-]
 aiosignal = [
     {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
     {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
@@ -1206,6 +1312,9 @@ async-timeout = [
     {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
     {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
 ]
+asyncache = [
+    {file = "asyncache-0.1.1.tar.gz", hash = "sha256:c741b3ccef2c5291b3da05d97bab3cc8d50f2ac8efd7fd79d47e3d7b6a3774de"},
+]
 atomicwrites = [
     {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"},
 ]
@@ -1217,6 +1326,10 @@ bleach = [
     {file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"},
     {file = "bleach-5.0.1.tar.gz", hash = "sha256:0d03255c47eb9bd2f26aa9bb7f2107732e7e8fe195ca2f64709fcf3b0a4a085c"},
 ]
+cachetools = [
+    {file = "cachetools-5.2.0-py3-none-any.whl", hash = "sha256:f9f17d2aec496a9aa6b76f53e3b614c965223c061982d434d160f930c698a9db"},
+    {file = "cachetools-5.2.0.tar.gz", hash = "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757"},
+]
 certifi = [
     {file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"},
     {file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"},
@@ -1288,8 +1401,8 @@ cffi = [
     {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"},
 ]
 charset-normalizer = [
-    {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"},
-    {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"},
+    {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"},
+    {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"},
 ]
 click = [
     {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
@@ -1335,18 +1448,33 @@ docutils = [
     {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"},
     {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"},
 ]
-expression = [
-    {file = "Expression-2.2.0-py3-none-any.whl", hash = "sha256:b66ff9cf5e08b5ba99ed8013a30741f97bf13d62b3bcdf4fcf2368f71ab5f2fb"},
-    {file = "Expression-2.2.0.tar.gz", hash = "sha256:b703c2a3bf46fc7ff6fd585ccc3bc7b791b80b583a8e691f9049b651afd4a024"},
-]
 fastapi = [
-    {file = "fastapi-0.79.0-py3-none-any.whl", hash = "sha256:d337563424ceada23857f73d5abe8dae0c28e4cccb53b2af06e78b7bb4a1c7d7"},
-    {file = "fastapi-0.79.0.tar.gz", hash = "sha256:cf0ff6db25b91d321050c4112baab0908c90f19b40bf257f9591d2f9780d1f22"},
+    {file = "fastapi-0.79.1-py3-none-any.whl", hash = "sha256:3c584179c64e265749e88221c860520fc512ea37e253282dab378cc503dfd7fd"},
+    {file = "fastapi-0.79.1.tar.gz", hash = "sha256:006862dec0f0f5683ac21fb0864af2ff12a931e7ba18920f28cc8eceed51896b"},
 ]
 flake8 = [
     {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"},
     {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"},
 ]
+frozendict = [
+    {file = "frozendict-2.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a3b32d47282ae0098b9239a6d53ec539da720258bd762d62191b46f2f87c5fc"},
+    {file = "frozendict-2.3.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84c9887179a245a66a50f52afa08d4d92ae0f269839fab82285c70a0fa0dd782"},
+    {file = "frozendict-2.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:b98a0d65a59af6da03f794f90b0c3085a7ee14e7bf8f0ef36b079ee8aa992439"},
+    {file = "frozendict-2.3.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d8042b7dab5e992e30889c9b71b781d5feef19b372d47d735e4d7d45846fd4a"},
+    {file = "frozendict-2.3.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25a6d2e8b7cf6b6e5677a1a4b53b4073e5d9ec640d1db30dc679627668d25e90"},
+    {file = "frozendict-2.3.4-cp36-cp36m-win_amd64.whl", hash = "sha256:dbbe1339ac2646523e0bb00d1896085d1f70de23780e4927ca82b36ab8a044d3"},
+    {file = "frozendict-2.3.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95bac22f7f09d81f378f2b3f672b7a50a974ca180feae1507f5e21bc147e8bc8"},
+    {file = "frozendict-2.3.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dae686722c144b333c4dbdc16323a5de11406d26b76d2be1cc175f90afacb5ba"},
+    {file = "frozendict-2.3.4-cp37-cp37m-win_amd64.whl", hash = "sha256:389f395a74eb16992217ac1521e689c1dea2d70113bcb18714669ace1ed623b9"},
+    {file = "frozendict-2.3.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ccb6450a416c9cc9acef7683e637e28356e3ceeabf83521f74cc2718883076b7"},
+    {file = "frozendict-2.3.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aca59108b77cadc13ba7dfea7e8f50811208c7652a13dc6c7f92d7782a24d299"},
+    {file = "frozendict-2.3.4-cp38-cp38-win_amd64.whl", hash = "sha256:3ec86ebf143dd685184215c27ec416c36e0ba1b80d81b1b9482f7d380c049b4e"},
+    {file = "frozendict-2.3.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5809e6ff6b7257043a486f7a3b73a7da71cf69a38980b4171e4741291d0d9eb3"},
+    {file = "frozendict-2.3.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c550ed7fdf1962984bec21630c584d722b3ee5d5f57a0ae2527a0121dc0414a"},
+    {file = "frozendict-2.3.4-cp39-cp39-win_amd64.whl", hash = "sha256:3e93aebc6e69a8ef329bbe9afb8342bd33c7b5c7a0c480cb9f7e60b0cbe48072"},
+    {file = "frozendict-2.3.4-py3-none-any.whl", hash = "sha256:d722f3d89db6ae35ef35ecc243c40c800eb344848c83dba4798353312cd37b15"},
+    {file = "frozendict-2.3.4.tar.gz", hash = "sha256:15b4b18346259392b0d27598f240e9390fafbff882137a9c48a1e0104fb17f78"},
+]
 frozenlist = [
     {file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5f271c93f001748fc26ddea409241312a75e13466b06c94798d1a341cf0e6989"},
     {file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c6ef8014b842f01f5d2b55315f1af5cbfde284eb184075c189fd657c2fd8204"},
@@ -1409,8 +1537,12 @@ frozenlist = [
     {file = "frozenlist-1.3.1.tar.gz", hash = "sha256:3a735e4211a04ccfa3f4833547acdf5d2f863bfeb01cfd3edaffbc251f15cec8"},
 ]
 h11 = [
-    {file = "h11-0.13.0-py3-none-any.whl", hash = "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"},
-    {file = "h11-0.13.0.tar.gz", hash = "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06"},
+    {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+    {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httpcore = [
+    {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"},
+    {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"},
 ]
 httptools = [
     {file = "httptools-0.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcddfe70553be717d9745990dfdb194e22ee0f60eb8f48c0794e7bfeda30d2d5"},
@@ -1448,6 +1580,10 @@ httptools = [
     {file = "httptools-0.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:34d2903dd2a3dd85d33705b6fde40bf91fc44411661283763fd0746723963c83"},
     {file = "httptools-0.4.0.tar.gz", hash = "sha256:2c9a930c378b3d15d6b695fb95ebcff81a7395b4f9775c4f10a076beb0b2c1ff"},
 ]
+httpx = [
+    {file = "httpx-0.21.3-py3-none-any.whl", hash = "sha256:df9a0fd43fa79dbab411d83eb1ea6f7a525c96ad92e60c2d7f40388971b25777"},
+    {file = "httpx-0.21.3.tar.gz", hash = "sha256:7a3eb67ef0b8abbd6d9402248ef2f84a76080fa1c839f8662e6eb385640e445a"},
+]
 idna = [
     {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
     {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
@@ -1465,8 +1601,8 @@ jeepney = [
     {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"},
 ]
 keyring = [
-    {file = "keyring-23.8.1-py3-none-any.whl", hash = "sha256:73dec1c13b15a69979fc9952e01c5c69e38c8ba340cc664d0461a947fc90b93c"},
-    {file = "keyring-23.8.1.tar.gz", hash = "sha256:9715c8461f6d9e61066a5883b1fc8aec68f950e81db4ce9f8635cbaa4b9625bb"},
+    {file = "keyring-23.8.2-py3-none-any.whl", hash = "sha256:10d2a8639663fe2090705a00b8c47c687cacdf97598ea9c11456679fa974473a"},
+    {file = "keyring-23.8.2.tar.gz", hash = "sha256:0d9973f8891850f1ade5f26aafd06bb16865fbbae3fc56b0defb6a14a2624003"},
 ]
 lazy = [
     {file = "lazy-1.4-py2.py3-none-any.whl", hash = "sha256:9f293fd531546f3eda039da8f919b513b94f219e562764e3ceefc26fa503bf03"},
@@ -1484,6 +1620,60 @@ motor = [
     {file = "motor-3.0.0-py3-none-any.whl", hash = "sha256:b076de44970f518177f0eeeda8b183f52eafa557775bfe3294e93bda18867a71"},
     {file = "motor-3.0.0.tar.gz", hash = "sha256:3e36d29406c151b61342e6a8fa5e90c00c4723b76e30f11276a4373ea2064b7d"},
 ]
+msgpack = [
+    {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"},
+    {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:112b0f93202d7c0fef0b7810d465fde23c746a2d482e1e2de2aafd2ce1492c88"},
+    {file = "msgpack-1.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:002b5c72b6cd9b4bafd790f364b8480e859b4712e91f43014fe01e4f957b8467"},
+    {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35bc0faa494b0f1d851fd29129b2575b2e26d41d177caacd4206d81502d4c6a6"},
+    {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4733359808c56d5d7756628736061c432ded018e7a1dff2d35a02439043321aa"},
+    {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb514ad14edf07a1dbe63761fd30f89ae79b42625731e1ccf5e1f1092950eaa6"},
+    {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c23080fdeec4716aede32b4e0ef7e213c7b1093eede9ee010949f2a418ced6ba"},
+    {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:49565b0e3d7896d9ea71d9095df15b7f75a035c49be733051c34762ca95bbf7e"},
+    {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aca0f1644d6b5a73eb3e74d4d64d5d8c6c3d577e753a04c9e9c87d07692c58db"},
+    {file = "msgpack-1.0.4-cp310-cp310-win32.whl", hash = "sha256:0dfe3947db5fb9ce52aaea6ca28112a170db9eae75adf9339a1aec434dc954ef"},
+    {file = "msgpack-1.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dea20515f660aa6b7e964433b1808d098dcfcabbebeaaad240d11f909298075"},
+    {file = "msgpack-1.0.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e83f80a7fec1a62cf4e6c9a660e39c7f878f603737a0cdac8c13131d11d97f52"},
+    {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c11a48cf5e59026ad7cb0dc29e29a01b5a66a3e333dc11c04f7e991fc5510a9"},
+    {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1276e8f34e139aeff1c77a3cefb295598b504ac5314d32c8c3d54d24fadb94c9"},
+    {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c9566f2c39ccced0a38d37c26cc3570983b97833c365a6044edef3574a00c08"},
+    {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fcb8a47f43acc113e24e910399376f7277cf8508b27e5b88499f053de6b115a8"},
+    {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:76ee788122de3a68a02ed6f3a16bbcd97bc7c2e39bd4d94be2f1821e7c4a64e6"},
+    {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0a68d3ac0104e2d3510de90a1091720157c319ceeb90d74f7b5295a6bee51bae"},
+    {file = "msgpack-1.0.4-cp36-cp36m-win32.whl", hash = "sha256:85f279d88d8e833ec015650fd15ae5eddce0791e1e8a59165318f371158efec6"},
+    {file = "msgpack-1.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:c1683841cd4fa45ac427c18854c3ec3cd9b681694caf5bff04edb9387602d661"},
+    {file = "msgpack-1.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a75dfb03f8b06f4ab093dafe3ddcc2d633259e6c3f74bb1b01996f5d8aa5868c"},
+    {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9667bdfdf523c40d2511f0e98a6c9d3603be6b371ae9a238b7ef2dc4e7a427b0"},
+    {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11184bc7e56fd74c00ead4f9cc9a3091d62ecb96e97653add7a879a14b003227"},
+    {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac5bd7901487c4a1dd51a8c58f2632b15d838d07ceedaa5e4c080f7190925bff"},
+    {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1e91d641d2bfe91ba4c52039adc5bccf27c335356055825c7f88742c8bb900dd"},
+    {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2a2df1b55a78eb5f5b7d2a4bb221cd8363913830145fad05374a80bf0877cb1e"},
+    {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:545e3cf0cf74f3e48b470f68ed19551ae6f9722814ea969305794645da091236"},
+    {file = "msgpack-1.0.4-cp37-cp37m-win32.whl", hash = "sha256:2cc5ca2712ac0003bcb625c96368fd08a0f86bbc1a5578802512d87bc592fe44"},
+    {file = "msgpack-1.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:eba96145051ccec0ec86611fe9cf693ce55f2a3ce89c06ed307de0e085730ec1"},
+    {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7760f85956c415578c17edb39eed99f9181a48375b0d4a94076d84148cf67b2d"},
+    {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:449e57cc1ff18d3b444eb554e44613cffcccb32805d16726a5494038c3b93dab"},
+    {file = "msgpack-1.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d603de2b8d2ea3f3bcb2efe286849aa7a81531abc52d8454da12f46235092bcb"},
+    {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f5d88c99f64c456413d74a975bd605a9b0526293218a3b77220a2c15458ba9"},
+    {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916c78f33602ecf0509cc40379271ba0f9ab572b066bd4bdafd7434dee4bc6e"},
+    {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81fc7ba725464651190b196f3cd848e8553d4d510114a954681fd0b9c479d7e1"},
+    {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5b5b962221fa2c5d3a7f8133f9abffc114fe218eb4365e40f17732ade576c8e"},
+    {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:77ccd2af37f3db0ea59fb280fa2165bf1b096510ba9fe0cc2bf8fa92a22fdb43"},
+    {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b17be2478b622939e39b816e0aa8242611cc8d3583d1cd8ec31b249f04623243"},
+    {file = "msgpack-1.0.4-cp38-cp38-win32.whl", hash = "sha256:2bb8cdf50dd623392fa75525cce44a65a12a00c98e1e37bf0fb08ddce2ff60d2"},
+    {file = "msgpack-1.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:26b8feaca40a90cbe031b03d82b2898bf560027160d3eae1423f4a67654ec5d6"},
+    {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:462497af5fd4e0edbb1559c352ad84f6c577ffbbb708566a0abaaa84acd9f3ae"},
+    {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2999623886c5c02deefe156e8f869c3b0aaeba14bfc50aa2486a0415178fce55"},
+    {file = "msgpack-1.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f0029245c51fd9473dc1aede1160b0a29f4a912e6b1dd353fa6d317085b219da"},
+    {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed6f7b854a823ea44cf94919ba3f727e230da29feb4a99711433f25800cf747f"},
+    {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df96d6eaf45ceca04b3f3b4b111b86b33785683d682c655063ef8057d61fd92"},
+    {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a4192b1ab40f8dca3f2877b70e63799d95c62c068c84dc028b40a6cb03ccd0f"},
+    {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e3590f9fb9f7fbc36df366267870e77269c03172d086fa76bb4eba8b2b46624"},
+    {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1576bd97527a93c44fa856770197dec00d223b0b9f36ef03f65bac60197cedf8"},
+    {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:63e29d6e8c9ca22b21846234913c3466b7e4ee6e422f205a2988083de3b08cae"},
+    {file = "msgpack-1.0.4-cp39-cp39-win32.whl", hash = "sha256:fb62ea4b62bfcb0b380d5680f9a4b3f9a2d166d9394e9bbd9666c0ee09a3645c"},
+    {file = "msgpack-1.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4d5834a2a48965a349da1c5a79760d94a1a0172fbb5ab6b5b33cbf8447e109ce"},
+    {file = "msgpack-1.0.4.tar.gz", hash = "sha256:f5d869c18f030202eb412f08b28d2afeea553d6613aee89e200d7aca7ef01f5f"},
+]
 multidict = [
     {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
     {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
@@ -1545,10 +1735,26 @@ multidict = [
     {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
     {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
 ]
+nonebot-adapter-kaiheila = [
+    {file = "nonebot-adapter-kaiheila-0.0.5.tar.gz", hash = "sha256:af21f33e26884a16986a898826256d82c08f264b12b7fe741ea3dcb7e4b0ec85"},
+    {file = "nonebot_adapter_kaiheila-0.0.5-py3-none-any.whl", hash = "sha256:e0e0ae4d28598210a0a70f00874a06c7c9339c9e992dbeb056acacd8fe4012da"},
+]
+nonebot-adapter-onebot = [
+    {file = "nonebot-adapter-onebot-2.1.3.tar.gz", hash = "sha256:2090847c58d0058604077b9404be78e6bee225d4972ab59f7223685f79bd5a86"},
+    {file = "nonebot_adapter_onebot-2.1.3-py3-none-any.whl", hash = "sha256:d44eb7b119fdcc3f7936c77e2c0922728c959ad6d6b36ebd004e94856d9ccf49"},
+]
 nonebot-plugin-apscheduler = [
     {file = "nonebot-plugin-apscheduler-0.1.4.tar.gz", hash = "sha256:7541cfb2af48f244b74b66fd14f6486ef39a6d79f17b15c31cf1c6ad9a9632ca"},
     {file = "nonebot_plugin_apscheduler-0.1.4-py3-none-any.whl", hash = "sha256:c904c344c22c8fb2bafec9875ee195b7be7d1f0b758a04e96da6758d62cf4e83"},
 ]
+nonebot-plugin-pixivbot-kook = [
+    {file = "nonebot-plugin-pixivbot-kook-1.3.0.post1.tar.gz", hash = "sha256:c03a2ce0950706f30b14fd5d8a5353a57e3796f849fc7fccfface4e91ba57ff5"},
+    {file = "nonebot_plugin_pixivbot_kook-1.3.0.post1-py3-none-any.whl", hash = "sha256:efbf32c3d1a5165a5ad2c88d9e6d5364f538af178fdca452f2264ad418294d7e"},
+]
+nonebot-plugin-pixivbot-onebot-v11 = [
+    {file = "nonebot-plugin-pixivbot-onebot-v11-1.3.0.tar.gz", hash = "sha256:271fa48fa6a98a503305e64f220ff62e319770abdc24401c52db1780789dd3a5"},
+    {file = "nonebot_plugin_pixivbot_onebot_v11-1.3.0-py3-none-any.whl", hash = "sha256:2eb027cf440195d08e54862a30f7ec496410c9e4a9e394a55425cc6821ed7d1c"},
+]
 nonebot2 = [
     {file = "nonebot2-2.0.0b5-py3-none-any.whl", hash = "sha256:5cfbfcd62ffcf55f884f9fb750a71cd92c97e347c99e891ef617da4d445a1f22"},
     {file = "nonebot2-2.0.0b5.tar.gz", hash = "sha256:fccb5af749cceb289d756901b7abc7a679e5952dbe88c8b6ad8b2d906715c3ee"},
@@ -1558,28 +1764,34 @@ nonebug = [
     {file = "nonebug-0.2.1.tar.gz", hash = "sha256:2f363bd5d65081c802b7b19a72b07ada1ad8e61968cf313176f38a5cf97e84e2"},
 ]
 numpy = [
-    {file = "numpy-1.23.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b15c3f1ed08df4980e02cc79ee058b788a3d0bef2fb3c9ca90bb8cbd5b8a3a04"},
-    {file = "numpy-1.23.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9ce242162015b7e88092dccd0e854548c0926b75c7924a3495e02c6067aba1f5"},
-    {file = "numpy-1.23.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d7447679ae9a7124385ccf0ea990bb85bb869cef217e2ea6c844b6a6855073"},
-    {file = "numpy-1.23.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3119daed207e9410eaf57dcf9591fdc68045f60483d94956bee0bfdcba790953"},
-    {file = "numpy-1.23.1-cp310-cp310-win32.whl", hash = "sha256:3ab67966c8d45d55a2bdf40701536af6443763907086c0a6d1232688e27e5447"},
-    {file = "numpy-1.23.1-cp310-cp310-win_amd64.whl", hash = "sha256:1865fdf51446839ca3fffaab172461f2b781163f6f395f1aed256b1ddc253622"},
-    {file = "numpy-1.23.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:aeba539285dcf0a1ba755945865ec61240ede5432df41d6e29fab305f4384db2"},
-    {file = "numpy-1.23.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7e8229f3687cdadba2c4faef39204feb51ef7c1a9b669247d49a24f3e2e1617c"},
-    {file = "numpy-1.23.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68b69f52e6545af010b76516f5daaef6173e73353e3295c5cb9f96c35d755641"},
-    {file = "numpy-1.23.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1408c3527a74a0209c781ac82bde2182b0f0bf54dea6e6a363fe0cc4488a7ce7"},
-    {file = "numpy-1.23.1-cp38-cp38-win32.whl", hash = "sha256:47f10ab202fe4d8495ff484b5561c65dd59177949ca07975663f4494f7269e3e"},
-    {file = "numpy-1.23.1-cp38-cp38-win_amd64.whl", hash = "sha256:37e5ebebb0eb54c5b4a9b04e6f3018e16b8ef257d26c8945925ba8105008e645"},
-    {file = "numpy-1.23.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:173f28921b15d341afadf6c3898a34f20a0569e4ad5435297ba262ee8941e77b"},
-    {file = "numpy-1.23.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:876f60de09734fbcb4e27a97c9a286b51284df1326b1ac5f1bf0ad3678236b22"},
-    {file = "numpy-1.23.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35590b9c33c0f1c9732b3231bb6a72d1e4f77872390c47d50a615686ae7ed3fd"},
-    {file = "numpy-1.23.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c4e64dfca659fe4d0f1421fc0f05b8ed1ca8c46fb73d9e5a7f175f85696bb"},
-    {file = "numpy-1.23.1-cp39-cp39-win32.whl", hash = "sha256:c2f91f88230042a130ceb1b496932aa717dcbd665350beb821534c5c7e15881c"},
-    {file = "numpy-1.23.1-cp39-cp39-win_amd64.whl", hash = "sha256:37ece2bd095e9781a7156852e43d18044fd0d742934833335599c583618181b9"},
-    {file = "numpy-1.23.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8002574a6b46ac3b5739a003b5233376aeac5163e5dcd43dd7ad062f3e186129"},
-    {file = "numpy-1.23.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d732d17b8a9061540a10fda5bfeabca5785700ab5469a5e9b93aca5e2d3a5fb"},
-    {file = "numpy-1.23.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:55df0f7483b822855af67e38fb3a526e787adf189383b4934305565d71c4b148"},
-    {file = "numpy-1.23.1.tar.gz", hash = "sha256:d748ef349bfef2e1194b59da37ed5a29c19ea8d7e6342019921ba2ba4fd8b624"},
+    {file = "numpy-1.23.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e603ca1fb47b913942f3e660a15e55a9ebca906857edfea476ae5f0fe9b457d5"},
+    {file = "numpy-1.23.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:633679a472934b1c20a12ed0c9a6c9eb167fbb4cb89031939bfd03dd9dbc62b8"},
+    {file = "numpy-1.23.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17e5226674f6ea79e14e3b91bfbc153fdf3ac13f5cc54ee7bc8fdbe820a32da0"},
+    {file = "numpy-1.23.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdc02c0235b261925102b1bd586579b7158e9d0d07ecb61148a1799214a4afd5"},
+    {file = "numpy-1.23.2-cp310-cp310-win32.whl", hash = "sha256:df28dda02c9328e122661f399f7655cdcbcf22ea42daa3650a26bce08a187450"},
+    {file = "numpy-1.23.2-cp310-cp310-win_amd64.whl", hash = "sha256:8ebf7e194b89bc66b78475bd3624d92980fca4e5bb86dda08d677d786fefc414"},
+    {file = "numpy-1.23.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dc76bca1ca98f4b122114435f83f1fcf3c0fe48e4e6f660e07996abf2f53903c"},
+    {file = "numpy-1.23.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ecfdd68d334a6b97472ed032b5b37a30d8217c097acfff15e8452c710e775524"},
+    {file = "numpy-1.23.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5593f67e66dea4e237f5af998d31a43e447786b2154ba1ad833676c788f37cde"},
+    {file = "numpy-1.23.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac987b35df8c2a2eab495ee206658117e9ce867acf3ccb376a19e83070e69418"},
+    {file = "numpy-1.23.2-cp311-cp311-win32.whl", hash = "sha256:d98addfd3c8728ee8b2c49126f3c44c703e2b005d4a95998e2167af176a9e722"},
+    {file = "numpy-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ecb818231afe5f0f568c81f12ce50f2b828ff2b27487520d85eb44c71313b9e"},
+    {file = "numpy-1.23.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:909c56c4d4341ec8315291a105169d8aae732cfb4c250fbc375a1efb7a844f8f"},
+    {file = "numpy-1.23.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8247f01c4721479e482cc2f9f7d973f3f47810cbc8c65e38fd1bbd3141cc9842"},
+    {file = "numpy-1.23.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8b97a8a87cadcd3f94659b4ef6ec056261fa1e1c3317f4193ac231d4df70215"},
+    {file = "numpy-1.23.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5b7ccae24e3d8501ee5563e82febc1771e73bd268eef82a1e8d2b4d556ae66"},
+    {file = "numpy-1.23.2-cp38-cp38-win32.whl", hash = "sha256:9b83d48e464f393d46e8dd8171687394d39bc5abfe2978896b77dc2604e8635d"},
+    {file = "numpy-1.23.2-cp38-cp38-win_amd64.whl", hash = "sha256:dec198619b7dbd6db58603cd256e092bcadef22a796f778bf87f8592b468441d"},
+    {file = "numpy-1.23.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4f41f5bf20d9a521f8cab3a34557cd77b6f205ab2116651f12959714494268b0"},
+    {file = "numpy-1.23.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:806cc25d5c43e240db709875e947076b2826f47c2c340a5a2f36da5bb10c58d6"},
+    {file = "numpy-1.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9d84a24889ebb4c641a9b99e54adb8cab50972f0166a3abc14c3b93163f074"},
+    {file = "numpy-1.23.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c403c81bb8ffb1c993d0165a11493fd4bf1353d258f6997b3ee288b0a48fce77"},
+    {file = "numpy-1.23.2-cp39-cp39-win32.whl", hash = "sha256:cf8c6aed12a935abf2e290860af8e77b26a042eb7f2582ff83dc7ed5f963340c"},
+    {file = "numpy-1.23.2-cp39-cp39-win_amd64.whl", hash = "sha256:5e28cd64624dc2354a349152599e55308eb6ca95a13ce6a7d5679ebff2962913"},
+    {file = "numpy-1.23.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:806970e69106556d1dd200e26647e9bee5e2b3f1814f9da104a943e8d548ca38"},
+    {file = "numpy-1.23.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd879d3ca4b6f39b7770829f73278b7c5e248c91d538aab1e506c628353e47f"},
+    {file = "numpy-1.23.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:be6b350dfbc7f708d9d853663772a9310783ea58f6035eec649fb9c4371b5389"},
+    {file = "numpy-1.23.2.tar.gz", hash = "sha256:b78d00e48261fbbd04aa0d7427cf78d18401ee0abd89c7559bbf422e5b1c7d01"},
 ]
 packaging = [
     {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
@@ -1670,49 +1882,49 @@ pycparser = [
     {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
 ]
 pydantic = [
-    {file = "pydantic-1.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8098a724c2784bf03e8070993f6d46aa2eeca031f8d8a048dff277703e6e193"},
-    {file = "pydantic-1.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c320c64dd876e45254bdd350f0179da737463eea41c43bacbee9d8c9d1021f11"},
-    {file = "pydantic-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310"},
-    {file = "pydantic-1.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c11951b404e08b01b151222a1cb1a9f0a860a8153ce8334149ab9199cd198131"},
-    {file = "pydantic-1.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8bc541a405423ce0e51c19f637050acdbdf8feca34150e0d17f675e72d119580"},
-    {file = "pydantic-1.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e565a785233c2d03724c4dc55464559639b1ba9ecf091288dd47ad9c629433bd"},
-    {file = "pydantic-1.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a4a88dcd6ff8fd47c18b3a3709a89adb39a6373f4482e04c1b765045c7e282fd"},
-    {file = "pydantic-1.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:447d5521575f18e18240906beadc58551e97ec98142266e521c34968c76c8761"},
-    {file = "pydantic-1.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:985ceb5d0a86fcaa61e45781e567a59baa0da292d5ed2e490d612d0de5796918"},
-    {file = "pydantic-1.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059b6c1795170809103a1538255883e1983e5b831faea6558ef873d4955b4a74"},
-    {file = "pydantic-1.9.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d12f96b5b64bec3f43c8e82b4aab7599d0157f11c798c9f9c528a72b9e0b339a"},
-    {file = "pydantic-1.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ae72f8098acb368d877b210ebe02ba12585e77bd0db78ac04a1ee9b9f5dd2166"},
-    {file = "pydantic-1.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:79b485767c13788ee314669008d01f9ef3bc05db9ea3298f6a50d3ef596a154b"},
-    {file = "pydantic-1.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:494f7c8537f0c02b740c229af4cb47c0d39840b829ecdcfc93d91dcbb0779892"},
-    {file = "pydantic-1.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0f047e11febe5c3198ed346b507e1d010330d56ad615a7e0a89fae604065a0e"},
-    {file = "pydantic-1.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:969dd06110cb780da01336b281f53e2e7eb3a482831df441fb65dd30403f4608"},
-    {file = "pydantic-1.9.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:177071dfc0df6248fd22b43036f936cfe2508077a72af0933d0c1fa269b18537"},
-    {file = "pydantic-1.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9bcf8b6e011be08fb729d110f3e22e654a50f8a826b0575c7196616780683380"},
-    {file = "pydantic-1.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a955260d47f03df08acf45689bd163ed9df82c0e0124beb4251b1290fa7ae728"},
-    {file = "pydantic-1.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9ce157d979f742a915b75f792dbd6aa63b8eccaf46a1005ba03aa8a986bde34a"},
-    {file = "pydantic-1.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0bf07cab5b279859c253d26a9194a8906e6f4a210063b84b433cf90a569de0c1"},
-    {file = "pydantic-1.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d93d4e95eacd313d2c765ebe40d49ca9dd2ed90e5b37d0d421c597af830c195"},
-    {file = "pydantic-1.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1542636a39c4892c4f4fa6270696902acb186a9aaeac6f6cf92ce6ae2e88564b"},
-    {file = "pydantic-1.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a9af62e9b5b9bc67b2a195ebc2c2662fdf498a822d62f902bf27cccb52dbbf49"},
-    {file = "pydantic-1.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fe4670cb32ea98ffbf5a1262f14c3e102cccd92b1869df3bb09538158ba90fe6"},
-    {file = "pydantic-1.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:9f659a5ee95c8baa2436d392267988fd0f43eb774e5eb8739252e5a7e9cf07e0"},
-    {file = "pydantic-1.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b83ba3825bc91dfa989d4eed76865e71aea3a6ca1388b59fc801ee04c4d8d0d6"},
-    {file = "pydantic-1.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1dd8fecbad028cd89d04a46688d2fcc14423e8a196d5b0a5c65105664901f810"},
-    {file = "pydantic-1.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02eefd7087268b711a3ff4db528e9916ac9aa18616da7bca69c1871d0b7a091f"},
-    {file = "pydantic-1.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7eb57ba90929bac0b6cc2af2373893d80ac559adda6933e562dcfb375029acee"},
-    {file = "pydantic-1.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4ce9ae9e91f46c344bec3b03d6ee9612802682c1551aaf627ad24045ce090761"},
-    {file = "pydantic-1.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:72ccb318bf0c9ab97fc04c10c37683d9eea952ed526707fabf9ac5ae59b701fd"},
-    {file = "pydantic-1.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:61b6760b08b7c395975d893e0b814a11cf011ebb24f7d869e7118f5a339a82e1"},
-    {file = "pydantic-1.9.1-py3-none-any.whl", hash = "sha256:4988c0f13c42bfa9ddd2fe2f569c9d54646ce84adc5de84228cfe83396f3bd58"},
-    {file = "pydantic-1.9.1.tar.gz", hash = "sha256:1ed987c3ff29fff7fd8c3ea3a3ea877ad310aae2ef9889a119e22d3f2db0691a"},
+    {file = "pydantic-1.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c9e04a6cdb7a363d7cb3ccf0efea51e0abb48e180c0d31dca8d247967d85c6e"},
+    {file = "pydantic-1.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fafe841be1103f340a24977f61dee76172e4ae5f647ab9e7fd1e1fca51524f08"},
+    {file = "pydantic-1.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afacf6d2a41ed91fc631bade88b1d319c51ab5418870802cedb590b709c5ae3c"},
+    {file = "pydantic-1.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ee0d69b2a5b341fc7927e92cae7ddcfd95e624dfc4870b32a85568bd65e6131"},
+    {file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ff68fc85355532ea77559ede81f35fff79a6a5543477e168ab3a381887caea76"},
+    {file = "pydantic-1.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c0f5e142ef8217019e3eef6ae1b6b55f09a7a15972958d44fbd228214cede567"},
+    {file = "pydantic-1.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:615661bfc37e82ac677543704437ff737418e4ea04bef9cf11c6d27346606044"},
+    {file = "pydantic-1.9.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:328558c9f2eed77bd8fffad3cef39dbbe3edc7044517f4625a769d45d4cf7555"},
+    {file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd446bdb7755c3a94e56d7bdfd3ee92396070efa8ef3a34fab9579fe6aa1d84"},
+    {file = "pydantic-1.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0b214e57623a535936005797567231a12d0da0c29711eb3514bc2b3cd008d0f"},
+    {file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d8ce3fb0841763a89322ea0432f1f59a2d3feae07a63ea2c958b2315e1ae8adb"},
+    {file = "pydantic-1.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b34ba24f3e2d0b39b43f0ca62008f7ba962cff51efa56e64ee25c4af6eed987b"},
+    {file = "pydantic-1.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:84d76ecc908d917f4684b354a39fd885d69dd0491be175f3465fe4b59811c001"},
+    {file = "pydantic-1.9.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4de71c718c9756d679420c69f216776c2e977459f77e8f679a4a961dc7304a56"},
+    {file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5803ad846cdd1ed0d97eb00292b870c29c1f03732a010e66908ff48a762f20e4"},
+    {file = "pydantic-1.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8c5360a0297a713b4123608a7909e6869e1b56d0e96eb0d792c27585d40757f"},
+    {file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:cdb4272678db803ddf94caa4f94f8672e9a46bae4a44f167095e4d06fec12979"},
+    {file = "pydantic-1.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19b5686387ea0d1ea52ecc4cffb71abb21702c5e5b2ac626fd4dbaa0834aa49d"},
+    {file = "pydantic-1.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e0b4fb13ad4db4058a7c3c80e2569adbd810c25e6ca3bbd8b2a9cc2cc871d7"},
+    {file = "pydantic-1.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:91089b2e281713f3893cd01d8e576771cd5bfdfbff5d0ed95969f47ef6d676c3"},
+    {file = "pydantic-1.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e631c70c9280e3129f071635b81207cad85e6c08e253539467e4ead0e5b219aa"},
+    {file = "pydantic-1.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b3946f87e5cef3ba2e7bd3a4eb5a20385fe36521d6cc1ebf3c08a6697c6cfb3"},
+    {file = "pydantic-1.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5565a49effe38d51882cb7bac18bda013cdb34d80ac336428e8908f0b72499b0"},
+    {file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bd67cb2c2d9602ad159389c29e4ca964b86fa2f35c2faef54c3eb28b4efd36c8"},
+    {file = "pydantic-1.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4aafd4e55e8ad5bd1b19572ea2df546ccace7945853832bb99422a79c70ce9b8"},
+    {file = "pydantic-1.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:d70916235d478404a3fa8c997b003b5f33aeac4686ac1baa767234a0f8ac2326"},
+    {file = "pydantic-1.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ca86b525264daa5f6b192f216a0d1e860b7383e3da1c65a1908f9c02f42801"},
+    {file = "pydantic-1.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1061c6ee6204f4f5a27133126854948e3b3d51fcc16ead2e5d04378c199b2f44"},
+    {file = "pydantic-1.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e78578f0c7481c850d1c969aca9a65405887003484d24f6110458fb02cca7747"},
+    {file = "pydantic-1.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5da164119602212a3fe7e3bc08911a89db4710ae51444b4224c2382fd09ad453"},
+    {file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ead3cd020d526f75b4188e0a8d71c0dbbe1b4b6b5dc0ea775a93aca16256aeb"},
+    {file = "pydantic-1.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7d0f183b305629765910eaad707800d2f47c6ac5bcfb8c6397abdc30b69eeb15"},
+    {file = "pydantic-1.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1a68f4f65a9ee64b6ccccb5bf7e17db07caebd2730109cb8a95863cfa9c4e55"},
+    {file = "pydantic-1.9.2-py3-none-any.whl", hash = "sha256:78a4d6bdfd116a559aeec9a4cfe77dda62acc6233f8b56a716edad2651023e5e"},
+    {file = "pydantic-1.9.2.tar.gz", hash = "sha256:8cb0bc509bfb71305d7a59d00163d5f9fc4530f0881ea32c74ff4f74c85f3d3d"},
 ]
 pyflakes = [
     {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"},
     {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"},
 ]
 pygments = [
-    {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"},
-    {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"},
+    {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
+    {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
 ]
 pygtrie = [
     {file = "pygtrie-2.5.0.tar.gz", hash = "sha256:203514ad826eb403dab1d2e2ddd034e0d1534bbe4dbe0213bb0593f66beba4e2"},
@@ -1810,8 +2022,8 @@ python-socks = [
     {file = "python_socks-2.0.3-py3-none-any.whl", hash = "sha256:950723f27d2cf401e193a9e0a0d45baab848341298f5b397d27fda0c4635e9a9"},
 ]
 pytz = [
-    {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"},
-    {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"},
+    {file = "pytz-2022.2.1-py2.py3-none-any.whl", hash = "sha256:220f481bdafa09c3955dfbdddb7b57780e9a94f5127e35456a48589b9e0c0197"},
+    {file = "pytz-2022.2.1.tar.gz", hash = "sha256:cea221417204f2d1a2aa03ddae3e867921971d0d76f14d87abb4414415bbdcf5"},
 ]
 pytz-deprecation-shim = [
     {file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"},
@@ -1869,16 +2081,16 @@ requests-toolbelt = [
     {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},
 ]
 rfc3986 = [
-    {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"},
-    {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"},
+    {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
+    {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
 ]
 rich = [
     {file = "rich-12.5.1-py3-none-any.whl", hash = "sha256:2eb4e6894cde1e017976d2975ac210ef515d7548bc595ba20e195fb9628acdeb"},
     {file = "rich-12.5.1.tar.gz", hash = "sha256:63a5c5ce3673d3d5fbbf23cd87e11ab84b6b451436f1b7f19ec54b6bc36ed7ca"},
 ]
 secretstorage = [
-    {file = "SecretStorage-3.3.2-py3-none-any.whl", hash = "sha256:755dc845b6ad76dcbcbc07ea3da75ae54bb1ea529eb72d15f83d26499a5df319"},
-    {file = "SecretStorage-3.3.2.tar.gz", hash = "sha256:0a8eb9645b320881c222e827c26f4cfcf55363e8b374a021981ef886657a912f"},
+    {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"},
+    {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"},
 ]
 six = [
     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
@@ -1897,8 +2109,8 @@ toml = [
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 tomlkit = [
-    {file = "tomlkit-0.11.2-py3-none-any.whl", hash = "sha256:69e0675671a2eed1c08a53f342c955c4ead5d373a10f756219bf39f3d4f0018a"},
-    {file = "tomlkit-0.11.2.tar.gz", hash = "sha256:d1b49c3e460f5910b22d799b13513504acb4f5fcaee01660ee66f07bd45a271c"},
+    {file = "tomlkit-0.11.4-py3-none-any.whl", hash = "sha256:25d4e2e446c453be6360c67ddfb88838cfc42026322770ba13d1fbd403a93a5c"},
+    {file = "tomlkit-0.11.4.tar.gz", hash = "sha256:3235a9010fae54323e727c3ac06fb720752fe6635b3426e379daec60fbd44a83"},
 ]
 twine = [
     {file = "twine-4.0.1-py3-none-any.whl", hash = "sha256:42026c18e394eac3e06693ee52010baa5313e4811d5a11050e7d48436cf41b9e"},
@@ -1909,8 +2121,8 @@ typing-extensions = [
     {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
 ]
 tzdata = [
-    {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"},
-    {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"},
+    {file = "tzdata-2022.2-py2.py3-none-any.whl", hash = "sha256:c3119520447d68ef3eb8187a55a4f44fa455f30eb1b4238fa5691ba094f2b05b"},
+    {file = "tzdata-2022.2.tar.gz", hash = "sha256:21f4f0d7241572efa7f7a4fdabb052e61b55dc48274e6842697ccdf5253e5451"},
 ]
 tzlocal = [
     {file = "tzlocal-4.2-py3-none-any.whl", hash = "sha256:89885494684c929d9191c57aa27502afc87a579be5cdd3225c77c463ea043745"},
diff --git a/pyproject.toml b/pyproject.toml
index c710bb5..e679b05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nonebot-plugin-pixivbot"
-version = "1.2.0"
+version = "1.3.0"
 description = "Nonebot Plugin PixivBot"
 authors = ["ssttkkl <huang.wen.long@hotmail.com>"]
 readme = "README.md"
@@ -11,7 +11,7 @@ packages = [
 ]
 
 [tool.poetry.dependencies]
-python = ">=3.9,<3.11"
+python = "^3.9"
 nonebot2 = "^2.0.0b4"
 nonebot_plugin_apscheduler = "^0.1.3"
 PixivPy-Async = "^1.2.14"
@@ -19,8 +19,11 @@ Pillow = "^9.2.0"
 motor = "^3.0.0"
 numpy = "^1.23.1"
 lazy = "^1.4"
-aiohttp-socks = { version = "^0.7.1", optional = true }
-aioreactive = "^0.16.0"
+aiohttp-socks = "^0.7.1"
+frozendict = "^2.3.4"
+tzlocal = "^4.2"
+nonebot-plugin-pixivbot-onebot-v11 = { version = "^1.3.0", optional = true }
+nonebot-plugin-pixivbot-kook = { version = "^1.3.0", optional = true }
 
 [tool.poetry.dev-dependencies]
 flake8 = "^5.0.4"
@@ -29,7 +32,8 @@ nonebug = "^0.2.1"
 pytest-mock = "^3.8.2"
 
 [tool.poetry.extras]
-socks = ["aiohttp-socks"]
+onebot = ["nonebot-plugin-pixivbot-onebot-v11"]
+kook = ["nonebot-plugin-pixivbot-kook"]
 
 #[[tool.poetry.source]]
 #name = "tsinghua"
diff --git a/src/nonebot_plugin_pixivbot/__init__.py b/src/nonebot_plugin_pixivbot/__init__.py
index 9aac4c0..c16d309 100644
--- a/src/nonebot_plugin_pixivbot/__init__.py
+++ b/src/nonebot_plugin_pixivbot/__init__.py
@@ -7,15 +7,27 @@
 """
 
 # ================= provide beans =================
+from importlib import import_module
+
+from nonebot import logger
+
 from .global_context import context
 from .providers import provide
 
 provide(context)
 
-# ================= register query =================
-from .query import *
+# =========== register query & service ============
+from . import query
+from . import service
+
+# ============== load custom protocol =============
+supported_modules = ["nonebot_plugin_pixivbot_onebot_v11", "nonebot_plugin_pixivbot_kook"]
 
-# =============== register scheduler ===============
-from .service.scheduler import Scheduler
+for p in supported_modules:
+    try:
+        import_module(p)
+        logger.success("Loaded Module: " + p)
+    except ModuleNotFoundError:
+        pass
 
 __all__ = ("context",)
diff --git a/src/nonebot_plugin_pixivbot/config.py b/src/nonebot_plugin_pixivbot/config.py
index 611c3b9..c747c41 100644
--- a/src/nonebot_plugin_pixivbot/config.py
+++ b/src/nonebot_plugin_pixivbot/config.py
@@ -136,6 +136,15 @@ def ranking_default_range_validator(cls, v, field: ModelField):
     pixiv_random_bookmark_max_page = 2 ** 31
     pixiv_random_bookmark_max_item = 2 ** 31
 
+    pixiv_random_following_illust_query_enabled = True
+    pixiv_random_following_illust_method = RandomIllustMethod.timedelta_proportion
+    pixiv_random_following_illust_min_bookmark = 0
+    pixiv_random_following_illust_min_view = 0
+    pixiv_random_following_illust_max_page = 2 ** 31
+    pixiv_random_following_illust_max_item = 2 ** 31
+
+    pixiv_watch_interval = 3600 * 2
+
     class Config:
         extra = "ignore"
 
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_binding_repo.py b/src/nonebot_plugin_pixivbot/data/pixiv_binding_repo.py
index 613c9ac..59b770f 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_binding_repo.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_binding_repo.py
@@ -13,9 +13,13 @@ class PixivBindingRepo:
     mongo: MongoDataSource
 
     async def get(self, adapter: str, user_id: UID) -> Optional[PixivBinding]:
-        return await self.mongo.db.pixiv_binding.find_one(
+        doc = await self.mongo.db.pixiv_binding.find_one(
             {"adapter": adapter, "user_id": user_id}
         )
+        if doc:
+            return PixivBinding.parse_obj(doc)
+        else:
+            return None
 
     async def update(self, binding: PixivBinding):
         await self.mongo.db.pixiv_binding.update_one(
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/abstract_repo.py b/src/nonebot_plugin_pixivbot/data/pixiv_repo/abstract_repo.py
index 01b55ad..faf7824 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/abstract_repo.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_repo/abstract_repo.py
@@ -1,5 +1,5 @@
 from abc import ABC, abstractmethod
-from datetime import datetime
+from datetime import datetime, timezone
 from typing import Union, Optional, AsyncGenerator
 
 from pydantic import BaseModel
@@ -10,7 +10,7 @@
 
 
 class PixivRepoMetadata(BaseModel):
-    update_time: datetime = datetime.now()
+    update_time: datetime = datetime.now(timezone.utc)
     pages: Optional[int]
     next_qs: Optional[dict]
 
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/enums.py b/src/nonebot_plugin_pixivbot/data/pixiv_repo/enums.py
index fd8451e..87b07e1 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/enums.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_repo/enums.py
@@ -1,10 +1,19 @@
-SEARCH_ILLUST = 0
-SEARCH_USER = 1
-USER_ILLUSTS = 2
-USER_BOOKMARKS = 3
-RECOMMENDED_ILLUSTS = 4
-ILLUST_RANKING = 5
-ILLUST_DETAIL = 6
-IMAGE = 7
-RELATED_ILLUSTS = 8
-USER_DETAIL = 9
+from enum import Enum
+
+
+class PixivResType(Enum):
+    SEARCH_ILLUST = 0
+    SEARCH_USER = 1
+    USER_ILLUSTS = 2
+    USER_BOOKMARKS = 3
+    RECOMMENDED_ILLUSTS = 4
+    ILLUST_RANKING = 5
+    ILLUST_DETAIL = 6
+    IMAGE = 7
+    RELATED_ILLUSTS = 8
+    USER_DETAIL = 9
+
+
+class CacheStrategy(Enum):
+    NORMAL = 0
+    FORCE_EXPIRATION = 1
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/lazy_illust.py b/src/nonebot_plugin_pixivbot/data/pixiv_repo/lazy_illust.py
index c2b1ea3..a9cf063 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/lazy_illust.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_repo/lazy_illust.py
@@ -21,7 +21,7 @@ def __init__(self, id: int, content: Optional[Illust] = None) -> None:
         self.id = id
         self.content = content
 
-    async def get(self):
+    async def get(self) -> Illust:
         if self.content is None:
             async for x in self.src.illust_detail(self.id):
                 self.content = x
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/local_repo.py b/src/nonebot_plugin_pixivbot/data/pixiv_repo/local_repo.py
index 2ecbcbc..d9dd9d9 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/local_repo.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_repo/local_repo.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from typing import List, Union, Sequence, Any, Mapping, AsyncGenerator
 
 import bson
@@ -29,7 +29,7 @@ def __init__(self, metadata: PixivRepoMetadata):
 
 
 def handle_expires_in(doc: Mapping, expires_in: int):
-    if datetime.now() - doc["metadata"]["update_time"] >= timedelta(expires_in):
+    if datetime.now(timezone.utc) - doc["metadata"]["update_time"] >= timedelta(seconds=expires_in):
         raise CacheExpiredError(PixivRepoMetadata.parse_obj(doc["metadata"]))
 
 
@@ -386,7 +386,7 @@ async def update_user_detail(self, user: User, metadata: PixivRepoMetadata = Pix
         logger.debug(f"[local] update user_detail {user.id} {metadata_to_text(metadata)}")
 
         if not metadata:
-            metadata = PixivRepoMetadata(update_time=datetime.now())
+            metadata = PixivRepoMetadata(update_time=datetime.now(timezone.utc))
 
         await self.mongo.db.user_detail_cache.update_one(
             {"user.id": user.id},
@@ -439,7 +439,7 @@ async def append_search_user(self, word: str, content: List[User],
         logger.debug(f"[local] append search_user {word} "
                      f"({len(content)} items) "
                      f"{metadata_to_text(metadata)}")
-        return await self._append_and_check_users("search_illust_cache", {"word": word}, content, metadata)
+        return await self._append_and_check_users("search_user_cache", {"word": word}, content, metadata)
 
     # ================ user_illusts ================
     def user_illusts(self, user_id: int, *, offset: int = 0) \
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/mediator.py b/src/nonebot_plugin_pixivbot/data/pixiv_repo/mediator.py
index 0a04ba9..5dc820d 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/mediator.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_repo/mediator.py
@@ -1,5 +1,5 @@
-from datetime import datetime
-from typing import List, AsyncGenerator, TypeVar, Union, Callable, Awaitable, Optional, Any
+from datetime import datetime, timezone
+from typing import List, AsyncGenerator, TypeVar, Union, Callable, Awaitable, Optional, Any, Mapping
 
 from .abstract_repo import PixivRepoMetadata
 from .local_repo import NoSuchItemError, CacheExpiredError
@@ -7,7 +7,36 @@
 T = TypeVar("T")
 
 
-async def _load_local_and_remote_and_update_local(
+async def mediate_single(cache_factory: Callable[[], AsyncGenerator[Union[T, PixivRepoMetadata], None]],
+                         remote_factory: Callable[..., AsyncGenerator[Union[T, PixivRepoMetadata], None]],
+                         cache_updater: Callable[[T, Optional[PixivRepoMetadata]], Awaitable[Any]],
+                         force_expiration: bool = False) -> AsyncGenerator[Union[T, PixivRepoMetadata], None]:
+    try:
+        if force_expiration:
+            raise NoSuchItemError()
+        async for x in cache_factory():
+            yield x
+    except (NoSuchItemError, CacheExpiredError):
+        content = None
+        metadata = None
+
+        async for x in remote_factory():
+            if isinstance(x, PixivRepoMetadata):
+                metadata = x
+            else:
+                content = x
+
+        if metadata:
+            try:
+                yield metadata
+                yield content
+            finally:
+                await cache_updater(content, metadata)
+        else:
+            raise RuntimeError("no metadata")
+
+
+async def _load_many_from_local_and_remote_and_update(
         cache_factory: Callable[[], AsyncGenerator[Union[T, PixivRepoMetadata], None]],
         remote_factory: Callable[..., AsyncGenerator[Union[T, PixivRepoMetadata], None]],
         cache_appender: Callable[[List[T], Optional[PixivRepoMetadata]], Awaitable[Any]],
@@ -32,116 +61,97 @@ async def _load_local_and_remote_and_update_local(
             return
 
     # then check metadata["next_qs"] and load from remote
-    # finally update local cache
     if metadata and metadata.next_qs:
-        next_qs = metadata.next_qs
-        content = []
-
-        try:
-            async for x in remote_factory(**next_qs):
-                if isinstance(x, PixivRepoMetadata):
-                    loaded_pages += 1
-                    metadata = x
-                    metadata.pages = loaded_pages
-                else:
-                    loaded_items += 1
-                    content.append(x)
-
-                yield x
-
-                # check whether we approach limit
-                if loaded_items >= max_item or loaded_pages >= max_page:
-                    return
-        finally:
-            await cache_appender(content, metadata)
+        async for x in _load_many_from_remote_and_append(remote_factory, cache_appender,
+                                                         max_item - loaded_items, max_page - loaded_pages,
+                                                         metadata.next_qs):
+            if isinstance(x, PixivRepoMetadata):
+                x.pages += loaded_pages
+            yield x
 
 
-async def _load_remote_and_update_local(
+async def _load_many_from_remote_and_append(
         remote_factory: Callable[..., AsyncGenerator[Union[T, PixivRepoMetadata], None]],
-        cache_updater: Callable[[T, Optional[PixivRepoMetadata]], Awaitable[Any]],
+        cache_appender: Callable[[T, Optional[PixivRepoMetadata]], Awaitable[Any]],
         max_item: int,
-        max_page: int):
+        max_page: int,
+        next_qs: Optional[Mapping[str, Any]] = None):
+    if next_qs is None:
+        next_qs = {}
+
     loaded_items = 0
     loaded_pages = 0
 
-    # load from remote and update local cache
-    content = []
-    metadata = None
-
-    try:
-        async for x in remote_factory():
-            if isinstance(x, PixivRepoMetadata):
-                loaded_pages += 1
-                metadata = x
-                metadata.pages = loaded_pages
-            else:
-                loaded_items += 1
-                content.append(x)
+    buffer = []
 
-            yield x
+    async for item in remote_factory(**next_qs):
+        if isinstance(item, PixivRepoMetadata):
+            loaded_pages = item.pages
+            metadata = item
+            metadata.pages = loaded_pages
 
-            # check whether we approach limit
-            if loaded_items >= max_item or loaded_pages >= max_page:
-                return
-    finally:
-        await cache_updater(content, metadata)
+            if len(buffer) > 0:
+                await cache_appender(buffer, metadata)
 
+                for x in buffer:
+                    yield x
 
-async def mediate_single(cache_factory: Callable[[], AsyncGenerator[Union[T, PixivRepoMetadata], None]],
-                         remote_factory: Callable[..., AsyncGenerator[Union[T, PixivRepoMetadata], None]],
-                         cache_updater: Callable[[T, Optional[PixivRepoMetadata]], Awaitable[Any]]) \
-        -> AsyncGenerator[Union[T, PixivRepoMetadata], None]:
-    try:
-        async for x in cache_factory():
-            yield x
-    except (NoSuchItemError, CacheExpiredError):
-        content = None
-        metadata = None
+                buffer.clear()
 
-        async for x in remote_factory():
-            if isinstance(x, PixivRepoMetadata):
-                metadata = x
-            else:
-                content = x
+                # check whether we approach limit
+                if loaded_items >= max_item or loaded_pages >= max_page:
+                    return
 
-        try:
-            yield metadata
-            yield content
-        finally:
-            await cache_updater(content, metadata)
+            yield item
+        else:
+            loaded_items += 1
+            buffer.append(item)
 
 
 async def mediate_many(cache_factory: Callable[[], AsyncGenerator[Union[T, PixivRepoMetadata], None]],
                        remote_factory: Callable[..., AsyncGenerator[Union[T, PixivRepoMetadata], None]],
-                       cache_updater: Callable[[List[T], Optional[PixivRepoMetadata]], Awaitable[Any]],
                        cache_appender: Callable[[List[T], Optional[PixivRepoMetadata]], Awaitable[Any]],
                        max_item: int = 2 ** 31,
-                       max_page: int = 2 ** 31) \
-        -> AsyncGenerator[Union[T, PixivRepoMetadata], None]:
+                       max_page: int = 2 ** 31,
+                       force_expiration: bool = False) -> AsyncGenerator[Union[T, PixivRepoMetadata], None]:
     try:
-        async for x in _load_local_and_remote_and_update_local(cache_factory, remote_factory, cache_appender,
-                                                               max_item, max_page):
+        if force_expiration:
+            raise NoSuchItemError()
+
+        async for x in _load_many_from_local_and_remote_and_update(cache_factory, remote_factory, cache_appender,
+                                                                   max_item, max_page):
             yield x
     except (CacheExpiredError, NoSuchItemError):
-        async for x in _load_remote_and_update_local(remote_factory, cache_updater,
-                                                     max_item, max_page):
+        async for x in _load_many_from_remote_and_append(remote_factory, cache_appender,
+                                                         max_item, max_page):
             yield x
 
 
 async def mediate_append(cache_factory: Callable[[], AsyncGenerator[Union[T, PixivRepoMetadata], None]],
                          remote_factory: Callable[..., AsyncGenerator[Union[T, PixivRepoMetadata], None]],
-                         cache_updater: Callable[[List[T], Optional[PixivRepoMetadata]], Awaitable[Any]],
                          cache_appender: Callable[[List[T], Optional[PixivRepoMetadata]], Awaitable[bool]],
                          max_item: int = 2 ** 31,
-                         max_page: int = 2 ** 31) \
-        -> AsyncGenerator[Union[T, PixivRepoMetadata], None]:
+                         max_page: int = 2 ** 31,
+                         force_expiration: bool = False) -> AsyncGenerator[Union[T, PixivRepoMetadata], None]:
     try:
-        async for x in _load_local_and_remote_and_update_local(cache_factory, remote_factory, cache_appender,
-                                                               max_item, max_page):
+        if force_expiration:
+            metadata = None
+            async for x in cache_factory():
+                if isinstance(x, PixivRepoMetadata):
+                    metadata = x
+                    break
+
+            if not metadata:
+                raise NoSuchItemError()
+            else:
+                raise CacheExpiredError(metadata)
+
+        async for x in _load_many_from_local_and_remote_and_update(cache_factory, remote_factory, cache_appender,
+                                                                   max_item, max_page):
             yield x
     except NoSuchItemError:
-        async for x in _load_remote_and_update_local(remote_factory, cache_updater,
-                                                     max_item, max_page):
+        async for x in _load_many_from_remote_and_append(remote_factory, cache_appender,
+                                                         max_item, max_page):
             yield x
     except CacheExpiredError as e:
         loaded_items = 0
@@ -154,28 +164,24 @@ async def mediate_append(cache_factory: Callable[[], AsyncGenerator[Union[T, Pix
 
         async for x in remote_factory():
             if isinstance(x, PixivRepoMetadata):
-                loaded_pages += 1
+                loaded_pages = x.pages
                 # we don't use this metadata
+
+                if len(buffer) > 0:
+                    metadata.update_time = datetime.now(timezone.utc)
+                    if await cache_appender(buffer, metadata):
+                        break
+                    buffer = []
+
+                    # check whether we approach limit
+                    if loaded_items >= max_item or loaded_pages >= max_page:
+                        return
             else:
                 loaded_items += 1
                 buffer.append(x)
 
-            if len(buffer) >= 20:
-                metadata.update_time = datetime.now()
-                if await cache_appender(buffer, metadata):
-                    break
-                buffer = []
-
-            # check whether we approach limit
-            if loaded_items >= max_item or loaded_pages >= max_page:
-                return
-        else:
-            if len(buffer) > 0:
-                metadata.update_time = datetime.now()
-                await cache_appender(buffer, metadata)
-
-        async for x in _load_local_and_remote_and_update_local(cache_factory, remote_factory, cache_appender,
-                                                               max_item, max_page):
+        async for x in _load_many_from_local_and_remote_and_update(cache_factory, remote_factory, cache_appender,
+                                                                   max_item, max_page):
             yield x
 
 
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/remote_repo.py b/src/nonebot_plugin_pixivbot/data/pixiv_repo/remote_repo.py
index 7f82823..49b0ba1 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/remote_repo.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_repo/remote_repo.py
@@ -8,15 +8,15 @@
 from pixivpy_async.error import TokenError
 
 from nonebot_plugin_pixivbot.config import Config
+from nonebot_plugin_pixivbot.data.local_tag_repo import LocalTagRepo
 from nonebot_plugin_pixivbot.enums import DownloadQuantity, RankingMode
-from nonebot_plugin_pixivbot.model import Illust, User
+from nonebot_plugin_pixivbot.model import Illust, User, UserPreview
 from nonebot_plugin_pixivbot.utils.errors import QueryError
+from nonebot_plugin_pixivbot.utils.lifecycler import on_startup, on_shutdown
 from .abstract_repo import AbstractPixivRepo, PixivRepoMetadata
 from .compressor import Compressor
 from .lazy_illust import LazyIllust
 from .pkg_context import context
-from ..local_tag_repo import LocalTagRepo
-from ...utils.lifecycler import on_startup, on_shutdown
 
 T = TypeVar("T")
 
@@ -53,6 +53,7 @@ class RemotePixivRepo(AbstractPixivRepo):
     _local_tags: LocalTagRepo
     _compressor: Compressor
 
+    # noinspection PyTypeChecker
     def __init__(self):
         self._sema: Semaphore = None
         self._pclient: PixivClient = None
@@ -188,6 +189,7 @@ async def load_many_pages(self, papi_search_func: Callable[..., Awaitable[dict]]
             loaded_items += len(page)
             logger.debug(f"[remote] {loaded_pages} pages loaded ({loaded_items} items in total)")
 
+            metadata.pages = loaded_pages
             yield page, metadata
 
             next_qs = metadata.next_qs
@@ -202,7 +204,8 @@ async def _get_illusts(self, papi_search_func: Callable[[], Awaitable[dict]],
                            *, block_tags: Optional[List[str]] = None,
                            min_bookmark: int = 0,
                            min_view: int = 0,
-                           **kwargs) -> AsyncGenerator[Union[LazyIllust, PixivRepoMetadata], None]:
+                           **kwargs) \
+            -> AsyncGenerator[Union[LazyIllust, PixivRepoMetadata], None]:
         """
         加载插画
         :param papi_search_func: PixivPy-Async的加载方法
@@ -244,6 +247,28 @@ def illust_filter(illust: Illust) -> bool:
                     yield LazyIllust(item.id, item)
             yield metadata
 
+    async def _get_user_previews(self, papi_search_func: Callable[[], Awaitable[dict]], **kwargs) \
+            -> AsyncGenerator[Union[PixivRepoMetadata, UserPreview], None]:
+        yield PixivRepoMetadata(pages=0, next_qs=kwargs)
+        async with self._sema:
+            async for page, metadata in self.load_many_pages(papi_search_func, "user_previews",
+                                                             mapper=lambda x: UserPreview.parse_obj(x),
+                                                             **kwargs):
+                for item in page:
+                    yield item
+                yield metadata
+
+    async def _get_users(self, papi_search_func: Callable[[], Awaitable[dict]], **kwargs) \
+            -> AsyncGenerator[Union[PixivRepoMetadata, User], None]:
+        yield PixivRepoMetadata(pages=0, next_qs=kwargs)
+        async with self._sema:
+            async for page, metadata in self.load_many_pages(papi_search_func, "user_previews",
+                                                             mapper=lambda x: User.parse_obj(x["user"]),
+                                                             **kwargs):
+                for item in page:
+                    yield item
+                yield metadata
+
     @auto_retry
     async def _illust_detail(self, illust_id: int, **kwargs) -> Illust:
         logger.debug(f"[remote] illust_detail {illust_id}")
@@ -279,16 +304,29 @@ def search_illust(self, word: str, **kwargs) \
                                  min_view=self._conf.pixiv_random_illust_min_view,
                                  word=word, **kwargs)
 
-    async def search_user(self, word: str, **kwargs) \
+    def search_user(self, word: str, **kwargs) \
             -> AsyncGenerator[Union[User, PixivRepoMetadata], None]:
         logger.debug(f"[remote] search_user {word}")
-        async with self._sema:
-            page, metadata = await self.load_page(self._papi.search_user, "user_previews",
-                                                  mapper=lambda x: User.parse_obj(x["user"]),
-                                                  word=word, **kwargs)
-            for item in page:
-                yield item
-            yield metadata
+        return self._get_users(self._papi.search_user,
+                               word=word, **kwargs)
+
+    def search_user_with_preview(self, word: str, **kwargs) \
+            -> AsyncGenerator[Union[UserPreview, PixivRepoMetadata], None]:
+        logger.debug(f"[remote] search_user {word}")
+        return self._get_user_previews(self._papi.search_user,
+                                       word=word, **kwargs)
+
+    def user_following(self, user_id: int, **kwargs) \
+            -> AsyncGenerator[Union[User, PixivRepoMetadata], None]:
+        logger.debug(f"[remote] following_users {user_id}")
+        return self._get_users(self._papi.user_following,
+                               user_id=user_id, **kwargs)
+
+    def user_following_with_preview(self, user_id: int, **kwargs) \
+            -> AsyncGenerator[Union[UserPreview, PixivRepoMetadata], None]:
+        logger.debug(f"[remote] following_users {user_id}")
+        return self._get_user_previews(self._papi.user_following,
+                                       user_id=user_id, **kwargs)
 
     def user_illusts(self, user_id: int, **kwargs) \
             -> AsyncGenerator[Union[LazyIllust, PixivRepoMetadata], None]:
@@ -311,6 +349,15 @@ def user_bookmarks(self, user_id: int = 0, **kwargs) \
                                  min_view=self._conf.pixiv_random_bookmark_min_view,
                                  user_id=user_id, **kwargs)
 
+    # def following_illusts(self, **kwargs) \
+    #         -> AsyncGenerator[Union[LazyIllust, PixivRepoMetadata], None]:
+    #     logger.debug(f"[remote] following_illusts")
+    #     return self._get_illusts(self._papi.illust_follow,
+    #                              block_tags=self._conf.pixiv_block_tags,
+    #                              min_bookmark=self._conf.pixiv_random_following_illust_min_bookmark,
+    #                              min_view=self._conf.pixiv_random_following_illust_min_view,
+    #                              **kwargs)
+
     def recommended_illusts(self, **kwargs) \
             -> AsyncGenerator[Union[LazyIllust, PixivRepoMetadata], None]:
         logger.debug(f"[remote] recommended_illusts")
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/repo.py b/src/nonebot_plugin_pixivbot/data/pixiv_repo/repo.py
index 3bcdc01..23f5645 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/repo.py
+++ b/src/nonebot_plugin_pixivbot/data/pixiv_repo/repo.py
@@ -1,247 +1,310 @@
 from datetime import datetime, timedelta
 from functools import partial
-from typing import Any, AsyncGenerator, List, Tuple
+from typing import Any, AsyncGenerator, List
 
+from frozendict import frozendict
 from nonebot import logger
+from pydantic import BaseModel
 
 from nonebot_plugin_pixivbot.config import Config
 from nonebot_plugin_pixivbot.enums import RankingMode
-from nonebot_plugin_pixivbot.model import Illust, User
+from nonebot_plugin_pixivbot.model import Illust, User, UserPreview
+from nonebot_plugin_pixivbot.utils.shared_agen import SharedAsyncGeneratorManager
 from . import LazyIllust
 from .abstract_repo import AbstractPixivRepo
 from .abstract_repo import PixivRepoMetadata
-from .enums import *
+from .enums import PixivResType, CacheStrategy
 from .local_repo import LocalPixivRepo
 from .mediator import mediate_single, mediate_many, mediate_append
 from .pkg_context import context
 from .remote_repo import RemotePixivRepo
-from .shared_agen import SharedAsyncGeneratorManager
+
+
+class SharedAgenIdentifier(BaseModel):
+    type: PixivResType
+    kwargs: frozendict[str, Any]
+
+    def __init__(self, type: PixivResType, **kwargs):
+        super().__init__(type=type, kwargs=frozendict(kwargs))
+
+    def __str__(self):
+        return f"({self.type.name} {', '.join(map(lambda k: f'{k}={self.kwargs[k]}', {**self.kwargs}))})"
+
+    class Config:
+        frozen = True
 
 
 @context.inject
 @context.register_singleton()
-class PixivSharedAsyncGeneratorManager(SharedAsyncGeneratorManager[Tuple[int, Any], Any]):
+class PixivSharedAsyncGeneratorManager(SharedAsyncGeneratorManager[SharedAgenIdentifier, Any]):
+    log_tag = "pixiv_shared_agen"
+
     conf: Config
     local: LocalPixivRepo
     remote: RemotePixivRepo
 
-    def calc_expires_time(self, identifier: Tuple[int, Any], update_time: datetime) -> datetime:
-        if identifier[0] == ILLUST_DETAIL:
-            return update_time + timedelta(seconds=self.conf.pixiv_illust_detail_cache_expires_in)
-        elif identifier[0] == USER_DETAIL:
-            return update_time + timedelta(seconds=self.conf.pixiv_user_detail_cache_expires_in)
-        elif identifier[0] == SEARCH_ILLUST:
-            return update_time + timedelta(seconds=self.conf.pixiv_search_illust_cache_expires_in)
-        elif identifier[0] == SEARCH_USER:
-            return update_time + timedelta(seconds=self.conf.pixiv_search_user_cache_expires_in)
-        elif identifier[0] == USER_ILLUSTS:
-            return update_time + timedelta(seconds=self.conf.pixiv_user_illusts_cache_expires_in)
-        elif identifier[0] == USER_BOOKMARKS:
-            return update_time + timedelta(seconds=self.conf.pixiv_user_bookmarks_cache_expires_in)
-        elif identifier[0] == RECOMMENDED_ILLUSTS:
-            return update_time + timedelta(seconds=self.conf.pixiv_other_cache_expires_in)
-        elif identifier[0] == RELATED_ILLUSTS:
-            return update_time + timedelta(seconds=self.conf.pixiv_related_illusts_cache_expires_in)
-        elif identifier[0] == ILLUST_RANKING:
-            return update_time + timedelta(seconds=self.conf.pixiv_illust_ranking_cache_expires_in)
-        elif identifier[0] == IMAGE:
-            return update_time + timedelta(seconds=self.conf.pixiv_download_cache_expires_in)
-        else:
-            raise ValueError("invalid identifier: " + str(identifier))
-
-    async def on_agen_next(self, identifier: Tuple[int, Any], item: Any):
-        if isinstance(item, PixivRepoMetadata) and not self.get_expires_time(identifier):
-            self.set_expires_time(identifier, self.calc_expires_time(identifier, item.update_time))
-
-    def illust_detail_factory(self, illust_id: int) -> AsyncGenerator[Illust, None]:
+    def illust_detail_factory(self, illust_id: int,
+                              cache_strategy: CacheStrategy) -> AsyncGenerator[Illust, None]:
         return mediate_single(
             cache_factory=partial(self.local.illust_detail, illust_id=illust_id),
             remote_factory=partial(self.remote.illust_detail, illust_id=illust_id),
-            cache_updater=lambda data, metadata: self.local.update_illust_detail(data, metadata)
+            cache_updater=lambda data, metadata: self.local.update_illust_detail(data, metadata),
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def user_detail_factory(self, user_id: int) -> AsyncGenerator[User, None]:
+    def user_detail_factory(self, user_id: int,
+                            cache_strategy: CacheStrategy) -> AsyncGenerator[User, None]:
         return mediate_single(
             cache_factory=partial(self.local.user_detail, user_id=user_id),
             remote_factory=partial(self.remote.user_detail, user_id=user_id),
-            cache_updater=lambda data, metadata: self.local.update_user_detail(data, metadata)
+            cache_updater=lambda data, metadata: self.local.update_user_detail(data, metadata),
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def search_illust_factory(self, word: str) -> AsyncGenerator[LazyIllust, None]:
+    def search_illust_factory(self, word: str,
+                              cache_strategy: CacheStrategy) -> AsyncGenerator[LazyIllust, None]:
         return mediate_many(
             cache_factory=partial(self.local.search_illust, word=word),
             remote_factory=partial(self.remote.search_illust, word=word),
-            cache_updater=lambda data, metadata: self.local.update_search_illust(word, data, metadata),
             cache_appender=lambda data, metadata: self.local.append_search_illust(word, data, metadata),
             max_item=self.conf.pixiv_random_illust_max_item,
             max_page=self.conf.pixiv_random_illust_max_page,
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def search_user_factory(self, word: str) -> AsyncGenerator[User, None]:
+    def search_user_factory(self, word: str,
+                            cache_strategy: CacheStrategy) -> AsyncGenerator[User, None]:
         return mediate_many(
             cache_factory=partial(self.local.search_user, word=word),
             remote_factory=partial(self.remote.search_user, word=word),
-            cache_updater=lambda data, metadata: self.local.update_search_user(word, data, metadata),
             cache_appender=lambda data, metadata: self.local.append_search_user(word, data, metadata),
             max_page=1,
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def user_illusts_factory(self, user_id: int) -> AsyncGenerator[LazyIllust, None]:
+    def user_illusts_factory(self, user_id: int,
+                             cache_strategy: CacheStrategy) -> AsyncGenerator[LazyIllust, None]:
         return mediate_append(
             cache_factory=partial(self.local.user_illusts, user_id=user_id),
             remote_factory=partial(self.remote.user_illusts, user_id=user_id),
-            cache_updater=lambda data, metadata: self.local.update_user_illusts(user_id, data, metadata),
             cache_appender=lambda data, metadata: self.local.append_user_illusts(user_id, data, metadata),
             max_item=self.conf.pixiv_random_user_illust_max_item,
             max_page=self.conf.pixiv_random_user_illust_max_page,
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def user_bookmarks_factory(self, user_id: int) -> AsyncGenerator[LazyIllust, None]:
+    def user_bookmarks_factory(self, user_id: int,
+                               cache_strategy: CacheStrategy) -> AsyncGenerator[LazyIllust, None]:
         return mediate_append(
             cache_factory=partial(self.local.user_bookmarks, user_id=user_id),
             remote_factory=partial(self.remote.user_bookmarks, user_id=user_id),
-            cache_updater=lambda data, metadata: self.local.update_user_bookmarks(user_id, data, metadata),
             cache_appender=lambda data, metadata: self.local.append_user_bookmarks(user_id, data, metadata),
             max_item=self.conf.pixiv_random_bookmark_max_item,
             max_page=self.conf.pixiv_random_bookmark_max_page,
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def recommended_illusts_factory(self) -> AsyncGenerator[LazyIllust, None]:
+    def recommended_illusts_factory(self, cache_strategy: CacheStrategy) -> AsyncGenerator[LazyIllust, None]:
         return mediate_many(
             cache_factory=self.local.recommended_illusts,
             remote_factory=self.remote.recommended_illusts,
-            cache_updater=lambda data, metadata: self.local.update_recommended_illusts(data, metadata),
             cache_appender=lambda data, metadata: self.local.append_recommended_illusts(data, metadata),
             max_item=self.conf.pixiv_random_recommended_illust_max_item,
             max_page=self.conf.pixiv_random_recommended_illust_max_page,
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def related_illusts_factory(self, illust_id: int) -> AsyncGenerator[LazyIllust, None]:
+    def related_illusts_factory(self, illust_id: int,
+                                cache_strategy: CacheStrategy) -> AsyncGenerator[LazyIllust, None]:
         return mediate_many(
             cache_factory=partial(self.local.related_illusts, illust_id),
             remote_factory=partial(self.remote.related_illusts, illust_id),
-            cache_updater=lambda data, metadata: self.local.update_related_illusts(illust_id, data, metadata),
             cache_appender=lambda data, metadata: self.local.append_related_illusts(illust_id, data, metadata),
             max_item=self.conf.pixiv_random_related_illust_max_item,
             max_page=self.conf.pixiv_random_related_illust_max_page,
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def illust_ranking_factory(self, mode: RankingMode) -> AsyncGenerator[List[LazyIllust], None]:
+    def illust_ranking_factory(self, mode: RankingMode,
+                               cache_strategy: CacheStrategy) -> AsyncGenerator[List[LazyIllust], None]:
         return mediate_many(
             cache_factory=partial(self.local.illust_ranking, mode),
             remote_factory=partial(self.remote.illust_ranking, mode),
-            cache_updater=lambda data, metadata: self.local.update_illust_ranking(mode, data, metadata),
             cache_appender=lambda data, metadata: self.local.append_illust_ranking(mode, data, metadata),
             max_item=self.conf.pixiv_ranking_fetch_item,
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def image_factory(self, illust: Illust) -> AsyncGenerator[LazyIllust, None]:
+    def image_factory(self, illust_id: int, illust: Illust,
+                      cache_strategy: CacheStrategy) -> AsyncGenerator[LazyIllust, None]:
         return mediate_single(
             cache_factory=partial(self.local.image, illust),
             remote_factory=partial(self.remote.image, illust),
-            cache_updater=lambda data, metadata: self.local.update_image(illust.id, data, metadata)
+            cache_updater=lambda data, metadata: self.local.update_image(illust.id, data, metadata),
+            force_expiration=cache_strategy == CacheStrategy.FORCE_EXPIRATION,
         )
 
-    def agen_factory(self, identifier: Tuple[int, Any], *args, **kwargs) -> AsyncGenerator[Any, None]:
-        if identifier[0] == ILLUST_DETAIL:
-            return self.illust_detail_factory(identifier[1])
-        elif identifier[0] == USER_DETAIL:
-            return self.user_detail_factory(identifier[1])
-        elif identifier[0] == SEARCH_ILLUST:
-            return self.search_illust_factory(identifier[1])
-        elif identifier[0] == SEARCH_USER:
-            return self.search_user_factory(identifier[1])
-        elif identifier[0] == USER_ILLUSTS:
-            return self.user_illusts_factory(identifier[1])
-        elif identifier[0] == USER_BOOKMARKS:
-            return self.user_bookmarks_factory(identifier[1])
-        elif identifier[0] == RECOMMENDED_ILLUSTS:
-            return self.recommended_illusts_factory()
-        elif identifier[0] == RELATED_ILLUSTS:
-            return self.related_illusts_factory(identifier[1])
-        elif identifier[0] == ILLUST_RANKING:
-            return self.illust_ranking_factory(identifier[1])
-        elif identifier[0] == IMAGE:
-            return self.image_factory(kwargs.get("illust", None) or args[0])
+    factories = {
+        PixivResType.ILLUST_DETAIL: illust_detail_factory,
+        PixivResType.USER_DETAIL: user_detail_factory,
+        PixivResType.SEARCH_ILLUST: search_illust_factory,
+        PixivResType.SEARCH_USER: search_user_factory,
+        PixivResType.USER_ILLUSTS: user_illusts_factory,
+        PixivResType.USER_BOOKMARKS: user_bookmarks_factory,
+        PixivResType.RECOMMENDED_ILLUSTS: recommended_illusts_factory,
+        PixivResType.RELATED_ILLUSTS: related_illusts_factory,
+        PixivResType.ILLUST_RANKING: illust_ranking_factory,
+        PixivResType.IMAGE: image_factory,
+    }
+
+    def agen_factory(self, identifier: SharedAgenIdentifier,
+                     cache_strategy: CacheStrategy = CacheStrategy.NORMAL,
+                     **kwargs) -> AsyncGenerator[Any, None]:
+        if identifier.type in self.factories:
+            merged_kwargs = identifier.kwargs | kwargs
+            # noinspection PyTypeChecker
+            return self.factories[identifier.type](self, cache_strategy=cache_strategy, **merged_kwargs)
+        else:
+            raise ValueError("invalid identifier: " + str(identifier))
+
+    expires_in = {
+        PixivResType.ILLUST_DETAIL: timedelta(seconds=context.require(Config).pixiv_illust_detail_cache_expires_in),
+        PixivResType.USER_DETAIL: timedelta(seconds=context.require(Config).pixiv_user_detail_cache_expires_in),
+        PixivResType.SEARCH_ILLUST: timedelta(seconds=context.require(Config).pixiv_search_illust_cache_expires_in),
+        PixivResType.SEARCH_USER: timedelta(seconds=context.require(Config).pixiv_search_user_cache_expires_in),
+        PixivResType.USER_ILLUSTS: timedelta(seconds=context.require(Config).pixiv_user_illusts_cache_expires_in),
+        PixivResType.USER_BOOKMARKS: timedelta(seconds=context.require(Config).pixiv_user_bookmarks_cache_expires_in),
+        PixivResType.RECOMMENDED_ILLUSTS: timedelta(seconds=context.require(Config).pixiv_other_cache_expires_in),
+        PixivResType.RELATED_ILLUSTS: timedelta(seconds=context.require(Config).pixiv_related_illusts_cache_expires_in),
+        PixivResType.ILLUST_RANKING: timedelta(seconds=context.require(Config).pixiv_illust_ranking_cache_expires_in),
+        PixivResType.IMAGE: timedelta(seconds=context.require(Config).pixiv_download_cache_expires_in),
+    }
+
+    def calc_expires_time(self, identifier: SharedAgenIdentifier, update_time: datetime) -> datetime:
+        if identifier.type in self.factories:
+            return update_time + self.expires_in[identifier.type]
         else:
             raise ValueError("invalid identifier: " + str(identifier))
 
+    async def on_agen_next(self, identifier: SharedAgenIdentifier, item: Any):
+        if isinstance(item, PixivRepoMetadata) and not self.get_expires_time(identifier):
+            expires_time = self.calc_expires_time(identifier, item.update_time)
+            self.set_expires_time(identifier, expires_time)
+
 
 @context.inject
 @context.root.register_singleton()
 class PixivRepo(AbstractPixivRepo):
     _shared_agen_mgr: PixivSharedAsyncGeneratorManager
     _local: LocalPixivRepo
+    _remote: RemotePixivRepo
 
     async def invalidate_cache(self):
+        self._shared_agen_mgr.invalidate_all()
         await self._local.invalidate_cache()
 
-    async def illust_detail(self, illust_id: int) -> AsyncGenerator[Illust, None]:
-        logger.info(f"[repo] illust_detail {illust_id}")
-        with self._shared_agen_mgr.get((ILLUST_DETAIL, illust_id)) as gen:
+    async def illust_detail(self, illust_id: int,
+                            cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[Illust, None]:
+        logger.info(f"[repo] illust_detail {illust_id} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.ILLUST_DETAIL, illust_id=illust_id),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def user_detail(self, user_id: int) -> AsyncGenerator[User, None]:
-        logger.info(f"[repo] user_detail {user_id}")
-        with self._shared_agen_mgr.get((USER_DETAIL, user_id)) as gen:
+    async def user_detail(self, user_id: int,
+                          cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[User, None]:
+        logger.info(f"[repo] user_detail {user_id} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.USER_DETAIL, user_id=user_id),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def search_illust(self, word: str) -> AsyncGenerator[LazyIllust, None]:
-        logger.info(f"[repo] search_illust {word}")
-        with self._shared_agen_mgr.get((SEARCH_ILLUST, word)) as gen:
+    async def search_illust(self, word: str,
+                            cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[LazyIllust, None]:
+        logger.info(f"[repo] search_illust {word} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.SEARCH_ILLUST, word=word),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def search_user(self, word: str) -> AsyncGenerator[User, None]:
-        logger.info(f"[repo] search_user {word}")
-        with self._shared_agen_mgr.get((SEARCH_USER, word)) as gen:
+    async def search_user(self, word: str,
+                          cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[User, None]:
+        logger.info(f"[repo] search_user {word} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.SEARCH_USER, word=word),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def user_illusts(self, user_id: int = 0) -> AsyncGenerator[LazyIllust, None]:
-        logger.info(f"[repo] user_illusts {user_id}")
-        with self._shared_agen_mgr.get((USER_ILLUSTS, user_id)) as gen:
+    async def user_following_with_preview(self, user_id: int, **kwargs) \
+            -> AsyncGenerator[UserPreview, None]:
+        async for x in self._remote.user_following_with_preview(user_id, **kwargs):
+            if not isinstance(x, PixivRepoMetadata):
+                yield x
+
+    async def user_illusts(self, user_id: int = 0,
+                           cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[LazyIllust, None]:
+        logger.info(f"[repo] user_illusts {user_id} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.USER_ILLUSTS, user_id=user_id),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def user_bookmarks(self, user_id: int = 0) -> AsyncGenerator[LazyIllust, None]:
-        logger.info(f"[repo] user_bookmarks {user_id}")
-        with self._shared_agen_mgr.get((USER_BOOKMARKS, user_id)) as gen:
+    async def user_bookmarks(self, user_id: int = 0,
+                             cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[LazyIllust, None]:
+        logger.info(f"[repo] user_bookmarks {user_id} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.USER_BOOKMARKS, user_id=user_id),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def recommended_illusts(self) -> AsyncGenerator[LazyIllust, None]:
-        logger.info(f"[repo] recommended_illusts")
-        with self._shared_agen_mgr.get((RECOMMENDED_ILLUSTS, None)) as gen:
+    async def recommended_illusts(self, cache_strategy: CacheStrategy = CacheStrategy.NORMAL) \
+            -> AsyncGenerator[LazyIllust, None]:
+        logger.info(f"[repo] recommended_illusts "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.RECOMMENDED_ILLUSTS),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def related_illusts(self, illust_id: int) -> AsyncGenerator[LazyIllust, None]:
-        logger.info(f"[repo] related_illusts {illust_id}")
-        with self._shared_agen_mgr.get((RELATED_ILLUSTS, illust_id)) as gen:
+    async def related_illusts(self, illust_id: int,
+                              cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[LazyIllust, None]:
+        logger.info(f"[repo] related_illusts {illust_id} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.RELATED_ILLUSTS, illust_id=illust_id),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def illust_ranking(self, mode: RankingMode) -> AsyncGenerator[LazyIllust, None]:
-        logger.info(f"[repo] illust_ranking {mode}")
-        with self._shared_agen_mgr.get((ILLUST_RANKING, mode)) as gen:
+    async def illust_ranking(self, mode: RankingMode,
+                             cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[LazyIllust, None]:
+        logger.info(f"[repo] illust_ranking {mode} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.ILLUST_RANKING, mode=mode),
+                                       cache_strategy) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
 
-    async def image(self, illust: Illust) -> AsyncGenerator[bytes, None]:
-        logger.info(f"[repo] image {illust.id}")
-        with self._shared_agen_mgr.get((IMAGE, illust.id), illust) as gen:
+    async def image(self, illust: Illust,
+                    cache_strategy: CacheStrategy = CacheStrategy.NORMAL) -> AsyncGenerator[bytes, None]:
+        logger.info(f"[repo] image {illust.id} "
+                    f"cache_strategy={cache_strategy.name}")
+        with self._shared_agen_mgr.get(SharedAgenIdentifier(PixivResType.IMAGE, illust_id=illust.id),
+                                       cache_strategy, illust=illust) as gen:
             async for x in gen:
                 if not isinstance(x, PixivRepoMetadata):
                     yield x
diff --git a/src/nonebot_plugin_pixivbot/data/source/mongo/migration/__init__.py b/src/nonebot_plugin_pixivbot/data/source/mongo/migration/__init__.py
index 7d57e7a..6c9015d 100644
--- a/src/nonebot_plugin_pixivbot/data/source/mongo/migration/__init__.py
+++ b/src/nonebot_plugin_pixivbot/data/source/mongo/migration/__init__.py
@@ -1,5 +1,6 @@
-from nonebot_plugin_pixivbot.data.source.mongo.migration.mongo_migration_manager import MongoMigrationManager
-from .mongo_v1_to_v2 import *
-from .mongo_v2_to_v3 import *
+from . import mongo_v1_to_v2
+from . import mongo_v2_to_v3
+from . import mongo_v3_to_v4
+from .mongo_migration_manager import MongoMigrationManager
 
 __all__ = ("MongoMigrationManager",)
diff --git a/src/nonebot_plugin_pixivbot/data/source/mongo/migration/mongo_v1_to_v2.py b/src/nonebot_plugin_pixivbot/data/source/mongo/migration/mongo_v1_to_v2.py
index d45afb4..cb24007 100644
--- a/src/nonebot_plugin_pixivbot/data/source/mongo/migration/mongo_v1_to_v2.py
+++ b/src/nonebot_plugin_pixivbot/data/source/mongo/migration/mongo_v1_to_v2.py
@@ -32,22 +32,22 @@ async def migrate_pixiv_binding(self, db: AsyncIOMotorDatabase):
 
     async def migrate_subscription(self, db: AsyncIOMotorDatabase):
         try:
-            await db["pixiv_binding"].drop_index("user_id_1")
+            await db["subscription"].drop_index("user_id_1")
         except:
             pass
 
         try:
-            await db["pixiv_binding"].drop_index("group_id_1")
+            await db["subscription"].drop_index("group_id_1")
         except:
             pass
 
         try:
-            await db["pixiv_binding"].drop_index("type_1_user_id_1")
+            await db["subscription"].drop_index("type_1_user_id_1")
         except:
             pass
 
         try:
-            await db["pixiv_binding"].drop_index("type_1_group_id_1")
+            await db["subscription"].drop_index("type_1_group_id_1")
         except:
             pass
 
diff --git a/src/nonebot_plugin_pixivbot/data/source/mongo/migration/mongo_v3_to_v4.py b/src/nonebot_plugin_pixivbot/data/source/mongo/migration/mongo_v3_to_v4.py
new file mode 100644
index 0000000..6419def
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/data/source/mongo/migration/mongo_v3_to_v4.py
@@ -0,0 +1,47 @@
+from motor.motor_asyncio import AsyncIOMotorDatabase
+
+from nonebot_plugin_pixivbot import context
+from .mongo_migration import MongoMigration
+from .mongo_migration_manager import MongoMigrationManager
+
+
+@context.require(MongoMigrationManager).register
+class MongoV3ToV4(MongoMigration):
+    from_db_version = 3
+    to_db_version = 4
+
+    async def migrate(self, db: AsyncIOMotorDatabase):
+        await self.migrate_subscription(db)
+
+    async def migrate_subscription(self, db: AsyncIOMotorDatabase):
+        try:
+            await db["subscription"].drop_index("adapter_1_group_id_1_type_1")
+        except:
+            pass
+
+        try:
+            await db["subscription"].drop_index("adapter_1_user_id_1_type_1")
+        except:
+            pass
+
+        await db["subscription"].update_many(
+            {},
+            [
+                {
+                    "$set": {
+                        "subscriber": {
+                            "adapter": "$$ROOT.adapter",
+                            "user_id": "$$ROOT.user_id",
+                            "group_id": "$$ROOT.group_id",
+                        }
+                    }
+                },
+                {
+                    "$unset": [
+                        "adapter",
+                        "user_id",
+                        "group_id"
+                    ]
+                }
+            ]
+        )
diff --git a/src/nonebot_plugin_pixivbot/data/source/mongo/mongo.py b/src/nonebot_plugin_pixivbot/data/source/mongo/mongo.py
index 64fb3be..689392e 100644
--- a/src/nonebot_plugin_pixivbot/data/source/mongo/mongo.py
+++ b/src/nonebot_plugin_pixivbot/data/source/mongo/mongo.py
@@ -1,3 +1,4 @@
+from bson import CodecOptions
 from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
 from nonebot import logger
 from pymongo.errors import OperationFailure
@@ -14,7 +15,7 @@
 class MongoDataSource:
     conf: Config
     mongo_migration_mgr: MongoMigrationManager
-    app_db_version = 3
+    app_db_version = 4
 
     def __init__(self):
         self._client = None
@@ -76,7 +77,8 @@ async def _ensure_ttl_index(db: AsyncIOMotorDatabase, coll_name: str, expires_in
 
     async def initialize(self):
         client = AsyncIOMotorClient(self.conf.pixiv_mongo_conn_url)
-        db = client[self.conf.pixiv_mongo_database_name]
+        options = CodecOptions(tz_aware=True)
+        db = client[self.conf.pixiv_mongo_database_name].with_options(options)
 
         # migrate
         db_version = await self._get_db_version(db)
@@ -88,8 +90,13 @@ async def initialize(self):
 
         await self._ensure_index(db, 'pixiv_binding', [("adapter", 1), ("user_id", 1)], unique=True)
 
-        await self._ensure_index(db, 'subscription', [("adapter", 1), ("user_id", 1), ("type", 1)])
-        await self._ensure_index(db, 'subscription', [("adapter", 1), ("group_id", 1), ("type", 1)])
+        await self._ensure_index(db, 'subscription', [("subscriber.adapter", 1)])
+        await self._ensure_index(db, 'subscription', [("subscriber", 1),
+                                                      ("type", 1)], unique=True)
+
+        await self._ensure_index(db, 'watch_task', [("subscriber.adapter", 1)])
+        await self._ensure_index(db, 'watch_task', [("subscriber.adapter", 1),
+                                                    ("type", 1)])
 
         await self._ensure_index(db, 'local_tags', [("name", 1)], unique=True)
         await self._ensure_index(db, 'local_tags', [("translated_name", 1)])
diff --git a/src/nonebot_plugin_pixivbot/data/subscription_repo.py b/src/nonebot_plugin_pixivbot/data/subscription_repo.py
index 34d180f..3f2a723 100644
--- a/src/nonebot_plugin_pixivbot/data/subscription_repo.py
+++ b/src/nonebot_plugin_pixivbot/data/subscription_repo.py
@@ -1,11 +1,11 @@
-from typing import TypeVar, AsyncGenerator
+from typing import TypeVar, AsyncGenerator, Optional
 
 from pymongo import ReturnDocument
 
 from nonebot_plugin_pixivbot.global_context import context
-from nonebot_plugin_pixivbot.model import Subscription
-from nonebot_plugin_pixivbot.model.identifier import PostIdentifier
+from nonebot_plugin_pixivbot.model import Subscription, PostIdentifier, ScheduleType
 from .source import MongoDataSource
+from .utils.process_subscriber import process_subscriber
 
 UID = TypeVar("UID")
 GID = TypeVar("GID")
@@ -18,48 +18,52 @@
 class SubscriptionRepo:
     mongo: MongoDataSource
 
-    async def get(self, identifier: ID) -> AsyncGenerator[Subscription, None]:
-        if identifier.group_id:
-            query = {"adapter": identifier.adapter, "group_id": identifier.group_id}
-        elif identifier.user_id:
-            query = {"adapter": identifier.adapter, "user_id": identifier.user_id}
-        else:
-            raise ValueError("at least one of user_id and group_id should be not None")
-
+    async def get_by_subscriber(self, subscriber: ID) -> AsyncGenerator[Subscription, None]:
+        query = {
+            "subscriber": process_subscriber(subscriber).dict()
+        }
         async for obj in self.mongo.db.subscription.find(query):
             yield Subscription.parse_obj(obj)
 
-    async def get_all(self, adapter: str) -> AsyncGenerator[Subscription, None]:
-        query = {"adapter": adapter}
-
+    async def get_by_adapter(self, adapter: str) -> AsyncGenerator[Subscription, None]:
+        query = {
+            "subscriber.adapter": adapter
+        }
         async for obj in self.mongo.db.subscription.find(query):
             yield Subscription.parse_obj(obj)
 
-    async def update(self, subscription: Subscription) -> Subscription:
-        if subscription.group_id:
-            query = {"adapter": subscription.adapter, "group_id": subscription.group_id, "type": subscription.type}
-        elif subscription.user_id:
-            query = {"adapter": subscription.adapter, "user_id": subscription.user_id, "type": subscription.type}
-        else:
-            raise ValueError("at least one of user_id and group_id should be not None")
+    async def update(self, subscription: Subscription) -> Optional[Subscription]:
+        subscription.subscriber = process_subscriber(subscription.subscriber)
 
-        return await self.mongo.db.subscription.find_one_and_replace(query, subscription.dict(),
-                                                                     return_document=ReturnDocument.BEFORE,
-                                                                     upsert=True)
+        query = {
+            "type": subscription.type.value,
+            "subscriber": subscription.subscriber.dict()
+        }
 
-    async def delete(self, identifier: ID, type: str):
-        if identifier.group_id:
-            query = {"adapter": identifier.adapter, "group_id": identifier.group_id, "type": type}
-        elif identifier.user_id:
-            query = {"adapter": identifier.adapter, "user_id": identifier.user_id, "type": type}
-        else:
-            raise ValueError("at least one of user_id and group_id should be not None")
+        sub_dict = subscription.dict()
+        sub_dict["type"] = subscription.type.value
 
-        if type == 'all':
-            del query["type"]
-            await self.mongo.db.subscription.delete_many(query)
+        old_doc = await self.mongo.db.subscription.find_one_and_replace(query, sub_dict,
+                                                                        return_document=ReturnDocument.BEFORE,
+                                                                        upsert=True)
+        if old_doc:
+            return Subscription.parse_obj(old_doc)
         else:
-            await self.mongo.db.subscription.delete_one(query)
+            return None
+
+    async def delete_one(self, subscriber: ID, type: ScheduleType) -> bool:
+        query = {
+            "type": type.value,
+            "subscriber": process_subscriber(subscriber).dict()
+        }
+        cnt = await self.mongo.db.subscription.delete_one(query)
+        return cnt == 1
+
+    async def delete_many_by_subscriber(self, subscriber: ID):
+        query = {
+            "subscriber": process_subscriber(subscriber)
+        }
+        await self.mongo.db.subscription.delete_many(query)
 
 
 __all__ = ("SubscriptionRepo",)
diff --git a/src/nonebot_plugin_pixivbot/data/utils/__init__.py b/src/nonebot_plugin_pixivbot/data/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/nonebot_plugin_pixivbot/data/utils/process_subscriber.py b/src/nonebot_plugin_pixivbot/data/utils/process_subscriber.py
new file mode 100644
index 0000000..62c6b7d
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/data/utils/process_subscriber.py
@@ -0,0 +1,17 @@
+from typing import TypeVar
+
+from nonebot_plugin_pixivbot.model import PostIdentifier
+
+UID = TypeVar("UID")
+GID = TypeVar("GID")
+
+ID = PostIdentifier[UID, GID]
+
+
+def process_subscriber(subscriber: ID) -> PostIdentifier[UID, GID]:
+    if subscriber.group_id:
+        return PostIdentifier(subscriber.adapter, None, subscriber.group_id)
+    elif subscriber.user_id:
+        return PostIdentifier(subscriber.adapter, subscriber.user_id, None)
+    else:
+        raise ValueError("at least one of user_id and group_id should be not None")
diff --git a/src/nonebot_plugin_pixivbot/data/watch_task_repo.py b/src/nonebot_plugin_pixivbot/data/watch_task_repo.py
new file mode 100644
index 0000000..d9c906d
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/data/watch_task_repo.py
@@ -0,0 +1,85 @@
+from typing import TypeVar, AsyncGenerator, Optional, Any, Dict
+
+from pymongo import ReturnDocument
+
+from nonebot_plugin_pixivbot.global_context import context
+from nonebot_plugin_pixivbot.model import WatchTask, WatchType, PostIdentifier
+from .source import MongoDataSource
+from .utils.process_subscriber import process_subscriber
+
+UID = TypeVar("UID")
+GID = TypeVar("GID")
+
+ID = PostIdentifier[UID, GID]
+
+
+@context.inject
+@context.register_singleton()
+class WatchTaskRepo:
+    mongo: MongoDataSource
+
+    # async def get(self, type: WatchType,
+    #               args: Dict[str, Any],
+    #               subscriber: ID):
+    #     query = {
+    #         "type": type.value,
+    #         "args": args,
+    #         "subscriber": subscriber.dict(),
+    #     }
+    #     return WatchTask.parse_obj(
+    #         await self.mongo.db.watch_task.find_one(query)
+    #     )
+
+    async def get_by_subscriber(self, subscriber: ID) -> AsyncGenerator[WatchTask, None]:
+        query = {
+            "subscriber": process_subscriber(subscriber).dict()
+        }
+        async for obj in self.mongo.db.watch_task.find(query):
+            yield WatchTask.parse_obj(obj)
+
+    async def get_by_adapter(self, adapter: str) -> AsyncGenerator[WatchTask, None]:
+        query = {
+            "subscriber.adapter": adapter
+        }
+        async for obj in self.mongo.db.watch_task.find(query):
+            yield WatchTask.parse_obj(obj)
+
+    async def update(self, task: WatchTask) -> Optional[WatchTask]:
+        task.subscriber = process_subscriber(task.subscriber)
+
+        query = {
+            "type": task.type.value,
+            "kwargs": task.kwargs,
+            "subscriber": task.subscriber.dict(),
+        }
+
+        task_dict = task.dict()
+        task_dict["type"] = task.type.value
+
+        old_doc = await self.mongo.db.watch_task.find_one_and_replace(query, task_dict,
+                                                                      return_document=ReturnDocument.BEFORE,
+                                                                      upsert=True)
+        if old_doc:
+            return WatchTask.parse_obj(old_doc)
+        else:
+            return None
+
+    async def delete_one(self, type: WatchType,
+                         kwargs: Dict[str, Any],
+                         subscriber: PostIdentifier[UID, GID]) -> bool:
+        query = {
+            "type": type.value,
+            "kwargs": kwargs,
+            "subscriber": process_subscriber(subscriber).dict(),
+        }
+        cnt = await self.mongo.db.watch_task.delete_one(query)
+        return cnt != 0
+
+    async def delete_many_by_subscriber(self, subscriber: ID):
+        query = {
+            "subscriber": process_subscriber(subscriber)
+        }
+        await self.mongo.db.watch_task.delete_many(query)
+
+
+__all__ = ("WatchTaskRepo",)
diff --git a/src/nonebot_plugin_pixivbot/enums.py b/src/nonebot_plugin_pixivbot/enums.py
index 4c4e0eb..1df2e08 100644
--- a/src/nonebot_plugin_pixivbot/enums.py
+++ b/src/nonebot_plugin_pixivbot/enums.py
@@ -1,6 +1,6 @@
 from enum import Enum
 
-__all__ = ("BlockAction", "DownloadQuantity", "RandomIllustMethod", "RankingMode")
+__all__ = ("BlockAction", "DownloadQuantity", "RandomIllustMethod", "RankingMode",)
 
 
 class BlockAction(Enum):
diff --git a/src/nonebot_plugin_pixivbot/handler/command/__init__.py b/src/nonebot_plugin_pixivbot/handler/command/__init__.py
index a73ae07..bc2a0d4 100644
--- a/src/nonebot_plugin_pixivbot/handler/command/__init__.py
+++ b/src/nonebot_plugin_pixivbot/handler/command/__init__.py
@@ -3,5 +3,6 @@
 from .help import HelpHandler
 from .invalidate_cache import InvalidateCacheHandler
 from .schedule import ScheduleHandler, UnscheduleHandler
+from .watch import WatchHandler, UnwatchHandler
 
 __all__ = ("CommandHandler",)
diff --git a/src/nonebot_plugin_pixivbot/handler/command/bind.py b/src/nonebot_plugin_pixivbot/handler/command/bind.py
index 22ccd13..0ab11db 100644
--- a/src/nonebot_plugin_pixivbot/handler/command/bind.py
+++ b/src/nonebot_plugin_pixivbot/handler/command/bind.py
@@ -22,29 +22,38 @@ def type(cls) -> str:
     def enabled(self) -> bool:
         return True
 
-    def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
+    async def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
         if len(args) < 1:
             raise BadRequestError()
         else:
-            pixiv_user_id = int(args[0])
+            try:
+                pixiv_user_id = int(args[0])
+            except ValueError as e:
+                raise BadRequestError("请输入正确格式的Pixiv账号") from e
+
             return {"pixiv_user_id": pixiv_user_id}
 
+    # noinspection PyMethodOverriding
     async def actual_handle(self, *, pixiv_user_id: int,
                             post_dest: PostDestination[UID, GID],
                             silently: bool = False):
         await self.binder.bind(post_dest.adapter, post_dest.user_id, pixiv_user_id)
         await self.post_plain_text(message="Pixiv账号绑定成功", post_dest=post_dest)
 
-    async def actual_handle_bad_request(self, *, post_dest: PostDestination[UID, GID],
-                                        silently: bool = False,
-                                        err: BadRequestError):
-        pixiv_user_id = await self.binder.get_binding(post_dest.adapter, post_dest.user_id)
-        if pixiv_user_id is not None:
-            msg = f"当前绑定账号：{pixiv_user_id}\n"
-        else:
-            msg = "当前未绑定Pixiv账号\n"
-        msg += "命令格式：/pixivbot bind <pixiv_user_id>"
-        await self.post_plain_text(message=msg, post_dest=post_dest)
+    async def actual_handle_bad_request(self, err: BadRequestError,
+                                        *, post_dest: PostDestination[UID, GID],
+                                        silently: bool = False):
+        if not silently:
+            if err.message:
+                await self.post_plain_text(message=err.message, post_dest=post_dest)
+            else:
+                pixiv_user_id = await self.binder.get_binding(post_dest.adapter, post_dest.user_id)
+                if pixiv_user_id is not None:
+                    msg = f"当前绑定账号：{pixiv_user_id}\n"
+                else:
+                    msg = "当前未绑定Pixiv账号\n"
+                msg += "命令格式：/pixivbot bind <pixiv_user_id>"
+                await self.post_plain_text(message=msg, post_dest=post_dest)
 
 
 @context.inject
@@ -62,12 +71,11 @@ def enabled(self) -> bool:
     def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
         return {}
 
+    # noinspection PyMethodOverriding
     async def actual_handle(self, *, post_dest: PostDestination[UID, GID],
                             silently: bool = False):
-        await self.binder.unbind(post_dest.adapter, post_dest.user_id)
-        await self.post_plain_text(message="Pixiv账号解绑成功", post_dest=post_dest)
-
-    async def actual_handle_bad_request(self, *, post_dest: PostDestination[UID, GID],
-                                        silently: bool = False,
-                                        err: BadRequestError):
-        await self.post_plain_text(message="当前未绑定Pixiv账号", post_dest=post_dest)
+        result = await self.binder.unbind(post_dest.adapter, post_dest.user_id)
+        if result:
+            await self.post_plain_text(message="Pixiv账号解绑成功", post_dest=post_dest)
+        else:
+            await self.post_plain_text(message="当前未绑定Pixiv账号", post_dest=post_dest)
diff --git a/src/nonebot_plugin_pixivbot/handler/command/command.py b/src/nonebot_plugin_pixivbot/handler/command/command.py
index 015d1eb..3640368 100644
--- a/src/nonebot_plugin_pixivbot/handler/command/command.py
+++ b/src/nonebot_plugin_pixivbot/handler/command/command.py
@@ -1,6 +1,6 @@
 from abc import ABC
 from abc import abstractmethod
-from typing import Type, Callable, Union, Awaitable, TypeVar, Sequence, Any
+from typing import Type, Callable, Union, Awaitable, TypeVar, Sequence
 
 from nonebot import logger
 
@@ -19,20 +19,29 @@ class SubCommandHandler(Handler, ABC):
     def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> Union[dict, Awaitable[dict]]:
         raise NotImplementedError()
 
-    async def handle_bad_request(self, *, post_dest: PostDestination[UID, GID],
-                                 silently: bool = False,
-                                 err: BadRequestError):
+    async def handle(self, *args,
+                     post_dest: PostDestination[UID, GID],
+                     silently: bool = False,
+                     disabled_interceptors: bool = False,
+                     **kwargs):
+        try:
+            await super().handle(*args, post_dest=post_dest, silently=silently,
+                                 disabled_interceptors=disabled_interceptors, **kwargs)
+        except BadRequestError as e:
+            await self.handle_bad_request(err=e, post_dest=post_dest, silently=silently)
+
+    async def handle_bad_request(self, err: BadRequestError, *,
+                                 post_dest: PostDestination[UID, GID],
+                                 silently: bool = False):
         if self.interceptor is not None:
-            await self.interceptor.intercept(self.actual_handle_bad_request,
-                                             post_dest=post_dest, silently=silently,
-                                             err=err)
+            await self.interceptor.intercept(self.actual_handle_bad_request, err,
+                                             post_dest=post_dest, silently=silently)
         else:
-            await self.actual_handle_bad_request(post_dest=post_dest, silently=silently,
-                                                 err=err)
+            await self.actual_handle_bad_request(err, post_dest=post_dest, silently=silently)
 
-    async def actual_handle_bad_request(self, *, post_dest: PostDestination[UID, GID],
-                                        silently: bool = False,
-                                        err: BadRequestError):
+    async def actual_handle_bad_request(self, err: BadRequestError, *,
+                                        post_dest: PostDestination[UID, GID],
+                                        silently: bool = False):
         if not silently:
             await self.post_plain_text(err.message, post_dest=post_dest)
 
@@ -64,6 +73,7 @@ def decorator(cls: Type[SubCommandHandler]):
     def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
         return {"args": args[0]}
 
+    # noinspection PyMethodOverriding
     async def actual_handle(self, *, args: Sequence[str],
                             post_dest: PostDestination[UID, GID],
                             silently: bool = False):
@@ -77,7 +87,4 @@ async def actual_handle(self, *, args: Sequence[str],
         else:
             handler = context.require(self.handlers[args[0]])
 
-        try:
-            await handler.handle(*args[1:], post_dest=post_dest)
-        except BadRequestError as e:
-            await handler.handle_bad_request(err=e, post_dest=post_dest, silently=silently)
+        await handler.handle(*args[1:], post_dest=post_dest)
diff --git a/src/nonebot_plugin_pixivbot/handler/command/help.py b/src/nonebot_plugin_pixivbot/handler/command/help.py
index fd38aa7..9c5e624 100644
--- a/src/nonebot_plugin_pixivbot/handler/command/help.py
+++ b/src/nonebot_plugin_pixivbot/handler/command/help.py
@@ -1,4 +1,4 @@
-from typing import Union, Awaitable, TypeVar, Sequence, Any
+from typing import Union, Awaitable, TypeVar, Sequence
 
 from nonebot_plugin_pixivbot.global_context import context
 from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
diff --git a/src/nonebot_plugin_pixivbot/handler/command/invalidate_cache.py b/src/nonebot_plugin_pixivbot/handler/command/invalidate_cache.py
index 58bbbd4..bd6efe1 100644
--- a/src/nonebot_plugin_pixivbot/handler/command/invalidate_cache.py
+++ b/src/nonebot_plugin_pixivbot/handler/command/invalidate_cache.py
@@ -1,4 +1,4 @@
-from typing import TypeVar, Sequence, Any
+from typing import TypeVar, Sequence
 
 from nonebot_plugin_pixivbot.data.pixiv_repo import PixivRepo
 from nonebot_plugin_pixivbot.global_context import context
@@ -29,6 +29,7 @@ def enabled(self) -> bool:
     def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
         return {}
 
+    # noinspection PyMethodOverriding
     async def actual_handle(self, *, post_dest: PostDestination[UID, GID],
                             silently: bool = False):
         await self.repo.invalidate_cache()
diff --git a/src/nonebot_plugin_pixivbot/handler/command/schedule.py b/src/nonebot_plugin_pixivbot/handler/command/schedule.py
index c25879c..d98a19a 100644
--- a/src/nonebot_plugin_pixivbot/handler/command/schedule.py
+++ b/src/nonebot_plugin_pixivbot/handler/command/schedule.py
@@ -3,7 +3,7 @@
 from nonebot_plugin_pixivbot.global_context import context
 from nonebot_plugin_pixivbot.handler.interceptor.permission_interceptor import GroupAdminInterceptor, \
     AnyPermissionInterceptor, SuperuserInterceptor
-from nonebot_plugin_pixivbot.model import PostIdentifier
+from nonebot_plugin_pixivbot.model import PostIdentifier, ScheduleType
 from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
 from nonebot_plugin_pixivbot.service.scheduler import Scheduler
 from nonebot_plugin_pixivbot.utils.errors import BadRequestError
@@ -13,6 +13,20 @@
 GID = TypeVar("GID")
 
 
+async def build_subscriptions_msg(subscriber: PostIdentifier[UID, GID]):
+    scheduler = context.require(Scheduler)
+    subscription = await scheduler.get_by_subscriber(subscriber)
+    msg = "当前订阅：\n"
+    if len(subscription) > 0:
+        for x in subscription:
+            msg += f'{x.type.name} ' \
+                   f'{str(x.schedule[0]).zfill(2)}:{str(x.schedule[1]).zfill(2)}' \
+                   f'+{str(x.schedule[2]).zfill(2)}:{str(x.schedule[3]).zfill(2)}*x\n'
+    else:
+        msg += '无\n'
+    return msg
+
+
 @context.inject
 @context.require(CommandHandler).sub_command("schedule")
 class ScheduleHandler(SubCommandHandler):
@@ -33,11 +47,17 @@ def enabled(self) -> bool:
         return True
 
     def parse_args(self, args: Sequence[str], post_dest: PostIdentifier[UID, GID]) -> dict:
-        return {"type": args[0],
-                "schedule": args[1],
-                "args": args[2:]}
-
-    async def actual_handle(self, *, type: str,
+        if len(args) < 2:
+            raise BadRequestError()
+        try:
+            return {"type": ScheduleType(args[0]),
+                    "schedule": args[1],
+                    "args": args[2:]}
+        except ValueError as e:
+            raise BadRequestError(f"未知订阅类型：{args[0]}") from e
+
+    # noinspection PyMethodOverriding
+    async def actual_handle(self, *, type: ScheduleType,
                             schedule: str,
                             args: List,
                             post_dest: PostDestination[UID, GID],
@@ -45,19 +65,23 @@ async def actual_handle(self, *, type: str,
         await self.scheduler.schedule(type, schedule, args, post_dest=post_dest)
         await self.post_plain_text(message="订阅成功", post_dest=post_dest)
 
-    async def actual_handle_bad_request(self, *, post_dest: PostDestination[UID, GID],
-                                        silently: bool = False,
-                                        err: BadRequestError):
-        subscription = await self.scheduler.all_subscription(post_dest.identifier)
-        msg = "当前订阅：\n"
-        if len(subscription) > 0:
-            for x in subscription:
-                msg += f'{x.type} {str(x.schedule[0]).zfill(2)}:{str(x.schedule[1]).zfill(2)}+{str(x.schedule[2]).zfill(2)}:{str(x.schedule[3]).zfill(2)}*x\n'
-        else:
-            msg += '无\n'
-        msg += "\n"
-        msg += "命令格式：/pixivbot schedule <type> <schedule> <..args>\n"
-        msg += "示例：/pixivbot schedule ranking 06:00*x day 1-5\n"
+    async def actual_handle_bad_request(self, err: BadRequestError,
+                                        *, post_dest: PostDestination[UID, GID],
+                                        silently: bool = False):
+        msg = ""
+        if err.message:
+            msg += err.message
+            msg += '\n'
+
+        msg += await build_subscriptions_msg(post_dest.identifier)
+        msg += "\n" \
+               "命令格式：/pixivbot schedule <type> <schedule> [..args]\n" \
+               "参数：\n" \
+               "  <type>：可选值有random_bookmark, random_recommended_illust, random_illust, " \
+               "random_user_illust, ranking\n" \
+               "  <schedule>：格式为HH:mm（每日固定时间点推送）或HH:mm*x（间隔时间推送）\n" \
+               "  [...args]：根据<type>不同需要提供不同的参数\n" \
+               "示例：/pixivbot schedule ranking 06:00*x day 1-5"
         await self.post_plain_text(message=msg, post_dest=post_dest)
 
 
@@ -81,24 +105,31 @@ def enabled(self) -> bool:
         return True
 
     def parse_args(self, args: Sequence[str], post_dest: PostIdentifier[UID, GID]) -> dict:
-        return {"type": args[0]}
-
-    async def actual_handle(self, *, type: str,
+        if len(args) == 0:
+            raise BadRequestError()
+        try:
+            return {"type": ScheduleType(args[0])}
+        except ValueError as e:
+            raise BadRequestError(f"未知订阅类型：{args[0]}") from e
+
+    # noinspection PyMethodOverriding
+    async def actual_handle(self, *, type: ScheduleType,
                             post_dest: PostDestination[UID, GID],
                             silently: bool = False):
-        await self.scheduler.unschedule(type, post_dest.identifier)
-        await self.post_plain_text(message="取消订阅成功", post_dest=post_dest)
-
-    async def actual_handle_bad_request(self, *, post_dest: PostDestination[UID, GID],
-                                        silently: bool = False,
-                                        err: BadRequestError):
-        subscription = await self.scheduler.all_subscription(post_dest.identifier)
-        msg = "当前订阅：\n"
-        if len(subscription) > 0:
-            for x in subscription:
-                msg += f'{x.type} {str(x.schedule[0]).zfill(2)}:{str(x.schedule[1]).zfill(2)}+{str(x.schedule[2]).zfill(2)}:{str(x.schedule[3]).zfill(2)}*x\n'
+        if await self.scheduler.unschedule(type, post_dest.identifier):
+            await self.post_plain_text(message="取消订阅成功", post_dest=post_dest)
         else:
-            msg += '无\n'
+            raise BadRequestError("取消订阅失败，不存在该订阅")
+
+    async def actual_handle_bad_request(self, err: BadRequestError,
+                                        *, post_dest: PostDestination[UID, GID],
+                                        silently: bool = False):
+        msg = ""
+        if err.message:
+            msg += err.message
+            msg += '\n'
+
+        msg += await build_subscriptions_msg(post_dest.identifier)
         msg += "\n"
         msg += "命令格式：/pixivbot unschedule <type>"
         await self.post_plain_text(message=msg, post_dest=post_dest)
diff --git a/src/nonebot_plugin_pixivbot/handler/command/watch.py b/src/nonebot_plugin_pixivbot/handler/command/watch.py
new file mode 100644
index 0000000..9168278
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/handler/command/watch.py
@@ -0,0 +1,191 @@
+from typing import TypeVar, Sequence, Dict, Any
+
+from nonebot_plugin_pixivbot.global_context import context
+from nonebot_plugin_pixivbot.handler.interceptor.permission_interceptor import GroupAdminInterceptor, \
+    AnyPermissionInterceptor, SuperuserInterceptor
+from nonebot_plugin_pixivbot.model import PostIdentifier, WatchType
+from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+from nonebot_plugin_pixivbot.service.pixiv_service import PixivService
+from nonebot_plugin_pixivbot.service.watchman import Watchman
+from nonebot_plugin_pixivbot.utils.errors import BadRequestError
+from .command import CommandHandler, SubCommandHandler
+
+UID = TypeVar("UID")
+GID = TypeVar("GID")
+
+
+async def parse_and_get_user(raw_user: str):
+    pixiv = context.require(PixivService)
+    # try parse
+    try:
+        user = int(raw_user)
+        return await pixiv.get_user(user)
+    except ValueError:
+        return await pixiv.get_user(raw_user)
+
+
+async def build_tasks_msg(identifier: PostIdentifier):
+    watchman = context.require(Watchman)
+    tasks = await watchman.get_by_subscriber(identifier)
+    msg = "当前订阅：\n"
+    if len(tasks) > 0:
+        for t in tasks:
+            args_text = " ".join(map(lambda k: f'{k}={t.kwargs[k]}', t.kwargs))
+            msg += f'{t.type.name} {args_text}\n'
+    else:
+        msg += '无\n'
+    return msg
+
+
+async def parse_user_illusts_args(args: Sequence[str]):
+    if len(args) < 2:
+        raise BadRequestError()
+
+    user = await parse_and_get_user(args[1])
+
+    watch_args = {"user_id": user.id}
+    message = f"{user.name}({user.id})老师的插画更新"
+
+    return watch_args, message
+
+
+async def parse_following_illusts_args(args: Sequence[str], post_dest: PostDestination[UID, GID]):
+    if len(args) > 1:
+        user = await parse_and_get_user(args[1])
+
+        watch_args = {"pixiv_user_id": user.id,
+                      "sender_user_id": post_dest.user_id}
+        message = f"{user.name}({user.id})用户的关注者插画更新"
+    else:
+        watch_args = {"pixiv_user_id": 0,
+                      "sender_user_id": post_dest.user_id}
+        message = f"关注者插画更新"
+
+    return watch_args, message
+
+
+@context.inject
+@context.require(CommandHandler).sub_command("watch")
+class WatchHandler(SubCommandHandler):
+    watchman: Watchman
+
+    def __init__(self):
+        super().__init__()
+        self.add_interceptor(AnyPermissionInterceptor(
+            context.require(SuperuserInterceptor),
+            context.require(GroupAdminInterceptor)
+        ))
+
+    @classmethod
+    def type(cls) -> str:
+        return "watch"
+
+    def enabled(self) -> bool:
+        return True
+
+    async def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
+        if len(args) == 0:
+            raise BadRequestError()
+
+        try:
+            type = WatchType[args[0]]
+            if type == WatchType.user_illusts:
+                watch_kwargs, message = await parse_user_illusts_args(args)
+            elif type == WatchType.following_illusts:
+                watch_kwargs, message = await parse_following_illusts_args(args, post_dest)
+            else:
+                raise KeyError()
+        except KeyError as e:
+            raise BadRequestError(f"未知订阅类型：{args[0]}") from e
+
+        return {"type": type, "watch_kwargs": watch_kwargs, "success_message": "成功订阅" + message}
+
+    # noinspection PyMethodOverriding
+    async def actual_handle(self, *, type: WatchType,
+                            watch_kwargs: Dict[str, Any],
+                            success_message: str,
+                            post_dest: PostDestination[UID, GID],
+                            silently: bool = False):
+        await self.watchman.watch(type, watch_kwargs, post_dest)
+        await self.post_plain_text(success_message, post_dest)
+
+    async def actual_handle_bad_request(self, err: BadRequestError,
+                                        *, post_dest: PostDestination[UID, GID],
+                                        silently: bool = False):
+        msg = ""
+        if err.message:
+            msg += err.message
+            msg += '\n\n'
+
+        msg += await build_tasks_msg(post_dest.identifier)
+        msg += "\n" \
+               "命令格式：/pixivbot watch <type> [..args]\n" \
+               "参数：\n" \
+               "  <type>：可选值有user_illusts, following_illusts" \
+               "  [...args]：根据<type>不同需要提供不同的参数\n" \
+               "示例：/pixivbot watch user_illusts <用户名>\n"
+        await self.post_plain_text(message=msg, post_dest=post_dest)
+
+
+@context.inject
+@context.require(CommandHandler).sub_command("unwatch")
+class UnwatchHandler(SubCommandHandler):
+    watchman: Watchman
+
+    def __init__(self):
+        super().__init__()
+        self.add_interceptor(AnyPermissionInterceptor(
+            context.require(SuperuserInterceptor),
+            context.require(GroupAdminInterceptor)
+        ))
+
+    @classmethod
+    def type(cls) -> str:
+        return "unwatch"
+
+    def enabled(self) -> bool:
+        return True
+
+    async def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
+        if len(args) == 0:
+            raise BadRequestError()
+
+        try:
+            type = WatchType[args[0]]
+            if type == WatchType.user_illusts:
+                watch_kwargs, message = await parse_user_illusts_args(args)
+            elif type == WatchType.following_illusts:
+                watch_kwargs, message = await parse_following_illusts_args(args, post_dest)
+            else:
+                raise KeyError()
+        except KeyError as e:
+            raise BadRequestError(f"未知订阅类型：{args[0]}") from e
+
+        return {"type": type, "watch_kwargs": watch_kwargs}
+
+    # noinspection PyMethodOverriding
+    async def actual_handle(self, *, type: WatchType,
+                            watch_kwargs: Dict[str, Any],
+                            post_dest: PostDestination[UID, GID],
+                            silently: bool = False):
+        if await self.watchman.unwatch(type, watch_kwargs, post_dest.identifier):
+            await self.post_plain_text(message="成功取消订阅", post_dest=post_dest)
+        else:
+            raise BadRequestError("取消订阅失败，不存在该订阅")
+
+    async def actual_handle_bad_request(self, err: BadRequestError,
+                                        *, post_dest: PostDestination[UID, GID],
+                                        silently: bool = False):
+        msg = ""
+        if err.message:
+            msg += err.message
+            msg += '\n\n'
+
+        msg += await build_tasks_msg(post_dest.identifier)
+        msg += "\n" \
+               "命令格式：/pixivbot unwatch <type> [..args]\n" \
+               "参数：\n" \
+               "  <type>：可选值有user_illusts, following_illusts" \
+               "  [...args]：根据<type>不同需要提供不同的参数\n" \
+               "示例：/pixivbot unwatch user_illusts <用户名>\n"
+        await self.post_plain_text(message=msg, post_dest=post_dest)
diff --git a/src/nonebot_plugin_pixivbot/handler/common/common.py b/src/nonebot_plugin_pixivbot/handler/common/common.py
index 48d8828..88c0daf 100644
--- a/src/nonebot_plugin_pixivbot/handler/common/common.py
+++ b/src/nonebot_plugin_pixivbot/handler/common/common.py
@@ -1,5 +1,4 @@
 from abc import ABC
-from asyncio import create_task, gather
 from typing import TypeVar, Optional, Sequence
 
 from nonebot_plugin_pixivbot.global_context import context
@@ -48,6 +47,7 @@ class CommonHandler(EntryHandler, ABC):
 
     def __init__(self):
         super().__init__()
+        # noinspection PyTypeChecker
         self.postman_manager = RecordPostmanManager(self.postman_manager)
         self.add_interceptor(context.require(CooldownInterceptor))
         self.add_interceptor(context.require(TimeoutInterceptor))
@@ -65,18 +65,6 @@ async def post_illusts(self, illusts: Sequence[Illust], *,
                            header: Optional[str] = None,
                            number: Optional[int] = None,
                            post_dest: PD):
-        tasks = [
-            create_task(
-                IllustMessageModel.from_illust(x, number=number + i if number is not None else None)
-            ) for i, x in enumerate(illusts)
-        ]
-        await gather(*tasks)
-
-        messages = []
-        for t in tasks:
-            result = await t
-            if result:
-                messages.append(result)
-
-        model = IllustMessagesModel(header=header, messages=messages)
-        await self.postman_manager.send_illusts(model, post_dest=post_dest)
+        model = await IllustMessagesModel.from_illusts(illusts, header=header, number=number)
+        if model:
+            await self.postman_manager.send_illusts(model, post_dest=post_dest)
diff --git a/src/nonebot_plugin_pixivbot/handler/common/illust.py b/src/nonebot_plugin_pixivbot/handler/common/illust.py
index 89b77e3..f5f898c 100644
--- a/src/nonebot_plugin_pixivbot/handler/common/illust.py
+++ b/src/nonebot_plugin_pixivbot/handler/common/illust.py
@@ -1,4 +1,4 @@
-from typing import TypeVar, Generic, Sequence, Any
+from typing import TypeVar, Generic, Sequence
 
 from nonebot_plugin_pixivbot.global_context import context
 from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
@@ -24,6 +24,7 @@ def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID])
         except ValueError:
             raise BadRequestError(f"{args[0]}不是合法的插画ID")
 
+    # noinspection PyMethodOverriding
     async def actual_handle(self, *, illust_id: int,
                             post_dest: PostDestination[UID, GID],
                             silently: bool = False):
diff --git a/src/nonebot_plugin_pixivbot/handler/common/random_bookmark.py b/src/nonebot_plugin_pixivbot/handler/common/random_bookmark.py
index 8e2d88b..c804ba2 100644
--- a/src/nonebot_plugin_pixivbot/handler/common/random_bookmark.py
+++ b/src/nonebot_plugin_pixivbot/handler/common/random_bookmark.py
@@ -37,8 +37,10 @@ def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID])
             except ValueError:
                 raise BadRequestError(f"{args[0]}不是合法的ID")
 
+        # 因为群组的订阅会把PostIdentifier的user_id抹去，所以这里必须传递sender_user_id
         return {"pixiv_user_id": pixiv_user_id, "sender_user_id": sender_user_id}
 
+    # noinspection PyMethodOverriding
     async def actual_handle(self, *, sender_user_id: UID,
                             pixiv_user_id: int = 0,
                             count: int = 1,
diff --git a/src/nonebot_plugin_pixivbot/handler/common/random_illust.py b/src/nonebot_plugin_pixivbot/handler/common/random_illust.py
index f156521..aeafe93 100644
--- a/src/nonebot_plugin_pixivbot/handler/common/random_illust.py
+++ b/src/nonebot_plugin_pixivbot/handler/common/random_illust.py
@@ -1,4 +1,4 @@
-from typing import TypeVar, Sequence, Any
+from typing import TypeVar, Sequence
 
 from nonebot_plugin_pixivbot.global_context import context
 from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
diff --git a/src/nonebot_plugin_pixivbot/handler/common/random_user_illust.py b/src/nonebot_plugin_pixivbot/handler/common/random_user_illust.py
index beafb82..0a5a161 100644
--- a/src/nonebot_plugin_pixivbot/handler/common/random_user_illust.py
+++ b/src/nonebot_plugin_pixivbot/handler/common/random_user_illust.py
@@ -1,4 +1,4 @@
-from typing import TypeVar, Union, Sequence, Any
+from typing import TypeVar, Union, Sequence
 
 from nonebot_plugin_pixivbot.global_context import context
 from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
@@ -23,12 +23,14 @@ def enabled(self) -> bool:
         return self.conf.pixiv_random_user_illust_query_enabled
 
     async def parse_args(self, args: Sequence[str], post_dest: PostDestination[UID, GID]) -> dict:
-        user = args[0]
-        if isinstance(user, str):
-            user = await self.service.get_user(user)
-
-        return {"user": user.id}
-
+        try:
+            user_id = int(args[0])
+            return {"user": user_id}
+        except ValueError:
+            user = await self.service.get_user(args[0])
+            return {"user": user.id}
+
+    # noinspection PyMethodOverriding
     async def actual_handle(self, *, user: Union[str, int],
                             count: int = 1,
                             post_dest: PostDestination[UID, GID],
@@ -36,5 +38,5 @@ async def actual_handle(self, *, user: Union[str, int],
         userinfo, illusts = await self.service.random_user_illust(user, count=count)
 
         await self.post_illusts(illusts,
-                                header=f"这是您点的{userinfo.name}老师({userinfo.id})的图",
+                                header=f"这是您点的{userinfo.name}({userinfo.id})老师的图",
                                 post_dest=post_dest)
diff --git a/src/nonebot_plugin_pixivbot/handler/common/ranking.py b/src/nonebot_plugin_pixivbot/handler/common/ranking.py
index 92f79d5..384eeb2 100644
--- a/src/nonebot_plugin_pixivbot/handler/common/ranking.py
+++ b/src/nonebot_plugin_pixivbot/handler/common/ranking.py
@@ -1,4 +1,4 @@
-from typing import Optional, Sequence, Union, TypeVar, Any, Tuple
+from typing import Sequence, Union, TypeVar, Tuple
 
 from nonebot_plugin_pixivbot.enums import RankingMode
 from nonebot_plugin_pixivbot.global_context import context
diff --git a/src/nonebot_plugin_pixivbot/handler/handler.py b/src/nonebot_plugin_pixivbot/handler/handler.py
index 9de2b10..6c6b3ca 100644
--- a/src/nonebot_plugin_pixivbot/handler/handler.py
+++ b/src/nonebot_plugin_pixivbot/handler/handler.py
@@ -5,7 +5,6 @@
 from nonebot_plugin_pixivbot.config import Config
 from nonebot_plugin_pixivbot.global_context import context
 from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
-from nonebot_plugin_pixivbot.utils.errors import BadRequestError
 from .interceptor.combined_interceptor import CombinedInterceptor
 from .interceptor.interceptor import Interceptor
 from ..protocol_dep.postman import PostmanManager
@@ -54,26 +53,29 @@ async def handle(self, *args,
         if not self.enabled():
             return
 
-        try:
-            parsed_kwargs = self.parse_args(args, post_dest)
-            if isawaitable(parsed_kwargs):
-                parsed_kwargs = await parsed_kwargs
-        except Exception as e:
-            raise BadRequestError("参数错误") from e
-
-        kwargs = {**kwargs, **parsed_kwargs}
-
         if self.interceptor is not None and not disabled_interceptors:
-            await self.interceptor.intercept(self.actual_handle,
+            await self.interceptor.intercept(self.handle_with_args, *args,
                                              post_dest=post_dest,
                                              silently=silently,
                                              **kwargs)
         else:
-            await self.actual_handle(post_dest=post_dest, silently=silently, **kwargs)
+            await self.handle_with_args(*args, post_dest=post_dest, silently=silently, **kwargs)
+
+    async def handle_with_args(self, *args,
+                               post_dest: PD,
+                               silently: bool = False,
+                               **kwargs):
+        parsed_kwargs = self.parse_args(args, post_dest)
+        if isawaitable(parsed_kwargs):
+            parsed_kwargs = await parsed_kwargs
+
+        kwargs = {**kwargs, **parsed_kwargs}
+        await self.actual_handle(post_dest=post_dest, silently=silently, **kwargs)
 
     @abstractmethod
     async def actual_handle(self, *, post_dest: PD,
-                            silently: bool = False, **kwargs):
+                            silently: bool = False,
+                            **kwargs):
         """
         处理指令
         :param post_dest: PostDestination
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/combined_interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/combined_interceptor.py
index 39b4d95..7a0282e 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/combined_interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/combined_interceptor.py
@@ -40,11 +40,11 @@ def find(self, interceptor_type: Type[Interceptor]) -> Optional[Interceptor]:
                 result = self.y.find(interceptor_type)
             return result
 
-    async def intercept(self, wrapped_func: Callable, *,
+    async def intercept(self, wrapped_func: Callable, *args,
                         post_dest: PostDestination[UID, GID],
                         silently: bool,
                         **kwargs):
         await self.x.intercept(
-            partial(self.y.intercept, wrapped_func),
+            partial(self.y.intercept, wrapped_func), *args,
             post_dest=post_dest, silently=silently, **kwargs
         )
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/cooldown_interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/cooldown_interceptor.py
index 15e0171..98c1154 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/cooldown_interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/cooldown_interceptor.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
 from math import ceil
 from typing import TypeVar
 
@@ -37,7 +37,7 @@ def has_permission(self, post_dest: PostDestination[UID, GID]) -> bool:
                 or str(identifier) in self.conf.pixiv_no_query_cooldown_users:
             return True
 
-        now = datetime.now()
+        now = datetime.now(timezone.utc)
         if identifier not in self.last_query_time:
             self.last_query_time[identifier] = now
             return True
@@ -54,7 +54,7 @@ def has_permission(self, post_dest: PostDestination[UID, GID]) -> bool:
 
     def get_permission_denied_msg(self, post_dest: PostDestination[UID, GID]) -> str:
         identifier = UserIdentifier(post_dest.adapter, post_dest.user_id)
-        now = datetime.now()
+        now = datetime.now(timezone.utc)
         delta = now - self.last_query_time[identifier]
         cooldown = ceil(self.conf.pixiv_query_cooldown - delta.total_seconds())
         return f"你的CD还有{cooldown}s转好"
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/default_error_interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/default_error_interceptor.py
index fa854f4..1e9f287 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/default_error_interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/default_error_interceptor.py
@@ -16,12 +16,12 @@
 @context.register_singleton()
 class DefaultErrorInterceptor(Interceptor):
 
-    async def intercept(self, wrapped_func: Callable, *,
+    async def intercept(self, wrapped_func: Callable, *args,
                         post_dest: PostDestination[UID, GID],
                         silently: bool,
                         **kwargs):
         try:
-            await wrapped_func(post_dest=post_dest, silently=silently, **kwargs)
+            await wrapped_func(*args, post_dest=post_dest, silently=silently, **kwargs)
         except asyncio.TimeoutError:
             logger.warning("Timeout")
             if not silently:
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/interceptor.py
index c01eb69..faaf60d 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/interceptor.py
@@ -19,7 +19,7 @@ async def post_plain_text(self, message: str,
         await self.postman_manager.send_plain_text(message, post_dest=post_dest)
 
     @abstractmethod
-    async def intercept(self, wrapped_func: Callable, *,
+    async def intercept(self, wrapped_func: Callable, *args,
                         post_dest: PostDestination[UID, GID],
                         silently: bool,
                         **kwargs):
@@ -27,9 +27,9 @@ async def intercept(self, wrapped_func: Callable, *,
 
     def __call__(self, wrapped: Callable):
         @wraps(wrapped)
-        async def wrapper(post_dest: PostDestination[UID, GID],
+        async def wrapper(*args, post_dest: PostDestination[UID, GID],
                           silently: bool,
                           **kwargs):
-            await self.intercept(wrapped, post_dest=post_dest, silently=silently, **kwargs)
+            await self.intercept(wrapped, *args, post_dest=post_dest, silently=silently, **kwargs)
 
         return wrapper
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/loading_interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/loading_interceptor.py
index cdaecd0..7847ce4 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/loading_interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/loading_interceptor.py
@@ -16,7 +16,7 @@ async def send_delayed_loading(self, post_dest: PostDestination[UID, GID]):
         await shield(self.post_plain_text("努力加载中", post_dest))
 
     async def intercept(self, wrapped_func: Callable,
-                        *, post_dest: PostDestination[UID, GID],
+                        *args, post_dest: PostDestination[UID, GID],
                         silently: bool,
                         **kwargs):
         task: Optional[Task] = None
@@ -24,7 +24,7 @@ async def intercept(self, wrapped_func: Callable,
             task = create_task(self.send_delayed_loading(post_dest))
 
         try:
-            await wrapped_func(post_dest=post_dest, silently=silently, **kwargs)
+            await wrapped_func(*args, post_dest=post_dest, silently=silently, **kwargs)
         finally:
             if task and not task.done():
                 task.cancel()
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/permission_interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/permission_interceptor.py
index c517377..0792d3c 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/permission_interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/permission_interceptor.py
@@ -24,7 +24,7 @@ def get_permission_denied_msg(self, post_dest: PostDestination[UID, GID]) \
             -> Union[Optional[str], Awaitable[Optional[str]]]:
         return None
 
-    async def intercept(self, wrapped_func: Callable, *,
+    async def intercept(self, wrapped_func: Callable, *args,
                         post_dest: PostDestination[UID, GID],
                         silently: bool,
                         **kwargs):
@@ -33,7 +33,7 @@ async def intercept(self, wrapped_func: Callable, *,
             p = await p
 
         if p:
-            await wrapped_func(post_dest=post_dest, silently=silently, **kwargs)
+            await wrapped_func(*args, post_dest=post_dest, silently=silently, **kwargs)
         else:
             logger.debug(f"permission denied {post_dest}")
             if not silently:
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/record_req_interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/record_req_interceptor.py
index cfd646d..8ba6b20 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/record_req_interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/record_req_interceptor.py
@@ -14,9 +14,9 @@
 class RecordReqInterceptor(Interceptor):
     recorder: Recorder
 
-    async def intercept(self, wrapped_func: Callable, *,
+    async def intercept(self, wrapped_func: Callable, *args,
                         post_dest: PostDestination[UID, GID],
                         silently: bool,
                         **kwargs):
-        await wrapped_func(post_dest=post_dest, silently=silently, **kwargs)
-        self.recorder.record_req(Req(wrapped_func, **kwargs), post_dest.identifier)
+        await wrapped_func(*args, post_dest=post_dest, silently=silently, **kwargs)
+        self.recorder.record_req(Req(wrapped_func, *args, **kwargs), post_dest.identifier)
diff --git a/src/nonebot_plugin_pixivbot/handler/interceptor/timeout_interceptor.py b/src/nonebot_plugin_pixivbot/handler/interceptor/timeout_interceptor.py
index 0810930..388a5ac 100644
--- a/src/nonebot_plugin_pixivbot/handler/interceptor/timeout_interceptor.py
+++ b/src/nonebot_plugin_pixivbot/handler/interceptor/timeout_interceptor.py
@@ -15,11 +15,11 @@
 class TimeoutInterceptor(Interceptor):
     conf: Config
 
-    async def intercept(self, wrapped_func: Callable, *,
+    async def intercept(self, wrapped_func: Callable, *args,
                         post_dest: PostDestination[UID, GID],
                         silently: bool,
                         **kwargs):
-        await wait_for(wrapped_func(post_dest=post_dest, silently=silently, **kwargs),
+        await wait_for(wrapped_func(*args, post_dest=post_dest, silently=silently, **kwargs),
                        timeout=self.conf.pixiv_query_timeout)
 
 
diff --git a/src/nonebot_plugin_pixivbot/model/__init__.py b/src/nonebot_plugin_pixivbot/model/__init__.py
index 1f283f9..25f996a 100644
--- a/src/nonebot_plugin_pixivbot/model/__init__.py
+++ b/src/nonebot_plugin_pixivbot/model/__init__.py
@@ -1,8 +1,12 @@
 from .identifier import UserIdentifier, PostIdentifier
 from .illust import Illust
 from .pixiv_binding import PixivBinding
-from .subscription import Subscription
+from .subscription import Subscription, ScheduleType
 from .tag import Tag
 from .user import User
+from .user_preview import UserPreview
+from .watch_task import WatchTask, WatchType
 
-__all__ = ("Illust", "User", "Tag", "Subscription", "UserIdentifier", "PostIdentifier", "PixivBinding")
+__all__ = ("Illust", "User", "UserPreview", "Tag", "Subscription", "ScheduleType",
+           "UserIdentifier", "PostIdentifier", "PixivBinding",
+           "WatchTask", "WatchType")
diff --git a/src/nonebot_plugin_pixivbot/model/identifier.py b/src/nonebot_plugin_pixivbot/model/identifier.py
index b208853..7dbe8a3 100644
--- a/src/nonebot_plugin_pixivbot/model/identifier.py
+++ b/src/nonebot_plugin_pixivbot/model/identifier.py
@@ -1,28 +1,42 @@
-from dataclasses import dataclass
 from typing import TypeVar, Generic, Optional
 
+from pydantic import root_validator
+from pydantic.generics import GenericModel
+
 UID = TypeVar("UID")
 GID = TypeVar("GID")
 
 
-@dataclass(frozen=True)
-class UserIdentifier(Generic[UID]):
+class UserIdentifier(GenericModel, Generic[UID]):
     adapter: str
     user_id: UID
 
+    def __init__(self, adapter: str, user_id: UID):
+        super().__init__(adapter=adapter, user_id=user_id)
+
     def __str__(self):
         return f"{self.adapter}:{self.user_id}"
 
+    class Config:
+        frozen = True
+
 
-@dataclass(frozen=True)
-class PostIdentifier(Generic[UID, GID]):
+class PostIdentifier(GenericModel, Generic[UID, GID]):
     adapter: str
     user_id: Optional[UID]
     group_id: Optional[GID]
 
+    def __init__(self, adapter: str, user_id: Optional[UID] = None, group_id: Optional[GID] = None):
+        super().__init__(adapter=adapter, user_id=user_id, group_id=group_id)
+
     def __str__(self):
         return f"{self.adapter}:{self.user_id}:{self.group_id}"
 
-    def __post_init__(self):
-        if not self.user_id and not self.group_id:
-            raise ValueError("at least one of user_id and group_id should be not None")
+    @root_validator(allow_reuse=True)
+    def validator(cls, values):
+        if not values.get("user_id", None) and not values.get("group_id", None):
+            raise ValueError("at least one of user_id and group_id should not be None")
+        return values
+
+    class Config:
+        frozen = True
diff --git a/src/nonebot_plugin_pixivbot/model/message/illust_message.py b/src/nonebot_plugin_pixivbot/model/message/illust_message.py
index 021501b..bb3943c 100644
--- a/src/nonebot_plugin_pixivbot/model/message/illust_message.py
+++ b/src/nonebot_plugin_pixivbot/model/message/illust_message.py
@@ -2,6 +2,7 @@
 from typing import Optional
 
 from pydantic import BaseModel
+from tzlocal import get_localzone
 
 from nonebot_plugin_pixivbot.config import Config
 from nonebot_plugin_pixivbot.data.pixiv_repo import PixivRepo
@@ -54,7 +55,7 @@ async def from_illust(illust: Illust, *,
             model.image = await download_image(illust)
         model.title = illust.title
         model.author = f"{illust.user.name} ({illust.user.id})"
-        model.create_time = illust.create_date.strftime('%Y-%m-%d %H:%M:%S')
+        model.create_time = illust.create_date.astimezone(get_localzone()).strftime('%Y-%m-%d %H:%M:%S')
         model.link = f"https://www.pixiv.net/artworks/{illust.id}"
 
         return model
diff --git a/src/nonebot_plugin_pixivbot/model/message/illust_messages.py b/src/nonebot_plugin_pixivbot/model/message/illust_messages.py
index 68e845f..baeef55 100644
--- a/src/nonebot_plugin_pixivbot/model/message/illust_messages.py
+++ b/src/nonebot_plugin_pixivbot/model/message/illust_messages.py
@@ -1,7 +1,9 @@
+from asyncio import create_task, gather
 from typing import Optional, Sequence
 
 from pydantic import BaseModel
 
+from nonebot_plugin_pixivbot.model import Illust
 from nonebot_plugin_pixivbot.model.message.illust_message import IllustMessageModel
 
 
@@ -9,6 +11,26 @@ class IllustMessagesModel(BaseModel):
     header: Optional[str] = None
     messages: Sequence[IllustMessageModel]
 
+    @staticmethod
+    async def from_illusts(illusts: Sequence[Illust], *,
+                           header: Optional[str] = None,
+                           number: Optional[int] = None) -> Optional["IllustMessagesModel"]:
+        tasks = [
+            create_task(
+                IllustMessageModel.from_illust(x, number=number + i if number is not None else None)
+            ) for i, x in enumerate(illusts)
+        ]
+        await gather(*tasks)
+
+        messages = []
+        for t in tasks:
+            result = await t
+            if result:
+                messages.append(result)
+
+        model = IllustMessagesModel(header=header, messages=messages)
+        return model
+
     def flat(self):
         for x in self.messages:
             yield x.copy(update={"header": self.header})
diff --git a/src/nonebot_plugin_pixivbot/model/subscription.py b/src/nonebot_plugin_pixivbot/model/subscription.py
index 2d77946..b3a7573 100644
--- a/src/nonebot_plugin_pixivbot/model/subscription.py
+++ b/src/nonebot_plugin_pixivbot/model/subscription.py
@@ -1,6 +1,6 @@
-from typing import TypeVar, Generic, Sequence, Dict, Any, Optional
+from enum import Enum
+from typing import TypeVar, Generic, Sequence, Dict, Any
 
-from pydantic import validator
 from pydantic.generics import GenericModel
 
 from nonebot_plugin_pixivbot.model import PostIdentifier
@@ -9,27 +9,19 @@
 GID = TypeVar("GID")
 
 
+class ScheduleType(Enum):
+    random_bookmark = "random_bookmark"
+    random_recommended_illust = "random_recommended_illust"
+    random_illust = "random_illust"
+    random_user_illust = "random_user_illust"
+    ranking = "ranking"
+
+
 class Subscription(GenericModel, Generic[UID, GID]):
-    adapter: str
-    user_id: Optional[UID]
-    group_id: Optional[GID]
-    type: str
+    type: ScheduleType
     kwargs: Dict[str, Any]
+    subscriber: PostIdentifier[UID, GID]
     schedule: Sequence[int]
 
-    @validator("user_id", allow_reuse=True)
-    def validate(cls, user_id, values):
-        group_id = None
-        if "group_id" in values:
-            group_id = values["group_id"]
-
-        if not user_id and not group_id:
-            raise ValueError("at least one of user_id and group_id should be not None")
-        return user_id
-
-    @property
-    def identifier(self):
-        return PostIdentifier(self.adapter, self.user_id, self.group_id)
-
 
-__all__ = ("Subscription",)
+__all__ = ("Subscription", "ScheduleType")
diff --git a/src/nonebot_plugin_pixivbot/model/user_preview.py b/src/nonebot_plugin_pixivbot/model/user_preview.py
new file mode 100644
index 0000000..9e0dc69
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/model/user_preview.py
@@ -0,0 +1,13 @@
+from typing import List
+
+from pydantic import BaseModel
+
+from nonebot_plugin_pixivbot.model import User, Illust
+
+
+class UserPreview(BaseModel):
+    user: User
+    illusts: List[Illust]
+
+    class Config:
+        extra = "ignore"
diff --git a/src/nonebot_plugin_pixivbot/model/watch_task.py b/src/nonebot_plugin_pixivbot/model/watch_task.py
new file mode 100644
index 0000000..e584c91
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/model/watch_task.py
@@ -0,0 +1,22 @@
+from datetime import datetime, timezone
+from enum import Enum
+from typing import Any, TypeVar, Generic, Dict
+
+from pydantic import BaseModel
+
+from nonebot_plugin_pixivbot.model import PostIdentifier
+
+UID = TypeVar("UID")
+GID = TypeVar("GID")
+
+
+class WatchType(Enum):
+    user_illusts = "user_illusts"
+    following_illusts = "following_illusts"
+
+
+class WatchTask(BaseModel, Generic[UID, GID]):
+    type: WatchType
+    kwargs: Dict[str, Any]
+    subscriber: PostIdentifier[UID, GID]
+    checkpoint: datetime = datetime.now(timezone.utc)
diff --git a/src/nonebot_plugin_pixivbot/protocol_dep/post_dest.py b/src/nonebot_plugin_pixivbot/protocol_dep/post_dest.py
index 2fc5eb6..cc0922d 100644
--- a/src/nonebot_plugin_pixivbot/protocol_dep/post_dest.py
+++ b/src/nonebot_plugin_pixivbot/protocol_dep/post_dest.py
@@ -17,7 +17,7 @@ class PostDestination(ABC, Generic[UID, GID]):
 
     @property
     @abstractmethod
-    def identifier(self) -> PostIdentifier:
+    def identifier(self) -> PostIdentifier[UID, GID]:
         raise NotImplementedError()
 
     @property
@@ -56,10 +56,10 @@ def from_event(self, bot: Bot, event: Event) -> PostDestination:
 
 @context.register_singleton()
 class PostDestinationFactoryManager(ProtocolDepManager[PostDestinationFactory]):
-    def build(self, bot: Bot, user_id: Optional[UID], group_id: Optional[GID]) -> PostDestination:
+    def build(self, bot: Bot, user_id: Optional[UID], group_id: Optional[GID]) -> PostDestination[UID, GID]:
         return self[get_adapter_name(bot)].build(bot, user_id, group_id)
 
-    def from_event(self, bot: Bot, event: Event) -> PostDestination:
+    def from_event(self, bot: Bot, event: Event) -> PostDestination[UID, GID]:
         return self[get_adapter_name(bot)].from_event(bot, event)
 
 
diff --git a/src/nonebot_plugin_pixivbot/service/__init__.py b/src/nonebot_plugin_pixivbot/service/__init__.py
index e69de29..339bc81 100644
--- a/src/nonebot_plugin_pixivbot/service/__init__.py
+++ b/src/nonebot_plugin_pixivbot/service/__init__.py
@@ -0,0 +1,2 @@
+from . import scheduler
+from . import watchman
diff --git a/src/nonebot_plugin_pixivbot/service/pixiv_account_binder.py b/src/nonebot_plugin_pixivbot/service/pixiv_account_binder.py
index 1cdbad0..fade945 100644
--- a/src/nonebot_plugin_pixivbot/service/pixiv_account_binder.py
+++ b/src/nonebot_plugin_pixivbot/service/pixiv_account_binder.py
@@ -22,6 +22,6 @@ async def unbind(self, adapter: str, user_id: UID) -> bool:
     async def get_binding(self, adapter: str, user_id: UID) -> Optional[int]:
         binding = await self.repo.get(adapter, user_id)
         if binding:
-            return binding["pixiv_user_id"]
+            return binding.pixiv_user_id
         else:
             return None
diff --git a/src/nonebot_plugin_pixivbot/service/pixiv_service.py b/src/nonebot_plugin_pixivbot/service/pixiv_service.py
index d90cf79..dd75ba6 100644
--- a/src/nonebot_plugin_pixivbot/service/pixiv_service.py
+++ b/src/nonebot_plugin_pixivbot/service/pixiv_service.py
@@ -29,7 +29,7 @@ async def _choice_and_load(self, illusts: List[LazyIllust], random_method: Rando
             raise QueryError("别看了，没有的。")
 
         winners = roulette(illusts, random_method, count)
-        logger.info(f"choice {[x.id for x in winners]}")
+        logger.info(f"[pixiv_service] choice {[x.id for x in winners]}")
         return [await x.get() for x in winners]
 
     async def illust_ranking(self, mode: RankingMode, range: Tuple[int, int]) -> List[Illust]:
@@ -46,7 +46,7 @@ async def random_illust(self, word: str, *, count: int = 1) -> List[Illust]:
             if not tag:
                 tag = await self.local_tags.get_by_translated_name(word)
                 if tag:
-                    logger.info(f"found translation {word} -> {tag.name}")
+                    logger.info(f"[pixiv_service] found translation {word} -> {tag.name}")
                     word = tag.name
 
         illusts = [x async for x in self.repo.search_illust(word)]
@@ -55,6 +55,7 @@ async def random_illust(self, word: str, *, count: int = 1) -> List[Illust]:
     async def get_user(self, user: Union[str, int]) -> User:
         if isinstance(user, str):
             async for x in self.repo.search_user(user):
+                logger.info(f"[pixiv_service] select user {x.name}({x.id})")
                 return x
             raise QueryError("未找到用户")
         else:
diff --git a/src/nonebot_plugin_pixivbot/service/scheduler.py b/src/nonebot_plugin_pixivbot/service/scheduler.py
index 9351c1b..5f7e33a 100644
--- a/src/nonebot_plugin_pixivbot/service/scheduler.py
+++ b/src/nonebot_plugin_pixivbot/service/scheduler.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import re
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from inspect import isawaitable
 from typing import TypeVar, Dict, List, Sequence, Union, Optional, TYPE_CHECKING
 
@@ -13,6 +13,7 @@
 from nonebot_plugin_pixivbot.data.subscription_repo import SubscriptionRepo
 from nonebot_plugin_pixivbot.global_context import context
 from nonebot_plugin_pixivbot.model import Subscription, PostIdentifier
+from nonebot_plugin_pixivbot.model.subscription import ScheduleType
 from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination, PostDestinationFactoryManager
 from nonebot_plugin_pixivbot.utils.errors import BadRequestError
 from nonebot_plugin_pixivbot.utils.lifecycler import on_bot_connect, on_bot_disconnect
@@ -24,7 +25,36 @@
 UID = TypeVar("UID")
 GID = TypeVar("GID")
 
-ID = PostIdentifier[UID, GID]
+
+def parse_schedule(raw_schedule: str) -> Sequence[int]:
+    start_only_mat = re.fullmatch(r'(\d+):(\d+)', raw_schedule)
+    if start_only_mat is not None:
+        g = start_only_mat.groups()
+        start_hour, start_minute = int(g[0]), int(g[1])
+        interval_hour, interval_minute = 24, 0
+    else:
+        interval_only_mat = re.fullmatch(
+            r'(\d+):(\d+)\*x', raw_schedule)
+        if interval_only_mat is not None:
+            g = interval_only_mat.groups()
+            start_hour, start_minute = 0, 0
+            interval_hour, interval_minute = int(g[0]), int(g[1])
+        else:
+            mat = re.fullmatch(
+                r'(\d+):(\d+)\+(\d+):(\d+)\*x', raw_schedule)
+            if mat is not None:
+                g = mat.groups()
+                start_hour, start_minute = int(g[0]), int(g[1])
+                interval_hour, interval_minute = int(g[2]), int(g[3])
+            else:
+                raise BadRequestError(f'{raw_schedule}不是合法的时间')
+
+    if start_hour < 0 or start_hour >= 24 or start_minute < 0 or start_minute >= 60 \
+            or interval_hour < 0 or interval_minute >= 60 \
+            or (interval_hour > 0 and interval_minute < 0) or (interval_hour == 0 and interval_minute <= 0):
+        raise BadRequestError(f'{raw_schedule}不是合法的时间')
+
+    return start_hour, start_minute, interval_hour, interval_minute
 
 
 @context.inject
@@ -39,136 +69,94 @@ def __init__(self):
         on_bot_disconnect(self.on_bot_disconnect)
 
     @staticmethod
-    def _make_job_id(type: str, identifier: ID):
-        if identifier.group_id:
-            return f'{type} {identifier.adapter}:g{identifier.group_id}'
-        else:
-            return f'{type} {identifier.adapter}:u{identifier.user_id}'
-
-    @staticmethod
-    def _parse_schedule(raw_schedule: str) -> Sequence[int]:
-        start_only_mat = re.fullmatch(r'(\d+):(\d+)', raw_schedule)
-        if start_only_mat is not None:
-            g = start_only_mat.groups()
-            start_hour, start_minute = int(g[0]), int(g[1])
-            interval_hour, interval_minute = 24, 0
-        else:
-            interval_only_mat = re.fullmatch(
-                r'(\d+):(\d+)\*x', raw_schedule)
-            if interval_only_mat is not None:
-                g = interval_only_mat.groups()
-                start_hour, start_minute = 0, 0
-                interval_hour, interval_minute = int(g[0]), int(g[1])
-            else:
-                mat = re.fullmatch(
-                    r'(\d+):(\d+)\+(\d+):(\d+)\*x', raw_schedule)
-                if mat is not None:
-                    g = mat.groups()
-                    start_hour, start_minute = int(g[0]), int(g[1])
-                    interval_hour, interval_minute = int(g[2]), int(g[3])
-                else:
-                    raise BadRequestError(f'{raw_schedule}不是合法的时间')
-
-        if start_hour < 0 or start_hour >= 24 or start_minute < 0 or start_minute >= 60 \
-                or interval_hour < 0 or interval_minute >= 60 \
-                or (interval_hour > 0 and interval_minute < 0) or (interval_hour == 0 and interval_minute <= 0):
-            raise BadRequestError(f'{raw_schedule}不是合法的时间')
-
-        return start_hour, start_minute, interval_hour, interval_minute
+    def _make_job_id(type: ScheduleType, identifier: PostIdentifier[UID, GID]):
+        return f'scheduler {type.name} {identifier}'
 
     @lazy
-    def _handlers(self) -> Dict[str, Handler]:
+    def _handlers(self) -> Dict[ScheduleType, Handler]:
         # 解决Handler和Scheduler的循环引用
         from nonebot_plugin_pixivbot.handler.common import RandomBookmarkHandler, RandomRecommendedIllustHandler, \
             RankingHandler, RandomIllustHandler, RandomUserIllustHandler
         return {
-            RandomBookmarkHandler.type(): context.require(RandomBookmarkHandler),
-            RandomRecommendedIllustHandler.type(): context.require(RandomRecommendedIllustHandler),
-            RankingHandler.type(): context.require(RankingHandler),
-            RandomIllustHandler.type(): context.require(RandomIllustHandler),
-            RandomUserIllustHandler.type(): context.require(RandomUserIllustHandler),
+            ScheduleType.random_bookmark: context.require(RandomBookmarkHandler),
+            ScheduleType.random_recommended_illust: context.require(RandomRecommendedIllustHandler),
+            ScheduleType.ranking: context.require(RankingHandler),
+            ScheduleType.random_illust: context.require(RandomIllustHandler),
+            ScheduleType.random_user_illust: context.require(RandomUserIllustHandler),
         }
 
     async def _on_trigger(self, sub: Subscription[UID, GID], post_dest: PostDestination[UID, GID], silently: bool):
-        job_id = self._make_job_id(sub.type, sub.identifier)
-        logger.info(f"triggered {job_id}")
+        job_id = self._make_job_id(sub.type, sub.subscriber)
+        logger.info(f"[scheduler] triggered {job_id}")
         await self._handlers[sub.type].handle(post_dest=post_dest, silently=silently, **sub.kwargs)
 
     def _add_job(self, post_dest: PostDestination[UID, GID], sub: Subscription[UID, GID]):
         offset_hour, offset_minute, hours, minutes = sub.schedule
         trigger = IntervalTrigger(hours=hours, minutes=minutes,
-                                  start_date=datetime.now().replace(hour=offset_hour, minute=offset_minute,
-                                                                    second=0, microsecond=0) + timedelta(days=-1))
+                                  start_date=datetime.now(timezone.utc).replace(hour=offset_hour,
+                                                                                minute=offset_minute,
+                                                                                second=0,
+                                                                                microsecond=0) + timedelta(days=-1))
 
-        job_id = self._make_job_id(sub.type, sub.identifier)
+        job_id = self._make_job_id(sub.type, sub.subscriber)
         self.apscheduler.add_job(self._on_trigger, id=job_id, trigger=trigger,
                                  kwargs={"sub": sub, "post_dest": post_dest, "silently": True})
-        logger.success(f"scheduled {job_id} {trigger}")
+        logger.success(f"[scheduler] added job \"{job_id}\" on {trigger}")
 
-    def _remove_job(self, type: str, identifier: ID):
-        job_id = self._make_job_id(type, identifier)
+    def _remove_job(self, type: ScheduleType, subscriber: PostIdentifier[UID, GID]):
+        job_id = self._make_job_id(type, subscriber)
         self.apscheduler.remove_job(job_id)
-        logger.success(f"unscheduled {job_id}")
+        logger.success(f"[scheduler] removed job \"{job_id}\"")
 
     async def on_bot_connect(self, bot: Bot):
         adapter = get_adapter_name(bot)
-        async for sub in self.repo.get_all(adapter):
-            post_dest = self.pd_factory_mgr.build(
-                bot, sub.user_id, sub.group_id)
+        async for sub in self.repo.get_by_adapter(adapter):
+            post_dest = self.pd_factory_mgr.build(bot, sub.subscriber.user_id, sub.subscriber.group_id)
             self._add_job(post_dest, sub)
 
     async def on_bot_disconnect(self, bot: Bot):
-        async for subscription in self.repo.get_all(get_adapter_name(bot)):
+        async for subscription in self.repo.get_by_adapter(get_adapter_name(bot)):
             try:
-                self._remove_job(subscription.type, subscription.identifier)
+                self._remove_job(subscription.type, subscription.subscriber)
             except Exception as e:
-                logger.error(
-                    f"error occurred when remove job {self._make_job_id(subscription.type, subscription.identifier)}")
+                logger.error(f"[scheduler] error occurred when remove job "
+                             f"{self._make_job_id(subscription.type, subscription.subscriber)}")
                 logger.exception(e)
 
-    async def schedule(self, type: str,
+    async def schedule(self, type: ScheduleType,
                        schedule: Union[str, Sequence[int]],
                        args: Optional[list] = None,
                        *, post_dest: PostDestination[UID, GID]):
-        if type not in self._handlers:
-            raise BadRequestError(f"{type}不是合法的类型")
-
         if args is None:
             args = []
 
         if isinstance(schedule, str):
-            schedule = self._parse_schedule(schedule)
+            schedule = parse_schedule(schedule)
 
         kwargs = self._handlers[type].parse_args(args, post_dest)
         if isawaitable(kwargs):
             kwargs = await kwargs
 
-        sub = Subscription(adapter=post_dest.adapter,
-                           user_id=post_dest.user_id,
-                           group_id=post_dest.group_id,
-                           type=type,
-                           schedule=schedule,
-                           kwargs=kwargs)
-
-        old_sub = await self.repo.update(sub)
+        sub = Subscription(type=type, kwargs=kwargs, subscriber=post_dest.identifier, schedule=schedule)
+        old_sub = await self.repo.update(sub)  # will also update sub
         if old_sub is not None:
-            self._remove_job(sub.type, sub.identifier)
+            self._remove_job(sub.type, sub.subscriber)
         self._add_job(post_dest.normalized(), sub)
 
-    async def unschedule(self, type: str,
-                         identifier: PostIdentifier[UID, GID]):
-        if type == "all":
-            async for sub in self.repo.get(identifier):
-                self._remove_job(sub.type, identifier)
-        elif type in self._handlers:
-            self._remove_job(type, identifier)
+    async def unschedule(self, type: ScheduleType, subscriber: PostIdentifier[UID, GID]) -> bool:
+        if await self.repo.delete_one(subscriber, type):
+            self._remove_job(type, subscriber)
+            return True
         else:
-            raise BadRequestError(f"{type}不是合法的类型")
+            return False
 
-        await self.repo.delete(identifier, type)
+    async def unschedule_all_by_subscriber(self, subscriber: PostIdentifier[UID, GID]):
+        async for sub in self.repo.get_by_subscriber(subscriber):
+            self._remove_job(sub.type, subscriber)
+        await self.repo.delete_many_by_subscriber(subscriber)
 
-    async def all_subscription(self, identifier: PostIdentifier[UID, GID]) -> List[Subscription]:
-        return [x async for x in self.repo.get(identifier)]
+    async def get_by_subscriber(self, subscriber: PostIdentifier[UID, GID]) -> List[Subscription]:
+        return [x async for x in self.repo.get_by_subscriber(subscriber)]
 
 
 __all__ = ("Scheduler",)
diff --git a/src/nonebot_plugin_pixivbot/service/watchman/__init__.py b/src/nonebot_plugin_pixivbot/service/watchman/__init__.py
new file mode 100644
index 0000000..0de5a3a
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/service/watchman/__init__.py
@@ -0,0 +1,3 @@
+from .watchman import Watchman
+
+__all__ = ("Watchman",)
diff --git a/src/nonebot_plugin_pixivbot/service/watchman/pkg_context.py b/src/nonebot_plugin_pixivbot/service/watchman/pkg_context.py
new file mode 100644
index 0000000..fb71c69
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/service/watchman/pkg_context.py
@@ -0,0 +1,4 @@
+from nonebot_plugin_pixivbot.context import Context
+from nonebot_plugin_pixivbot.global_context import context as parent_context
+
+context = Context(parent=parent_context)
diff --git a/src/nonebot_plugin_pixivbot/service/watchman/shared_agen.py b/src/nonebot_plugin_pixivbot/service/watchman/shared_agen.py
new file mode 100644
index 0000000..89dc6bf
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/service/watchman/shared_agen.py
@@ -0,0 +1,55 @@
+from datetime import datetime, timedelta, timezone
+from typing import AsyncGenerator, TypeVar, Any
+
+from frozendict import frozendict
+from pydantic import BaseModel
+
+from nonebot_plugin_pixivbot.data.pixiv_repo import PixivRepo
+from nonebot_plugin_pixivbot.data.pixiv_repo.enums import CacheStrategy
+from nonebot_plugin_pixivbot.data.pixiv_repo.remote_repo import RemotePixivRepo
+from nonebot_plugin_pixivbot.model import Illust, WatchType
+from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+from nonebot_plugin_pixivbot.utils.shared_agen import SharedAsyncGeneratorManager
+from .pkg_context import context
+from .user_following_illusts import user_following_illusts
+
+UID = TypeVar("UID")
+GID = TypeVar("GID")
+
+PD = PostDestination[UID, GID]
+
+
+class WatchmanSharedAgenIdentifier(BaseModel):
+    type: WatchType
+    kwargs: frozendict[str, Any]
+
+    def __init__(self, type: WatchType, **kwargs):
+        super().__init__(type=type, kwargs=frozendict(kwargs))
+
+    def __str__(self):
+        return f"({self.type.name} {', '.join(map(lambda k: f'{k}={self.kwargs[k]}', {**self.kwargs}))})"
+
+    class Config:
+        frozen = True
+
+
+@context.inject
+@context.register_singleton()
+class WatchmanSharedAsyncGeneratorManager(SharedAsyncGeneratorManager[WatchmanSharedAgenIdentifier, Illust]):
+    log_tag = "watchman_shared_agen"
+
+    pixiv: PixivRepo
+    remote_pixiv: RemotePixivRepo
+
+    async def agen_factory(self, identifier: WatchmanSharedAgenIdentifier,
+                           cache_strategy: CacheStrategy,  # 这里的cache_strategy和PixivRepo没关系
+                           *args, **kwargs) -> AsyncGenerator[Illust, None]:
+        self.set_expires_time(identifier, datetime.now(timezone.utc) + timedelta(seconds=30))  # 保证每分钟的所有task都能共享
+        if identifier.type == WatchType.user_illusts:
+            async for x in self.pixiv.user_illusts(cache_strategy=CacheStrategy.FORCE_EXPIRATION, **identifier.kwargs):
+                yield await x.get()
+        elif identifier.type == WatchType.following_illusts:
+            async for x in user_following_illusts(identifier.kwargs["user_id"]):
+                yield x
+        else:
+            raise ValueError(f"invalid type: {identifier.type}")
diff --git a/src/nonebot_plugin_pixivbot/service/watchman/user_following_illusts.py b/src/nonebot_plugin_pixivbot/service/watchman/user_following_illusts.py
new file mode 100644
index 0000000..a7b37a8
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/service/watchman/user_following_illusts.py
@@ -0,0 +1,56 @@
+from typing import AsyncGenerator
+
+from nonebot_plugin_pixivbot.data.pixiv_repo import PixivRepo
+from nonebot_plugin_pixivbot.data.pixiv_repo.enums import CacheStrategy
+from nonebot_plugin_pixivbot.model import UserPreview, Illust
+from nonebot_plugin_pixivbot.service.watchman.pkg_context import context
+
+
+async def user_illusts(user_preview: UserPreview) -> AsyncGenerator[Illust, None]:
+    if len(user_preview.illusts) < 3:
+        for illust in user_preview.illusts:
+            yield illust
+    else:
+        for illust in user_preview.illusts:
+            yield illust
+
+        gen = context.require(PixivRepo).user_illusts(user_preview.user.id, CacheStrategy.FORCE_EXPIRATION)
+
+        for i in range(len(user_preview.illusts)):
+            await gen.__anext__()
+
+        async for x in gen:
+            yield await x.get()
+
+
+async def user_following_illusts(user_id: int) -> AsyncGenerator[Illust, None]:
+    n = 0
+    gen = []
+    peeked = []
+
+    async for user_preview in context.require(PixivRepo).user_following_with_preview(user_id):
+        n += 1
+        gen.append(user_illusts(user_preview))
+        peeked.append(None)
+
+    try:
+        while True:
+            select = -1
+            for i in range(n):
+                try:
+                    if not peeked[i]:
+                        peeked[i] = await gen[i].__anext__()
+
+                    if select == -1 or peeked[i].create_date > peeked[select].create_date:
+                        select = i
+                except StopAsyncIteration:
+                    pass
+
+            if select == -1:
+                break
+
+            yield peeked[select]
+            peeked[select] = None
+    except GeneratorExit:
+        for gen in gen:
+            await gen.aclose()
diff --git a/src/nonebot_plugin_pixivbot/service/watchman/watchman.py b/src/nonebot_plugin_pixivbot/service/watchman/watchman.py
new file mode 100644
index 0000000..b1b7fb6
--- /dev/null
+++ b/src/nonebot_plugin_pixivbot/service/watchman/watchman.py
@@ -0,0 +1,170 @@
+from datetime import datetime, timedelta, timezone
+from typing import Dict, Any, TypeVar, Optional
+
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+from apscheduler.triggers.interval import IntervalTrigger
+from nonebot import logger, Bot
+
+from nonebot_plugin_pixivbot.config import Config
+from nonebot_plugin_pixivbot.data.watch_task_repo import WatchTaskRepo
+from nonebot_plugin_pixivbot.model import Illust, PostIdentifier, WatchTask, WatchType
+from nonebot_plugin_pixivbot.model.message import IllustMessageModel
+from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination, PostDestinationFactoryManager
+from nonebot_plugin_pixivbot.protocol_dep.postman import PostmanManager
+from nonebot_plugin_pixivbot.service.pixiv_account_binder import PixivAccountBinder
+from nonebot_plugin_pixivbot.utils.lifecycler import on_bot_connect, on_bot_disconnect
+from nonebot_plugin_pixivbot.utils.nonebot import get_adapter_name
+from .pkg_context import context
+from .shared_agen import WatchmanSharedAsyncGeneratorManager, WatchmanSharedAgenIdentifier
+
+UID = TypeVar("UID")
+GID = TypeVar("GID")
+
+PD = PostDestination[UID, GID]
+ID = PostIdentifier[UID, GID]
+
+headers = {
+    WatchType.user_illusts: "您订阅的画师更新了",
+    WatchType.following_illusts: "您关注的画师更新了"
+}
+
+trigger_hasher_mapper = {
+    WatchType.user_illusts: lambda args: args["user_id"],
+    WatchType.following_illusts: lambda args: hash(args["sender_user_id"]),
+}
+
+
+@context.inject
+@context.root.register_eager_singleton()
+class Watchman:
+    conf: Config
+    apscheduler: AsyncIOScheduler
+    repo: WatchTaskRepo
+    binder: PixivAccountBinder
+    postman_mgr: PostmanManager
+    pd_factory_mgr: PostDestinationFactoryManager
+    shared_agen: WatchmanSharedAsyncGeneratorManager
+
+    def __init__(self):
+        on_bot_connect(self.on_bot_connect, replay=True)
+        on_bot_disconnect(self.on_bot_disconnect)
+
+    async def _post_illust(self, illust: Illust, *,
+                           header: Optional[str] = None,
+                           post_dest: PD):
+        model = await IllustMessageModel.from_illust(illust, header=header)
+        if model is not None:
+            await self.postman_mgr.send_illust(model, post_dest=post_dest)
+
+    async def _get_following_illusts(self, task: WatchTask, post_dest: PD):
+        pixiv_user_id = task.kwargs.get("pixiv_user_id", 0)
+        sender_user_id = task.kwargs.get("sender_user_id", 0)
+
+        if not pixiv_user_id and sender_user_id:
+            pixiv_user_id = await self.binder.get_binding(post_dest.adapter, sender_user_id)
+
+        if not pixiv_user_id:
+            pixiv_user_id = self.conf.pixiv_random_bookmark_user_id
+
+        if not pixiv_user_id:
+            logger.warning(f"[watchman] no binding found for {post_dest}")
+            return None
+        else:
+            return self.shared_agen.get(
+                WatchmanSharedAgenIdentifier(WatchType.following_illusts, user_id=pixiv_user_id)
+            )
+
+    async def _get_user_illusts(self, task: WatchTask, post_dest: PD):
+        return self.shared_agen.get(WatchmanSharedAgenIdentifier(WatchType.user_illusts, **task.kwargs))
+
+    _ctx_mgr_factory = {
+        WatchType.following_illusts: _get_following_illusts,
+        WatchType.user_illusts: _get_user_illusts
+    }
+
+    async def _handle(self, task: WatchTask, post_dest: PD):
+        # logger.info(f"[watchman] handle user_illust {pixiv_user_id}")
+        ctx_mgr = await Watchman._ctx_mgr_factory[task.type](self, task, post_dest)
+        if ctx_mgr:
+            with ctx_mgr as iter:
+                async for illust in iter:
+                    if illust.create_date > task.checkpoint:
+                        logger.info(f"[watchman] send illust {illust.id} to {task.subscriber}")
+                        await self._post_illust(illust, header=headers.get(task.type, ""),
+                                                post_dest=post_dest)
+                    else:
+                        break
+
+        task.checkpoint = datetime.now(timezone.utc)
+        await self.repo.update(task)
+
+    @staticmethod
+    def _make_job_id(type: WatchType,
+                     kwargs: Dict[str, Any],
+                     subscriber: PostIdentifier[UID, GID]):
+        args = ', '.join(map(lambda k: f'{k}={kwargs[k]}', kwargs))
+        return f'watchman {type.name} {args} {subscriber}'
+
+    def _make_job_trigger(self, task: WatchTask):
+        hasher = trigger_hasher_mapper[task.type]
+        hash_sec = hasher(task.kwargs) % self.conf.pixiv_watch_interval
+        yesterday = datetime.now(timezone.utc).replace(
+            hour=0, minute=0, second=0, microsecond=0
+        ) + timedelta(days=-1) + timedelta(seconds=hash_sec)
+        trigger = IntervalTrigger(seconds=self.conf.pixiv_watch_interval, start_date=yesterday)
+        return trigger
+
+    def _add_job(self, task: WatchTask, post_dest: PD):
+        job_id = self._make_job_id(task.type, task.kwargs, task.subscriber)
+        trigger = self._make_job_trigger(task)
+        self.apscheduler.add_job(self._handle, id=job_id, trigger=trigger,
+                                 args=[task, post_dest],
+                                 max_instances=1)
+        logger.success(f"[watchman] add job \"{job_id}\" on {trigger}")
+
+    def _remove_job(self, type: WatchType,
+                    kwargs: Dict[str, Any],
+                    subscriber: PostIdentifier[UID, GID]):
+        job_id = self._make_job_id(type, kwargs, subscriber)
+        self.apscheduler.remove_job(job_id)
+        logger.success(f"[watchman] remove job \"{job_id}\"")
+
+    async def on_bot_connect(self, bot: Bot):
+        adapter = get_adapter_name(bot)
+        async for task in self.repo.get_by_adapter(adapter):
+            pd = self.pd_factory_mgr.build(bot, task.subscriber.user_id, task.subscriber.group_id)
+            self._add_job(task, pd)
+
+    async def on_bot_disconnect(self, bot: Bot):
+        adapter = get_adapter_name(bot)
+        async for task in self.repo.get_by_adapter(adapter):
+            try:
+                self._remove_job(task.type, task.kwargs, task.subscriber)
+            except Exception as e:
+                logger.exception(e)
+
+    async def watch(self, type: WatchType,
+                    kwargs: Dict[str, Any],
+                    subscriber: PD):
+        task = WatchTask(type=type, kwargs=kwargs, subscriber=subscriber.identifier)
+        old_task = await self.repo.update(task)
+        if old_task is not None:
+            self._remove_job(old_task.type, old_task.kwargs, old_task.subscriber)
+        self._add_job(task, subscriber.normalized())
+
+    async def unwatch(self, type: WatchType,
+                      kwargs: Dict[str, Any],
+                      subscriber: ID) -> bool:
+        if await self.repo.delete_one(type, kwargs, subscriber):
+            self._remove_job(type, kwargs, subscriber)
+            return True
+        else:
+            return False
+
+    async def unwatch_all_by_subscriber(self, subscriber: ID):
+        async for task in self.repo.get_by_subscriber(subscriber):
+            self._remove_job(task.type, task.kwargs, subscriber)
+        await self.repo.delete_many_by_subscriber(subscriber)
+
+    async def get_by_subscriber(self, subscriber: ID):
+        return [x async for x in self.repo.get_by_subscriber(subscriber)]
diff --git a/src/nonebot_plugin_pixivbot/utils/expires_lru_dict.py b/src/nonebot_plugin_pixivbot/utils/expires_lru_dict.py
index 886353b..de22d04 100644
--- a/src/nonebot_plugin_pixivbot/utils/expires_lru_dict.py
+++ b/src/nonebot_plugin_pixivbot/utils/expires_lru_dict.py
@@ -1,7 +1,7 @@
 from collections import OrderedDict
 from collections.abc import MutableMapping
 from dataclasses import dataclass
-from datetime import datetime
+from datetime import datetime, timezone
 from functools import total_ordering
 from heapq import heappop, heapify, heappush
 from typing import TypeVar, Generic, Iterator, List, Dict, Optional, Callable
@@ -30,7 +30,7 @@ def __init__(self, maxsize: int, on_cleanup: Optional[Callable[[_KT, _VT], None]
         self._in_expires_heap: Dict[_KT, bool] = {}
 
     def _collate(self, ensure_size_for_put: bool):
-        now = datetime.now()
+        now = datetime.now(timezone.utc)
         while len(self._expires_heap) > 0 and self._expires_heap[0].expires_time <= now:
             k = self._expires_heap[0].key
             if k in self._data:
diff --git a/src/nonebot_plugin_pixivbot/utils/lazy_delegation.py b/src/nonebot_plugin_pixivbot/utils/lazy_delegation.py
index 24a36ab..69d8e97 100644
--- a/src/nonebot_plugin_pixivbot/utils/lazy_delegation.py
+++ b/src/nonebot_plugin_pixivbot/utils/lazy_delegation.py
@@ -1,13 +1,9 @@
-from lazy import lazy
-
-
 class LazyDelegation:
     def __init__(self, builder):
+        self.cache = None
         self.builder = builder
 
-    @lazy
-    def by(self):
-        return self.builder()
-
-    def __getattr__(self, item):
-        return self.by.__getattribute__(item)
+    def __getattribute__(self, item):
+        if object.__getattribute__(self, "cache") is None:
+            object.__setattr__(self, "cache", object.__getattribute__(self, "builder")())
+        return getattr(object.__getattribute__(self, "cache"), item)
diff --git a/src/nonebot_plugin_pixivbot/utils/lifecycler.py b/src/nonebot_plugin_pixivbot/utils/lifecycler.py
index e484dea..e1eacfb 100644
--- a/src/nonebot_plugin_pixivbot/utils/lifecycler.py
+++ b/src/nonebot_plugin_pixivbot/utils/lifecycler.py
@@ -4,8 +4,6 @@
 """
 
 import asyncio
-import threading
-from asyncio import Future
 from inspect import isawaitable
 
 from nonebot import Bot, get_driver, logger
diff --git a/src/nonebot_plugin_pixivbot/data/pixiv_repo/shared_agen.py b/src/nonebot_plugin_pixivbot/utils/shared_agen.py
similarity index 64%
rename from src/nonebot_plugin_pixivbot/data/pixiv_repo/shared_agen.py
rename to src/nonebot_plugin_pixivbot/utils/shared_agen.py
index 7dd87b2..c2fb557 100644
--- a/src/nonebot_plugin_pixivbot/data/pixiv_repo/shared_agen.py
+++ b/src/nonebot_plugin_pixivbot/utils/shared_agen.py
@@ -1,13 +1,14 @@
 from abc import ABC, abstractmethod
 from asyncio import Lock, create_task
 from contextlib import AbstractContextManager
-from datetime import datetime
+from datetime import datetime, timezone
 from inspect import isawaitable
 from types import TracebackType
 from typing import Any, Awaitable, Generic, TypeVar, Callable, Union, AsyncGenerator, List, Type, Optional, Dict
 
 from nonebot import logger
 
+from nonebot_plugin_pixivbot.data.pixiv_repo.enums import CacheStrategy
 from nonebot_plugin_pixivbot.utils.expires_lru_dict import ExpiresLruDict
 
 T_ID = TypeVar("T_ID")
@@ -82,59 +83,96 @@ def close(self):
 
 
 class SharedAsyncGeneratorManager(ABC, Generic[T_ID, T_ITEM]):
+    log_tag = "shared_agen"
+
     def __init__(self):
         self._ctx_mgr = {}
-        self._paused_ctx_mgr = ExpiresLruDict(1024, SharedAsyncGeneratorManager._cleanup)
+        self._paused_ctx_mgr = ExpiresLruDict(1024, self._cleanup)
         self._expires_time: Dict[T_ID, datetime] = {}
 
     @abstractmethod
-    def agen_factory(self, identifier: T_ID, *args, **kwargs) -> AsyncGenerator[T_ITEM, None]:
+    def agen_factory(self, identifier: T_ID,
+                     cache_strategy: CacheStrategy,
+                     **kwargs) -> AsyncGenerator[T_ITEM, None]:
         raise NotImplementedError()
 
-    def on_agen_next(self, identifier: T_ID, item: T_ITEM) -> Union[None, Awaitable[None]]:
+    async def on_agen_next(self, identifier: T_ID, item: T_ITEM):
         pass
 
-    def on_agen_stop(self, identifier: T_ID, items: List[T_ITEM]) -> Union[None, Awaitable[None]]:
+    async def on_agen_stop(self, identifier: T_ID, items: List[T_ITEM]):
         pass
 
-    @staticmethod
-    def _cleanup(identifier: T_ID, ctx_mgr: SharedAsyncGeneratorContextManager[T_ITEM]):
+    def _cleanup(self, identifier: T_ID, ctx_mgr: SharedAsyncGeneratorContextManager[T_ITEM]):
+        logger.debug(f"[{self.log_tag}] {identifier} was popped")
         ctx_mgr.close()
+        if identifier in self._expires_time:
+            del self._expires_time[identifier]
 
     def on_consumers_changed(self, identifier: T_ID,
                              ctx_mgr: SharedAsyncGeneratorContextManager[T_ITEM],
                              consumers: int):
         if identifier in self._paused_ctx_mgr and consumers > 0:
-            logger.debug(f"[agen_manager] {identifier} re-started")
+            logger.debug(f"[{self.log_tag}] {identifier} re-started")
             del self._paused_ctx_mgr[identifier]
             self._ctx_mgr[identifier] = ctx_mgr
         elif identifier in self._ctx_mgr and consumers == 0:
             del self._ctx_mgr[identifier]
-            self._cleanup(identifier, ctx_mgr)
             if identifier in self._expires_time:
-                logger.debug(f"[agen_manager] {identifier} paused")
+                logger.debug(f"[{self.log_tag}] {identifier} paused")
                 self._paused_ctx_mgr.add(identifier, ctx_mgr, self._expires_time[identifier])
             else:
-                logger.debug(f"[agen_manager] {identifier} stopped")
+                self._cleanup(identifier, ctx_mgr)
+                logger.debug(f"[{self.log_tag}] {identifier} stopped")
+
+    def invalidate(self, identifier: T_ID):
+        if identifier in self._paused_ctx_mgr:
+            logger.debug(f"[{self.log_tag}] {identifier} was invalidated from paused state")
+            self._paused_ctx_mgr.pop(identifier).close()
+        elif identifier in self._ctx_mgr:
+            logger.debug(f"[{self.log_tag}] {identifier} was invalidated from running state")
+            self._ctx_mgr.pop(identifier)
+
+        if identifier in self._expires_time:
+            del self._expires_time[identifier]
+
+    def invalidate_all(self):
+        for identifier in self._paused_ctx_mgr:
+            logger.debug(f"[{self.log_tag}] {identifier} was invalidated from paused state")
+            self._paused_ctx_mgr.pop(identifier).close()
+
+        for identifier in self._ctx_mgr:
+            logger.debug(f"[{self.log_tag}] {identifier} was invalidated from running state")
+            self._ctx_mgr.pop(identifier)
+
+        self._expires_time.clear()
 
     def get_expires_time(self, identifier: T_ID) -> Optional[datetime]:
         return self._expires_time.get(identifier, None)
 
     def set_expires_time(self, identifier: T_ID, expires_time: datetime):
-        if identifier not in self._expires_time:
+        # 即使当前没有该ctx_mgr也可以设置
+        # 在调用invalidate或者被_paused_ctx_mgr弹出后从_expires_time中删除
+        if expires_time <= datetime.now(timezone.utc):
+            self.invalidate(identifier)
+        elif identifier not in self._expires_time:
             self._expires_time[identifier] = expires_time
-            logger.debug(f"[agen_manager] {identifier} will expire at {expires_time}")
+            logger.debug(f"[{self.log_tag}] {identifier} will expire at {expires_time}")
         elif self._expires_time[identifier] != expires_time:
-            raise RuntimeError(f"{identifier} expires time already set")
+            logger.warning(f"[{self.log_tag}] {identifier}'s expires time was already set")
 
-    def get(self, identifier: Any, *args, **kwargs) \
+    def get(self, identifier: Any,
+            cache_strategy: CacheStrategy = CacheStrategy.NORMAL,
+            **kwargs) \
             -> SharedAsyncGeneratorContextManager[T_ITEM]:
+        if cache_strategy == CacheStrategy.FORCE_EXPIRATION:
+            self.invalidate(identifier)
+
         if identifier in self._paused_ctx_mgr:
             return self._paused_ctx_mgr[identifier]
         elif identifier in self._ctx_mgr:
             return self._ctx_mgr[identifier]
         else:
-            origin = self.agen_factory(identifier, *args, **kwargs)
+            origin = self.agen_factory(identifier, cache_strategy, **kwargs)
             self._ctx_mgr[identifier] = SharedAsyncGeneratorContextManager(
                 origin=origin,
                 on_each=lambda item: self.on_agen_next(identifier, item),


Test patch:
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index 96e45e3..097edc8 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -6,3 +6,4 @@ def conf_env(monkeypatch):
     monkeypatch.setenv("PIXIV_MONGO_CONN_URL", "123")
     monkeypatch.setenv("PIXIV_MONGO_DATABASE_NAME", "123")
     monkeypatch.setenv("PIXIV_REFRESH_TOKEN", "123")
+    monkeypatch.setenv("SUPERUSERS", "[\"test:123456\"]")
\ No newline at end of file
diff --git a/src/tests/data/pixiv_repo/fake_pixiv_repo.py b/src/tests/data/pixiv_repo/fake_pixiv_repo.py
new file mode 100644
index 0000000..abc5ca0
--- /dev/null
+++ b/src/tests/data/pixiv_repo/fake_pixiv_repo.py
@@ -0,0 +1,53 @@
+from typing import AsyncGenerator
+from unittest.mock import AsyncMock
+
+import pytest
+
+from nonebot_plugin_pixivbot.data.pixiv_repo import LazyIllust
+from nonebot_plugin_pixivbot.enums import RankingMode
+from nonebot_plugin_pixivbot.model import Illust, User
+from tests import MyTest
+
+
+class FakePixivRepoMixin(MyTest):
+    @pytest.fixture
+    def fake_pixiv_repo(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.data.pixiv_repo import PixivRepo
+        from nonebot_plugin_pixivbot.data.pixiv_repo.abstract_repo import AbstractPixivRepo
+
+        @context.bind_singleton_to(PixivRepo)
+        class FakePixivRepo(AbstractPixivRepo):
+            invalidate_cache = AsyncMock()
+
+            def illust_detail(self, illust_id: int) -> AsyncGenerator[Illust, None]:
+                pass
+
+            def user_detail(self, user_id: int) -> AsyncGenerator[User, None]:
+                pass
+
+            def search_illust(self, word: str) -> AsyncGenerator[LazyIllust, None]:
+                pass
+
+            def search_user(self, word: str) -> AsyncGenerator[User, None]:
+                pass
+
+            def user_illusts(self, user_id: int) -> AsyncGenerator[LazyIllust, None]:
+                pass
+
+            def user_bookmarks(self, user_id: int = 0) -> AsyncGenerator[LazyIllust, None]:
+                pass
+
+            def recommended_illusts(self) -> AsyncGenerator[LazyIllust, None]:
+                pass
+
+            def related_illusts(self, illust_id: int) -> AsyncGenerator[LazyIllust, None]:
+                pass
+
+            def illust_ranking(self, mode: RankingMode) -> AsyncGenerator[LazyIllust, None]:
+                pass
+
+            def image(self, illust: Illust) -> AsyncGenerator[bytes, None]:
+                pass
+
+        return FakePixivRepo
diff --git a/src/tests/data/pixiv_repo/test_mediator.py b/src/tests/data/pixiv_repo/test_mediator.py
index 85667d7..135f4ee 100644
--- a/src/tests/data/pixiv_repo/test_mediator.py
+++ b/src/tests/data/pixiv_repo/test_mediator.py
@@ -1,6 +1,6 @@
 from asyncio import sleep
 from datetime import datetime
-from unittest.mock import AsyncMock, call
+from unittest.mock import AsyncMock
 
 import pytest
 
@@ -96,10 +96,16 @@ def cache_factory_with_cache(self):
 
         async def agen():
             await sleep(0.1)
+
+            cache_metadata.pages = 0
             yield cache_metadata
+
             for i in range(30):
                 yield i
 
+            cache_metadata.pages = 5
+            yield cache_metadata
+
             # meta, 0, 1, ..., 29
 
         return agen
@@ -114,10 +120,16 @@ def cache_factory_with_cache_and_empty_next_qs(self):
 
         async def agen():
             await sleep(0.1)
+
+            cache_metadata.pages = 0
             yield cache_metadata
+
             for i in range(30):
                 yield i
 
+            cache_metadata.pages = 5
+            yield cache_metadata
+
             # meta, 0, 1, ..., 29
 
         return agen
@@ -175,13 +187,11 @@ async def test_with_cache_and_empty_next_qs(self, cache_factory_with_cache_and_e
         from nonebot_plugin_pixivbot.data.pixiv_repo.mediator import mediate_many
         from nonebot_plugin_pixivbot.data.pixiv_repo.abstract_repo import PixivRepoMetadata
 
-        cache_updater = AsyncMock()
         cache_appender = AsyncMock()
 
         items = []
         metadata = None
-        async for x in mediate_many(cache_factory_with_cache_and_empty_next_qs, remote_factory,
-                                    cache_updater, cache_appender):
+        async for x in mediate_many(cache_factory_with_cache_and_empty_next_qs, remote_factory, cache_appender):
             if not isinstance(x, PixivRepoMetadata):
                 items.append(x)
             else:
@@ -189,7 +199,6 @@ async def test_with_cache_and_empty_next_qs(self, cache_factory_with_cache_and_e
 
         assert items == [x for x in range(30)]
         assert metadata.update_time == datetime.fromtimestamp(0)  # assert metadata comes from cache
-        cache_updater.assert_not_awaited()
         cache_appender.assert_not_awaited()
 
     @pytest.mark.asyncio
@@ -197,13 +206,14 @@ async def test_with_cache_and_remote(self, cache_factory_with_cache, remote_fact
         from nonebot_plugin_pixivbot.data.pixiv_repo.mediator import mediate_many
         from nonebot_plugin_pixivbot.data.pixiv_repo.abstract_repo import PixivRepoMetadata
 
-        cache_updater = AsyncMock()
-        cache_appender = AsyncMock()
+        cache_appender_calls = []
+
+        async def cache_appender(items, metadata):
+            cache_appender_calls.append((list(items), metadata))
 
         items = []
         metadata = None
-        async for x in mediate_many(cache_factory_with_cache, remote_factory,
-                                    cache_updater, cache_appender):
+        async for x in mediate_many(cache_factory_with_cache, remote_factory, cache_appender):
             if not isinstance(x, PixivRepoMetadata):
                 items.append(x)
             else:
@@ -211,21 +221,25 @@ async def test_with_cache_and_remote(self, cache_factory_with_cache, remote_fact
 
         assert items == [x for x in range(90)]
         assert metadata.update_time != datetime.fromtimestamp(0)  # assert metadata comes from remote
-        cache_updater.assert_not_awaited()
-        cache_appender.assert_awaited_once_with([x for x in range(30, 90)], metadata)
+        assert cache_appender_calls == [
+            ([x for x in range(30, 50)], PixivRepoMetadata(pages=6, next_qs={"next": 50})),
+            ([x for x in range(50, 70)], PixivRepoMetadata(pages=7, next_qs={"next": 70})),
+            ([x for x in range(70, 90)], PixivRepoMetadata(pages=8, next_qs={"next": 90})),
+        ]
 
     @pytest.mark.asyncio
     async def test_with_remote(self, cache_factory_with_no_cache, remote_factory):
         from nonebot_plugin_pixivbot.data.pixiv_repo.mediator import mediate_many
         from nonebot_plugin_pixivbot.data.pixiv_repo.abstract_repo import PixivRepoMetadata
 
-        cache_updater = AsyncMock()
-        cache_appender = AsyncMock()
+        cache_appender_calls = []
+
+        async def cache_appender(items, metadata):
+            cache_appender_calls.append((list(items), metadata))
 
         items = []
         metadata = None
-        async for x in mediate_many(cache_factory_with_no_cache, remote_factory,
-                                    cache_updater, cache_appender):
+        async for x in mediate_many(cache_factory_with_no_cache, remote_factory, cache_appender):
             if not isinstance(x, PixivRepoMetadata):
                 items.append(x)
             else:
@@ -233,8 +247,11 @@ async def test_with_remote(self, cache_factory_with_no_cache, remote_factory):
 
         assert items == [x for x in range(60)]
         assert metadata.update_time != datetime.fromtimestamp(0)  # assert metadata comes from remote
-        cache_updater.assert_awaited_once_with([x for x in range(60)], metadata)
-        cache_appender.assert_not_awaited()
+        assert cache_appender_calls == [
+            ([x for x in range(0, 20)], PixivRepoMetadata(pages=1, next_qs={"next": 20})),
+            ([x for x in range(20, 40)], PixivRepoMetadata(pages=2, next_qs={"next": 40})),
+            ([x for x in range(40, 60)], PixivRepoMetadata(pages=3, next_qs={"next": 60})),
+        ]
 
 
 class TestMediateAppend(TestMediateCollection):
@@ -243,13 +260,11 @@ async def test_with_cache_and_empty_next_qs(self, cache_factory_with_cache_and_e
         from nonebot_plugin_pixivbot.data.pixiv_repo.mediator import mediate_append
         from nonebot_plugin_pixivbot.data.pixiv_repo.abstract_repo import PixivRepoMetadata
 
-        cache_updater = AsyncMock()
         cache_appender = AsyncMock()
 
         items = []
         metadata = None
-        async for x in mediate_append(cache_factory_with_cache_and_empty_next_qs, remote_factory,
-                                      cache_updater, cache_appender):
+        async for x in mediate_append(cache_factory_with_cache_and_empty_next_qs, remote_factory, cache_appender):
             if not isinstance(x, PixivRepoMetadata):
                 items.append(x)
             else:
@@ -257,7 +272,6 @@ async def test_with_cache_and_empty_next_qs(self, cache_factory_with_cache_and_e
 
         assert items == [x for x in range(30)]
         assert metadata.update_time == datetime.fromtimestamp(0)  # assert metadata comes from cache
-        cache_updater.assert_not_awaited()
         cache_appender.assert_not_awaited()
 
     @pytest.mark.asyncio
@@ -265,13 +279,14 @@ async def test_with_cache_and_remote(self, cache_factory_with_cache, remote_fact
         from nonebot_plugin_pixivbot.data.pixiv_repo.mediator import mediate_append
         from nonebot_plugin_pixivbot.data.pixiv_repo.abstract_repo import PixivRepoMetadata
 
-        cache_updater = AsyncMock()
-        cache_appender = AsyncMock()
+        cache_appender_calls = []
+
+        async def cache_appender(items, metadata):
+            cache_appender_calls.append((list(items), metadata))
 
         items = []
         metadata = None
-        async for x in mediate_append(cache_factory_with_cache, remote_factory,
-                                      cache_updater, cache_appender):
+        async for x in mediate_append(cache_factory_with_cache, remote_factory, cache_appender):
             if not isinstance(x, PixivRepoMetadata):
                 items.append(x)
             else:
@@ -279,21 +294,25 @@ async def test_with_cache_and_remote(self, cache_factory_with_cache, remote_fact
 
         assert items == [x for x in range(90)]
         assert metadata.update_time != datetime.fromtimestamp(0)  # assert metadata comes from remote
-        cache_updater.assert_not_awaited()
-        cache_appender.assert_awaited_once_with([x for x in range(30, 90)], metadata)
+        assert cache_appender_calls == [
+            ([x for x in range(30, 50)], PixivRepoMetadata(pages=6, next_qs={"next": 50})),
+            ([x for x in range(50, 70)], PixivRepoMetadata(pages=7, next_qs={"next": 70})),
+            ([x for x in range(70, 90)], PixivRepoMetadata(pages=8, next_qs={"next": 90})),
+        ]
 
     @pytest.mark.asyncio
     async def test_with_remote(self, cache_factory_with_no_cache, remote_factory):
         from nonebot_plugin_pixivbot.data.pixiv_repo.mediator import mediate_append
         from nonebot_plugin_pixivbot.data.pixiv_repo.abstract_repo import PixivRepoMetadata
 
-        cache_updater = AsyncMock()
-        cache_appender = AsyncMock()
+        cache_appender_calls = []
+
+        async def cache_appender(items, metadata):
+            cache_appender_calls.append((list(items), metadata))
 
         items = []
         metadata = None
-        async for x in mediate_append(cache_factory_with_no_cache, remote_factory,
-                                      cache_updater, cache_appender):
+        async for x in mediate_append(cache_factory_with_no_cache, remote_factory, cache_appender):
             if not isinstance(x, PixivRepoMetadata):
                 items.append(x)
             else:
@@ -301,8 +320,11 @@ async def test_with_remote(self, cache_factory_with_no_cache, remote_factory):
 
         assert items == [x for x in range(60)]
         assert metadata.update_time != datetime.fromtimestamp(0)  # assert metadata comes from remote
-        cache_updater.assert_awaited_once_with([x for x in range(60)], metadata)
-        cache_appender.assert_not_awaited()
+        assert cache_appender_calls == [
+            ([x for x in range(0, 20)], PixivRepoMetadata(pages=1, next_qs={"next": 20})),
+            ([x for x in range(20, 40)], PixivRepoMetadata(pages=2, next_qs={"next": 40})),
+            ([x for x in range(40, 60)], PixivRepoMetadata(pages=3, next_qs={"next": 60})),
+        ]
 
     @pytest.mark.asyncio
     async def test_with_expired_cache(self, cache_factory_with_cache,
@@ -319,20 +341,17 @@ def cache_factory():
             else:
                 return cache_factory_with_cache()
 
+        cache_appender_calls = []
+
         async def cache_appender(items, metadata):
             nonlocal cache_updated
-
-            await sleep(0.1)
             cache_updated = True
+            cache_appender_calls.append((list(items), metadata))
             return True
 
-        cache_updater = AsyncMock()
-        cache_appender = AsyncMock(side_effect=cache_appender)
-
         items = []
         metadata = []
-        async for x in mediate_append(cache_factory, remote_factory,
-                                      cache_updater, cache_appender):
+        async for x in mediate_append(cache_factory, remote_factory, cache_appender):
             if not isinstance(x, PixivRepoMetadata):
                 items.append(x)
             else:
@@ -341,11 +360,14 @@ async def cache_appender(items, metadata):
         assert items == [x for x in range(90)]
         assert metadata[0].update_time == datetime.fromtimestamp(0)  # assert the first metadata comes from cache
         assert metadata[-1].update_time != datetime.fromtimestamp(0)  # assert the last metadata comes from remote
-        cache_updater.assert_not_awaited()
 
         # assert the first call for peek remote update with updated metadata
-        assert cache_appender.await_args_list[0].args[0] == [x for x in range(20)]
-        assert cache_appender.await_args_list[0].args[1].next_qs == metadata[0].next_qs
+        assert cache_appender_calls[0][0] == [x for x in range(20)]
+        assert cache_appender_calls[0][1].next_qs == metadata[0].next_qs
 
         # assert the second call for append remote data
-        assert cache_appender.await_args_list[1] == call([x for x in range(30, 90)], metadata[-1])
+        assert cache_appender_calls[1:] == [
+            ([x for x in range(30, 50)], PixivRepoMetadata(pages=6, next_qs={"next": 50})),
+            ([x for x in range(50, 70)], PixivRepoMetadata(pages=7, next_qs={"next": 70})),
+            ([x for x in range(70, 90)], PixivRepoMetadata(pages=8, next_qs={"next": 90})),
+        ]
diff --git a/src/tests/handler/__init__.py b/src/tests/handler/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/handler/command/__init__.py b/src/tests/handler/command/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/handler/command/test_bind.py b/src/tests/handler/command/test_bind.py
new file mode 100644
index 0000000..1176561
--- /dev/null
+++ b/src/tests/handler/command/test_bind.py
@@ -0,0 +1,97 @@
+import pytest
+
+from tests import MyTest
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_account_binder import FakePixivAccountBinderMixin
+
+
+class TestBindHandler(FakePostDestinationMixin,
+                      FakePostmanManagerMixin,
+                      FakePixivAccountBinderMixin,
+                      MyTest):
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import BindHandler
+
+        context.require(BindHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg(self, fake_post_destination, fake_postman_manager, fake_pixiv_account_binder):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import BindHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前未绑定Pixiv账号\n命令格式：/pixivbot bind <pixiv_user_id>"
+
+        await context.require(BindHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_bind(self, fake_post_destination, fake_postman_manager, fake_pixiv_account_binder):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import BindHandler
+        from nonebot_plugin_pixivbot.model import PixivBinding
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "Pixiv账号绑定成功"
+
+        await context.require(BindHandler).handle(123321, post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+        assert context.require(fake_pixiv_account_binder).bindings[("test", 1234)] == PixivBinding(adapter="test",
+                                                                                                   user_id=1234,
+                                                                                                   pixiv_user_id=123321)
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_arg(self, fake_post_destination, fake_postman_manager, fake_pixiv_account_binder):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import BindHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "请输入正确格式的Pixiv账号"
+
+        await context.require(BindHandler).handle("invalid_arg_lol", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+        assert ("test", 1234) not in context.require(fake_pixiv_account_binder).bindings
+
+
+class TestUnbindHandler(FakePostDestinationMixin,
+                        FakePostmanManagerMixin,
+                        FakePixivAccountBinderMixin,
+                        MyTest):
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import UnbindHandler
+
+        context.require(UnbindHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle_bind(self, fake_post_destination, fake_postman_manager, fake_pixiv_account_binder):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import UnbindHandler
+
+        await context.require(fake_pixiv_account_binder).bind(adapter="test",
+                                                              user_id=1234,
+                                                              pixiv_user_id=123321)
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "Pixiv账号解绑成功"
+
+        await context.require(UnbindHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+        assert ("test", 1234) not in context.require(fake_pixiv_account_binder).bindings
+
+    @pytest.mark.asyncio
+    async def test_handle_not_bind(self, fake_post_destination, fake_postman_manager, fake_pixiv_account_binder):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import UnbindHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前未绑定Pixiv账号"
+
+        await context.require(UnbindHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
diff --git a/src/tests/handler/command/test_command.py b/src/tests/handler/command/test_command.py
new file mode 100644
index 0000000..d9a06c5
--- /dev/null
+++ b/src/tests/handler/command/test_command.py
@@ -0,0 +1,85 @@
+from typing import Sequence
+
+import pytest
+
+from tests import MyTest
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+
+
+class TestCommandHandler(FakePostDestinationMixin,
+                         FakePostmanManagerMixin,
+                         MyTest):
+
+    @pytest.fixture
+    def stub_handler(self, load_pixivbot, fake_postman_manager):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+        from nonebot_plugin_pixivbot.handler.command.command import SubCommandHandler, CommandHandler
+
+        @context.require(CommandHandler).sub_command("stub")
+        class StubHandler(SubCommandHandler):
+            @classmethod
+            def type(cls) -> str:
+                return "stub"
+
+            def enabled(self) -> bool:
+                return True
+
+            def parse_args(self, args: Sequence[str], post_dest: PostDestination[int, int]) -> dict:
+                return {"a": args[0], "b": args[1]}
+
+            # noinspection PyMethodOverriding
+            async def actual_handle(self, *,
+                                    a: str,
+                                    b: str,
+                                    post_dest: PostDestination[int, int],
+                                    silently: bool = False,
+                                    **kwargs):
+                await self.post_plain_text(f"stub a={a} b={b}", post_dest)
+
+        return StubHandler
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import CommandHandler
+
+        context.require(CommandHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_no_arg(self, fake_post_destination,
+                          fake_postman_manager,
+                          mocker):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import CommandHandler, HelpHandler
+
+        help_handler = context.require(HelpHandler)
+        help_handler.interceptor = None
+        spy = mocker.spy(help_handler, "handle")
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(CommandHandler).handle([], post_dest=post_dest)
+        spy.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_invalid_subcommand(self, fake_post_destination,
+                                      fake_postman_manager):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import CommandHandler
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(CommandHandler).handle(["this_should_be_an_invalid_cmd"], post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, f"不存在命令 'this_should_be_an_invalid_cmd'")
+
+    @pytest.mark.asyncio
+    async def test(self, fake_post_destination, fake_postman_manager, stub_handler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import CommandHandler
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(CommandHandler).handle(["stub", "aa", "bb"], post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, f"stub a=aa b=bb")
diff --git a/src/tests/handler/command/test_invalidate_cache.py b/src/tests/handler/command/test_invalidate_cache.py
new file mode 100644
index 0000000..750dae5
--- /dev/null
+++ b/src/tests/handler/command/test_invalidate_cache.py
@@ -0,0 +1,42 @@
+from unittest.mock import AsyncMock
+
+import pytest
+
+from tests import MyTest
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+
+
+class TestInvalidateCache(FakePostDestinationMixin,
+                          FakePostmanManagerMixin,
+                          MyTest):
+    @pytest.fixture
+    def fake_pixiv_repo(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.data.pixiv_repo import PixivRepo
+
+        @context.bind_singleton_to(PixivRepo)
+        class PixivRepo:
+            invalidate_cache = AsyncMock()
+
+        return PixivRepo
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import InvalidateCacheHandler
+
+        context.require(InvalidateCacheHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_repo, fake_postman_manager):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command import InvalidateCacheHandler
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "ok"
+
+        await context.require(InvalidateCacheHandler).handle(post_dest=post_dest)
+        assert context.require(fake_postman_manager).calls[0] == (post_dest, except_msg)
+        context.require(fake_pixiv_repo).invalidate_cache.has_awaited()
diff --git a/src/tests/handler/command/test_schedule.py b/src/tests/handler/command/test_schedule.py
new file mode 100644
index 0000000..ae22ca5
--- /dev/null
+++ b/src/tests/handler/command/test_schedule.py
@@ -0,0 +1,264 @@
+import pytest
+
+from tests import MyTest
+from tests.protocol_dep.fake_auth_manager import FakeAuthenticatorManagerMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_account_binder import FakePixivAccountBinderMixin
+from tests.service.fake_scheduler import FakeSchedulerMixin
+
+
+class TestScheduleHandler(FakeSchedulerMixin,
+                          FakeAuthenticatorManagerMixin,
+                          FakePostDestinationMixin,
+                          FakePostmanManagerMixin,
+                          FakePixivAccountBinderMixin,
+                          MyTest):
+    help_text = "\n" \
+                "命令格式：/pixivbot schedule <type> <schedule> [..args]\n" \
+                "参数：\n" \
+                "  <type>：可选值有random_bookmark, random_recommended_illust, random_illust, " \
+                "random_user_illust, ranking\n" \
+                "  <schedule>：格式为HH:mm（每日固定时间点推送）或HH:mm*x（间隔时间推送）\n" \
+                "  [...args]：根据<type>不同需要提供不同的参数\n" \
+                "示例：/pixivbot schedule ranking 06:00*x day 1-5"
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import ScheduleHandler
+
+        context.require(ScheduleHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg_no_sub(self, fake_post_destination,
+                                        fake_postman_manager,
+                                        fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import ScheduleHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(ScheduleHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg(self, fake_post_destination,
+                                 fake_postman_manager,
+                                 fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import ScheduleHandler
+        from nonebot_plugin_pixivbot.model import Subscription, ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "random_bookmark 00:00+00:30*x\n" \
+                     "ranking 06:00+00:00*x\n" + self.help_text
+
+        context.require(fake_scheduler).subscriptions[
+            (post_dest.identifier, ScheduleType.random_bookmark)
+        ] = Subscription(
+            type=ScheduleType.random_bookmark,
+            kwargs={},
+            subscriber=post_dest.identifier,
+            schedule=(0, 0, 0, 30)
+        )
+
+        context.require(fake_scheduler).subscriptions[
+            (post_dest.identifier, ScheduleType.ranking)
+        ] = Subscription(
+            type=ScheduleType.ranking,
+            kwargs={"type": "day"},
+            subscriber=post_dest.identifier,
+            schedule=(6, 0, 0, 0)
+        )
+
+        await context.require(ScheduleHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_interval_schedule(self, fake_post_destination,
+                                            fake_postman_manager,
+                                            fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import ScheduleHandler
+        from nonebot_plugin_pixivbot.model import Subscription, ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "订阅成功"
+        except_sub = Subscription(
+            type=ScheduleType.random_bookmark,
+            kwargs={},
+            subscriber=post_dest.identifier,
+            schedule=(0, 0, 0, 30)
+        )
+
+        await context.require(ScheduleHandler).handle("random_bookmark", "00:30*x", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+        assert context.require(fake_scheduler).subscriptions[
+                   (post_dest.identifier, ScheduleType.random_bookmark)
+               ] == except_sub
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_args(self, fake_post_destination,
+                                       fake_postman_manager,
+                                       fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import ScheduleHandler
+        from nonebot_plugin_pixivbot.model import ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "未知订阅类型：invalid_arg_lol\n" \
+                     "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(ScheduleHandler).handle("invalid_arg_lol", "00:30*x", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+        assert (post_dest.identifier, ScheduleType.random_bookmark) not in context.require(fake_scheduler).subscriptions
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_args_count(self, fake_post_destination,
+                                             fake_postman_manager,
+                                             fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import ScheduleHandler
+        from nonebot_plugin_pixivbot.model import ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(ScheduleHandler).handle("random_bookmark", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+        assert (post_dest.identifier, ScheduleType.random_bookmark) not in context.require(fake_scheduler).subscriptions
+
+
+class TestUnscheduleHandler(FakeSchedulerMixin,
+                            FakeAuthenticatorManagerMixin,
+                            FakePostDestinationMixin,
+                            FakePostmanManagerMixin,
+                            FakePixivAccountBinderMixin,
+                            MyTest):
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import UnscheduleHandler
+
+        context.require(UnscheduleHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg_no_sub(self, fake_post_destination,
+                                        fake_postman_manager,
+                                        fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import UnscheduleHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "无\n" \
+                     "\n" \
+                     "命令格式：/pixivbot unschedule <type>"
+
+        await context.require(UnscheduleHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg(self, fake_post_destination,
+                                 fake_postman_manager,
+                                 fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import UnscheduleHandler
+        from nonebot_plugin_pixivbot.model import Subscription, ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "random_bookmark 00:00+00:30*x\n" \
+                     "ranking 06:00+00:00*x\n" \
+                     "\n" \
+                     "命令格式：/pixivbot unschedule <type>"
+
+        context.require(fake_scheduler).subscriptions[
+            (post_dest.identifier, ScheduleType.random_bookmark)
+        ] = Subscription(
+            type=ScheduleType.random_bookmark,
+            kwargs={},
+            subscriber=post_dest.identifier,
+            schedule=(0, 0, 0, 30)
+        )
+
+        context.require(fake_scheduler).subscriptions[
+            (post_dest.identifier, ScheduleType.ranking)
+        ] = Subscription(
+            type=ScheduleType.ranking,
+            kwargs={"type": "day"},
+            subscriber=post_dest.identifier,
+            schedule=(6, 0, 0, 0)
+        )
+
+        await context.require(UnscheduleHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_postman_manager,
+                          fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import UnscheduleHandler
+        from nonebot_plugin_pixivbot.model import Subscription, ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "取消订阅成功"
+
+        context.require(fake_scheduler).subscriptions[
+            (post_dest.identifier, ScheduleType.random_bookmark)
+        ] = Subscription(
+            type=ScheduleType.random_bookmark,
+            kwargs={},
+            subscriber=post_dest.identifier,
+            schedule=(0, 0, 0, 30)
+        )
+
+        await context.require(UnscheduleHandler).handle("random_bookmark", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+        assert (post_dest.identifier, ScheduleType.random_bookmark) not in context.require(fake_scheduler).subscriptions
+
+    @pytest.mark.asyncio
+    async def test_handle_not_exist(self, fake_post_destination,
+                                    fake_postman_manager,
+                                    fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import UnscheduleHandler
+        from nonebot_plugin_pixivbot.model import ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "取消订阅失败，不存在该订阅\n" \
+                     "当前订阅：\n" \
+                     "无\n" \
+                     "\n" \
+                     "命令格式：/pixivbot unschedule <type>"
+
+        await context.require(UnscheduleHandler).handle("random_bookmark", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+        assert (post_dest.identifier, ScheduleType.random_bookmark) not in context.require(fake_scheduler).subscriptions
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_args(self, fake_post_destination,
+                                       fake_postman_manager,
+                                       fake_scheduler):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.schedule import UnscheduleHandler
+        from nonebot_plugin_pixivbot.model import ScheduleType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "未知订阅类型：invalid_arg_lol\n" \
+                     "当前订阅：\n" \
+                     "无\n" \
+                     "\n" \
+                     "命令格式：/pixivbot unschedule <type>"
+
+        await context.require(UnscheduleHandler).handle("invalid_arg_lol", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest,  except_msg)
+        assert (post_dest.identifier, ScheduleType.random_bookmark) not in context.require(fake_scheduler).subscriptions
diff --git a/src/tests/handler/command/test_watch.py b/src/tests/handler/command/test_watch.py
new file mode 100644
index 0000000..0fd0a06
--- /dev/null
+++ b/src/tests/handler/command/test_watch.py
@@ -0,0 +1,476 @@
+import pytest
+
+from tests import MyTest
+from tests.protocol_dep.fake_auth_manager import FakeAuthenticatorManagerMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+from tests.service.fake_watchman import FakeWatchmanMixin
+
+
+class TestWatchHandler(FakeWatchmanMixin,
+                       FakePixivServiceMixin,
+                       FakeAuthenticatorManagerMixin,
+                       FakePostDestinationMixin,
+                       FakePostmanManagerMixin,
+                       MyTest):
+    help_text = "\n" \
+                "命令格式：/pixivbot watch <type> [..args]\n" \
+                "参数：\n" \
+                "  <type>：可选值有user_illusts, following_illusts" \
+                "  [...args]：根据<type>不同需要提供不同的参数\n" \
+                "示例：/pixivbot watch user_illusts <用户名>\n"
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+
+        context.require(WatchHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg_no_sub(self, fake_post_destination,
+                                        fake_postman_manager,
+                                        fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(WatchHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg(self, fake_post_destination,
+                                 fake_postman_manager,
+                                 fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "user_illusts user_id=54321\n" \
+                     "following_illusts sender_user_id=1234 pixiv_user_id=0\n" + self.help_text
+
+        await context.require(fake_watchman).watch(
+            type=WatchType.user_illusts,
+            kwargs={"user_id": 54321},
+            subscriber=post_dest
+        )
+
+        await context.require(fake_watchman).watch(
+            type=WatchType.following_illusts,
+            kwargs={"sender_user_id": 1234, "pixiv_user_id": 0},
+            subscriber=post_dest
+        )
+
+        await context.require(WatchHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_user_illusts(self, fake_post_destination,
+                                       fake_postman_manager,
+                                       fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType, WatchTask
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = f"成功订阅TestUser(54321)老师的插画更新"
+        except_task = WatchTask(
+            type=WatchType.user_illusts,
+            kwargs={"user_id": 54321},
+            subscriber=post_dest.identifier
+        )
+
+        await context.require(WatchHandler).handle("user_illusts", "54321", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert tasks[0] == except_task
+
+    @pytest.mark.asyncio
+    async def test_handle_user_illusts_by_name(self, fake_post_destination,
+                                               fake_postman_manager,
+                                               fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType, WatchTask
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = f"成功订阅TestUser(54321)老师的插画更新"
+        except_task = WatchTask(
+            type=WatchType.user_illusts,
+            kwargs={"user_id": 54321},
+            subscriber=post_dest.identifier
+        )
+
+        await context.require(WatchHandler).handle("user_illusts", "TestUser", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert tasks[0] == except_task
+
+    @pytest.mark.asyncio
+    async def test_handle_user_illusts_by_name_no_data(self, fake_post_destination,
+                                                       fake_pixiv_service,
+                                                       fake_postman_manager,
+                                                       fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        context.require(fake_pixiv_service).no_data = True
+
+        post_dest = fake_post_destination(1234, 56789)
+
+        # 因为Watch不是EntryHandler，没有配置异常拦截器
+        with pytest.raises(QueryError) as e:
+            await context.require(WatchHandler).handle("user_illusts", "NoSuchUser", post_dest=post_dest)
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts(self, fake_post_destination,
+                                            fake_postman_manager,
+                                            fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType, WatchTask
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = f"成功订阅TestUser(54321)用户的关注者插画更新"
+        except_task = WatchTask(
+            type=WatchType.following_illusts,
+            kwargs={"pixiv_user_id": 54321, "sender_user_id": 1234},
+            subscriber=post_dest.identifier
+        )
+
+        await context.require(WatchHandler).handle("following_illusts", "54321", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert tasks[0] == except_task
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts_by_name(self, fake_post_destination,
+                                                    fake_postman_manager,
+                                                    fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType, WatchTask
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = f"成功订阅TestUser(54321)用户的关注者插画更新"
+        except_task = WatchTask(
+            type=WatchType.following_illusts,
+            kwargs={"pixiv_user_id": 54321, "sender_user_id": 1234},
+            subscriber=post_dest.identifier
+        )
+
+        await context.require(WatchHandler).handle("following_illusts", "TestUser", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert tasks[0] == except_task
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts_by_name_no_data(self, fake_post_destination,
+                                                            fake_pixiv_service,
+                                                            fake_postman_manager,
+                                                            fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        context.require(fake_pixiv_service).no_data = True
+
+        post_dest = fake_post_destination(1234, 56789)
+
+        # 因为Watch不是EntryHandler，没有配置异常拦截器
+        with pytest.raises(QueryError) as e:
+            await context.require(WatchHandler).handle("following_illusts", "NoSuchUser", post_dest=post_dest)
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts_by_bind(self, fake_post_destination,
+                                                    fake_postman_manager,
+                                                    fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType, WatchTask
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = f"成功订阅关注者插画更新"
+        except_task = WatchTask(
+            type=WatchType.following_illusts,
+            kwargs={"pixiv_user_id": 0, "sender_user_id": 1234},
+            subscriber=post_dest.identifier
+        )
+
+        await context.require(WatchHandler).handle("following_illusts", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert tasks[0] == except_task
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_args(self, fake_post_destination,
+                                       fake_postman_manager,
+                                       fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "未知订阅类型：invalid_arg_lol\n" \
+                     "\n" \
+                     "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(WatchHandler).handle("invalid_arg_lol", "00:30*x", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+        assert len(await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)) == 0
+
+    @pytest.mark.asyncio
+    async def test_handle_user_illusts_invalid_args_count(self, fake_post_destination,
+                                                          fake_postman_manager,
+                                                          fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import WatchHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(WatchHandler).handle("user_illusts", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+        assert len(await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)) == 0
+
+
+class TestUnwatchHandler(FakeWatchmanMixin,
+                         FakePixivServiceMixin,
+                         FakeAuthenticatorManagerMixin,
+                         FakePostDestinationMixin,
+                         FakePostmanManagerMixin,
+                         MyTest):
+    help_text = "\n" \
+                "命令格式：/pixivbot unwatch <type> [..args]\n" \
+                "参数：\n" \
+                "  <type>：可选值有user_illusts, following_illusts" \
+                "  [...args]：根据<type>不同需要提供不同的参数\n" \
+                "示例：/pixivbot unwatch user_illusts <用户名>\n"
+
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+
+        context.require(UnwatchHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg_no_sub(self, fake_post_destination,
+                                        fake_postman_manager,
+                                        fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(UnwatchHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_arg(self, fake_post_destination,
+                                 fake_postman_manager,
+                                 fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "当前订阅：\n" \
+                     "user_illusts user_id=54321\n" \
+                     "following_illusts sender_user_id=1234 pixiv_user_id=0\n" + self.help_text
+
+        await context.require(fake_watchman).watch(
+            type=WatchType.user_illusts,
+            kwargs={"user_id": 54321},
+            subscriber=post_dest
+        )
+
+        await context.require(fake_watchman).watch(
+            type=WatchType.following_illusts,
+            kwargs={"sender_user_id": 1234, "pixiv_user_id": 0},
+            subscriber=post_dest
+        )
+
+        await context.require(UnwatchHandler).handle(post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+    @pytest.mark.asyncio
+    async def test_handle_user_illusts(self, fake_post_destination,
+                                       fake_postman_manager,
+                                       fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "成功取消订阅"
+
+        await context.require(fake_watchman).watch(
+            type=WatchType.user_illusts,
+            kwargs={"user_id": 54321},
+            subscriber=post_dest
+        )
+
+        await context.require(UnwatchHandler).handle("user_illusts", "54321", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert len(tasks) == 0
+
+    @pytest.mark.asyncio
+    async def test_handle_user_illusts_by_name(self, fake_post_destination,
+                                               fake_postman_manager,
+                                               fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "成功取消订阅"
+
+        await context.require(fake_watchman).watch(
+            type=WatchType.user_illusts,
+            kwargs={"user_id": 54321},
+            subscriber=post_dest
+        )
+
+        await context.require(UnwatchHandler).handle("user_illusts", "TestUser", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert len(tasks) == 0
+
+    @pytest.mark.asyncio
+    async def test_handle_user_illusts_by_name_no_data(self, fake_post_destination,
+                                                       fake_pixiv_service,
+                                                       fake_postman_manager,
+                                                       fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        context.require(fake_pixiv_service).no_data = True
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "总之是Pixiv返回的错误信息"
+
+        with pytest.raises(QueryError) as e:
+            await context.require(UnwatchHandler).handle("user_illusts", "NoSuchUser", post_dest=post_dest)
+        assert e.value.message == except_msg
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts(self, fake_post_destination,
+                                            fake_postman_manager,
+                                            fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "成功取消订阅"
+
+        await context.require(fake_watchman).watch(
+            type=WatchType.following_illusts,
+            kwargs={"pixiv_user_id": 54321, "sender_user_id": 1234},
+            subscriber=post_dest
+        )
+
+        await context.require(UnwatchHandler).handle("following_illusts", "54321", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert len(tasks) == 0
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts_by_name(self, fake_post_destination,
+                                                    fake_postman_manager,
+                                                    fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "成功取消订阅"
+        await context.require(fake_watchman).watch(
+            type=WatchType.following_illusts,
+            kwargs={"pixiv_user_id": 54321, "sender_user_id": 1234},
+            subscriber=post_dest
+        )
+
+        await context.require(UnwatchHandler).handle("following_illusts", "TestUser", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert len(tasks) == 0
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts_by_name_no_data(self, fake_post_destination,
+                                                            fake_pixiv_service,
+                                                            fake_postman_manager,
+                                                            fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        context.require(fake_pixiv_service).no_data = True
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "总之是Pixiv返回的错误信息"
+
+        with pytest.raises(QueryError) as e:
+            await context.require(UnwatchHandler).handle("following_illusts", "NoSuchUser", post_dest=post_dest)
+        assert e.value.message == except_msg
+
+    @pytest.mark.asyncio
+    async def test_handle_following_illusts_by_bind(self, fake_post_destination,
+                                                    fake_postman_manager,
+                                                    fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+        from nonebot_plugin_pixivbot.model import WatchType
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "成功取消订阅"
+        await context.require(fake_watchman).watch(
+            type=WatchType.following_illusts,
+            kwargs={"pixiv_user_id": 0, "sender_user_id": 1234},
+            subscriber=post_dest
+        )
+
+        await context.require(UnwatchHandler).handle("following_illusts", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+
+        tasks = await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)
+        assert len(tasks) == 0
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_args(self, fake_post_destination,
+                                       fake_postman_manager,
+                                       fake_watchman):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.command.watch import UnwatchHandler
+
+        post_dest = fake_post_destination(1234, 56789)
+        except_msg = "未知订阅类型：invalid_arg_lol\n" \
+                     "\n" \
+                     "当前订阅：\n" \
+                     "无\n" + self.help_text
+
+        await context.require(UnwatchHandler).handle("invalid_arg_lol", post_dest=post_dest)
+        context.require(fake_postman_manager).assert_call(post_dest, except_msg)
+        assert len(await context.require(fake_watchman).get_by_subscriber(post_dest.identifier)) == 0
diff --git a/src/tests/handler/common/__init__.py b/src/tests/handler/common/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/handler/common/fake_recorder.py b/src/tests/handler/common/fake_recorder.py
new file mode 100644
index 0000000..35aba58
--- /dev/null
+++ b/src/tests/handler/common/fake_recorder.py
@@ -0,0 +1,33 @@
+from typing import Optional
+
+import pytest
+
+from tests import MyTest
+
+
+class FakeRecorderMixin(MyTest):
+    @pytest.fixture
+    def fake_recorder(self):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common.recorder import Recorder, Req
+        from nonebot_plugin_pixivbot.model import PostIdentifier
+
+        @context.bind_singleton_to(Recorder)
+        class FakeRecorder:
+            def __init__(self):
+                self.req = {}
+                self.resp = {}
+
+            def get_req(self, key: PostIdentifier[int, int]) -> Optional[Req]:
+                return self.req.get(key, None)
+
+            def record_req(self, record: Req, key: PostIdentifier[int, int]):
+                self.req[key] = record
+
+            def get_resp(self, key: PostIdentifier[int, int]) -> Optional[int]:
+                return self.resp.get(key, None)
+
+            def record_resp(self, illust_id: int, key: PostIdentifier[int, int]):
+                self.resp[key] = illust_id
+
+        return FakeRecorder
diff --git a/src/tests/handler/common/test_illust.py b/src/tests/handler/common/test_illust.py
new file mode 100644
index 0000000..45cf735
--- /dev/null
+++ b/src/tests/handler/common/test_illust.py
@@ -0,0 +1,67 @@
+import pytest
+
+from tests import MyTest
+from tests.model.mock_message import MockMessageModelMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+
+
+class TestIllustHandler(FakePixivServiceMixin,
+                        MockMessageModelMixin,
+                        FakePostDestinationMixin,
+                        FakePostmanManagerMixin,
+                        MyTest):
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import IllustHandler
+
+        context.require(IllustHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_service,
+                          fake_postman_manager,
+                          mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import IllustHandler
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(IllustHandler).handle("123456", post_dest=post_dest)
+
+        illusts = context.require(fake_pixiv_service).spy_illust_detail.spy_return
+        except_model = await mock_illust_message_model(illusts)
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_data(self, fake_post_destination,
+                                  fake_pixiv_service,
+                                  fake_postman_manager,
+                                  mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import IllustHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        context.require(fake_pixiv_service).no_data = True
+
+        with pytest.raises(QueryError):
+            await context.require(IllustHandler).handle("123456", post_dest=post_dest)
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_arg(self, fake_post_destination,
+                                      fake_postman_manager):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import IllustHandler
+        from nonebot_plugin_pixivbot.utils.errors import BadRequestError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "abcdef不是合法的插画ID"
+
+        with pytest.raises(BadRequestError) as e:
+            await context.require(IllustHandler).handle("abcdef", post_dest=post_dest)
+        assert e.value.message == except_msg
diff --git a/src/tests/handler/common/test_more.py b/src/tests/handler/common/test_more.py
new file mode 100644
index 0000000..8d644e9
--- /dev/null
+++ b/src/tests/handler/common/test_more.py
@@ -0,0 +1,60 @@
+from unittest.mock import AsyncMock
+
+import pytest
+
+from tests import MyTest
+from tests.handler.common.fake_recorder import FakeRecorderMixin
+from tests.model.mock_message import MockMessageModelMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+
+
+class TestMoreHandler(FakePixivServiceMixin,
+                      FakeRecorderMixin,
+                      MockMessageModelMixin,
+                      FakePostDestinationMixin,
+                      FakePostmanManagerMixin,
+                      MyTest):
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import MoreHandler
+
+        context.require(MoreHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_service,
+                          fake_recorder,
+                          fake_postman_manager,
+                          mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import MoreHandler
+        from nonebot_plugin_pixivbot.handler.common.recorder import Req
+
+        post_dest = fake_post_destination(123456, 56789)
+        stub = AsyncMock()
+
+        context.require(fake_recorder).record_req(Req(stub, 114, 514), post_dest.identifier)
+
+        await context.require(MoreHandler).handle(count=3, post_dest=post_dest)
+
+        stub.assert_awaited_once_with(114, 514, count=3, post_dest=post_dest, silently=False)
+
+    @pytest.mark.asyncio
+    async def test_no_record_handle(self, fake_post_destination,
+                                    fake_pixiv_service,
+                                    fake_recorder,
+                                    fake_postman_manager,
+                                    mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import MoreHandler
+        from nonebot_plugin_pixivbot.utils.errors import BadRequestError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "你还没有发送过请求"
+
+        with pytest.raises(BadRequestError) as e:
+            await context.require(MoreHandler).handle(count=3, post_dest=post_dest)
+        assert e.value.message == except_msg
diff --git a/src/tests/handler/common/test_random_bookmark.py b/src/tests/handler/common/test_random_bookmark.py
new file mode 100644
index 0000000..4b0a402
--- /dev/null
+++ b/src/tests/handler/common/test_random_bookmark.py
@@ -0,0 +1,114 @@
+import pytest
+
+from tests import MyTest
+from tests.model.mock_message import MockMessageModelMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_account_binder import FakePixivAccountBinderMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+
+
+class TestRandomBookmarkHandler(FakePixivServiceMixin,
+                                FakePixivAccountBinderMixin,
+                                MockMessageModelMixin,
+                                FakePostDestinationMixin,
+                                FakePostmanManagerMixin,
+                                MyTest):
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomBookmarkHandler
+
+        context.require(RandomBookmarkHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_account_binder,
+                          fake_pixiv_service,
+                          fake_postman_manager,
+                          mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomBookmarkHandler
+        from nonebot_plugin_pixivbot.model.message import IllustMessagesModel
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(fake_pixiv_account_binder).bind(post_dest.adapter, post_dest.user_id, 54321)
+
+        await context.require(RandomBookmarkHandler).handle(count=3, post_dest=post_dest)
+
+        illusts = context.require(fake_pixiv_service).spy_random_bookmark.spy_return
+        except_model = await IllustMessagesModel.from_illusts(illusts, header="这是您点的私家车")
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_handle_not_bind(self, fake_post_destination,
+                                   fake_pixiv_account_binder,
+                                   fake_pixiv_service,
+                                   fake_postman_manager,
+                                   mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomBookmarkHandler
+        from nonebot_plugin_pixivbot.utils.errors import BadRequestError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "无效的Pixiv账号，或未绑定Pixiv账号"
+
+        with pytest.raises(BadRequestError) as e:
+            await context.require(RandomBookmarkHandler).handle(count=3, post_dest=post_dest)
+        assert e.value.message == except_msg
+
+    @pytest.mark.asyncio
+    async def test_handle_arg(self, fake_post_destination,
+                              fake_pixiv_account_binder,
+                              fake_pixiv_service,
+                              fake_postman_manager,
+                              mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomBookmarkHandler
+        from nonebot_plugin_pixivbot.model.message import IllustMessagesModel
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(RandomBookmarkHandler).handle("54321", count=3, post_dest=post_dest)
+
+        illusts = context.require(fake_pixiv_service).spy_random_bookmark.spy_return
+        except_model = await IllustMessagesModel.from_illusts(illusts, header="这是您点的私家车")
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_handle_invalid_arg(self, fake_post_destination,
+                                      fake_pixiv_account_binder,
+                                      fake_pixiv_service,
+                                      fake_postman_manager,
+                                      mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomBookmarkHandler
+        from nonebot_plugin_pixivbot.utils.errors import BadRequestError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "5a4b321不是合法的ID"
+
+        with pytest.raises(BadRequestError) as e:
+            await context.require(RandomBookmarkHandler).handle("5a4b321", count=3, post_dest=post_dest)
+        assert e.value.message == except_msg
+
+    @pytest.mark.asyncio
+    async def test_handle_no_data(self, fake_post_destination,
+                                  fake_pixiv_service,
+                                  fake_postman_manager,
+                                  mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomBookmarkHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "总之是Pixiv返回的错误信息"
+
+        context.require(fake_pixiv_service).no_data = True
+
+        with pytest.raises(QueryError) as e:
+            await context.require(RandomBookmarkHandler).handle("54321", post_dest=post_dest)
+        assert e.value.message == except_msg
diff --git a/src/tests/handler/common/test_random_illust.py b/src/tests/handler/common/test_random_illust.py
new file mode 100644
index 0000000..5922ff8
--- /dev/null
+++ b/src/tests/handler/common/test_random_illust.py
@@ -0,0 +1,56 @@
+import pytest
+
+from tests import MyTest
+from tests.model.mock_message import MockMessageModelMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+
+
+class TestRandomIllustHandler(FakePixivServiceMixin,
+                              MockMessageModelMixin,
+                              FakePostDestinationMixin,
+                              FakePostmanManagerMixin,
+                              MyTest):
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomIllustHandler
+
+        context.require(RandomIllustHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_service,
+                          fake_postman_manager,
+                          mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomIllustHandler
+        from nonebot_plugin_pixivbot.model.message import IllustMessagesModel
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(RandomIllustHandler).handle("some_word", count=3, post_dest=post_dest)
+
+        illusts = context.require(fake_pixiv_service).spy_random_illust.spy_return
+        except_model = await IllustMessagesModel.from_illusts(illusts, header="这是您点的some_word图")
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_data(self, fake_post_destination,
+                                  fake_pixiv_service,
+                                  fake_postman_manager,
+                                  mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomIllustHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "总之是Pixiv返回的错误信息"
+
+        context.require(fake_pixiv_service).no_data = True
+
+        with pytest.raises(QueryError) as e:
+            await context.require(RandomIllustHandler).handle("some_word", post_dest=post_dest)
+        assert e.value.message == except_msg
diff --git a/src/tests/handler/common/test_random_recommended_illust.py b/src/tests/handler/common/test_random_recommended_illust.py
new file mode 100644
index 0000000..11f9f90
--- /dev/null
+++ b/src/tests/handler/common/test_random_recommended_illust.py
@@ -0,0 +1,56 @@
+import pytest
+
+from tests import MyTest
+from tests.model.mock_message import MockMessageModelMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+
+
+class TestRandomRecommendedIllustHandler(FakePixivServiceMixin,
+                                         MockMessageModelMixin,
+                                         FakePostDestinationMixin,
+                                         FakePostmanManagerMixin,
+                                         MyTest):
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomRecommendedIllustHandler
+
+        context.require(RandomRecommendedIllustHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_service,
+                          fake_postman_manager,
+                          mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomRecommendedIllustHandler
+        from nonebot_plugin_pixivbot.model.message import IllustMessagesModel
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(RandomRecommendedIllustHandler).handle(count=3, post_dest=post_dest)
+
+        illusts = context.require(fake_pixiv_service).spy_random_recommended_illust.spy_return
+        except_model = await IllustMessagesModel.from_illusts(illusts, header="这是您点的图")
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_data(self, fake_post_destination,
+                                  fake_pixiv_service,
+                                  fake_postman_manager,
+                                  mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomRecommendedIllustHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "总之是Pixiv返回的错误信息"
+
+        context.require(fake_pixiv_service).no_data = True
+
+        with pytest.raises(QueryError) as e:
+            await context.require(RandomRecommendedIllustHandler).handle(post_dest=post_dest)
+        assert e.value.message == except_msg
diff --git a/src/tests/handler/common/test_random_related_illust.py b/src/tests/handler/common/test_random_related_illust.py
new file mode 100644
index 0000000..b8ccf0a
--- /dev/null
+++ b/src/tests/handler/common/test_random_related_illust.py
@@ -0,0 +1,80 @@
+import pytest
+
+from tests import MyTest
+from tests.handler.common.fake_recorder import FakeRecorderMixin
+from tests.model.mock_message import MockMessageModelMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+
+
+class TestRandomRelatedIllustHandler(FakePixivServiceMixin,
+                                     FakeRecorderMixin,
+                                     MockMessageModelMixin,
+                                     FakePostDestinationMixin,
+                                     FakePostmanManagerMixin,
+                                     MyTest):
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomRelatedIllustHandler
+
+        context.require(RandomRelatedIllustHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_service,
+                          fake_recorder,
+                          fake_postman_manager,
+                          mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomRelatedIllustHandler
+        from nonebot_plugin_pixivbot.model.message import IllustMessagesModel
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        context.require(fake_recorder).record_resp(114514, post_dest.identifier)
+
+        await context.require(RandomRelatedIllustHandler).handle(count=3, post_dest=post_dest)
+
+        illusts = context.require(fake_pixiv_service).spy_random_related_illust.spy_return
+        except_model = await IllustMessagesModel.from_illusts(illusts, header="这是您点的[114514]的相关图片")
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_no_record_handle(self, fake_post_destination,
+                                    fake_pixiv_service,
+                                    fake_recorder,
+                                    fake_postman_manager,
+                                    mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomRelatedIllustHandler
+        from nonebot_plugin_pixivbot.utils.errors import BadRequestError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "你还没有发送过请求"
+
+        with pytest.raises(BadRequestError) as e:
+            await context.require(RandomRelatedIllustHandler).handle(count=3, post_dest=post_dest)
+        assert e.value.message == except_msg
+
+    @pytest.mark.asyncio
+    async def test_handle_no_data(self, fake_post_destination,
+                                  fake_recorder,
+                                  fake_pixiv_service,
+                                  fake_postman_manager,
+                                  mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomRelatedIllustHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        post_dest = fake_post_destination(123456, 56789)
+        context.require(fake_recorder).record_resp(114514, post_dest.identifier)
+        except_msg = "总之是Pixiv返回的错误信息"
+
+        context.require(fake_pixiv_service).no_data = True
+
+        with pytest.raises(QueryError) as e:
+            await context.require(RandomRelatedIllustHandler).handle(post_dest=post_dest)
+        assert e.value.message == except_msg
diff --git a/src/tests/handler/common/test_random_user_illust.py b/src/tests/handler/common/test_random_user_illust.py
new file mode 100644
index 0000000..97b6b9a
--- /dev/null
+++ b/src/tests/handler/common/test_random_user_illust.py
@@ -0,0 +1,74 @@
+import pytest
+
+from tests import MyTest
+from tests.model.mock_message import MockMessageModelMixin
+from tests.protocol_dep.fake_post_dest import FakePostDestinationMixin
+from tests.protocol_dep.fake_postman import FakePostmanManagerMixin
+from tests.service.fake_pixiv_service import FakePixivServiceMixin
+
+
+class TestRandomUserIllustHandler(FakePixivServiceMixin,
+                                  MockMessageModelMixin,
+                                  FakePostDestinationMixin,
+                                  FakePostmanManagerMixin,
+                                  MyTest):
+    @pytest.fixture(autouse=True)
+    def remove_interceptor(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomUserIllustHandler
+
+        context.require(RandomUserIllustHandler).interceptor = None
+
+    @pytest.mark.asyncio
+    async def test_handle(self, fake_post_destination,
+                          fake_pixiv_service,
+                          fake_postman_manager,
+                          mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomUserIllustHandler
+        from nonebot_plugin_pixivbot.model.message import IllustMessagesModel
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(RandomUserIllustHandler).handle("54321", count=3, post_dest=post_dest)
+
+        user, illusts = context.require(fake_pixiv_service).spy_random_user_illust.spy_return
+        except_model = await IllustMessagesModel.from_illusts(illusts, header=f"这是您点的{user.name}({user.id})老师的图")
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_handle_by_name(self, fake_post_destination,
+                                  fake_pixiv_service,
+                                  fake_postman_manager,
+                                  mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomUserIllustHandler
+        from nonebot_plugin_pixivbot.model.message import IllustMessagesModel
+
+        post_dest = fake_post_destination(123456, 56789)
+
+        await context.require(RandomUserIllustHandler).handle("TestUser", count=3, post_dest=post_dest)
+
+        user, illusts = context.require(fake_pixiv_service).spy_random_user_illust.spy_return
+        except_model = await IllustMessagesModel.from_illusts(illusts, header=f"这是您点的{user.name}({user.id})老师的图")
+
+        context.require(fake_postman_manager).assert_call(post_dest, except_model)
+
+    @pytest.mark.asyncio
+    async def test_handle_no_data(self, fake_post_destination,
+                                  fake_pixiv_service,
+                                  fake_postman_manager,
+                                  mock_illust_message_model):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.handler.common import RandomUserIllustHandler
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        post_dest = fake_post_destination(123456, 56789)
+        except_msg = "总之是Pixiv返回的错误信息"
+
+        context.require(fake_pixiv_service).no_data = True
+
+        with pytest.raises(QueryError) as e:
+            await context.require(RandomUserIllustHandler).handle("TestUser", post_dest=post_dest)
+        assert e.value.message == except_msg
diff --git a/src/tests/model/__init__.py b/src/tests/model/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/model/mock_message.py b/src/tests/model/mock_message.py
new file mode 100644
index 0000000..8d0908a
--- /dev/null
+++ b/src/tests/model/mock_message.py
@@ -0,0 +1,30 @@
+from typing import Optional, Sequence
+
+import pytest
+from tzlocal import get_localzone
+
+from tests import MyTest
+
+
+class MockMessageModelMixin(MyTest):
+    @pytest.fixture(autouse=True)
+    def mock_illust_message_model(self, load_pixivbot, monkeypatch):
+        from nonebot_plugin_pixivbot.model import Illust
+        from nonebot_plugin_pixivbot.model.message import IllustMessageModel
+
+        async def mock_from_illust(illust: Illust, *,
+                             header: Optional[str] = None,
+                             number: Optional[int] = None):
+            return IllustMessageModel(
+                id=illust.id,
+                header=header,
+                number=number,
+                image=bytes(),
+                title=illust.title,
+                author=f"{illust.user.name} ({illust.user.id})",
+                create_time=illust.create_date.astimezone(get_localzone()).strftime('%Y-%m-%d %H:%M:%S'),
+                link=f"https://www.pixiv.net/artworks/{illust.id}"
+            )
+
+        monkeypatch.setattr(IllustMessageModel, "from_illust", mock_from_illust)
+        return mock_from_illust
diff --git a/src/tests/protocol_dep/__init__.py b/src/tests/protocol_dep/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/protocol_dep/fake_auth_manager.py b/src/tests/protocol_dep/fake_auth_manager.py
new file mode 100644
index 0000000..b252a8e
--- /dev/null
+++ b/src/tests/protocol_dep/fake_auth_manager.py
@@ -0,0 +1,18 @@
+import pytest
+
+from tests import MyTest
+
+
+class FakeAuthenticatorManagerMixin(MyTest):
+    @pytest.fixture(autouse=True)
+    def fake_auth_manager(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+        from nonebot_plugin_pixivbot.protocol_dep.authenticator import AuthenticatorManager
+
+        @context.bind_singleton_to(AuthenticatorManager)
+        class FakeAuthenticatorManager:
+            def group_admin(self, post_dest: PostDestination[int, int]) -> bool:
+                return True
+
+        return FakeAuthenticatorManager
diff --git a/src/tests/protocol_dep/fake_post_dest.py b/src/tests/protocol_dep/fake_post_dest.py
new file mode 100644
index 0000000..6f8db7c
--- /dev/null
+++ b/src/tests/protocol_dep/fake_post_dest.py
@@ -0,0 +1,45 @@
+from typing import Optional
+
+import pytest
+
+from tests import MyTest
+
+
+class FakePostDestinationMixin(MyTest):
+    @pytest.fixture
+    def fake_post_destination(self, load_pixivbot):
+        from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+        from nonebot_plugin_pixivbot.model import PostIdentifier
+
+        class FakePostDestination(PostDestination[int, int]):
+            def __init__(self, user_id: Optional[int], group_id: Optional[int]):
+                self._identifier = PostIdentifier("test", user_id, group_id)
+
+            @property
+            def identifier(self) -> PostIdentifier[int, int]:
+                return self._identifier
+
+            def normalized(self) -> "PostDestination[int, int]":
+                return self
+
+        return FakePostDestination
+
+
+class FakePostDestinationFactoryManagerMixin(FakePostDestinationMixin, MyTest):
+    @pytest.fixture(autouse=True)
+    def fake_post_destination_factory_manager(self, load_pixivbot, fake_post_destination):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination, PostDestinationFactoryManager
+
+        from nonebot import Bot
+        from nonebot.internal.adapter import Event
+
+        @context.bind_singleton_to(PostDestinationFactoryManager)
+        class FakePostDestinationFactoryManager:
+            def build(self, bot: Bot, user_id: Optional[int], group_id: Optional[int]) -> PostDestination[int, int]:
+                return fake_post_destination(user_id, group_id)
+
+            def from_event(self, bot: Bot, event: Event) -> PostDestination[int, int]:
+                return fake_post_destination(int(event.get_user_id()), None)
+
+        return FakePostDestinationFactoryManager
diff --git a/src/tests/protocol_dep/fake_postman.py b/src/tests/protocol_dep/fake_postman.py
new file mode 100644
index 0000000..2210acd
--- /dev/null
+++ b/src/tests/protocol_dep/fake_postman.py
@@ -0,0 +1,40 @@
+from typing import Union
+
+import pytest
+
+from tests import MyTest
+
+
+class FakePostmanManagerMixin(MyTest):
+    @pytest.fixture(autouse=True)
+    def fake_postman_manager(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.model.message import IllustMessageModel, IllustMessagesModel
+        from nonebot_plugin_pixivbot.protocol_dep.postman import PostmanManager
+        from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+
+        @context.bind_singleton_to(PostmanManager)
+        class FakePostmanManager:
+            def __init__(self):
+                self.calls = []
+
+            def assert_call(self, post_dest: PostDestination[int, int],
+                            message: Union[str, IllustMessageModel, IllustMessagesModel]):
+                assert (post_dest, message) in self.calls
+
+            async def send_plain_text(self, message: str,
+                                      *, post_dest: PostDestination[int, int]):
+                self.calls.append((post_dest, message))
+                print(f"send plain text: {message} to {post_dest}")
+
+            async def send_illust(self, model: IllustMessageModel,
+                                  *, post_dest: PostDestination[int, int]):
+                self.calls.append((post_dest, model))
+                print(f"send illust: {model} to {post_dest}")
+
+            async def send_illusts(self, model: IllustMessagesModel,
+                                   *, post_dest: PostDestination[int, int]):
+                self.calls.append((post_dest, model))
+                print(f"send illusts: {model} to {post_dest}")
+
+        return FakePostmanManager
diff --git a/src/tests/service/__init__.py b/src/tests/service/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/service/fake_pixiv_account_binder.py b/src/tests/service/fake_pixiv_account_binder.py
new file mode 100644
index 0000000..3ceb6ac
--- /dev/null
+++ b/src/tests/service/fake_pixiv_account_binder.py
@@ -0,0 +1,40 @@
+from typing import TypeVar, Optional
+
+import pytest
+
+from tests import MyTest
+
+
+class FakePixivAccountBinderMixin(MyTest):
+    @pytest.fixture(autouse=True)
+    def fake_pixiv_account_binder(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.model import PixivBinding
+        from nonebot_plugin_pixivbot.service.pixiv_account_binder import PixivAccountBinder
+
+        UID = TypeVar("UID")
+
+        @context.bind_singleton_to(PixivAccountBinder)
+        class FakePixivAccountBinder:
+            def __init__(self):
+                self.bindings = {}
+
+            async def bind(self, adapter: str, user_id: UID, pixiv_user_id: int):
+                binding = PixivBinding(adapter=adapter, user_id=user_id, pixiv_user_id=pixiv_user_id)
+                self.bindings[(adapter, user_id)] = binding
+
+            async def unbind(self, adapter: str, user_id: UID) -> bool:
+                if (adapter, user_id) in self.bindings:
+                    del self.bindings[(adapter, user_id)]
+                    return True
+                else:
+                    return False
+
+            async def get_binding(self, adapter: str, user_id: UID) -> Optional[int]:
+                binding = self.bindings.get((adapter, user_id), None)
+                if binding:
+                    return binding.pixiv_user_id
+                else:
+                    return None
+
+        return FakePixivAccountBinder
diff --git a/src/tests/service/fake_pixiv_service.py b/src/tests/service/fake_pixiv_service.py
new file mode 100644
index 0000000..4dceb9f
--- /dev/null
+++ b/src/tests/service/fake_pixiv_service.py
@@ -0,0 +1,111 @@
+import importlib.resources as pkg_resources
+import json
+import random
+from typing import Union, List, Tuple
+
+import pytest
+
+from tests import MyTest
+
+
+class FakePixivServiceMixin(MyTest):
+    @pytest.fixture
+    def sample_illusts(self):
+        from nonebot_plugin_pixivbot.model import Illust
+
+        from . import resources
+        with pkg_resources.open_text(resources, 'sample_illusts.json') as f:
+            obj = json.load(f)
+
+        samples = []
+        for x in obj:
+            samples.append(Illust.parse_obj(x))
+
+        return samples
+
+    @pytest.fixture(autouse=True)
+    def fake_pixiv_service(self, load_pixivbot, sample_illusts, mocker):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.enums import RankingMode
+        from nonebot_plugin_pixivbot.model import User, Illust
+        from nonebot_plugin_pixivbot.service.pixiv_service import PixivService
+        from nonebot_plugin_pixivbot.utils.errors import QueryError
+
+        @context.bind_singleton_to(PixivService)
+        class FakePixivService:
+            def __init__(self):
+                self.no_data = False
+
+                self.spy_illust_ranking = mocker.spy(self, "illust_ranking")
+                self.spy_illust_detail = mocker.spy(self, "illust_detail")
+                self.spy_random_illust = mocker.spy(self, "random_illust")
+                self.spy_get_user = mocker.spy(self, "get_user")
+                self.spy_random_user_illust = mocker.spy(self, "random_user_illust")
+                self.spy_random_recommended_illust = mocker.spy(self, "random_recommended_illust")
+                self.spy_random_bookmark = mocker.spy(self, "random_bookmark")
+                self.spy_random_related_illust = mocker.spy(self, "random_related_illust")
+
+            async def illust_ranking(self, mode: RankingMode, range: Tuple[int, int]) -> List[Illust]:
+                if self.no_data:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+                else:
+                    count = range[1] - range[0] + 1
+                    random.shuffle(sample_illusts)
+                    ans = sample_illusts[:count]
+                    return ans
+
+            async def illust_detail(self, illust: int) -> Illust:
+                if self.no_data:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+                else:
+                    random.shuffle(sample_illusts)
+                    ans = sample_illusts[0].copy(update={"id": illust})
+                    return ans
+
+            async def random_illust(self, word: str, *, count: int = 1) -> List[Illust]:
+                if self.no_data:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+                else:
+                    random.shuffle(sample_illusts)
+                    ans = sample_illusts[:count]
+                    return ans
+
+            async def get_user(self, user: Union[str, int]) -> User:
+                if user == "TestUser" or user == 54321:
+                    return User(id=54321, name="TestUser", account=user)
+                else:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+
+            async def random_user_illust(self, user: Union[str, int], *, count: int = 1) -> Tuple[User, List[Illust]]:
+                if self.no_data:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+                else:
+                    random.shuffle(sample_illusts)
+                    ans = sample_illusts[:count]
+                    return await self.get_user(user), ans
+
+            async def random_recommended_illust(self, *, count: int = 1) -> List[Illust]:
+                if self.no_data:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+                else:
+                    random.shuffle(sample_illusts)
+                    ans = sample_illusts[:count]
+                    return ans
+
+            async def random_bookmark(self, pixiv_user_id: int = 0, *, count: int = 1) -> List[Illust]:
+                if self.no_data:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+                else:
+                    random.shuffle(sample_illusts)
+                    ans = sample_illusts[:count]
+                    return ans
+
+            async def random_related_illust(self, illust_id: int, *, count: int = 1) -> List[Illust]:
+                if self.no_data:
+                    raise QueryError("总之是Pixiv返回的错误信息")
+                else:
+                    random.shuffle(sample_illusts)
+                    ans = sample_illusts[:count]
+                    return ans
+
+        return FakePixivService
diff --git a/src/tests/service/fake_scheduler.py b/src/tests/service/fake_scheduler.py
new file mode 100644
index 0000000..b307847
--- /dev/null
+++ b/src/tests/service/fake_scheduler.py
@@ -0,0 +1,59 @@
+from typing import Sequence, Optional, List, Union
+
+import pytest
+
+from tests import MyTest
+
+
+class FakeSchedulerMixin(MyTest):
+    @pytest.fixture
+    async def fake_scheduler(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+        from nonebot_plugin_pixivbot.model import PostIdentifier, Subscription, ScheduleType
+        from nonebot_plugin_pixivbot.service.scheduler import Scheduler
+
+        @context.bind_singleton_to(Scheduler)
+        class FakeScheduler:
+            def __init__(self):
+                self.subscriptions = {}
+
+            async def schedule(self, type: ScheduleType,
+                               schedule: Union[str, Sequence[int]],
+                               args: Optional[list] = None,
+                               *, post_dest: PostDestination[int, int]):
+                from nonebot_plugin_pixivbot.service.scheduler import parse_schedule
+
+                if isinstance(schedule, str):
+                    schedule = parse_schedule(schedule)
+
+                kwargs = {}
+                for i, arg in enumerate(args):
+                    kwargs[str(i)] = arg
+
+                self.subscriptions[(post_dest.identifier, type)] = Subscription(type=type,
+                                                                                schedule=schedule,
+                                                                                kwargs=kwargs,
+                                                                                subscriber=post_dest.identifier)
+
+            async def unschedule(self, type: ScheduleType,
+                                 subscriber: PostIdentifier[int, int]):
+                if (subscriber, type) in self.subscriptions:
+                    del self.subscriptions[(subscriber, type)]
+                    return True
+                else:
+                    return False
+
+            async def unschedule_all(self, subscriber: PostIdentifier[int, int]):
+                keys = list(filter(lambda x: x[0] == subscriber, self.subscriptions.keys()))
+                for k in keys:
+                    del self.subscriptions[k]
+
+            async def get_by_subscriber(self, subscriber: PostIdentifier[int, int]) -> List[Subscription]:
+                keys = list(filter(lambda x: x[0] == subscriber, self.subscriptions.keys()))
+                li = []
+                for k in keys:
+                    li.append(self.subscriptions[k])
+                return li
+
+        return FakeScheduler
diff --git a/src/tests/service/fake_watchman.py b/src/tests/service/fake_watchman.py
new file mode 100644
index 0000000..6eecd0e
--- /dev/null
+++ b/src/tests/service/fake_watchman.py
@@ -0,0 +1,49 @@
+from typing import List, Dict, Any
+
+import pytest
+from frozendict import frozendict
+
+from tests import MyTest
+
+
+class FakeWatchmanMixin(MyTest):
+    @pytest.fixture
+    async def fake_watchman(self, load_pixivbot):
+        from nonebot_plugin_pixivbot import context
+        from nonebot_plugin_pixivbot.model import PostIdentifier, WatchType, WatchTask
+        from nonebot_plugin_pixivbot.service.watchman import Watchman
+        from nonebot_plugin_pixivbot.protocol_dep.post_dest import PostDestination
+
+        @context.bind_singleton_to(Watchman)
+        class FakeWatchman:
+            def __init__(self):
+                self.tasks = {}
+
+            async def watch(self, type: WatchType,
+                            kwargs: Dict[str, Any],
+                            subscriber: PostDestination[int, int]):
+                key = (subscriber.identifier, type, frozendict(kwargs))
+                self.tasks[key] = WatchTask(
+                    type=type,
+                    kwargs=kwargs,
+                    subscriber=subscriber.identifier
+                )
+
+            async def unwatch(self, type: WatchType,
+                              kwargs: Dict[str, Any],
+                              subscriber: PostIdentifier[int, int]) -> bool:
+                key = (subscriber, type, frozendict(kwargs))
+                if key in self.tasks:
+                    del self.tasks[key]
+                    return True
+                else:
+                    return False
+
+            async def get_by_subscriber(self, subscriber: PostIdentifier[int, int]) -> List[WatchTask]:
+                keys = list(filter(lambda x: x[0] == subscriber, self.tasks.keys()))
+                li = []
+                for k in keys:
+                    li.append(self.tasks[k])
+                return li
+
+        return FakeWatchman
diff --git a/src/tests/service/resources/__init__.py b/src/tests/service/resources/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tests/service/resources/sample_illusts.json b/src/tests/service/resources/sample_illusts.json
new file mode 100644
index 0000000..0e7521b
--- /dev/null
+++ b/src/tests/service/resources/sample_illusts.json
@@ -0,0 +1,613 @@
+[
+  {
+    "id": 99606918,
+    "title": "待ちに待った夏🏊",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2022/07/09/18/57/15/99606918_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/07/09/18/57/15/99606918_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/07/09/18/57/15/99606918_p0_master1200.jpg"
+    },
+    "caption": "創作キャラ「翔真」のイラストです。<br /><br />作者は暑い夏は苦手ですが、<br />翔真にとって夏は「プールや海で泳げる絶好の季節」のようです🏊‍♂️<br />爽やかさを感じて頂けたら嬉しいです！！<br /><br />また、水泳男子らしく、肩周りの筋肉を意識して描きました。<br /> ◆ツイートのいいね&amp;RTも是非🙏<br /><a href=\"https://twitter.com/takumuda_1093/status/1544975760531656704\" target='_blank' rel='noopener noreferrer'>https://twitter.com/takumuda_1093/status/1544975760531656704</a><br /><br />◆キャラクター紹介<br /><a href=\"https://taku0.com/character/\" target='_blank' rel='noopener noreferrer'>https://taku0.com/character/</a><br /><br />◆イラストサイト<br /><a href=\"https://taku0.com/works/\" target='_blank' rel='noopener noreferrer'>https://taku0.com/works/</a><br /><br />・‥…━━━━━━━━━━━━━━━━━━━━…‥・<br /><br />This is an illustration of a creative character &quot;Shoma&quot;.<br /><br />The author is not good at hot summer.<br />But for Shoma&#44; summer seems to be &quot;the best season to swim in the pool or the sea&quot; 🏊<br />I hope you find it refreshing!<br /><br />I drew this figure with the muscles around the shoulders in mind.<br /><br />◆Please like and retweet Twitter.🙏<br /><a href=\"https://twitter.com/takumuda_1093/status/1544975760531656704\" target='_blank' rel='noopener noreferrer'>https://twitter.com/takumuda_1093/status/1544975760531656704</a> <br /><br />◆Character Introduction<br /><a href=\"https://taku0.com/character/\" target='_blank' rel='noopener noreferrer'>https://taku0.com/character/</a><br /><br />◆Illustration site<br /><a href=\"https://taku0.com/works/\" target='_blank' rel='noopener noreferrer'>https://taku0.com/works/</a> <br /><br />・‥…━━━━━━━━━━━━━━━━━━━━…‥・",
+    "user": {
+      "id": 34796504,
+      "name": "𝐓𝐚𝐤𝐮",
+      "account": "takumuda_1093"
+    },
+    "tags": [
+      {
+        "name": "オリジナル",
+        "translated_name": "原创"
+      },
+      {
+        "name": "創作",
+        "translated_name": "原创"
+      },
+      {
+        "name": "男子",
+        "translated_name": "guy"
+      },
+      {
+        "name": "イケメン",
+        "translated_name": "帅哥"
+      },
+      {
+        "name": "水着",
+        "translated_name": "泳装"
+      },
+      {
+        "name": "競パン",
+        "translated_name": "competition swimwear"
+      },
+      {
+        "name": "プール",
+        "translated_name": "泳池"
+      },
+      {
+        "name": "金髪",
+        "translated_name": "金发"
+      },
+      {
+        "name": "筋肉",
+        "translated_name": "肌肉"
+      },
+      {
+        "name": "ゴーグル",
+        "translated_name": "goggles"
+      }
+    ],
+    "create_date": "2022-07-09T09:57:15Z",
+    "page_count": 2,
+    "meta_single_page": {},
+    "meta_pages": [
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/07/09/18/57/15/99606918_p0_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/07/09/18/57/15/99606918_p0_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/07/09/18/57/15/99606918_p0_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/07/09/18/57/15/99606918_p0.png"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/07/09/18/57/15/99606918_p1_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/07/09/18/57/15/99606918_p1_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/07/09/18/57/15/99606918_p1_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/07/09/18/57/15/99606918_p1.png"
+        }
+      }
+    ],
+    "total_view": 7092,
+    "total_bookmarks": 999
+  },
+  {
+    "id": 82362716,
+    "title": "夏ファラオ",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2020/06/16/16/20/37/82362716_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2020/06/16/16/20/37/82362716_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2020/06/16/16/20/37/82362716_p0_master1200.jpg"
+    },
+    "caption": "霊衣開放！霊衣開放！（素振り）<br />落書きメイキング▷<a href=\"https://www.youtube.com/watch?v=EkbtoA9BTxM&amp;feature=youtu.be\" target='_blank' rel='noopener noreferrer'>https://www.youtube.com/watch?v=EkbtoA9BTxM&amp;feature=youtu.be</a>",
+    "user": {
+      "id": 2593590,
+      "name": "タカ氏",
+      "account": "tkh_c"
+    },
+    "tags": [
+      {
+        "name": "Fate/GrandOrder",
+        "translated_name": "命运－冠位指定"
+      },
+      {
+        "name": "オジマンディアス",
+        "translated_name": "奥兹曼迪亚斯"
+      },
+      {
+        "name": "濡れ髪",
+        "translated_name": "湿发"
+      },
+      {
+        "name": "夏メイク"
+      },
+      {
+        "name": "水着",
+        "translated_name": "泳装"
+      },
+      {
+        "name": "かきあげ",
+        "translated_name": "撩头发"
+      },
+      {
+        "name": "鎖骨",
+        "translated_name": "collarbone"
+      },
+      {
+        "name": "Fate/GO5000users入り",
+        "translated_name": "Fate/GO500users加入书籤"
+      }
+    ],
+    "create_date": "2020-06-16T07:18:24Z",
+    "page_count": 1,
+    "meta_single_page": {
+      "original_image_url": "https://i.pximg.net/img-original/img/2020/06/16/16/20/37/82362716_p0.png"
+    },
+    "meta_pages": [],
+    "total_view": 64760,
+    "total_bookmarks": 8486
+  },
+  {
+    "id": 81232531,
+    "title": "🌟",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2020/05/02/19/05/06/81232531_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2020/05/02/19/05/06/81232531_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2020/05/02/19/05/06/81232531_p0_master1200.jpg"
+    },
+    "caption": "",
+    "user": {
+      "id": 3753846,
+      "name": "yevnean",
+      "account": "dsrtg"
+    },
+    "tags": [
+      {
+        "name": "Fate"
+      },
+      {
+        "name": "Fate/GrandOrder",
+        "translated_name": "命运－冠位指定"
+      },
+      {
+        "name": "FGO",
+        "translated_name": "Fate/Grand Order"
+      },
+      {
+        "name": "蒼銀のフラグメンツ"
+      },
+      {
+        "name": "オジマンディアス",
+        "translated_name": "奥兹曼迪亚斯"
+      },
+      {
+        "name": "ライダー(フラグメンツ)",
+        "translated_name": "Ozymandias"
+      },
+      {
+        "name": "Fate/Prototype1000users入り"
+      }
+    ],
+    "create_date": "2020-05-02T10:05:06Z",
+    "page_count": 1,
+    "meta_single_page": {
+      "original_image_url": "https://i.pximg.net/img-original/img/2020/05/02/19/05/06/81232531_p0.png"
+    },
+    "meta_pages": [],
+    "total_view": 26434,
+    "total_bookmarks": 5654
+  },
+  {
+    "id": 98988330,
+    "title": "仕事絵【14枚】",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p0_master1200.jpg"
+    },
+    "caption": "好像大概没发过的一些工作稿……【我都忘了】<br /><br />1-6是战双帕弥什，7-10是解神者，11是云图计划，12-14是百闻牌",
+    "user": {
+      "id": 10489689,
+      "name": "Arsh",
+      "account": "thestarwish"
+    },
+    "tags": [
+      {
+        "name": "仕事絵",
+        "translated_name": "商业绘图"
+      },
+      {
+        "name": "男の子",
+        "translated_name": "男孩子"
+      },
+      {
+        "name": "ハイセンス",
+        "translated_name": "品味优雅"
+      }
+    ],
+    "create_date": "2022-06-11T15:27:36Z",
+    "page_count": 14,
+    "meta_single_page": {},
+    "meta_pages": [
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p0_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p0_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p0_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p0.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p1_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p1_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p1_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p1.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p2_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p2_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p2_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p2.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p3_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p3_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p3_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p3.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p4_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p4_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p4_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p4.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p5_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p5_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p5_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p5.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p6_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p6_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p6_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p6.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p7_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p7_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p7_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p7.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p8_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p8_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p8_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p8.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p9_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p9_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p9_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p9.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p10_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p10_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p10_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p10.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p11_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p11_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p11_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p11.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p12_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p12_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p12_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p12.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/06/12/00/27/36/98988330_p13_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/12/00/27/36/98988330_p13_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/12/00/27/36/98988330_p13_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/06/12/00/27/36/98988330_p13.jpg"
+        }
+      }
+    ],
+    "total_view": 41493,
+    "total_bookmarks": 3905
+  },
+  {
+    "id": 99345950,
+    "title": "あつい……",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2022/06/27/21/30/26/99345950_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/06/27/21/30/26/99345950_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/06/27/21/30/26/99345950_p0_master1200.jpg"
+    },
+    "caption": "",
+    "user": {
+      "id": 37245617,
+      "name": "うさまー🍉",
+      "account": "u_summer0719"
+    },
+    "tags": [
+      {
+        "name": "男の子",
+        "translated_name": "男孩子"
+      },
+      {
+        "name": "少年",
+        "translated_name": "young boy"
+      },
+      {
+        "name": "オリジナル",
+        "translated_name": "原创"
+      },
+      {
+        "name": "創作",
+        "translated_name": "原创"
+      },
+      {
+        "name": "オリジナル1000users入り",
+        "translated_name": "原创1000users加入书籤"
+      }
+    ],
+    "create_date": "2022-06-27T12:30:26Z",
+    "page_count": 1,
+    "meta_single_page": {
+      "original_image_url": "https://i.pximg.net/img-original/img/2022/06/27/21/30/26/99345950_p0.jpg"
+    },
+    "meta_pages": [],
+    "total_view": 4349,
+    "total_bookmarks": 1296
+  },
+  {
+    "id": 99435253,
+    "title": "オリジナルキャラクター１",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2022/07/02/00/00/13/99435253_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/07/02/00/00/13/99435253_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/07/02/00/00/13/99435253_p0_master1200.jpg"
+    },
+    "caption": "オリジナルキャラクターのダンくんです。<br />今後少しずつこのキャラでお絵描きしていきたいと思います。<br />よろしくお願いします。",
+    "user": {
+      "id": 3867822,
+      "name": "モンディ",
+      "account": "mondi_xxx"
+    },
+    "tags": [
+      {
+        "name": "オリジナル",
+        "translated_name": "原创"
+      },
+      {
+        "name": "オリキャラ",
+        "translated_name": "原创角色"
+      },
+      {
+        "name": "創作男子",
+        "translated_name": "创作男子"
+      },
+      {
+        "name": "雄っぱい",
+        "translated_name": "欧♂派"
+      }
+    ],
+    "create_date": "2022-07-01T15:00:13Z",
+    "page_count": 2,
+    "meta_single_page": {},
+    "meta_pages": [
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/07/02/00/00/13/99435253_p0_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/07/02/00/00/13/99435253_p0_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/07/02/00/00/13/99435253_p0_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/07/02/00/00/13/99435253_p0.jpg"
+        }
+      },
+      {
+        "image_urls": {
+          "square_medium": "https://i.pximg.net/c/360x360_10_webp/img-master/img/2022/07/02/00/00/13/99435253_p1_square1200.jpg",
+          "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/07/02/00/00/13/99435253_p1_master1200.jpg",
+          "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/07/02/00/00/13/99435253_p1_master1200.jpg",
+          "original": "https://i.pximg.net/img-original/img/2022/07/02/00/00/13/99435253_p1.jpg"
+        }
+      }
+    ],
+    "total_view": 10026,
+    "total_bookmarks": 1340
+  },
+  {
+    "id": 96649317,
+    "title": "「な、なんであんたがここにいんのよ…！！」",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2022/03/03/17/19/28/96649317_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/03/03/17/19/28/96649317_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/03/03/17/19/28/96649317_p0_master1200.jpg"
+    },
+    "caption": "ゲーセン寄ったら冬優子がmaimaiしてました",
+    "user": {
+      "id": 27816388,
+      "name": "テラ",
+      "account": "user_nsrn4574"
+    },
+    "tags": [
+      {
+        "name": "シャイニーカラーズ"
+      },
+      {
+        "name": "アイドルマスターシャイニーカラーズ",
+        "translated_name": "偶像大师 闪耀色彩"
+      },
+      {
+        "name": "ゲームセンター",
+        "translated_name": "game center"
+      },
+      {
+        "name": "maimai"
+      },
+      {
+        "name": "黛冬優子",
+        "translated_name": "Fuyuko Mayuzumi"
+      },
+      {
+        "name": "アイマス1000users入り",
+        "translated_name": "偶像大师1000users加入书籤"
+      }
+    ],
+    "create_date": "2022-03-03T08:19:28Z",
+    "page_count": 1,
+    "meta_single_page": {
+      "original_image_url": "https://i.pximg.net/img-original/img/2022/03/03/17/19/28/96649317_p0.jpg"
+    },
+    "meta_pages": [],
+    "total_view": 20715,
+    "total_bookmarks": 3809
+  },
+  {
+    "id": 96800354,
+    "title": "ミコッテちゃん",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2022/03/10/09/38/37/96800354_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2022/03/10/09/38/37/96800354_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2022/03/10/09/38/37/96800354_p0_master1200.jpg"
+    },
+    "caption": "skebでリクエストいただいたミコッテちゃんでした！リクエストありがとうございました…！<br />FFはそんなに詳しくないんですが、ミコッテのかわいい画像とかはよく見かけるので描けてたのしかったです！",
+    "user": {
+      "id": 7375118,
+      "name": "れつと",
+      "account": "250let"
+    },
+    "tags": [
+      {
+        "name": "女の子",
+        "translated_name": "女孩子"
+      },
+      {
+        "name": "ミコッテ",
+        "translated_name": "猫魅族"
+      },
+      {
+        "name": "腋",
+        "translated_name": "腋下"
+      }
+    ],
+    "create_date": "2022-03-09T15:00:07Z",
+    "page_count": 1,
+    "meta_single_page": {
+      "original_image_url": "https://i.pximg.net/img-original/img/2022/03/10/09/38/37/96800354_p0.jpg"
+    },
+    "meta_pages": [],
+    "total_view": 7425,
+    "total_bookmarks": 2090
+  },
+  {
+    "id": 61764381,
+    "title": "ミコッテの日！！",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2017/03/14/14/56/05/61764381_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2017/03/14/14/56/05/61764381_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2017/03/14/14/56/05/61764381_p0_master1200.jpg"
+    },
+    "caption": "",
+    "user": {
+      "id": 1113943,
+      "name": "ももこ@5/30画集",
+      "account": "momopoco"
+    },
+    "tags": [
+      {
+        "name": "ミコッテ",
+        "translated_name": "猫魅族"
+      },
+      {
+        "name": "ff14",
+        "translated_name": "最终幻想14"
+      },
+      {
+        "name": "猫耳",
+        "translated_name": "cat ears"
+      },
+      {
+        "name": "手袋",
+        "translated_name": "手套"
+      },
+      {
+        "name": "ミニハット",
+        "translated_name": "mini hat"
+      },
+      {
+        "name": "FF30000users入り",
+        "translated_name": "FF 30000收藏"
+      },
+      {
+        "name": "獣耳",
+        "translated_name": "兽耳"
+      }
+    ],
+    "create_date": "2017-03-05T11:57:08Z",
+    "page_count": 1,
+    "meta_single_page": {
+      "original_image_url": "https://i.pximg.net/img-original/img/2017/03/14/14/56/05/61764381_p0.png"
+    },
+    "meta_pages": [],
+    "total_view": 282338,
+    "total_bookmarks": 38958
+  },
+  {
+    "id": 87917341,
+    "title": "无题",
+    "type": "illust",
+    "image_urls": {
+      "square_medium": "https://i.pximg.net/c/540x540_10_webp/img-master/img/2021/02/20/20/28/50/87917341_p0_square1200.jpg",
+      "medium": "https://i.pximg.net/c/540x540_70/img-master/img/2021/02/20/20/28/50/87917341_p0_master1200.jpg",
+      "large": "https://i.pximg.net/c/600x1200_90_webp/img-master/img/2021/02/20/20/28/50/87917341_p0_master1200.jpg"
+    },
+    "caption": "",
+    "user": {
+      "id": 40875115,
+      "name": "秽浊猫",
+      "account": "user_yumw4747"
+    },
+    "tags": [
+      {
+        "name": "FF14",
+        "translated_name": "最终幻想14"
+      }
+    ],
+    "create_date": "2021-02-20T11:28:50Z",
+    "page_count": 1,
+    "meta_single_page": {
+      "original_image_url": "https://i.pximg.net/img-original/img/2021/02/20/20/28/50/87917341_p0.jpg"
+    },
+    "meta_pages": [],
+    "total_view": 2224,
+    "total_bookmarks": 513
+  }
+]
\ No newline at end of file
diff --git a/src/tests/utils/test_expires_lru_dict.py b/src/tests/utils/test_expires_lru_dict.py
index b77303d..f02d3a8 100644
--- a/src/tests/utils/test_expires_lru_dict.py
+++ b/src/tests/utils/test_expires_lru_dict.py
@@ -1,5 +1,5 @@
 from asyncio import sleep
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from unittest.mock import MagicMock
 
 import pytest
@@ -18,7 +18,7 @@ def lru(self):
     async def test_expires(self, lru):
         lru.on_cleanup = MagicMock()
 
-        expires = datetime.now() + timedelta(seconds=1)
+        expires = datetime.now(timezone.utc) + timedelta(seconds=1)
         lru.add("hello", "world", expires)
         assert lru["hello"] == "world"
 
@@ -26,14 +26,14 @@ async def test_expires(self, lru):
         assert "hello" not in lru
         lru.on_cleanup.assert_called_once_with("hello", "world")
 
-        expires = datetime.now() + timedelta(seconds=30)
+        expires = datetime.now(timezone.utc) + timedelta(seconds=30)
         lru.add("hello", "test", expires)
         assert lru["hello"] == "test"
 
     def test_lru(self, lru):
         lru.on_cleanup = MagicMock()
 
-        expires = datetime.now() + timedelta(seconds=3)
+        expires = datetime.now(timezone.utc) + timedelta(seconds=3)
         for i in range(11):
             lru.add(i, i, expires)
 
@@ -44,7 +44,7 @@ def test_lru(self, lru):
 
     @pytest.mark.asyncio
     async def test_expires_lru(self, lru):
-        now = datetime.now()
+        now = datetime.now(timezone.utc)
         for i in range(5):
             lru.add(i, i, now + timedelta(seconds=2))
         for i in range(5, 10):
@@ -58,7 +58,7 @@ async def test_expires_lru(self, lru):
             assert lru[i] == i
         # except: 5 6 7 8 9
 
-        now = datetime.now()
+        now = datetime.now(timezone.utc)
         for i in range(10, 18):
             lru.add(i, i, now + timedelta(seconds=20))
         # except: 8 9 10 11 12 13 14 15 16 17
@@ -74,13 +74,13 @@ def test_key_error(self, lru):
 
     @pytest.mark.asyncio
     async def test_collate_expires_heap(self, lru):
-        now = datetime.now()
+        now = datetime.now(timezone.utc)
         for i in range(20):
             lru.add(i, i, now + timedelta(milliseconds=100 * i))
 
         await sleep(3)
 
-        now = datetime.now()
+        now = datetime.now(timezone.utc)
         lru.add("hello", "world", now + timedelta(seconds=20))
 
         for i in range(20):
diff --git a/src/tests/utils/test_lazy_delegation.py b/src/tests/utils/test_lazy_delegation.py
new file mode 100644
index 0000000..f959d10
--- /dev/null
+++ b/src/tests/utils/test_lazy_delegation.py
@@ -0,0 +1,24 @@
+from unittest.mock import MagicMock
+
+from tests import MyTest
+
+
+class TestLazyDelegation(MyTest):
+    def test_lazy_delegation(self):
+        from nonebot_plugin_pixivbot.utils.lazy_delegation import LazyDelegation
+
+        class A:
+            def __init__(self):
+                self.hello = "world"
+                self.cache = "cache"
+                self.builder = "builder"
+
+        builder = MagicMock(return_value=A())
+        delegation = LazyDelegation(builder)
+
+        builder.assert_not_called()
+        assert delegation.hello == "world"
+        builder.assert_called()
+
+        assert delegation.cache == "cache"
+        assert delegation.builder == "builder"
diff --git a/src/tests/data/pixiv_repo/test_shared_agen.py b/src/tests/utils/test_shared_agen.py
similarity index 78%
rename from src/tests/data/pixiv_repo/test_shared_agen.py
rename to src/tests/utils/test_shared_agen.py
index 8271576..b8a381c 100644
--- a/src/tests/data/pixiv_repo/test_shared_agen.py
+++ b/src/tests/utils/test_shared_agen.py
@@ -1,5 +1,5 @@
 from asyncio import sleep, create_task, gather
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from typing import AsyncGenerator
 from unittest.mock import MagicMock, AsyncMock, call
 
@@ -24,7 +24,7 @@ async def agen():
 
     @pytest.fixture
     def ctx_mgr(self, origin_agen):
-        from nonebot_plugin_pixivbot.data.pixiv_repo.shared_agen import SharedAsyncGeneratorContextManager
+        from nonebot_plugin_pixivbot.utils.shared_agen import SharedAsyncGeneratorContextManager
 
         on_each = AsyncMock()
         on_stop = AsyncMock()
@@ -39,7 +39,7 @@ def ctx_mgr(self, origin_agen):
 
     @pytest.mark.asyncio
     async def test_on_each_and_on_stop(self, origin_agen):
-        from nonebot_plugin_pixivbot.data.pixiv_repo.shared_agen import SharedAsyncGeneratorContextManager
+        from nonebot_plugin_pixivbot.utils.shared_agen import SharedAsyncGeneratorContextManager
 
         on_each = AsyncMock()
         on_stop = AsyncMock()
@@ -65,6 +65,7 @@ async def test_single_consumer(self, ctx_mgr):
 
         assert items == [i for i in range(10)]
 
+        # noinspection PyUnresolvedReferences
         ctx_mgr._on_consumers_changed.assert_has_calls([call(ctx_mgr, 1), call(ctx_mgr, 0)])
 
     @pytest.mark.asyncio
@@ -88,6 +89,7 @@ async def consume():
 
         await gather(consumer1, consumer2, consumer3)
 
+        # noinspection PyUnresolvedReferences
         ctx_mgr._on_consumers_changed.assert_has_calls(
             [call(ctx_mgr, 1), call(ctx_mgr, 2), call(ctx_mgr, 1), call(ctx_mgr, 0), call(ctx_mgr, 1),
              call(ctx_mgr, 0)])
@@ -110,7 +112,7 @@ async def test_close(self, ctx_mgr):
 class TestSharedAsyncGeneratorManager(MyTest):
     @pytest.fixture
     def shared_agen_mgr(self):
-        from nonebot_plugin_pixivbot.data.pixiv_repo.shared_agen import SharedAsyncGeneratorManager
+        from nonebot_plugin_pixivbot.utils.shared_agen import SharedAsyncGeneratorManager
 
         class SharedAsyncGeneratorManagerImpl(SharedAsyncGeneratorManager[int, int]):
             def agen_factory(self, identifier: int, *args, **kwargs) -> AsyncGenerator[int, None]:
@@ -149,7 +151,7 @@ async def test_set_expires_time(self, shared_agen_mgr):
             await iter.__anext__()
             await iter.__anext__()
 
-            shared_agen_mgr.set_expires_time(0, datetime.now() + timedelta(seconds=1))
+            shared_agen_mgr.set_expires_time(0, datetime.now(timezone.utc) + timedelta(seconds=1))
 
         # prev inst was except to be saved
 
@@ -162,3 +164,27 @@ async def test_set_expires_time(self, shared_agen_mgr):
 
         inst2 = shared_agen_mgr.get(0)
         assert inst != inst2
+
+    @pytest.mark.asyncio
+    async def test_invalidate(self, shared_agen_mgr):
+        inst = shared_agen_mgr.get(0)
+        with inst as iter:
+            await iter.__anext__()
+            await iter.__anext__()
+
+            shared_agen_mgr.set_expires_time(0, datetime.now(timezone.utc) + timedelta(seconds=1))
+
+        # prev inst was except to be saved
+
+        inst2 = shared_agen_mgr.get(0)
+        assert inst == inst2
+
+        shared_agen_mgr.invalidate(0)
+
+        # prev inst was except to be removed
+
+        inst2 = shared_agen_mgr.get(0)
+        assert inst != inst2
+
+        # invalidate a non-existing key should not raise
+        shared_agen_mgr.invalidate(114514)