Files
aiagent/backend/app/services/feishu_app_service.py
renjianbo 7ee80c74b2 feat: 集成飞书通知和机器人对话系统
- 新增通知系统 (notifications 表、服务、API)
- 新增飞书定时任务结果推送 (webhook + 应用消息)
- 新增飞书应用消息发送服务 (feishu_app_service)
- 新增飞书 WebSocket 长连接事件监听 (苹果应用)
- 新增飞书账号绑定/解绑 API
- 新增橙子飞书机器人 (独立 WS 连接,固定路由到橙子助手 Agent)
- 执行记录添加 schedule_id,用户添加飞书绑定字段

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:17:49 +08:00

200 lines
6.5 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 get_verification_token() -> str:
"""获取飞书应用的 Verification Token用于验证事件回调"""
return settings.FEISHU_VERIFICATION_TOKEN