# 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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user