# feat(wechat): add personal-WeChat (iLink) backend with QR login (#212)

* feat(wechat): add personal-WeChat (iLink) backend with QR-code login

Adds a third WeChat backend alongside WeCom and Official Account:
``personal`` rides Tencent's iLink Bot long-poll gateway so a personal
WeChat account can act as a bot.  Credentials are obtained via QR-code
scan and persisted under ``DATA_DIR/wechat_personal/accounts/``.

- channels/wechat/personal.py: WeixinPersonalChannel + qr_login.
- channels/wechat/crypto.py: aes128_ecb_decrypt + parse_ilink_aes_key
  for the iLink CDN media protocol.
- channels/wechat/probe.py: validate_wechat_personal credential probe.
- channels/wechat/serve.py: --backend personal CLI + --qr-login flow.
- channels/wechat/__init__.py: factory dispatch on wechat_backend; pull
  in the new dependencies in the docstring.
- config/settings.py: wechat_personal_* fields.
- config/onboard.py: WeChat-backend picker + QR-scan flow in the wizard
  + personal-backend probe in _probe_channel.
- pyproject.toml / uv.lock: add qrcode + certifi to wechat & all-channels
  extras (aiohttp was already pulled in transitively).

* fix(wechat): address ruff failures and CodeRabbit review on personal-WeChat PR

- personal.py: drop unused imports (`field`, `PollingMixin`); replace
  `asyncio.TimeoutError` with builtin; hold references to background
  `asyncio.create_task` results so they aren't GC'd; wire `dm_policy`
  through `_process_message` (disabled/allowlist) so `wechat_personal_dm_policy`
  actually takes effect for DMs.
- onboard.py: import-check gate now validates the full WeChat dependency
  set (aiohttp, qrcode, Crypto, certifi) instead of only aiohttp; mask
  `WeCom Secret` and `MP App Secret` prompts via `questionary.password`;
  derive the QR-login hint path from `_account_dir()` instead of the
  hard-coded `~/.evoscientist/...`; stop copying the QR-login token into
  the main config (already persisted per-account on disk — copying broadens
  secret exposure and risks staleness).
- pyproject.toml: allow Chinese full-width punctuation in `allowed-confusables`
  for user-facing CN messages.

* style(wechat): apply ruff format

