feat: 集成飞书通知和机器人对话系统
- 新增通知系统 (notifications 表、服务、API) - 新增飞书定时任务结果推送 (webhook + 应用消息) - 新增飞书应用消息发送服务 (feishu_app_service) - 新增飞书 WebSocket 长连接事件监听 (苹果应用) - 新增飞书账号绑定/解绑 API - 新增橙子飞书机器人 (独立 WS 连接,固定路由到橙子助手 Agent) - 执行记录添加 schedule_id,用户添加飞书绑定字段 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
199
backend/app/services/feishu_app_service.py
Normal file
199
backend/app/services/feishu_app_service.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""飞书应用 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
|
||||
Reference in New Issue
Block a user