- 新增通知系统 (notifications 表、服务、API) - 新增飞书定时任务结果推送 (webhook + 应用消息) - 新增飞书应用消息发送服务 (feishu_app_service) - 新增飞书 WebSocket 长连接事件监听 (苹果应用) - 新增飞书账号绑定/解绑 API - 新增橙子飞书机器人 (独立 WS 连接,固定路由到橙子助手 Agent) - 执行记录添加 schedule_id,用户添加飞书绑定字段 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
244 lines
8.3 KiB
Python
244 lines
8.3 KiB
Python
"""飞书绑定 API — 绑定/解绑/事件回调"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from typing import Any, Dict
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.api.auth import get_current_user
|
||
from app.core.database import get_db, SessionLocal
|
||
from app.models.user import User
|
||
from app.models.agent import Agent
|
||
from app.services.feishu_ws_handler import get_pending_open_ids, clear_pending_open_ids
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/v1/feishu", tags=["feishu"])
|
||
|
||
|
||
# ─── 飞书事件回调(HTTP 模式备用,主用长连接)────────────────────
|
||
|
||
|
||
@router.post("/event", include_in_schema=False)
|
||
async def feishu_event_callback(request: Request):
|
||
"""飞书事件回调 — 处理 URL 验证(HTTP 回调模式备用)。
|
||
|
||
如果使用长连接订阅方式,事件通过 WebSocket 推送,此端点不需配置。
|
||
"""
|
||
from app.services.feishu_app_service import get_verification_token
|
||
from app.services.feishu_ws_handler import _pending_open_ids
|
||
|
||
try:
|
||
body = await request.json()
|
||
except Exception:
|
||
return {"error": "invalid json"}
|
||
|
||
# ── URL 验证 ──
|
||
if body.get("type") == "url_verify":
|
||
challenge = body.get("challenge")
|
||
token = body.get("token", "")
|
||
logger.info("飞书事件 URL 验证: challenge=%s", challenge)
|
||
if token != get_verification_token():
|
||
logger.warning("飞书事件 token 不匹配")
|
||
return {"challenge": challenge}
|
||
return {"challenge": challenge}
|
||
|
||
# ── 事件回调 ──
|
||
if body.get("type") == "event_callback":
|
||
token = body.get("token", "")
|
||
if token != get_verification_token():
|
||
logger.warning("飞书事件 token 不匹配")
|
||
return {"error": "invalid token"}
|
||
|
||
event = body.get("event", {})
|
||
event_type = event.get("type")
|
||
|
||
if event_type == "im.message.receive_v1":
|
||
sender = event.get("sender", {})
|
||
sender_id = sender.get("sender_id", {})
|
||
open_id = sender_id.get("open_id", "")
|
||
message = event.get("message", {})
|
||
chat_type = message.get("chat_type", "p2p") # p2p = 私聊
|
||
|
||
logger.info("飞书 HTTP 回调收到消息: open_id=%s chat_type=%s", open_id[:20], chat_type)
|
||
|
||
if open_id and chat_type == "p2p":
|
||
_pending_open_ids.append(open_id)
|
||
if len(_pending_open_ids) > 5:
|
||
_pending_open_ids.pop(0)
|
||
|
||
return {"code": 0, "msg": "ok"}
|
||
|
||
return {"error": "unknown event type"}
|
||
|
||
|
||
class BindFeishuRequest(BaseModel):
|
||
open_id: str
|
||
|
||
|
||
@router.post("/bind")
|
||
async def bind_feishu(
|
||
data: BindFeishuRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""绑定飞书用户 open_id 到当前账号。"""
|
||
if not data.open_id or not data.open_id.strip():
|
||
raise HTTPException(status_code=400, detail="open_id 不能为空")
|
||
|
||
current_user.feishu_open_id = data.open_id.strip()
|
||
db.commit()
|
||
logger.info("飞书绑定成功: user=%s open_id=%s", current_user.id, data.open_id)
|
||
return {"message": "飞书账号绑定成功"}
|
||
|
||
|
||
@router.post("/unbind")
|
||
async def unbind_feishu(
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""解绑飞书账号。"""
|
||
current_user.feishu_open_id = None
|
||
db.commit()
|
||
logger.info("飞书解绑成功: user=%s", current_user.id)
|
||
return {"message": "飞书账号解绑成功"}
|
||
|
||
|
||
@router.post("/lookup")
|
||
async def lookup_and_bind(
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""通过当前用户的邮箱自动查找并绑定飞书账号。
|
||
|
||
需要飞书应用已开通 contact:user.employee_id:readonly 权限。
|
||
系统会使用用户的注册邮箱在飞书中搜索匹配的 open_id 并自动绑定。
|
||
"""
|
||
if not current_user.email:
|
||
raise HTTPException(status_code=400, detail="当前用户没有邮箱信息")
|
||
|
||
from app.services.feishu_app_service import lookup_user_by_email
|
||
|
||
open_id = lookup_user_by_email(current_user.email)
|
||
if not open_id:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"在飞书中未找到邮箱 {current_user.email} 对应的用户。"
|
||
f"请确认:1) 该邮箱已在飞书通讯录中 2) 应用已拥有 contact:user.employee_id:readonly 权限",
|
||
)
|
||
|
||
current_user.feishu_open_id = open_id
|
||
db.commit()
|
||
logger.info("飞书自动绑定成功: user=%s email=%s open_id=%s", current_user.id, current_user.email, open_id)
|
||
|
||
# 发送测试消息
|
||
from app.services.feishu_app_service import send_message_to_user
|
||
send_message_to_user(
|
||
open_id=open_id,
|
||
title="飞书通知绑定成功 🎉",
|
||
content=f"你好 {current_user.username},你的平台账号已成功绑定飞书。\n\n"
|
||
f"从此开始,Agent 定时任务的执行结果将通过飞书实时推送给你。",
|
||
status="success",
|
||
)
|
||
|
||
return {
|
||
"message": "飞书账号绑定成功",
|
||
"open_id": open_id,
|
||
"test_message_sent": True,
|
||
}
|
||
|
||
|
||
@router.get("/pending")
|
||
async def get_pending_open_ids_api(
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""获取通过飞书消息事件捕获的 open_id 列表。
|
||
|
||
在飞书里给「苹果」应用发一条任意消息后,
|
||
调用此接口查看是否有待绑定的 open_id。
|
||
"""
|
||
return {"pending_ids": get_pending_open_ids()}
|
||
|
||
|
||
@router.post("/bind-pending")
|
||
async def bind_pending(
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""绑定最近一条从飞书事件捕获的 open_id。"""
|
||
ids = get_pending_open_ids()
|
||
if not ids:
|
||
raise HTTPException(status_code=404, detail="没有待绑定的 open_id,请在飞书里给应用发一条消息")
|
||
|
||
open_id = ids[-1]
|
||
current_user.feishu_open_id = open_id
|
||
db.commit()
|
||
clear_pending_open_ids()
|
||
|
||
logger.info("飞书事件绑定成功: user=%s open_id=%s", current_user.id, open_id)
|
||
|
||
# 发送测试消息
|
||
from app.services.feishu_app_service import send_message_to_user
|
||
send_message_to_user(
|
||
open_id=open_id,
|
||
title="飞书通知绑定成功",
|
||
content=f"你好 {current_user.username},你的平台账号已成功绑定飞书。\n定时任务执行结果将通过飞书实时推送给你。",
|
||
status="success",
|
||
)
|
||
|
||
return {"message": "飞书账号绑定成功", "open_id": open_id}
|
||
|
||
|
||
@router.get("/default-agent")
|
||
async def get_default_agent(
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取飞书对话默认 Agent。"""
|
||
agent_id = current_user.feishu_default_agent_id
|
||
agent_name = None
|
||
if agent_id:
|
||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||
agent_name = agent.name if agent else None
|
||
return {
|
||
"agent_id": agent_id,
|
||
"agent_name": agent_name,
|
||
}
|
||
|
||
|
||
class SetDefaultAgentRequest(BaseModel):
|
||
agent_id: str
|
||
|
||
|
||
@router.post("/default-agent")
|
||
async def set_default_agent(
|
||
data: SetDefaultAgentRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""设置飞书对话默认 Agent。"""
|
||
agent = db.query(Agent).filter(Agent.id == data.agent_id).first()
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||
if agent.user_id and agent.user_id != current_user.id and current_user.role != "admin":
|
||
raise HTTPException(status_code=403, detail="无权使用该 Agent")
|
||
|
||
current_user.feishu_default_agent_id = data.agent_id
|
||
db.commit()
|
||
logger.info("飞书默认 Agent 设置成功: user=%s agent=%s", current_user.id, data.agent_id)
|
||
return {"message": f"默认 Agent 已设置为「{agent.name}」", "agent_id": data.agent_id}
|
||
|
||
|
||
@router.get("/status")
|
||
async def feishu_status(
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""查询当前用户飞书绑定状态。"""
|
||
return {
|
||
"bound": bool(current_user.feishu_open_id),
|
||
"open_id": current_user.feishu_open_id,
|
||
}
|