From d2283397a4e56753b40eee13ae73be5465b3c34e Mon Sep 17 00:00:00 2001 From: Ziheng Zhang <142805986+MuXinCG2004@users.noreply.github.com> Date: Wed, 20 May 2026 22:53:16 +0800 Subject: [PATCH] feat(feishu): scan-to-create QR onboarding + silence unsubscribed WS events (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- EvoScientist/channels/feishu/__init__.py | 3 +- EvoScientist/channels/feishu/channel.py | 25 ++ EvoScientist/channels/feishu/onboard.py | 361 +++++++++++++++++++++++ EvoScientist/config/onboard.py | 107 ++++++- 4 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 EvoScientist/channels/feishu/onboard.py diff --git a/EvoScientist/channels/feishu/__init__.py b/EvoScientist/channels/feishu/__init__.py index 55c6ed4..f6ddb57 100644 --- a/EvoScientist/channels/feishu/__init__.py +++ b/EvoScientist/channels/feishu/__init__.py @@ -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: diff --git a/EvoScientist/channels/feishu/channel.py b/EvoScientist/channels/feishu/channel.py index c3e9016..28e3e50 100644 --- a/EvoScientist/channels/feishu/channel.py +++ b/EvoScientist/channels/feishu/channel.py @@ -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, diff --git a/EvoScientist/channels/feishu/onboard.py b/EvoScientist/channels/feishu/onboard.py new file mode 100644 index 0000000..c56e5c7 --- /dev/null +++ b/EvoScientist/channels/feishu/onboard.py @@ -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 diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index 48943c1..282b143 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -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(