Files
aiagent/backend/app/api/feishu_bind.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

244 lines
8.3 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
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,
}