Files
aiagent/backend/app/services/feishu_app_service.py
renjianbo f33bc461ff fix: resolve Feishu cross-app notification routing bug
Implement per-app open_id storage via user_feishu_open_ids table with
union_id-based cross-app user identification. WS handlers now auto-capture
open_id+union_id and resolve/associate user accounts. Schedule notifications
route through the correct bot's open_id instead of always falling back to 苹果.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 00:36:40 +08:00

238 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""飞书应用 API 服务 — 通过飞书应用发送消息通知到用户"""
from __future__ import annotations
import json
import logging
import time
from typing import Optional
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
# Token 缓存tenant_access_token 有效期 2 小时,提前 5 分钟刷新)
_token_cache: dict = {"token": None, "expires_at": 0}
def _get_tenant_access_token() -> Optional[str]:
"""获取飞书 tenant_access_token带缓存
使用 FEISHU_APP_ID + FEISHU_APP_SECRET 调用飞书 API 获取。
"""
now = time.time()
if _token_cache["token"] and now < _token_cache["expires_at"] - 300:
return _token_cache["token"]
app_id = settings.FEISHU_APP_ID
app_secret = settings.FEISHU_APP_SECRET
if not app_id or not app_secret:
logger.warning("飞书应用未配置FEISHU_APP_ID / FEISHU_APP_SECRET")
return None
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
token = result["tenant_access_token"]
expire = result.get("expire", 7200)
_token_cache["token"] = token
_token_cache["expires_at"] = now + expire
logger.info("飞书 tenant_access_token 获取成功")
return token
else:
logger.warning("飞书 token 获取失败: %s", result)
return None
except Exception as e:
logger.warning("飞书 token 获取异常: %s", e)
return None
def send_message_to_user(
open_id: str,
title: str,
content: str,
status: str = "info",
detail_link: Optional[str] = None,
) -> bool:
"""通过飞书应用向指定用户发送消息卡片。
Args:
open_id: 飞书用户的 open_id
title: 卡片标题
content: 卡片正文
status: info / success / failed
detail_link: 详情链接(可选)
Returns:
是否发送成功
"""
token = _get_tenant_access_token()
if not token:
return False
color_map = {"success": "green", "failed": "red", "info": "blue"}
color = color_map.get(status, "blue")
elements = [
{"tag": "markdown", "content": content},
]
if detail_link:
elements.append({
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "查看详情"},
"url": detail_link,
"type": "default",
}
],
})
card = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": color,
},
"elements": elements,
}
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
headers={"Authorization": f"Bearer {token}"},
json={
"receive_id": open_id,
"msg_type": "interactive",
"content": json.dumps(card, ensure_ascii=False),
},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
logger.info("飞书消息发送成功: open_id=%s title=%s", open_id[:20], title)
return True
else:
logger.warning(
"飞书消息发送失败: code=%s msg=%s", result.get("code"), result.get("msg"),
)
return False
except Exception as e:
logger.warning("飞书消息发送异常: %s", e)
return False
def send_plain_text(open_id: str, text: str) -> bool:
"""向用户发送纯文本消息。"""
token = _get_tenant_access_token()
if not token:
return False
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
headers={"Authorization": f"Bearer {token}"},
json={
"receive_id": open_id,
"msg_type": "text",
"content": json.dumps({"text": text}, ensure_ascii=False),
},
)
result = resp.json()
return resp.is_success and result.get("code") == 0
except Exception as e:
logger.warning("飞书文本消息发送异常: %s", e)
return False
def lookup_user_by_email(email: str) -> Optional[str]:
"""通过邮箱查询飞书用户的 open_id。
需要飞书应用已开通 contact:user.employee_id:readonly 权限。
Args:
email: 用户邮箱
Returns:
open_id 字符串,未找到返回 None
"""
token = _get_tenant_access_token()
if not token:
return None
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id",
headers={"Authorization": f"Bearer {token}"},
json={"emails": [email]},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
user_list = result.get("data", {}).get("user_list", [])
for user in user_list:
if user.get("email", "").lower() == email.lower():
open_id = user.get("open_id")
if open_id:
logger.info("飞书用户查询成功: email=%s open_id=%s", email, open_id)
return open_id
logger.info("飞书用户未找到: email=%s", email)
return None
else:
logger.warning("飞书用户查询失败: code=%s msg=%s", result.get("code"), result.get("msg"))
return None
except Exception as e:
logger.warning("飞书用户查询异常: %s", e)
return None
def lookup_union_id_by_open_id(open_id: str) -> Optional[str]:
"""通过 open_id 查询飞书用户的 union_id跨应用唯一标识
同一个用户的 union_id 在所有应用下相同,可用于跨应用关联。
Args:
open_id: 飞书用户的 open_id必须在当前苹果应用下有权限访问
Returns:
union_id 字符串,未找到返回 None
"""
token = _get_tenant_access_token()
if not token:
return None
try:
with httpx.Client(timeout=10) as client:
resp = client.get(
f"https://open.feishu.cn/open-apis/contact/v3/users/{open_id}",
headers={"Authorization": f"Bearer {token}"},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
user_data = result.get("data", {}).get("user", {})
union_id = user_data.get("union_id")
if union_id:
logger.info("飞书 union_id 查询成功: open_id=%s union_id=%s", open_id[:20], union_id)
return union_id
logger.info("飞书用户未找到 union_id: open_id=%s", open_id[:20])
return None
else:
logger.warning("飞书 union_id 查询失败: code=%s msg=%s", result.get("code"), result.get("msg"))
return None
except Exception as e:
logger.warning("飞书 union_id 查询异常: %s", e)
return None
def get_verification_token() -> str:
"""获取飞书应用的 Verification Token用于验证事件回调"""
return settings.FEISHU_VERIFICATION_TOKEN