2026-05-02 16:17:49 +08:00
|
|
|
|
"""橙子飞书应用 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(带缓存)。"""
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
if _token_cache["token"] and now < _token_cache["expires_at"] - 300:
|
|
|
|
|
|
return _token_cache["token"]
|
|
|
|
|
|
|
|
|
|
|
|
app_id = settings.ORANGE_APP_ID
|
|
|
|
|
|
app_secret = settings.ORANGE_APP_SECRET
|
|
|
|
|
|
if not app_id or not app_secret:
|
|
|
|
|
|
logger.warning("橙子应用未配置(ORANGE_APP_ID / ORANGE_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,
|
2026-06-29 01:17:21 +08:00
|
|
|
|
execution_log_id: Optional[str] = None,
|
|
|
|
|
|
agent_name: Optional[str] = None,
|
2026-05-02 16:17:49 +08:00
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""通过橙子应用向用户发送消息卡片。"""
|
2026-06-29 01:17:21 +08:00
|
|
|
|
from app.services.feishu_card_builder import build_reply_card
|
|
|
|
|
|
|
2026-05-02 16:17:49 +08:00
|
|
|
|
token = _get_tenant_access_token()
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-06-29 01:17:21 +08:00
|
|
|
|
card = build_reply_card(
|
|
|
|
|
|
title=title, content=content,
|
|
|
|
|
|
execution_log_id=execution_log_id, agent_name=agent_name,
|
|
|
|
|
|
status=status, detail_link=detail_link,
|
|
|
|
|
|
include_feedback=bool(execution_log_id),
|
|
|
|
|
|
)
|
2026-05-02 16:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
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
|