`ruff format --check` was failing CI on three files (one pre-existing in
`__init__.py` plus formatter-driven line-merges in the files touched by
the previous fix commit). Ran `ruff format` to bring them in line; both
`ruff check` and `ruff format --check` now pass.
This commit is contained in:
Ziheng Zhang
2026-05-07 22:33:30 +08:00
committed by GitHub
parent 9e51ec6fdd
commit 692dc491ac
9 changed files with 1733 additions and 37 deletions
+59 -21
View File
@@ -5,13 +5,16 @@ Supports multiple WeChat backends:
— Most stable, pure HTTP, no third-party dependencies
- **wechatmp**: 微信公众号 (WeChat Official Account) via official API
— Pure HTTP webhook, suitable for public-facing bots
- **personal**: 个人微信 via Tencent's iLink Bot API
— Long-poll + AES-128-ECB CDN media protocol; QR-code login required.
Adapted from hermes-agent.
Both backends use httpx (already a core dependency) and receive messages
via HTTP webhook, send replies via REST API.
Backends 1+2 share the HTTP-webhook ``WeChatChannel``; backend 3 uses the
long-poll ``WeixinPersonalChannel``.
Usage in config:
channel_enabled = "wechat"
wechat_backend = "wecom" # or "wechatmp"
wechat_backend = "wecom" # or "wechatmp" or "personal"
# WeCom settings
wechat_wecom_corp_id = "..."
@@ -27,19 +30,54 @@ Usage in config:
wechat_mp_token = "..."
wechat_mp_encoding_aes_key = "..."
wechat_webhook_port = 9001
# OR: Personal WeChat (iLink Bot)
# First run `python -m EvoScientist.channels.wechat.serve --qr-login`
# to obtain an account_id + token via QR-code scan.
wechat_personal_account_id = "..."
wechat_personal_token = "..." # optional if persisted on disk
wechat_personal_dm_policy = "open" # open | allowlist
wechat_personal_group_policy = "disabled"
"""
from ..channel_manager import _parse_csv, register_channel
from .channel import WeChatChannel, WeChatMPConfig, WeComConfig
from .personal import WeixinPersonalChannel, WeixinPersonalConfig, qr_login
__all__ = ["WeChatChannel", "WeChatMPConfig", "WeComConfig"]
__all__ = [
"WeChatChannel",
"WeChatMPConfig",
"WeComConfig",
"WeixinPersonalChannel",
"WeixinPersonalConfig",
"qr_login",
]
def create_from_config(config) -> WeChatChannel:
backend = config.wechat_backend or "wecom"
allowed = _parse_csv(config.wechat_allowed_senders)
proxy = config.wechat_proxy or None
port = int(config.wechat_webhook_port or 9001)
def create_from_config(config):
"""Factory dispatched on ``config.wechat_backend``."""
backend = (getattr(config, "wechat_backend", "") or "wecom").lower()
allowed = _parse_csv(getattr(config, "wechat_allowed_senders", ""))
proxy = getattr(config, "wechat_proxy", "") or None
port = int(getattr(config, "wechat_webhook_port", 9001) or 9001)
if backend == "personal":
group_allowed = _parse_csv(getattr(config, "wechat_personal_group_allowed", ""))
cfg = WeixinPersonalConfig(
account_id=getattr(config, "wechat_personal_account_id", ""),
token=getattr(config, "wechat_personal_token", ""),
base_url=getattr(config, "wechat_personal_base_url", "")
or "https://ilinkai.weixin.qq.com",
cdn_base_url=getattr(config, "wechat_personal_cdn_base_url", "")
or "https://novac2c.cdn.weixin.qq.com/c2c",
dm_policy=getattr(config, "wechat_personal_dm_policy", "open") or "open",
group_policy=getattr(config, "wechat_personal_group_policy", "disabled")
or "disabled",
group_allowed_senders=group_allowed,
allowed_senders=allowed,
proxy=proxy,
)
return WeixinPersonalChannel(cfg)
if backend == "wechatmp":
mp_config = WeChatMPConfig(
@@ -52,18 +90,18 @@ def create_from_config(config) -> WeChatChannel:
proxy=proxy,
)
return WeChatChannel(mp_config, backend="wechatmp")
else:
wecom_config = WeComConfig(
corp_id=config.wechat_wecom_corp_id,
agent_id=config.wechat_wecom_agent_id,
secret=config.wechat_wecom_secret,
token=config.wechat_wecom_token,
encoding_aes_key=config.wechat_wecom_encoding_aes_key,
webhook_port=port,
allowed_senders=allowed,
proxy=proxy,
)
return WeChatChannel(wecom_config, backend="wecom")
wecom_config = WeComConfig(
corp_id=config.wechat_wecom_corp_id,
agent_id=config.wechat_wecom_agent_id,
secret=config.wechat_wecom_secret,
token=config.wechat_wecom_token,
encoding_aes_key=config.wechat_wecom_encoding_aes_key,
webhook_port=port,
allowed_senders=allowed,
proxy=proxy,
)
return WeChatChannel(wecom_config, backend="wecom")
register_channel("wechat", create_from_config)
+47
View File
@@ -83,6 +83,53 @@ def _aes_encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes:
) from None
def aes128_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes:
"""AES-128-ECB decryption with PKCS#7 unpadding.
Used by the personal-WeChat (iLink) backend for CDN-encrypted media
payloads. Block size is 16; the WeChat CDN protocol pads with PKCS#7.
"""
if _HAS_PYCRYPTO:
cipher = AES.new(key, AES.MODE_ECB)
padded = cipher.decrypt(ciphertext)
else:
try:
import pyaes
decrypter = pyaes.Decrypter(pyaes.AESModeOfOperationECB(key))
padded = decrypter.feed(ciphertext)
padded += decrypter.feed()
except ImportError:
raise ImportError(
"WeChat CDN media decryption requires pycryptodome or pyaes. "
"Install with: pip install pycryptodome"
) from None
if not padded:
return padded
pad_len = padded[-1]
if 1 <= pad_len <= 16 and padded.endswith(bytes([pad_len]) * pad_len):
return padded[:-pad_len]
return padded
def parse_ilink_aes_key(aes_key_b64: str) -> bytes:
"""Parse the iLink CDN AES key.
iLink encodes the 16-byte key in two formats:
- direct base64 of 16 bytes
- base64 of a 32-char ASCII hex string (which decodes to 16 raw bytes)
"""
decoded = base64.b64decode(aes_key_b64)
if len(decoded) == 16:
return decoded
if len(decoded) == 32:
text = decoded.decode("ascii", errors="ignore")
if text and all(ch in "0123456789abcdefABCDEF" for ch in text):
return bytes.fromhex(text)
raise ValueError(f"unexpected aes_key format ({len(decoded)} decoded bytes)")
class WeChatCrypto:
"""Handles WeChat/WeCom message encryption and decryption.
File diff suppressed because it is too large Load Diff
+37
View File
@@ -70,3 +70,40 @@ async def validate_wechat_mp(
return False, f"Error ({data.get('errcode')}): {data.get('errmsg')}"
except Exception as e:
return False, f"Error: {e}"
async def validate_wechat_personal(
account_id: str,
token: str = "",
) -> tuple[bool, str]:
"""Validate that a personal WeChat (iLink) account has been logged in.
Personal-WeChat credentials are obtained via QR-code scan and persisted
on disk; there is no offline credential format the user can paste in.
This probe simply checks that:
- the account_id is set, and
- either *token* is supplied inline, or a saved-account file exists for
*account_id* under ``DATA_DIR/wechat_personal/accounts/``.
Online liveness is not checked because the iLink long-poll endpoint is
not designed for cheap probes.
"""
if not account_id:
return False, (
"account_id is required. Run "
"`python -m EvoScientist.channels.wechat.serve --qr-login` first."
)
if token:
return True, f"Personal WeChat account {account_id[:8]}… token provided"
from .personal import load_account
persisted = load_account(account_id)
if not persisted or not persisted.get("token"):
return False, (
f"No saved credentials for account_id={account_id[:8]}…. "
"Run `python -m EvoScientist.channels.wechat.serve --qr-login`."
)
return True, f"Personal WeChat account {account_id[:8]}… loaded from disk"
+76 -4
View File
@@ -20,6 +20,13 @@ Usage:
--token TOKEN \\
--aes-key AES_KEY
# Personal WeChat (个人微信 via iLink Bot)
# First, log in via QR scan to obtain credentials:
python -m EvoScientist.channels.wechat.serve --qr-login
# Then run with the saved account_id:
python -m EvoScientist.channels.wechat.serve \\
--backend personal --account-id <id>
Options:
--port PORT Webhook listen port (default: 9001)
--allow USER_ID Allowed sender (repeatable)
@@ -28,11 +35,18 @@ Options:
"""
import argparse
import asyncio
import logging
from ..bus import MessageBus
from ..standalone import run_standalone
from .channel import WeChatChannel, WeChatMPConfig, WeComConfig
from .personal import (
WeixinPersonalChannel,
WeixinPersonalConfig,
load_account,
qr_login,
)
logging.basicConfig(
level=logging.DEBUG,
@@ -50,10 +64,15 @@ def parse_args():
)
parser.add_argument(
"--backend",
choices=["wecom", "wechatmp"],
choices=["wecom", "wechatmp", "personal"],
default="wecom",
help="WeChat backend type (default: wecom)",
)
parser.add_argument(
"--qr-login",
action="store_true",
help="Run interactive QR-code login for personal WeChat and exit",
)
parser.add_argument("--port", type=int, default=9001, help="Webhook port")
parser.add_argument(
"--allow",
@@ -89,6 +108,31 @@ def parse_args():
mp.add_argument("--app-id", default="", help="MP App ID")
mp.add_argument("--app-secret", default="", help="MP App Secret")
# Personal-WeChat settings
personal = parser.add_argument_group("Personal WeChat (iLink Bot)")
personal.add_argument(
"--account-id",
default="",
help="iLink account_id (obtained via --qr-login)",
)
personal.add_argument(
"--bot-token",
default="",
help="iLink bearer token; if omitted, loaded from disk via account-id",
)
personal.add_argument(
"--dm-policy",
choices=["open", "allowlist"],
default="open",
help="Direct-message policy (default: open)",
)
personal.add_argument(
"--group-policy",
choices=["open", "allowlist", "disabled"],
default="disabled",
help="Group-message policy (default: disabled — iLink rarely delivers)",
)
# Shared settings
parser.add_argument("--token", default="", help="Callback verification token")
parser.add_argument("--aes-key", default="", help="EncodingAESKey")
@@ -100,6 +144,13 @@ def parse_args():
def main():
"""Entry point."""
args = parse_args()
if args.qr_login:
result = asyncio.run(qr_login())
if not result:
raise SystemExit(1)
return
allowed = set(args.allowed_senders) if args.allowed_senders else None
allowed_channels = set(args.allowed_channels) if args.allowed_channels else None
proxy = args.proxy or None
@@ -116,7 +167,8 @@ def main():
allowed_channels=allowed_channels,
proxy=proxy,
)
else:
channel = WeChatChannel(config, backend=args.backend)
elif args.backend == "wechatmp":
config = WeChatMPConfig(
app_id=args.app_id,
app_secret=args.app_secret,
@@ -127,11 +179,31 @@ def main():
allowed_channels=allowed_channels,
proxy=proxy,
)
channel = WeChatChannel(config, backend=args.backend)
else: # personal
token = args.bot_token
if not token and args.account_id:
persisted = load_account(args.account_id)
if persisted:
token = persisted.get("token", "")
if not args.account_id or not token:
raise SystemExit(
"Personal WeChat requires --account-id (and a saved token, "
"obtained via --qr-login)."
)
personal_config = WeixinPersonalConfig(
account_id=args.account_id,
token=token,
allowed_senders=allowed,
allowed_channels=allowed_channels,
dm_policy=args.dm_policy,
group_policy=args.group_policy,
proxy=proxy,
)
channel = WeixinPersonalChannel(personal_config)
send_thinking = args.thinking and args.agent
bus = MessageBus()
channel = WeChatChannel(config, backend=args.backend)
run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking)
+160 -10
View File
@@ -2436,7 +2436,12 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
"slack": ["slack-sdk>=3.27", "aiohttp>=3.9"],
"feishu": ["aiohttp>=3.9"],
"dingtalk": ["aiohttp>=3.9"],
"wechat": ["pycryptodome>=3.20", "aiohttp>=3.9"],
"wechat": [
"pycryptodome>=3.20",
"aiohttp>=3.9",
"qrcode>=7.4",
"certifi>=2024.0",
],
"qq": ["qq-botpy>=1.0"],
}
@@ -2487,12 +2492,8 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
(
"wechat",
"WeChat",
[
("wechat_wecom_corp_id", "WeCom Corp ID"),
("wechat_wecom_agent_id", "WeCom Agent ID"),
("wechat_wecom_secret", "WeCom Secret"),
],
"aiohttp",
[], # backend-specific fields prompted in the wechat branch below
("aiohttp", "qrcode", "Crypto", "certifi"),
"wechat",
),
(
@@ -2568,9 +2569,15 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
# Check pip dependency before proceeding
if import_check:
_required_imports: tuple[str, ...] = (
(import_check,)
if isinstance(import_check, str)
else tuple(import_check)
)
_pkg_ready = False
try:
__import__(import_check)
for _module_name in _required_imports:
__import__(_module_name)
_pkg_ready = True
except ImportError:
console.print(" [yellow]✗ Required package not installed.[/yellow]")
@@ -2596,9 +2603,10 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
else:
_ok = install_library(f"evoscientist[{pip_extra}]")
if _ok:
# Verify the import actually works now
# Verify the imports actually work now
try:
__import__(import_check)
for _module_name in _required_imports:
__import__(_module_name)
console.print(" [green]✓ Installed successfully.[/green]")
_pkg_ready = True
except ImportError:
@@ -2645,6 +2653,141 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
enabled_channels.append("imessage")
continue
# WeChat: pick backend (wecom / wechatmp / personal), then prompt
# backend-specific fields. Personal-WeChat has no static credentials —
# we offer an interactive QR-scan that obtains and persists them.
if ch_name == "wechat":
backend_choices = [
Choice(
title="WeCom (企业微信应用) — most stable, official API",
value="wecom",
),
Choice(
title="Official Account (微信公众号) — public-facing bots",
value="wechatmp",
),
Choice(
title="Personal WeChat (个人微信, iLink) — QR-code scan login",
value="personal",
),
]
wechat_backend = questionary.select(
"WeChat backend:",
choices=backend_choices,
default=getattr(config, "wechat_backend", "") or "wecom",
style=WIZARD_STYLE,
qmark=f" {QMARK}",
use_indicator=True,
).ask()
if wechat_backend is None:
raise KeyboardInterrupt()
updates["wechat_backend"] = wechat_backend
if wechat_backend == "wecom":
wechat_fields = [
("wechat_wecom_corp_id", "WeCom Corp ID", False),
("wechat_wecom_agent_id", "WeCom Agent ID", False),
("wechat_wecom_secret", "WeCom Secret", True),
]
for field_name, prompt_label, is_secret in wechat_fields:
current = getattr(config, field_name, "")
prompt_fn = questionary.password if is_secret else questionary.text
value = prompt_fn(
f"{prompt_label}:",
default=current,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if value is None:
raise KeyboardInterrupt()
updates[field_name] = value.strip()
elif wechat_backend == "wechatmp":
wechat_fields = [
("wechat_mp_app_id", "Official Account App ID", False),
("wechat_mp_app_secret", "Official Account App Secret", True),
]
for field_name, prompt_label, is_secret in wechat_fields:
current = getattr(config, field_name, "")
prompt_fn = questionary.password if is_secret else questionary.text
value = prompt_fn(
f"{prompt_label}:",
default=current,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if value is None:
raise KeyboardInterrupt()
updates[field_name] = value.strip()
else: # personal
personal_choices = [
Choice(
title="Scan QR code now (recommended — login to a personal WeChat account)",
value="scan",
),
Choice(
title="I already have an account_id — enter it manually",
value="manual",
),
]
personal_choice = questionary.select(
"Personal WeChat login:",
choices=personal_choices,
default="scan",
style=WIZARD_STYLE,
qmark=f" {QMARK}",
use_indicator=True,
).ask()
if personal_choice is None:
raise KeyboardInterrupt()
if personal_choice == "scan":
from ..channels.wechat.personal import _account_dir as _wp_dir
_accounts_path = _wp_dir()
console.print(
" [dim]A QR code will be printed below — open WeChat on"
" your phone and scan it. The session token is saved"
f" to {_accounts_path}.[/dim]"
)
try:
import asyncio
from ..channels.wechat.personal import qr_login
creds = asyncio.run(qr_login())
except Exception as exc:
console.print(f" [red]✗ Scan failed: {exc}[/red]")
creds = None
if creds:
updates["wechat_personal_account_id"] = creds["account_id"]
# Token is persisted on disk by qr_login(); the channel
# reads it from the per-account store at runtime, so we
# intentionally do NOT copy it into the main config here
# (avoids stale duplicates and broader secret exposure).
console.print(
f" [green]✓ Logged in (account_id: "
f"{creds['account_id'][:12]}…)[/green]"
)
else:
console.print(
" [yellow]⚠ QR login did not complete — falling"
" back to manual entry.[/yellow]"
)
personal_choice = "manual"
if personal_choice == "manual":
current_id = getattr(config, "wechat_personal_account_id", "")
account_id = questionary.text(
"iLink account_id (from a previous --qr-login run):",
default=current_id,
style=WIZARD_STYLE,
qmark=f" {QMARK}",
).ask()
if account_id is None:
raise KeyboardInterrupt()
updates["wechat_personal_account_id"] = account_id.strip()
# Prompt for required fields
for field_name, prompt_label in required_fields:
current = getattr(config, field_name, "")
@@ -2828,6 +2971,13 @@ def _probe_channel(
_val("wechat_mp_app_secret"),
_val("wechat_proxy") or None,
)
elif backend == "personal":
from ..channels.wechat.probe import validate_wechat_personal
return await validate_wechat_personal(
_val("wechat_personal_account_id"),
_val("wechat_personal_token"),
)
else:
from ..channels.wechat.probe import validate_wecom
+9
View File
@@ -201,6 +201,15 @@ class EvoScientistConfig:
wechat_mp_app_secret: str = ""
wechat_mp_token: str = ""
wechat_mp_encoding_aes_key: str = ""
# Personal WeChat (iLink Bot) — credentials obtained via QR-code login.
# Run: python -m EvoScientist.channels.wechat.serve --qr-login
wechat_personal_account_id: str = ""
wechat_personal_token: str = ""
wechat_personal_base_url: str = ""
wechat_personal_cdn_base_url: str = ""
wechat_personal_dm_policy: str = "open"
wechat_personal_group_policy: str = "disabled"
wechat_personal_group_allowed: str = ""
# DingTalk Settings
dingtalk_client_id: str = ""
+4 -2
View File
@@ -65,7 +65,7 @@ dev = [
telegram = ["python-telegram-bot>=21.0"]
discord = ["discord.py>=2.3"]
slack = ["slack-sdk>=3.27", "aiohttp>=3.9"]
wechat = ["pycryptodome>=3.20"]
wechat = ["pycryptodome>=3.20", "aiohttp>=3.9", "qrcode>=7.4", "certifi>=2024.0"]
feishu = ["lark-oapi>=1.4.0"]
qq = ["qq-botpy>=1.0"]
stt = ["faster-whisper>=1.0"]
@@ -76,6 +76,8 @@ all-channels = [
"aiohttp>=3.9",
"slack-sdk>=3.27",
"pycryptodome>=3.20",
"qrcode>=7.4",
"certifi>=2024.0",
"lark-oapi>=1.4.0",
"qq-botpy>=1.0",
]
@@ -164,7 +166,7 @@ ignore = [
]
fixable = ["ALL"]
unfixable = []
allowed-confusables = ["–", "❯"]
allowed-confusables = ["–", "❯", ":", "(", ")", ","]
[tool.ruff.format]
quote-style = "double"
Generated
+22
View File
@@ -906,11 +906,13 @@ dependencies = [
[package.optional-dependencies]
all-channels = [
{ name = "aiohttp" },
{ name = "certifi" },
{ name = "discord-py" },
{ name = "lark-oapi" },
{ name = "pycryptodome" },
{ name = "python-telegram-bot" },
{ name = "qq-botpy" },
{ name = "qrcode" },
{ name = "slack-sdk" },
]
dev = [
@@ -944,7 +946,10 @@ telegram = [
{ name = "python-telegram-bot" },
]
wechat = [
{ name = "aiohttp" },
{ name = "certifi" },
{ name = "pycryptodome" },
{ name = "qrcode" },
]
[package.dev-dependencies]
@@ -961,8 +966,11 @@ dev = [
requires-dist = [
{ name = "aiohttp", marker = "extra == 'all-channels'", specifier = ">=3.9" },
{ name = "aiohttp", marker = "extra == 'slack'", specifier = ">=3.9" },
{ name = "aiohttp", marker = "extra == 'wechat'", specifier = ">=3.9" },
{ name = "build", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "ccproxy-api", marker = "extra == 'oauth'", specifier = ">=0.2.7" },
{ name = "certifi", marker = "extra == 'all-channels'", specifier = ">=2024.0" },
{ name = "certifi", marker = "extra == 'wechat'", specifier = ">=2024.0" },
{ name = "deepagents", specifier = ">=0.5.7" },
{ name = "discord-py", marker = "extra == 'all-channels'", specifier = ">=2.3" },
{ name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" },
@@ -998,6 +1006,8 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "qq-botpy", marker = "extra == 'all-channels'", specifier = ">=1.0" },
{ name = "qq-botpy", marker = "extra == 'qq'", specifier = ">=1.0" },
{ name = "qrcode", marker = "extra == 'all-channels'", specifier = ">=7.4" },
{ name = "qrcode", marker = "extra == 'wechat'", specifier = ">=7.4" },
{ name = "questionary", specifier = ">=2.1" },
{ name = "rich", specifier = ">=15.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
@@ -3561,6 +3571,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/2e/cf662566627f1c3508924ef5a0f8277ffc4ac033d6c3a05d1ead6e76f60b/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01", size = 51356, upload-time = "2024-03-22T10:57:24.695Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[[package]]
name = "questionary"
version = "2.1.1"