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:
Ziheng Zhang
2026-05-20 22:53:16 +08:00
committed by GitHub
parent b11932f91b
commit d2283397a4
4 changed files with 493 additions and 3 deletions
+2 -1
View File
@@ -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:
+25
View File
@@ -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,
+361
View File
@@ -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
+105 -2
View File
@@ -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(