Files
aiagent/backend/app/services/feishu_app_service.py
renjianbo 9454dee976 feat: complete remaining plan items — all 4 phases fully implemented
- Task API: add execute and retry endpoints
- Agent API: add create-main-agent endpoint and execute with graph/debate/pipeline modes
- Feishu tools: add read_messages, create_sheet, upload_file (54 builtin tools total)
- Feishu events: group @mention handling, approval callback, auto daily reporting
- Feishu app service: add send_plain_text_to_group for group chat replies
- Typed Data Ports: template variable injection {{previous.output.field}} + output schema validation
- GoalDetail.vue: Gantt timeline view + real-time progress polling (10s)
- Autonomy loop: per-goal Celery Beat scheduling via sync_autonomy_schedule_for_goal

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

262 lines
8.8 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 send_plain_text_to_group(chat_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=chat_id",
headers={"Authorization": f"Bearer {token}"},
json={
"receive_id": chat_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