"""飞书绑定 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, }