├── main ├── __init__.py ├── schemas.py ├── dependencies.py ├── settings.py ├── utils.py ├── xml.py ├── ai_api │ ├── __init__.py │ └── gemini.py ├── application.py ├── middlewares.py └── routes.py ├── docker-compose.yml ├── Dockerfile ├── pyproject.toml ├── README.md ├── .gitignore ├── LICENSE └── pdm.lock /main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /main/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class WechatQrCodeEntity(BaseModel): 5 | ticket: str 6 | expire_seconds: int 7 | url: str 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | web: 4 | build: . 5 | command: ["uvicorn", "main.application:app", "--host", "0.0.0.0", "--port", "80"] 6 | ports: 7 | - "6576:80" 8 | env_file: 9 | - .env 10 | restart: always 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 as requirements 2 | 3 | WORKDIR /src 4 | 5 | RUN python -m pip install -U pdm 6 | 7 | RUN pdm config python.use_venv False 8 | 9 | COPY pyproject.toml pyproject.toml 10 | COPY pdm.lock pdm.lock 11 | 12 | RUN pdm export --production -f requirements -o requirements.txt --without-hashes 13 | 14 | FROM python:3.12 15 | 16 | WORKDIR /src 17 | 18 | ENV PYTHONDONTWRITEBYTECODE 1 19 | 20 | COPY --from=requirements /src/requirements.txt . 21 | RUN pip install --no-cache-dir -r requirements.txt 22 | 23 | COPY . . 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mywxmp" 3 | version = "0.1.0" 4 | description = "我的个人微信公众号" 5 | authors = [ 6 | {name = "aber", email = "me@abersheeran.com"}, 7 | ] 8 | dependencies = [ 9 | "kui>=1.6.0", 10 | "pydantic-settings>=2.1.0", 11 | "cool>=0.4.0", 12 | "uvicorn>=0.25.0", 13 | "httpx>=0.26.0", 14 | "loguru>=0.7.2", 15 | ] 16 | requires-python = "==3.12.*" 17 | readme = "README.md" 18 | license = {text = "Apache2.0"} 19 | 20 | [tool.pdm] 21 | package-type = "application" 22 | 23 | [tool.ruff] 24 | ignore = [ 25 | "E501", 26 | "E731", 27 | ] 28 | 29 | [tool.ruff.lint] 30 | select = ["I"] 31 | -------------------------------------------------------------------------------- /main/dependencies.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | from kui.asgi import request 5 | 6 | 7 | def get_picture_cache() -> dict[str, list[str]]: 8 | return request.app.state.picture_cache 9 | 10 | 11 | def get_pending_queue() -> dict[str, asyncio.Task[str]]: 12 | return request.app.state.pending_queue 13 | 14 | 15 | def get_pending_queue_count() -> dict[str, int]: 16 | return request.app.state.pending_queue_count 17 | 18 | 19 | async def get_access_token() -> str: 20 | if request.app.state.access_token_expired_at >= time.time(): 21 | await request.app.state.refresh_token() 22 | return request.app.state.access_token 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 我的个人微信公众号 2 | 3 | - [x] 由 Gemini 提供 AI 能力支持,可以使用图片+文字的方式进行对话。 4 | - [x] 支持 API 创建二维码,用户微信扫码后 HTTP 回调到指定 URL 5 | 6 | ## 部署方式 7 | 8 | `git clone https://github.com/abersheeran/mywxmp` 之后,在项目根目录下创建 `.env` 文件,内容如下: 9 | 10 | ```.env 11 | # 设置的公众号 Token 12 | WECHAT_TOKEN= 13 | # 公众号 AppID 和 AppSecret 14 | APP_ID= 15 | APP_SECRET= 16 | # 设置的公众号微信号 17 | WECHAT_ID= 18 | # 这是可选的,调用创建二维码 API 时使用的鉴权 TOKEN,默认为空字符串 19 | QRCODE_API_TOKEN="" 20 | # Gemini 服务的 API Key 21 | GEMINI_PRO_KEY= 22 | # 这两个可选,如果你的服务器 IP 本身就可以直连 Gemini 服务,那么可以不用配置 23 | GEMINI_PRO_URL=https://gemini.proxy/v1beta/models/gemini-pro:generateContent 24 | GEMINI_PRO_VISION_URL=https://gemini.proxy/v1beta/models/gemini-pro-vision:generateContent 25 | ``` 26 | 27 | 然后运行 `docker compose up --build -d`,本服务将运行在 `6576` 端口。 28 | -------------------------------------------------------------------------------- /main/settings.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings, SettingsConfigDict 2 | 3 | 4 | class Settings(BaseSettings): 5 | model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") 6 | 7 | # WeChat 8 | wechat_token: str 9 | app_id: str 10 | app_secret: str 11 | wechat_id: str 12 | 13 | qrcode_api_token: str = "" 14 | 15 | # Gemini 16 | gemini_pro_key: str 17 | gemini_pro_url: str = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent" 18 | gemini_pro_vision_url: str = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent" 19 | 20 | # GitHub 21 | github_webhook_secret: str | None = None 22 | 23 | 24 | settings = Settings.model_validate({}) 25 | -------------------------------------------------------------------------------- /main/utils.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Any, Awaitable, Callable, Coroutine, ParamSpec, TypeVar 3 | 4 | R = TypeVar("R") 5 | P = ParamSpec("P") 6 | 7 | 8 | def retry_when_exception(*exceptions: type[BaseException], max_tries: int = 3): 9 | def d(func: Callable[P, Awaitable[R]]) -> Callable[P, Coroutine[Any, Any, R]]: 10 | @wraps(func) 11 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 12 | for i in range(max_tries): 13 | try: 14 | return await func(*args, **kwargs) 15 | except exceptions: 16 | if i == max_tries - 1: 17 | raise 18 | raise RuntimeError("Unreachable") 19 | 20 | return wrapper 21 | 22 | return d 23 | -------------------------------------------------------------------------------- /main/xml.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree 2 | 3 | 4 | def parse_xml(xml_str: str) -> dict[str, str]: 5 | """ 6 | https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html 7 | """ 8 | root = xml.etree.ElementTree.fromstring(xml_str) 9 | return {child.tag: child.text or "" for child in root} 10 | 11 | 12 | def build_xml(data: dict[str, str]) -> str: 13 | """ 14 | https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html 15 | """ 16 | root = xml.etree.ElementTree.Element("xml") 17 | for key, value in data.items(): 18 | child = xml.etree.ElementTree.SubElement(root, key) 19 | child.text = value 20 | return xml.etree.ElementTree.tostring(root, encoding="unicode", method="xml") 21 | -------------------------------------------------------------------------------- /main/ai_api/__init__.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | 4 | class GenerateClientError(Exception): 5 | """ 6 | Generate error 7 | """ 8 | 9 | 10 | class GenerateNetworkError(GenerateClientError): 11 | """ 12 | Request network error 13 | """ 14 | 15 | 16 | class GenerateResponseError(GenerateClientError): 17 | """ 18 | Response error 19 | """ 20 | 21 | def __init__(self, message: str, response: httpx.Response) -> None: 22 | self.message = message 23 | self.response = response 24 | super().__init__(f"{response.status_code} {response.text}") 25 | 26 | 27 | class GenerateSafeError(GenerateClientError): 28 | """ 29 | Safe error 30 | """ 31 | 32 | def __init__(self, response: httpx.Response) -> None: 33 | self.response = response 34 | super().__init__(f"{response.status_code} {response.text}") 35 | -------------------------------------------------------------------------------- /main/application.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | import httpx 5 | from kui.asgi import Kui 6 | from loguru import logger 7 | 8 | from .ai_api.gemini import initial_gemini_config 9 | from .routes import routes 10 | from .settings import settings 11 | 12 | app = Kui() 13 | app.router <<= routes 14 | 15 | 16 | @app.on_startup 17 | async def initial_gemini(app: Kui) -> None: 18 | await initial_gemini_config( 19 | settings.gemini_pro_key, 20 | pro_url=settings.gemini_pro_url, 21 | pro_vision_url=settings.gemini_pro_vision_url, 22 | ) 23 | 24 | 25 | @app.on_startup 26 | async def initial_cache(app: Kui) -> None: 27 | app.state.picture_cache = {} 28 | app.state.pending_queue = {} 29 | app.state.pending_queue_count = {} 30 | 31 | 32 | @app.on_startup 33 | async def initial_token(app: Kui) -> None: 34 | app.state.refresh_token = lambda: initial_token(app) 35 | 36 | async with httpx.AsyncClient() as client: 37 | resp = await client.get( 38 | "https://api.weixin.qq.com/cgi-bin/token", 39 | params={ 40 | "grant_type": "client_credential", 41 | "appid": settings.app_id, 42 | "secret": settings.app_secret, 43 | }, 44 | ) 45 | resp.raise_for_status() 46 | data = resp.json() 47 | logger.debug(f"Status Code {resp.status_code}: {data}") 48 | app.state.access_token = data["access_token"] 49 | expires_in = data["expires_in"] - 60 50 | app.state.access_token_expired_at = time.time() + expires_in 51 | 52 | task = asyncio.create_task(asyncio.sleep(expires_in)) 53 | task.add_done_callback(lambda future: asyncio.create_task(initial_token(app))) 54 | -------------------------------------------------------------------------------- /main/middlewares.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | from typing import Annotated, Any 4 | 5 | from cool import F 6 | from kui.asgi import Header, HTTPException, PlainTextResponse, Query, request 7 | 8 | from .settings import settings 9 | 10 | 11 | def validate_wechat_signature(endpoint): 12 | """ 13 | https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html 14 | """ 15 | 16 | async def w( 17 | signature: Annotated[str, Query(...)], 18 | timestamp: Annotated[str, Query(...)], 19 | nonce: Annotated[str, Query(...)], 20 | ) -> Annotated[Any, PlainTextResponse[400]]: 21 | string = [settings.wechat_token, timestamp, nonce] | F(sorted) | F("".join) 22 | sha1 = string.encode("utf-8") | F(lambda x: hashlib.sha1(x).hexdigest()) 23 | if sha1 != signature: 24 | raise HTTPException(400, content="Invalid signature") 25 | return await endpoint() 26 | 27 | return w 28 | 29 | 30 | def validate_github_signature(endpoint): 31 | """ 32 | https://docs.github.com/en/developers/webhooks-and-events/securing-your-webhooks 33 | """ 34 | 35 | async def w( 36 | signature_header: Annotated[str, Header(..., alias="X-Hub-Signature-256")] 37 | ) -> None: 38 | """Verify that the payload was sent from GitHub by validating SHA256. 39 | 40 | Raise and return 403 if not authorized. 41 | """ 42 | if not signature_header: 43 | raise HTTPException( 44 | status_code=403, content="x-hub-signature-256 header is missing!" 45 | ) 46 | hash_object = hmac.new( 47 | settings.github_webhook_secret.encode("utf-8"), 48 | msg=await request.body, 49 | digestmod=hashlib.sha256, 50 | ) 51 | expected_signature = "sha256=" + hash_object.hexdigest() 52 | if not hmac.compare_digest(expected_signature, signature_header): 53 | raise HTTPException( 54 | status_code=403, content="Request signatures didn't match!" 55 | ) 56 | return await endpoint() 57 | 58 | return w 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | .pdm-python 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /main/ai_api/gemini.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, NotRequired, TypedDict 2 | 3 | import httpx 4 | from loguru import logger 5 | 6 | from ..utils import retry_when_exception 7 | from . import GenerateNetworkError, GenerateResponseError, GenerateSafeError 8 | 9 | 10 | def is_supported_mime_type(mime_type: str) -> bool: 11 | return mime_type in ( 12 | "image/png", 13 | "image/jpeg", 14 | "image/webp", 15 | "image/heic", 16 | "image/heif", 17 | ) 18 | 19 | 20 | async def initial_gemini_config( 21 | key: str, 22 | *, 23 | pro_url: str | None = None, 24 | pro_vision_url: str | None = None, 25 | ): 26 | global GEMINI_PRO_URL, GEMINI_PRO_VISION_URL, GEMINI_CLIENT 27 | GEMINI_PRO_URL = ( 28 | "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent" 29 | if pro_url is None 30 | else pro_url 31 | ) 32 | GEMINI_PRO_VISION_URL = ( 33 | "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent" 34 | if pro_vision_url is None 35 | else pro_vision_url 36 | ) 37 | 38 | client = httpx.AsyncClient(params={"key": key}) 39 | await client.__aenter__() 40 | GEMINI_CLIENT = client 41 | 42 | 43 | class InlineData(TypedDict): 44 | data: str 45 | # image/png, image/jpeg, image/webp, image/heic, or image/heif 46 | mime_type: Literal[ 47 | "image/png", "image/jpeg", "image/webp", "image/heic", "image/heif" 48 | ] 49 | 50 | 51 | class Part(TypedDict, total=False): 52 | text: str 53 | inline_data: InlineData 54 | 55 | 56 | class Content(TypedDict): 57 | parts: list[Part] 58 | role: NotRequired[Literal["user", "model"]] 59 | 60 | 61 | @retry_when_exception(GenerateResponseError) 62 | async def generate_content( 63 | contents: list[Content], 64 | *, 65 | safety_threshold: Literal[ 66 | "BLOCK_NONE", 67 | "BLOCK_ONLY_HIGH", 68 | "BLOCK_MEDIUM_AND_ABOVE", 69 | "BLOCK_LOW_AND_ABOVE", 70 | ] = "BLOCK_NONE", 71 | ) -> str: 72 | client = GEMINI_CLIENT 73 | 74 | use_vision = False 75 | for content in contents: 76 | for part in content["parts"]: 77 | if "inline_data" in part: 78 | use_vision = True 79 | break 80 | if len(contents) > 2: 81 | use_vision = False 82 | for content in contents: 83 | for part in tuple(content["parts"]): 84 | if "inline_data" in part: 85 | content["parts"].remove(part) 86 | 87 | url = GEMINI_PRO_VISION_URL if use_vision else GEMINI_PRO_URL 88 | 89 | logger.debug(f"Generating content from {url} with {contents}") 90 | 91 | try: 92 | resp = await client.post( 93 | url, 94 | json={ 95 | "contents": contents, 96 | "generationConfig": { 97 | "stopSequences": ["Title"], 98 | "temperature": 0.7, 99 | "maxOutputTokens": 800, 100 | "topP": 0.8, 101 | "topK": 10, 102 | }, 103 | "safetySettings": [ 104 | {"category": category, "threshold": safety_threshold} 105 | for category in ( 106 | "HARM_CATEGORY_HARASSMENT", 107 | "HARM_CATEGORY_HATE_SPEECH", 108 | "HARM_CATEGORY_SEXUALLY_EXPLICIT", 109 | "HARM_CATEGORY_DANGEROUS_CONTENT", 110 | ) 111 | ], 112 | }, 113 | timeout=None, 114 | ) 115 | except httpx.HTTPError as error: 116 | raise GenerateNetworkError(error) 117 | else: 118 | if not resp.is_success: 119 | raise GenerateResponseError(resp.text, resp) 120 | else: 121 | response_json = resp.json() 122 | candidates = response_json.get("candidates", None) 123 | if candidates is None: 124 | raise GenerateSafeError(resp) 125 | 126 | try: 127 | text = "".join( 128 | map( 129 | lambda x: x["text"], 130 | candidates[0]["content"]["parts"], 131 | ) 132 | ) 133 | logger.debug(f"Generated content: {text}") 134 | return text 135 | except KeyError: 136 | raise GenerateResponseError("内部错误", resp) 137 | -------------------------------------------------------------------------------- /main/routes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import time 4 | from typing import Annotated, Any, Literal 5 | 6 | import httpx 7 | from kui.asgi import ( 8 | Body, 9 | Depends, 10 | Header, 11 | HTTPException, 12 | HttpView, 13 | JSONResponse, 14 | PlainTextResponse, 15 | Query, 16 | Routes, 17 | api_key_auth_dependency, 18 | request, 19 | ) 20 | from loguru import logger 21 | from pydantic import HttpUrl 22 | 23 | from .ai_api import GenerateNetworkError, GenerateResponseError, GenerateSafeError 24 | from .ai_api.gemini import Content as GeminiRequestContent 25 | from .ai_api.gemini import Part as GeminiRequestPart 26 | from .ai_api.gemini import generate_content 27 | from .dependencies import ( 28 | get_access_token, 29 | get_pending_queue, 30 | get_pending_queue_count, 31 | get_picture_cache, 32 | ) 33 | from .middlewares import validate_github_signature, validate_wechat_signature 34 | from .schemas import WechatQrCodeEntity 35 | from .settings import settings 36 | from .xml import build_xml, parse_xml 37 | 38 | routes = Routes() 39 | 40 | 41 | @routes.http.post("/qrcode") 42 | async def create_wechat_qrcode( 43 | api_key: Annotated[str, Depends(api_key_auth_dependency("api-key"))], 44 | callback: Annotated[HttpUrl, Body(...)], 45 | ) -> Annotated[Any, JSONResponse[201, {}, WechatQrCodeEntity]]: 46 | if settings.qrcode_api_token != api_key: 47 | raise HTTPException(401) 48 | 49 | payload = { 50 | "action_name": "QR_STR_SCENE", 51 | "expire_seconds": 60 * 10, 52 | "action_info": { 53 | "scene": {"scene_str": str(callback)}, 54 | }, 55 | } 56 | 57 | async with httpx.AsyncClient() as client: 58 | resp = await client.post( 59 | "https://api.weixin.qq.com/cgi-bin/qrcode/create", 60 | params={"access_token": await get_access_token()}, 61 | json=payload, 62 | ) 63 | resp.raise_for_status() 64 | qrcode = resp.json() 65 | logger.debug(f"Generate WeChat QR code: {qrcode}") 66 | 67 | return ( 68 | WechatQrCodeEntity( 69 | ticket=qrcode["ticket"], 70 | expire_seconds=qrcode["expire_seconds"], 71 | url=qrcode["url"], 72 | ), 73 | 201, 74 | ) 75 | 76 | 77 | @routes.http("/wechat", middlewares=[validate_wechat_signature]) 78 | class WeChat(HttpView): 79 | @classmethod 80 | async def get( 81 | cls, echostr: Annotated[str, Query(...)] 82 | ) -> Annotated[ 83 | str, 84 | PlainTextResponse[200], 85 | ]: 86 | return echostr 87 | 88 | @classmethod 89 | async def post( 90 | cls, 91 | picture_cache: Annotated[dict[str, list[str]], Depends(get_picture_cache)], 92 | ) -> Annotated[ 93 | str | Literal[b""], 94 | PlainTextResponse[200], 95 | ]: 96 | text = (await request.body).decode("utf-8") 97 | xml = parse_xml(text) 98 | logger.debug(f"Received message: {xml}\n{text}") 99 | msg_type = xml["MsgType"] 100 | 101 | match msg_type: 102 | case "event": 103 | return await cls.handle_event(xml) 104 | case "image": 105 | user_id = xml["FromUserName"] 106 | picture_cache.setdefault(user_id, []).append(xml["PicUrl"]) 107 | asyncio.get_running_loop().call_later( 108 | 60, picture_cache.pop, user_id, None 109 | ) 110 | return b"" 111 | case "text": 112 | return await cls.handle_text(xml) 113 | case "voice": 114 | return await cls.handle_voice(xml) 115 | case _: 116 | user_id = xml["FromUserName"] 117 | return cls.reply_text(user_id, "暂不支持此消息类型。") 118 | 119 | @classmethod 120 | async def handle_event(cls, xml: dict[str, str]) -> str | Literal[b""]: 121 | if xml["EventKey"] and xml["Event"] in ("subscribe", "scan"): 122 | return await cls.handle_scan_callback(xml) 123 | 124 | match xml["Event"]: 125 | case "subscribe": 126 | return await cls.handle_event_subscribe(xml) 127 | case _: 128 | return b"" 129 | 130 | @classmethod 131 | async def handle_event_subscribe(cls, xml: dict[str, str]) -> str: 132 | return cls.reply_text( 133 | xml["FromUserName"], 134 | "欢迎关注我的微信公众号,我会在这里推送一些我写的小说。你可以直接给我发送消息来和我进行 7×24 的对话。", 135 | ) 136 | 137 | @classmethod 138 | async def handle_scan_callback(cls, xml: dict[str, str]) -> str: 139 | async with httpx.AsyncClient() as client: 140 | response = await client.post( 141 | xml["EventKey"], 142 | json={ 143 | "openid": xml["FromUserName"], 144 | "create_time": xml["CreateTime"], 145 | }, 146 | ) 147 | response.raise_for_status() 148 | if xml["Event"] == "subscribe": 149 | return await cls.handle_event_subscribe(xml) 150 | return cls.reply_text(xml["FromUserName"], "扫码成功。") 151 | 152 | @classmethod 153 | async def handle_text(cls, xml: dict[str, str]) -> str: 154 | user_id = xml["FromUserName"] 155 | msg_id = xml["MsgId"] 156 | content = xml["Content"] 157 | if content == "【收到不支持的消息类型,暂无法显示】": 158 | return cls.reply_text(user_id, "请不要发送表情包。") 159 | return await cls.wait_generate_content(user_id, msg_id, content) 160 | 161 | @classmethod 162 | async def handle_voice(cls, xml: dict[str, str]) -> str: 163 | user_id = xml["FromUserName"] 164 | if "Recognition" not in xml: 165 | return cls.reply_text( 166 | user_id, 167 | "开发者未开启“接收语音识别结果”功能,请到公众平台官网“设置与开发”页的“接口权限”里开启。", 168 | ) 169 | if not xml["Recognition"]: 170 | return cls.reply_text(user_id, "微信无法识别这条语音内容,请重新发送。") 171 | msg_id = xml["MsgId"] 172 | content = xml["Recognition"] 173 | return await cls.wait_generate_content(user_id, msg_id, content) 174 | 175 | @staticmethod 176 | def reply_text(user_id: str, content: str) -> str: 177 | """ 178 | https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html 179 | """ 180 | return build_xml( 181 | { 182 | "ToUserName": user_id, 183 | "FromUserName": settings.wechat_id, 184 | "CreateTime": str(int(time.time())), 185 | "MsgType": "text", 186 | "Content": content, 187 | } 188 | ) 189 | 190 | @classmethod 191 | async def wait_generate_content( 192 | cls, user_id: str, msg_id: str, content: str 193 | ) -> str: 194 | pending_queue = get_pending_queue() 195 | pending_queue_count = get_pending_queue_count() 196 | 197 | if msg_id in pending_queue: 198 | pending_queue_count[msg_id] += 1 199 | if pending_queue_count[msg_id] >= 3: 200 | return await pending_queue[msg_id] 201 | else: 202 | return await asyncio.shield(pending_queue[msg_id]) 203 | else: 204 | pending_queue_count[msg_id] = 1 205 | pending_queue[msg_id] = asyncio.create_task( 206 | cls.generate_content(user_id, content) 207 | ) 208 | asyncio.get_running_loop().call_later( 209 | 20, 210 | lambda: ( 211 | pending_queue.pop(msg_id, None), 212 | pending_queue_count.pop(msg_id, None), 213 | ), 214 | ) 215 | return await asyncio.shield(pending_queue[msg_id]) 216 | 217 | @classmethod 218 | async def generate_content(cls, user_id: str, message_text: str): 219 | parts: list[GeminiRequestPart] = [{"text": message_text}] 220 | photos: list[str] = get_picture_cache().pop(user_id, []) 221 | async with httpx.AsyncClient() as client: 222 | for photo_url in photos: 223 | resp = await client.get(photo_url) 224 | if not resp.is_success: 225 | return "微信图片服务器出现问题,请稍后再试。" 226 | image = resp.content 227 | image_base64 = base64.b64encode(image).decode("utf-8") 228 | parts.append( 229 | { 230 | "inline_data": { 231 | "mime_type": "image/jpeg", 232 | "data": image_base64, 233 | } 234 | } 235 | ) 236 | contents: list[GeminiRequestContent] = [{"parts": parts}] 237 | try: 238 | response_content = await generate_content( 239 | contents, safety_threshold="BLOCK_MEDIUM_AND_ABOVE" 240 | ) 241 | except GenerateSafeError as error: 242 | response_content = "这是不可以谈的话题。" 243 | logger.warning(f"Safe error: {error}") 244 | except GenerateResponseError as error: 245 | response_content = "我好像找不到我的大脑了。" 246 | logger.exception(f"Response error: {error}") 247 | except GenerateNetworkError as error: 248 | response_content = "网络出现问题,请稍后再试。" 249 | logger.warning(f"Network error: {error}") 250 | 251 | # 252 | # 253 | # 254 | # 12345678 255 | # 256 | # 257 | # 258 | return build_xml( 259 | { 260 | "ToUserName": user_id, 261 | "FromUserName": settings.wechat_id, 262 | "CreateTime": str(int(time.time())), 263 | "MsgType": "text", 264 | "Content": response_content, 265 | } 266 | ) 267 | 268 | 269 | @routes.http("/github", middlewares=[validate_github_signature]) 270 | class GitHub(HttpView): 271 | @classmethod 272 | async def post( 273 | cls, 274 | github_event_type: Annotated[str, Header(..., alias="X-GitHub-Event")], 275 | ): 276 | match github_event_type: 277 | case "ping": 278 | return "pong" 279 | case "push": 280 | # TODO 281 | return "OK" 282 | case _: 283 | return "Unsupported event type.", 400 284 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 abersheeran 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default"] 6 | strategy = ["cross_platform", "inherit_metadata"] 7 | lock_version = "4.4.1" 8 | content_hash = "sha256:bb6cdc00ca2fc08c5395bfcb964342398e29b56f82401a6d0dcedd62d4e4b2d6" 9 | 10 | [[package]] 11 | name = "annotated-types" 12 | version = "0.6.0" 13 | requires_python = ">=3.8" 14 | summary = "Reusable constraint types to use with typing.Annotated" 15 | groups = ["default"] 16 | files = [ 17 | {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, 18 | {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, 19 | ] 20 | 21 | [[package]] 22 | name = "anyio" 23 | version = "4.2.0" 24 | requires_python = ">=3.8" 25 | summary = "High level compatibility layer for multiple asynchronous event loop implementations" 26 | groups = ["default"] 27 | dependencies = [ 28 | "idna>=2.8", 29 | "sniffio>=1.1", 30 | ] 31 | files = [ 32 | {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"}, 33 | {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"}, 34 | ] 35 | 36 | [[package]] 37 | name = "baize" 38 | version = "0.20.8" 39 | requires_python = ">=3.7" 40 | summary = "Powerful and exquisite WSGI/ASGI framework/toolkit." 41 | groups = ["default"] 42 | files = [ 43 | {file = "baize-0.20.8-py3-none-any.whl", hash = "sha256:562bd3eeca18efd9e2518e8e0e01689b76e0de3368bff71ea3b0b1a5c5c792a5"}, 44 | {file = "baize-0.20.8.tar.gz", hash = "sha256:966eea93830beb003d8992218483d70fdd8955baedf225e519c8c3a0fe89dd64"}, 45 | ] 46 | 47 | [[package]] 48 | name = "certifi" 49 | version = "2023.11.17" 50 | requires_python = ">=3.6" 51 | summary = "Python package for providing Mozilla's CA Bundle." 52 | groups = ["default"] 53 | files = [ 54 | {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, 55 | {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, 56 | ] 57 | 58 | [[package]] 59 | name = "click" 60 | version = "8.1.7" 61 | requires_python = ">=3.7" 62 | summary = "Composable command line interface toolkit" 63 | groups = ["default"] 64 | dependencies = [ 65 | "colorama; platform_system == \"Windows\"", 66 | ] 67 | files = [ 68 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 69 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 70 | ] 71 | 72 | [[package]] 73 | name = "colorama" 74 | version = "0.4.6" 75 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 76 | summary = "Cross-platform colored terminal text." 77 | groups = ["default"] 78 | marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" 79 | files = [ 80 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 81 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 82 | ] 83 | 84 | [[package]] 85 | name = "cool" 86 | version = "0.4.0" 87 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 88 | summary = "" 89 | groups = ["default"] 90 | files = [ 91 | {file = "cool-0.4.0-py2.py3-none-any.whl", hash = "sha256:9a2afc89e7c05b27d4f9cba99e9353ef3f274e9afdd2494a3b70c7f7f647596b"}, 92 | {file = "cool-0.4.0.tar.gz", hash = "sha256:4521ea6acd39dbf6d662d7e60fd22c0f3b969683ffcc9efd25538d57c33d18bc"}, 93 | ] 94 | 95 | [[package]] 96 | name = "h11" 97 | version = "0.14.0" 98 | requires_python = ">=3.7" 99 | summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 100 | groups = ["default"] 101 | files = [ 102 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 103 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 104 | ] 105 | 106 | [[package]] 107 | name = "httpcore" 108 | version = "1.0.2" 109 | requires_python = ">=3.8" 110 | summary = "A minimal low-level HTTP client." 111 | groups = ["default"] 112 | dependencies = [ 113 | "certifi", 114 | "h11<0.15,>=0.13", 115 | ] 116 | files = [ 117 | {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, 118 | {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, 119 | ] 120 | 121 | [[package]] 122 | name = "httpx" 123 | version = "0.26.0" 124 | requires_python = ">=3.8" 125 | summary = "The next generation HTTP client." 126 | groups = ["default"] 127 | dependencies = [ 128 | "anyio", 129 | "certifi", 130 | "httpcore==1.*", 131 | "idna", 132 | "sniffio", 133 | ] 134 | files = [ 135 | {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, 136 | {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, 137 | ] 138 | 139 | [[package]] 140 | name = "idna" 141 | version = "3.6" 142 | requires_python = ">=3.5" 143 | summary = "Internationalized Domain Names in Applications (IDNA)" 144 | groups = ["default"] 145 | files = [ 146 | {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, 147 | {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, 148 | ] 149 | 150 | [[package]] 151 | name = "kui" 152 | version = "1.6.0" 153 | requires_python = ">=3.7,<4.0" 154 | summary = "An easy-to-use web framework." 155 | groups = ["default"] 156 | dependencies = [ 157 | "baize<0.21.0,>=0.20.0", 158 | "pydantic>=1.10", 159 | "typing-extensions>=4.2.0", 160 | ] 161 | files = [ 162 | {file = "kui-1.6.0-py3-none-any.whl", hash = "sha256:e347968c2a36b85907018d426a711d993b8d3d73729f7e42c7d48ad2b5e69658"}, 163 | {file = "kui-1.6.0.tar.gz", hash = "sha256:c3850668089bd3f4f26b131d0d0b9695e6ce1e61adde81ee0744f982e86c01a7"}, 164 | ] 165 | 166 | [[package]] 167 | name = "loguru" 168 | version = "0.7.2" 169 | requires_python = ">=3.5" 170 | summary = "Python logging made (stupidly) simple" 171 | groups = ["default"] 172 | dependencies = [ 173 | "colorama>=0.3.4; sys_platform == \"win32\"", 174 | "win32-setctime>=1.0.0; sys_platform == \"win32\"", 175 | ] 176 | files = [ 177 | {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, 178 | {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, 179 | ] 180 | 181 | [[package]] 182 | name = "pydantic" 183 | version = "2.5.3" 184 | requires_python = ">=3.7" 185 | summary = "Data validation using Python type hints" 186 | groups = ["default"] 187 | dependencies = [ 188 | "annotated-types>=0.4.0", 189 | "pydantic-core==2.14.6", 190 | "typing-extensions>=4.6.1", 191 | ] 192 | files = [ 193 | {file = "pydantic-2.5.3-py3-none-any.whl", hash = "sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4"}, 194 | {file = "pydantic-2.5.3.tar.gz", hash = "sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a"}, 195 | ] 196 | 197 | [[package]] 198 | name = "pydantic-core" 199 | version = "2.14.6" 200 | requires_python = ">=3.7" 201 | summary = "" 202 | groups = ["default"] 203 | dependencies = [ 204 | "typing-extensions!=4.7.0,>=4.6.0", 205 | ] 206 | files = [ 207 | {file = "pydantic_core-2.14.6-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:667aa2eac9cd0700af1ddb38b7b1ef246d8cf94c85637cbb03d7757ca4c3fdec"}, 208 | {file = "pydantic_core-2.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdee837710ef6b56ebd20245b83799fce40b265b3b406e51e8ccc5b85b9099b7"}, 209 | {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c5bcf3414367e29f83fd66f7de64509a8fd2368b1edf4351e862910727d3e51"}, 210 | {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a92ae76f75d1915806b77cf459811e772d8f71fd1e4339c99750f0e7f6324f"}, 211 | {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a983cca5ed1dd9a35e9e42ebf9f278d344603bfcb174ff99a5815f953925140a"}, 212 | {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb92f9061657287eded380d7dc455bbf115430b3aa4741bdc662d02977e7d0af"}, 213 | {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ace1e220b078c8e48e82c081e35002038657e4b37d403ce940fa679e57113b"}, 214 | {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef633add81832f4b56d3b4c9408b43d530dfca29e68fb1b797dcb861a2c734cd"}, 215 | {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7e90d6cc4aad2cc1f5e16ed56e46cebf4877c62403a311af20459c15da76fd91"}, 216 | {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8a5ac97ea521d7bde7621d86c30e86b798cdecd985723c4ed737a2aa9e77d0c"}, 217 | {file = "pydantic_core-2.14.6-cp312-none-win32.whl", hash = "sha256:f27207e8ca3e5e021e2402ba942e5b4c629718e665c81b8b306f3c8b1ddbb786"}, 218 | {file = "pydantic_core-2.14.6-cp312-none-win_amd64.whl", hash = "sha256:b3e5fe4538001bb82e2295b8d2a39356a84694c97cb73a566dc36328b9f83b40"}, 219 | {file = "pydantic_core-2.14.6-cp312-none-win_arm64.whl", hash = "sha256:64634ccf9d671c6be242a664a33c4acf12882670b09b3f163cd00a24cffbd74e"}, 220 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95"}, 221 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277"}, 222 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670"}, 223 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e"}, 224 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d"}, 225 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d"}, 226 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94"}, 227 | {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66"}, 228 | {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4241204e4b36ab5ae466ecec5c4c16527a054c69f99bba20f6f75232a6a534e2"}, 229 | {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e574de99d735b3fc8364cba9912c2bec2da78775eba95cbb225ef7dda6acea24"}, 230 | {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1302a54f87b5cd8528e4d6d1bf2133b6aa7c6122ff8e9dc5220fbc1e07bffebd"}, 231 | {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8e81e4b55930e5ffab4a68db1af431629cf2e4066dbdbfef65348b8ab804ea8"}, 232 | {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c99462ffc538717b3e60151dfaf91125f637e801f5ab008f81c402f1dff0cd0f"}, 233 | {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e4cf2d5829f6963a5483ec01578ee76d329eb5caf330ecd05b3edd697e7d768a"}, 234 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:cf10b7d58ae4a1f07fccbf4a0a956d705356fea05fb4c70608bb6fa81d103cda"}, 235 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:399ac0891c284fa8eb998bcfa323f2234858f5d2efca3950ae58c8f88830f145"}, 236 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c6a5c79b28003543db3ba67d1df336f253a87d3112dac3a51b94f7d48e4c0e1"}, 237 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599c87d79cab2a6a2a9df4aefe0455e61e7d2aeede2f8577c1b7c0aec643ee8e"}, 238 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e166ad47ba900f2542a80d83f9fc65fe99eb63ceec4debec160ae729824052"}, 239 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a0b5db001b98e1c649dd55afa928e75aa4087e587b9524a4992316fa23c9fba"}, 240 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:747265448cb57a9f37572a488a57d873fd96bf51e5bb7edb52cfb37124516da4"}, 241 | {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7ebe3416785f65c28f4f9441e916bfc8a54179c8dea73c23023f7086fa601c5d"}, 242 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2"}, 243 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03"}, 244 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f"}, 245 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a"}, 246 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf"}, 247 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556"}, 248 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341"}, 249 | {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e"}, 250 | {file = "pydantic_core-2.14.6.tar.gz", hash = "sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948"}, 251 | ] 252 | 253 | [[package]] 254 | name = "pydantic-settings" 255 | version = "2.1.0" 256 | requires_python = ">=3.8" 257 | summary = "Settings management using Pydantic" 258 | groups = ["default"] 259 | dependencies = [ 260 | "pydantic>=2.3.0", 261 | "python-dotenv>=0.21.0", 262 | ] 263 | files = [ 264 | {file = "pydantic_settings-2.1.0-py3-none-any.whl", hash = "sha256:7621c0cb5d90d1140d2f0ef557bdf03573aac7035948109adf2574770b77605a"}, 265 | {file = "pydantic_settings-2.1.0.tar.gz", hash = "sha256:26b1492e0a24755626ac5e6d715e9077ab7ad4fb5f19a8b7ed7011d52f36141c"}, 266 | ] 267 | 268 | [[package]] 269 | name = "python-dotenv" 270 | version = "1.0.0" 271 | requires_python = ">=3.8" 272 | summary = "Read key-value pairs from a .env file and set them as environment variables" 273 | groups = ["default"] 274 | files = [ 275 | {file = "python-dotenv-1.0.0.tar.gz", hash = "sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba"}, 276 | {file = "python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a"}, 277 | ] 278 | 279 | [[package]] 280 | name = "sniffio" 281 | version = "1.3.0" 282 | requires_python = ">=3.7" 283 | summary = "Sniff out which async library your code is running under" 284 | groups = ["default"] 285 | files = [ 286 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 287 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 288 | ] 289 | 290 | [[package]] 291 | name = "typing-extensions" 292 | version = "4.9.0" 293 | requires_python = ">=3.8" 294 | summary = "Backported and Experimental Type Hints for Python 3.8+" 295 | groups = ["default"] 296 | files = [ 297 | {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, 298 | {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, 299 | ] 300 | 301 | [[package]] 302 | name = "uvicorn" 303 | version = "0.25.0" 304 | requires_python = ">=3.8" 305 | summary = "The lightning-fast ASGI server." 306 | groups = ["default"] 307 | dependencies = [ 308 | "click>=7.0", 309 | "h11>=0.8", 310 | ] 311 | files = [ 312 | {file = "uvicorn-0.25.0-py3-none-any.whl", hash = "sha256:ce107f5d9bd02b4636001a77a4e74aab5e1e2b146868ebbad565237145af444c"}, 313 | {file = "uvicorn-0.25.0.tar.gz", hash = "sha256:6dddbad1d7ee0f5140aba5ec138ddc9612c5109399903828b4874c9937f009c2"}, 314 | ] 315 | 316 | [[package]] 317 | name = "win32-setctime" 318 | version = "1.1.0" 319 | requires_python = ">=3.5" 320 | summary = "A small Python utility to set file creation time on Windows" 321 | groups = ["default"] 322 | marker = "sys_platform == \"win32\"" 323 | files = [ 324 | {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, 325 | {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, 326 | ] 327 | --------------------------------------------------------------------------------