Files
daily_stock_analysis/scripts/e2e_test_feishu_app.py
Delicious233 3471afbd98 feat: add Feishu App Bot notification sender with P2P and group support (#1553)
* feat: add Feishu App Bot notification sender with P2P and group support

The existing FeishuSender only supports custom robot Webhook mode.
This commit extends it to support App Bot (lark-oapi SDK) mode, auto-routing
between webhook (priority) and App Bot when FEISHU_APP_ID + FEISHU_APP_SECRET
+ FEISHU_CHAT_ID are configured.

Design:
- send_to_feishu() routes: webhook if URL set, else App Bot
- DCLP lazy client init with thread-safe sentinel guard
- Retry (3 attempts, exponential backoff) with fixed UUID for idempotency
- Card-first / text-fallback content strategy
- Chunking for long messages
- Runtime enum validation for FEISHU_RECEIVE_ID_TYPE and FEISHU_DOMAIN
- Safe SDK defaults (FEISHU_DOMAIN/LARK_DOMAIN) before import try-block
  so Webhook path never depends on lark-oapi SDK presence
- Config, diagnostics, setup check, notification test, and CI workflow
  all consistent with the new App Bot channel semantics
- lark-oapi>=1.0.0 already in requirements.txt (line 23)

Verification:
- 20/20 unit tests pass (help metadata + FeishuSender)
- E2E: real Feishu API — SDK import, token, client init, P2P text+card send all PASS
- Webhook regression: verified no SDK dependency for existing Webhook path

* fix: add missing Feishu App Bot locale entries and env table keys, harden sender error handling

CI fix 1 (test_registry_help_keys_exist_in_locales):
- Add zh-CN and en-US locale entries for FEISHU_CHAT_ID,
  FEISHU_RECEIVE_ID_TYPE, FEISHU_DOMAIN in settingsHelp.ts

CI fix 2 (test_notification_actions_env_table_matches_generated_output):
- Add FEISHU_RECEIVE_ID_TYPE, FEISHU_DOMAIN to feishu advanced_keys
  in CHANNEL_SPECS so they appear in KEY_SPECS
- Regenerate managed env table in docs/notifications.md

feishu_sender.py hardening:
- Catch network exceptions in webhook _post_payload so card-to-text
  fallback actually executes on transient failures
- Guard response.json() and isinstance(result, dict) against
  non-JSON / non-dict HTTP 200 responses
- Extract shared _build_card_body() to de-duplicate card payload
  construction between webhook and App Bot paths
- Rename module-level lark -> _lark to avoid shadowing
- Guard resp.get_log_id() with try/except
- Add None guard on send_to_feishu content parameter

e2e script improvements:
- Support FEISHU_OPEN_ID for P2P test, FEISHU_DOMAIN for Lark
- Add FEISHU_TEST_SEND_TEXT=1 for plain-text-only path testing
- Clarify docstring: setup validation + smoke test, not full e2e

* fix: consolidate Feishu App Bot notification contract

* fix: align Feishu domain help scope

---------

Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
2026-06-05 08:56:42 +08:00

145 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""
Feishu App Bot manual smoke validation and live-send test.
Usage:
ssh <host> "python3 /path/to/e2e_test_feishu_app.py"
Requires FEISHU_APP_ID and FEISHU_APP_SECRET env vars set on the target host.
Optionally set FEISHU_CHAT_ID to send a live test message via interactive card.
Optionally set FEISHU_OPEN_ID to send a live P2P test message (overrides CHAT_ID).
Optionally set FEISHU_DOMAIN to "lark" for international (Lark) tenants.
"""
import logging
import os
import sys
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("feishu-manual-smoke")
# 1. Check credentials
app_id = os.getenv("FEISHU_APP_ID", "").strip()
app_secret = os.getenv("FEISHU_APP_SECRET", "").strip()
if not app_id or not app_secret:
logger.error("Missing FEISHU_APP_ID or FEISHU_APP_SECRET")
sys.exit(1)
# 2. Try getting tenant_access_token
import requests
_domain = os.getenv("FEISHU_DOMAIN", "feishu").strip().lower()
if _domain not in ("feishu", "lark"):
logger.warning("Invalid FEISHU_DOMAIN=%s; falling back to feishu", _domain)
_domain = "feishu"
_base_host_by_domain = {
"feishu": "open.feishu.cn",
"lark": "open.larksuite.com",
}
_base_host = _base_host_by_domain[_domain]
logger.info("using domain=%s base_host=%s", _domain, _base_host)
resp = requests.post(
f"https://{_base_host}/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
timeout=30,
)
token_data = resp.json()
if token_data.get("code") != 0:
logger.error("Failed to get tenant_token: %s", token_data)
sys.exit(1)
token = token_data["tenant_access_token"]
logger.info("token obtained OK")
# 3. List chats (groups) to find available chat_ids
chats_resp = requests.get(
f"https://{_base_host}/open-apis/im/v1/chats?page_size=20",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
chats_data = chats_resp.json()
logger.info("chats API response code=%s", chats_data.get("code"))
if chats_data.get("code") == 0:
items = chats_data.get("data", {}).get("items", [])
logger.info("Found %d chats:", len(items))
for chat in items:
logger.info(
" chat_id=%s name=%s type=%s",
chat.get("chat_id"), chat.get("name"), chat.get("chat_type"),
)
else:
logger.warning("chat list failed (may lack im:chat permission): %s", chats_data)
logger.warning("Trying /bot/v3/info instead...")
# 4. Import lark-oapi SDK (installed by standard requirements.txt setup)
try:
import lark_oapi as lark
except ImportError:
logger.error(
"lark-oapi is NOT installed. Standard project setup installs it via:\n"
" pip install -r requirements.txt"
)
sys.exit(1)
# 5. Verify SDK client initialisation (with domain support)
smoke_client = (
lark.Client.builder()
.app_id(app_id)
.app_secret(app_secret)
.domain(
lark.core.const.FEISHU_DOMAIN if _domain == "feishu" else lark.core.const.LARK_DOMAIN
)
.build()
)
logger.info("lark-oapi SDK client init OK (domain=%s, client=%s)", _domain, type(smoke_client).__name__)
logger.info("manual smoke setup verification passed.")
# 6. Live send test
_chat_id = os.getenv("FEISHU_CHAT_ID", "").strip()
_open_id = os.getenv("FEISHU_OPEN_ID", "").strip()
_receive_id_type = "chat_id"
_receive_id = _chat_id
if _open_id:
_receive_id_type = "open_id"
_receive_id = _open_id
logger.info("FEISHU_OPEN_ID=%s will send P2P message", _open_id)
if _receive_id:
logger.info("FEISHU_CHAT_ID=%s, performing live send test...", _receive_id)
# Add project root to path so source imports resolve
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _project_root not in sys.path:
sys.path.insert(0, _project_root)
from src.notification_sender.feishu_sender import FeishuSender
from src.config import Config
config = Config()
config.feishu_app_id = app_id
config.feishu_app_secret = app_secret
config.feishu_chat_id = _receive_id
config.feishu_receive_id_type = _receive_id_type
config.feishu_domain = _domain
sender = FeishuSender(config)
# Interactive-card send (default FeishuSender path: card-first, text-fallback)
ok_card = sender.send_to_feishu(
"**Feishu Manual Smoke Test Message**\n\n"
"This is a manual smoke test from `e2e_test_feishu_app.py`\n"
f"(mode: {_receive_id_type})."
)
if ok_card:
logger.info("Live send test PASSED (via card or text fallback).")
else:
logger.error("Live send test FAILED - check FEISHU_CHAT_ID and bot permissions.")
sys.exit(1)
else:
logger.info(
"Neither FEISHU_CHAT_ID nor FEISHU_OPEN_ID set; skipping live send. "
"Set FEISHU_CHAT_ID or FEISHU_OPEN_ID to test actual message delivery."
)