feat(feishu): scan-to-create QR onboarding + silence unsubscribed WS events (#239)
* feat(feishu): scan-to-create QR onboarding flow
Add a device-code flow against accounts.feishu.cn/oauth/v1/app/registration
that lets users scan a terminal QR code with Feishu / Lark mobile to
auto-create a PersonalAgent bot app with the required IM permissions
pre-attached. The poll endpoint returns app_id + app_secret, which the
onboarding wizard then writes into the channel config — no manual app
creation on open.feishu.cn required.
- channels/feishu/onboard.py: qr_register() public entry, init/begin/poll
helpers, QR rendering via the soft qrcode dep, automatic feishu↔lark
domain switch based on the scanning user's tenant_brand, and a
best-effort bot probe to surface the bot name in the wizard
- channels/feishu/__init__.py: re-export qr_register (mirrors qq)
- config/onboard.py: offer "Scan QR code (recommended) / Enter manually"
in the Feishu branch, ask for region (feishu vs lark), then call
qr_register and populate feishu_app_id / feishu_app_secret /
feishu_domain; add qrcode>=7.4 to the feishu pip extras
* fix(feishu): silently absorb unsubscribed WebSocket events
Feishu auto-subscribes PersonalAgent apps to many event types
(im.message.reaction.created_v1, message.read_v1, message.recalled_v1,
chat.member.*, ...) that EvoScientist doesn't register handlers for.
Without intervention, lark-oapi's dispatcher raises EventException
("processor not found, type: ..."), the WS client logs it at ERROR and
replies HTTP 500 on the frame, and Feishu marks the event as failed
and retries it.
The problem is amplified by _send_ack_reaction: every inbound message
triggers our own reaction, which Feishu echoes back as
reaction.created_v1, creating a continuous ERROR-log feedback loop and
pointless retries.
Wrap EventDispatcherHandler._do_without_validation after build() to
swallow "processor not found" EventExceptions (debug log + return None)
while letting all other errors propagate. Failure-safe: if lark-oapi's
internal API changes the wrapper degrades to the prior behavior rather
than breaking the channel.
---------
Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
from ..channel_manager import _parse_csv, register_channel
|
||||
from .channel import FeishuChannel, FeishuConfig
|
||||
from .onboard import qr_register
|
||||
|
||||
__all__ = ["FeishuChannel", "FeishuConfig"]
|
||||
__all__ = ["FeishuChannel", "FeishuConfig", "qr_register"]
|
||||
|
||||
|
||||
def create_from_config(config) -> FeishuChannel:
|
||||
|
||||
@@ -376,6 +376,31 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin):
|
||||
.build()
|
||||
)
|
||||
|
||||
# Silently absorb events we don't have a handler for. Feishu auto-
|
||||
# subscribes a PersonalAgent app to many event types (reactions,
|
||||
# read receipts, recalls, member changes…) that EvoScientist doesn't
|
||||
# care about. Without this wrapper, ``_do_without_validation``
|
||||
# raises ``EventException("processor not found, type: ...")``,
|
||||
# which lark-oapi's WS client (ws/client.py) catches and turns into
|
||||
# an HTTP 500 reply on the WebSocket frame — Feishu then marks the
|
||||
# event as failed and retries it. This is especially noisy because
|
||||
# our own ``_send_ack_reaction`` triggers ``im.message.reaction.
|
||||
# created_v1`` on every inbound message, causing a feedback loop.
|
||||
from lark_oapi.core.exception import EventException
|
||||
|
||||
_original_dispatch = handler._do_without_validation
|
||||
|
||||
def _silent_dispatch(payload: bytes):
|
||||
try:
|
||||
return _original_dispatch(payload)
|
||||
except EventException as exc:
|
||||
if "processor not found" in str(exc):
|
||||
logger.debug("Feishu: ignored unsubscribed event (%s)", exc)
|
||||
return None
|
||||
raise
|
||||
|
||||
handler._do_without_validation = _silent_dispatch
|
||||
|
||||
ws_client = lark.ws.Client(
|
||||
self.config.app_id,
|
||||
self.config.app_secret,
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Feishu / Lark scan-to-create (QR code onboard) flow.
|
||||
|
||||
Drives the Feishu open-platform device-code flow at
|
||||
``accounts.feishu.cn/oauth/v1/app/registration`` (and the Lark equivalent
|
||||
at ``accounts.larksuite.com``). The user scans a terminal QR code with
|
||||
Feishu / Lark mobile, the platform provisions a ``PersonalAgent``-archetype
|
||||
bot application with the required IM permissions pre-attached, and the
|
||||
poll endpoint returns ``client_id`` / ``client_secret`` — enough to fully
|
||||
configure :class:`FeishuChannel`.
|
||||
|
||||
Domain auto-switches from ``feishu`` to ``lark`` if the poll response's
|
||||
``user_info.tenant_brand`` reports a Lark tenant.
|
||||
|
||||
The HTTP shape mirrors RFC 8628 (OAuth Device Authorization Grant) with
|
||||
a vendor-specific ``action`` form field selecting init / begin / poll.
|
||||
|
||||
Style follows :mod:`EvoScientist.channels.qq.onboard` — httpx, plain
|
||||
``print`` for progress, and an optional ``qrcode`` dependency for ASCII
|
||||
rendering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ACCOUNTS_URLS: dict[str, str] = {
|
||||
"feishu": "https://accounts.feishu.cn",
|
||||
"lark": "https://accounts.larksuite.com",
|
||||
}
|
||||
_OPEN_URLS: dict[str, str] = {
|
||||
"feishu": "https://open.feishu.cn",
|
||||
"lark": "https://open.larksuite.com",
|
||||
}
|
||||
_REGISTRATION_PATH = "/oauth/v1/app/registration"
|
||||
|
||||
_REQUEST_TIMEOUT_S = 10.0
|
||||
_DEFAULT_POLL_INTERVAL_S = 5
|
||||
_DEFAULT_EXPIRE_S = 600
|
||||
|
||||
|
||||
def _accounts_base_url(domain: str) -> str:
|
||||
return _ACCOUNTS_URLS.get(domain, _ACCOUNTS_URLS["feishu"])
|
||||
|
||||
|
||||
def _open_base_url(domain: str) -> str:
|
||||
return _OPEN_URLS.get(domain, _OPEN_URLS["feishu"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import qrcode as _qrcode_mod
|
||||
except (ImportError, TypeError):
|
||||
_qrcode_mod = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _render_qr(url: str) -> bool:
|
||||
"""Render *url* as an ASCII QR in the terminal. Returns True on success."""
|
||||
if _qrcode_mod is None:
|
||||
return False
|
||||
try:
|
||||
qr = _qrcode_mod.QRCode(
|
||||
error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration HTTP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _post_registration(base_url: str, body: dict[str, str]) -> dict:
|
||||
"""POST form-encoded *body* to the registration endpoint.
|
||||
|
||||
The endpoint replies with JSON even on 4xx responses (``authorization_pending``
|
||||
comes back as HTTP 400 with a parseable body), so we always read the body
|
||||
and only fall back to raising if the bytes are missing or not JSON.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
url = f"{base_url}{_REGISTRATION_PATH}"
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
with httpx.Client(timeout=_REQUEST_TIMEOUT_S, follow_redirects=True) as client:
|
||||
resp = client.post(url, data=body, headers=headers)
|
||||
# Don't raise_for_status — 4xx may still carry a usable JSON body.
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
resp.raise_for_status() # re-raise underlying HTTP error
|
||||
raise # pragma: no cover — raise_for_status already raised
|
||||
|
||||
|
||||
def _init_registration(domain: str) -> None:
|
||||
"""Probe the registration environment. Raises if client_secret auth is unavailable."""
|
||||
res = _post_registration(_accounts_base_url(domain), {"action": "init"})
|
||||
methods = res.get("supported_auth_methods") or []
|
||||
if "client_secret" not in methods:
|
||||
raise RuntimeError(
|
||||
f"Feishu / Lark registration environment does not support "
|
||||
f"client_secret auth (got: {methods})"
|
||||
)
|
||||
|
||||
|
||||
def _begin_registration(domain: str) -> dict:
|
||||
"""Start the device-code flow.
|
||||
|
||||
Returns a dict with ``device_code``, ``qr_url``, ``user_code``,
|
||||
``interval``, and ``expire_in``.
|
||||
"""
|
||||
res = _post_registration(
|
||||
_accounts_base_url(domain),
|
||||
{
|
||||
"action": "begin",
|
||||
"archetype": "PersonalAgent",
|
||||
"auth_method": "client_secret",
|
||||
"request_user_info": "open_id",
|
||||
},
|
||||
)
|
||||
device_code = res.get("device_code")
|
||||
if not device_code:
|
||||
raise RuntimeError(
|
||||
f"Feishu / Lark registration did not return a device_code: {res}"
|
||||
)
|
||||
qr_url = res.get("verification_uri_complete") or ""
|
||||
sep = "&" if "?" in qr_url else "?"
|
||||
qr_url = f"{qr_url}{sep}from=evoscientist&tp=evoscientist"
|
||||
return {
|
||||
"device_code": device_code,
|
||||
"qr_url": qr_url,
|
||||
"user_code": res.get("user_code", ""),
|
||||
"interval": int(res.get("interval") or _DEFAULT_POLL_INTERVAL_S),
|
||||
"expire_in": int(res.get("expire_in") or _DEFAULT_EXPIRE_S),
|
||||
}
|
||||
|
||||
|
||||
def _poll_registration(
|
||||
*,
|
||||
device_code: str,
|
||||
interval: int,
|
||||
expire_in: int,
|
||||
domain: str,
|
||||
) -> dict | None:
|
||||
"""Poll until the user scans, or the device_code expires / is denied.
|
||||
|
||||
Auto-switches the polling domain to ``lark`` if the server reports
|
||||
``user_info.tenant_brand == "lark"`` — the credentials only resolve
|
||||
against the matching open-platform host.
|
||||
|
||||
Returns a dict with ``app_id``, ``app_secret``, ``domain``, ``open_id``
|
||||
on success, or ``None`` on timeout / explicit denial.
|
||||
"""
|
||||
deadline = time.monotonic() + expire_in
|
||||
current_domain = domain
|
||||
domain_switched = False
|
||||
poll_count = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
res = _post_registration(
|
||||
_accounts_base_url(current_domain),
|
||||
{
|
||||
"action": "poll",
|
||||
"device_code": device_code,
|
||||
"tp": "ob_app",
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("[Feishu onboard] poll request error: %s", exc)
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
poll_count += 1
|
||||
if poll_count == 1:
|
||||
print(" Waiting for scan…", end="", flush=True)
|
||||
elif poll_count % 6 == 0:
|
||||
print(".", end="", flush=True)
|
||||
|
||||
# Domain auto-detection — the server may still return creds in
|
||||
# this same poll, so we fall through rather than restarting.
|
||||
user_info = res.get("user_info") or {}
|
||||
if (
|
||||
user_info.get("tenant_brand") == "lark"
|
||||
and not domain_switched
|
||||
and current_domain != "lark"
|
||||
):
|
||||
current_domain = "lark"
|
||||
domain_switched = True
|
||||
|
||||
if res.get("client_id") and res.get("client_secret"):
|
||||
print() # newline after the dots
|
||||
return {
|
||||
"app_id": res["client_id"],
|
||||
"app_secret": res["client_secret"],
|
||||
"domain": current_domain,
|
||||
"open_id": user_info.get("open_id"),
|
||||
}
|
||||
|
||||
error = res.get("error", "")
|
||||
if error in {"access_denied", "expired_token"}:
|
||||
print()
|
||||
logger.warning("[Feishu onboard] Registration %s", error)
|
||||
return None
|
||||
|
||||
# authorization_pending / slow_down / unknown — keep polling
|
||||
time.sleep(interval)
|
||||
|
||||
print()
|
||||
logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bot probe (best-effort, uses tenant_access_token + /bot/v3/info)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _probe_bot(app_id: str, app_secret: str, domain: str) -> dict | None:
|
||||
"""Fetch bot name / bot_open_id via the open-platform REST API.
|
||||
|
||||
Best-effort: failures return ``None`` and the caller proceeds without
|
||||
a friendly bot name. Uses raw HTTP so we don't require ``lark-oapi``
|
||||
to be installed at onboard time (it's only needed for WebSocket mode).
|
||||
"""
|
||||
import httpx
|
||||
|
||||
base = _open_base_url(domain)
|
||||
token_url = f"{base}/open-apis/auth/v3/tenant_access_token/internal"
|
||||
info_url = f"{base}/open-apis/bot/v3/info"
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=_REQUEST_TIMEOUT_S, follow_redirects=True) as client:
|
||||
tok_resp = client.post(
|
||||
token_url,
|
||||
json={"app_id": app_id, "app_secret": app_secret},
|
||||
)
|
||||
tok_data = tok_resp.json()
|
||||
if tok_data.get("code") != 0:
|
||||
logger.debug("[Feishu onboard] token fetch failed: %s", tok_data)
|
||||
return None
|
||||
token = tok_data.get("tenant_access_token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
info_resp = client.get(
|
||||
info_url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
info_data = info_resp.json()
|
||||
except Exception as exc:
|
||||
logger.debug("[Feishu onboard] bot probe failed: %s", exc)
|
||||
return None
|
||||
|
||||
if info_data.get("code") != 0:
|
||||
return None
|
||||
bot = info_data.get("bot") or info_data.get("data", {}).get("bot") or {}
|
||||
return {
|
||||
"bot_name": bot.get("app_name") or bot.get("bot_name"),
|
||||
"bot_open_id": bot.get("open_id"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry-point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def qr_register(
|
||||
*,
|
||||
initial_domain: str = "feishu",
|
||||
timeout_seconds: int = 600,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Run the Feishu / Lark scan-to-create QR registration flow.
|
||||
|
||||
Args:
|
||||
initial_domain: ``"feishu"`` (default, mainland) or ``"lark"`` (overseas).
|
||||
Auto-switches mid-flow if the scanning user is on the other tenant.
|
||||
timeout_seconds: Wall-clock budget for the whole flow.
|
||||
|
||||
Returns on success::
|
||||
|
||||
{
|
||||
"app_id": str,
|
||||
"app_secret": str,
|
||||
"domain": "feishu" | "lark",
|
||||
"open_id": str | None,
|
||||
"bot_name": str | None,
|
||||
"bot_open_id": str | None,
|
||||
}
|
||||
|
||||
Returns ``None`` on expected failures (network, denial, timeout).
|
||||
"""
|
||||
try:
|
||||
return _qr_register_inner(
|
||||
initial_domain=initial_domain,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[Feishu onboard] Registration failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _qr_register_inner(
|
||||
*,
|
||||
initial_domain: str,
|
||||
timeout_seconds: int,
|
||||
) -> dict[str, Any] | None:
|
||||
print(" Connecting to Feishu / Lark…", end="", flush=True)
|
||||
_init_registration(initial_domain)
|
||||
begin = _begin_registration(initial_domain)
|
||||
print(" done.")
|
||||
|
||||
print()
|
||||
qr_url = begin["qr_url"]
|
||||
if _render_qr(qr_url):
|
||||
print(
|
||||
f"\n Scan the QR code above with Feishu / Lark on your phone,\n"
|
||||
f" or open this URL directly:\n {qr_url}"
|
||||
)
|
||||
else:
|
||||
print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n")
|
||||
print(
|
||||
" Tip: pip install qrcode to display a scannable QR code here next time"
|
||||
)
|
||||
print()
|
||||
|
||||
result = _poll_registration(
|
||||
device_code=begin["device_code"],
|
||||
interval=begin["interval"],
|
||||
expire_in=min(begin["expire_in"], timeout_seconds),
|
||||
domain=initial_domain,
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
bot_info = _probe_bot(result["app_id"], result["app_secret"], result["domain"])
|
||||
if bot_info:
|
||||
result["bot_name"] = bot_info.get("bot_name")
|
||||
result["bot_open_id"] = bot_info.get("bot_open_id")
|
||||
else:
|
||||
result["bot_name"] = None
|
||||
result["bot_open_id"] = None
|
||||
|
||||
return result
|
||||
@@ -2481,7 +2481,7 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
|
||||
"telegram": ["python-telegram-bot>=21.0"],
|
||||
"discord": ["discord.py>=2.3"],
|
||||
"slack": ["slack-sdk>=3.27", "aiohttp>=3.9"],
|
||||
"feishu": ["aiohttp>=3.9"],
|
||||
"feishu": ["aiohttp>=3.9", "qrcode>=7.4"],
|
||||
"dingtalk": ["aiohttp>=3.9"],
|
||||
"wechat": [
|
||||
"pycryptodome>=3.20",
|
||||
@@ -2704,6 +2704,7 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
|
||||
# The bot must already exist at q.qq.com — scanning binds the
|
||||
# developer's QQ account to it and returns app_id + client_secret.
|
||||
_qq_scanned = False
|
||||
_feishu_scanned = False
|
||||
if ch_name == "qq":
|
||||
scan_choices = [
|
||||
Choice(
|
||||
@@ -2775,6 +2776,108 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
|
||||
" back to manual entry.[/yellow]"
|
||||
)
|
||||
|
||||
# Feishu: offer scan-to-create before falling back to manual entry.
|
||||
# Unlike QQ, this provisions a brand-new PersonalAgent app with the
|
||||
# required IM permissions attached, then returns app_id + app_secret.
|
||||
if ch_name == "feishu":
|
||||
scan_choices = [
|
||||
Choice(
|
||||
title="Scan QR code (recommended — auto-create app, fill App ID & Secret)",
|
||||
value="scan",
|
||||
),
|
||||
Choice(title="Enter App ID and Secret manually", value="manual"),
|
||||
]
|
||||
scan_choice = questionary.select(
|
||||
"Configure Feishu / Lark:",
|
||||
choices=scan_choices,
|
||||
default="scan",
|
||||
style=WIZARD_STYLE,
|
||||
qmark=f" {QMARK}",
|
||||
use_indicator=True,
|
||||
).ask()
|
||||
if scan_choice is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
if scan_choice == "scan":
|
||||
# `qrcode` is the only soft dep needed — onboard prints the URL
|
||||
# if it's missing, but the UX is much worse, so offer to install.
|
||||
try:
|
||||
import qrcode # noqa: F401
|
||||
except ImportError:
|
||||
console.print(
|
||||
' [yellow]✗ QR scan looks best with "qrcode".[/yellow]'
|
||||
)
|
||||
install_now = questionary.confirm(
|
||||
'Install "qrcode" now?',
|
||||
default=True,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=f" {QMARK}",
|
||||
).ask()
|
||||
if install_now is None:
|
||||
raise KeyboardInterrupt() from None
|
||||
if install_now and install_library("qrcode>=7.4"):
|
||||
console.print(" [green]✓ Installed qrcode.[/green]")
|
||||
|
||||
# Region selection — accounts.feishu.cn vs accounts.larksuite.com.
|
||||
# The poll endpoint auto-switches if the scanning user is on the
|
||||
# other tenant, so this is just a starting hint.
|
||||
region_choices = [
|
||||
Choice(title="Feishu (飞书, mainland China)", value="feishu"),
|
||||
Choice(title="Lark (overseas)", value="lark"),
|
||||
]
|
||||
region = questionary.select(
|
||||
"Region:",
|
||||
choices=region_choices,
|
||||
default="feishu",
|
||||
style=WIZARD_STYLE,
|
||||
qmark=f" {QMARK}",
|
||||
use_indicator=True,
|
||||
).ask()
|
||||
if region is None:
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
if scan_choice == "scan":
|
||||
from ..channels.feishu.onboard import qr_register
|
||||
|
||||
console.print(
|
||||
" [dim]A QR code will be printed below — open Feishu or"
|
||||
" Lark on your phone and scan it. The platform will"
|
||||
" auto-create a bot app with IM permissions and return"
|
||||
" the credentials here.[/dim]"
|
||||
)
|
||||
try:
|
||||
creds = qr_register(initial_domain=region)
|
||||
except Exception as exc:
|
||||
console.print(f" [red]✗ Scan failed: {exc}[/red]")
|
||||
creds = None
|
||||
|
||||
if creds:
|
||||
updates["feishu_app_id"] = creds["app_id"]
|
||||
updates["feishu_app_secret"] = creds["app_secret"]
|
||||
# Sync open-platform domain to the resolved region
|
||||
updates["feishu_domain"] = (
|
||||
"https://open.larksuite.com"
|
||||
if creds.get("domain") == "lark"
|
||||
else "https://open.feishu.cn"
|
||||
)
|
||||
bot_name = creds.get("bot_name")
|
||||
if bot_name:
|
||||
console.print(
|
||||
f' [green]✓ Bound Feishu bot "{bot_name}"'
|
||||
f" (App ID: {creds['app_id']})[/green]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f" [green]✓ Bound Feishu app"
|
||||
f" (App ID: {creds['app_id']})[/green]"
|
||||
)
|
||||
_feishu_scanned = True
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]⚠ Scan did not complete — falling"
|
||||
" back to manual entry.[/yellow]"
|
||||
)
|
||||
|
||||
# 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.
|
||||
@@ -2911,7 +3014,7 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]:
|
||||
updates["wechat_personal_account_id"] = account_id.strip()
|
||||
|
||||
# Prompt for required fields
|
||||
if not _qq_scanned:
|
||||
if not _qq_scanned and not _feishu_scanned:
|
||||
for field_name, prompt_label in required_fields:
|
||||
current = getattr(config, field_name, "")
|
||||
value = questionary.text(
|
||||
|
||||
Reference in New Issue
Block a user