Files
aiagent/backend/app/services/feishu_ws_handler.py
renjianbo f33bc461ff fix: resolve Feishu cross-app notification routing bug
Implement per-app open_id storage via user_feishu_open_ids table with
union_id-based cross-app user identification. WS handlers now auto-capture
open_id+union_id and resolve/associate user accounts. Schedule notifications
route through the correct bot's open_id instead of always falling back to 苹果.

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

390 lines
13 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.
"""飞书长连接事件监听 — 通过 lark-oapi SDK 建立 WebSocket 接收事件"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from collections import deque
from typing import List, Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
# 存储通过事件捕获的 open_id与 HTTP 事件回调共用)
_pending_open_ids: List[str] = []
# 已处理消息 ID 去重(防止 WS 重连导致重复处理)
_processed_msg_ids: deque[str] = deque(maxlen=20)
_ws_thread: threading.Thread | None = None
def _get_message_id(data) -> Optional[str]:
"""从 Feishu 消息事件中提取 message_id。"""
try:
ev = data.event
msg = getattr(ev, "message", None)
if msg:
return getattr(msg, "message_id", None)
except Exception:
return None
return None
def _get_message_text(data) -> Optional[str]:
"""从 Feishu 消息事件中提取纯文本内容。"""
try:
ev = data.event
msg = getattr(ev, "message", None)
if not msg:
return None
content_str = getattr(msg, "content", None)
msg_type = getattr(msg, "message_type", "")
if not content_str:
return None
if msg_type == "text":
parsed = json.loads(content_str)
return parsed.get("text", "")
# 其他消息类型暂不支持
return None
except Exception as e:
logger.warning("解析飞书消息内容失败: %s", e)
return None
def _get_sender_open_id(data) -> Optional[str]:
"""从 Feishu 消息事件中提取发送者 open_id。"""
try:
ev = data.event
sender = getattr(ev, "sender", None)
if not sender:
return None
sender_id = getattr(sender, "sender_id", None)
if not sender_id:
return None
return getattr(sender_id, "open_id", None)
except Exception:
return None
def _get_sender_union_id(data) -> Optional[str]:
"""从 Feishu 消息事件中提取发送者 union_id跨应用唯一"""
try:
ev = data.event
sender = getattr(ev, "sender", None)
if not sender:
return None
sender_id = getattr(sender, "sender_id", None)
if not sender_id:
return None
return getattr(sender_id, "union_id", None)
except Exception:
return None
def _get_chat_type(data) -> str:
"""获取聊天类型。"""
try:
ev = data.event
msg = getattr(ev, "message", None)
return getattr(msg, "chat_type", "") if msg else ""
except Exception:
return ""
def _reply_to_feishu(open_id: str, text: str):
"""通过飞书 API 回复用户消息。"""
try:
from app.services.feishu_app_service import send_plain_text
send_plain_text(open_id, text)
except Exception as e:
logger.warning("飞书回复消息失败: %s", e)
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
"""通过飞书 API 回复卡片消息。"""
try:
from app.services.feishu_app_service import send_message_to_user
send_message_to_user(open_id, title, content, status=status)
except Exception as e:
logger.warning("飞书回复卡片失败: %s", e)
async def _handle_message_async(data):
"""异步处理飞书消息。"""
open_id = _get_sender_open_id(data)
union_id = _get_sender_union_id(data)
chat_type = _get_chat_type(data)
text = _get_message_text(data)
if not open_id or chat_type != "p2p":
return
_pending_open_ids.append(open_id)
if len(_pending_open_ids) > 5:
_pending_open_ids.pop(0)
logger.info("飞书收到消息: open_id=%s text=%s", open_id[:20], text[:50] if text else "(空)")
try:
with open("/tmp/feishu_open_id.txt", "w") as f:
f.write(open_id)
except Exception:
pass
if not text:
return
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.models.user import User
from app.models.agent import Agent
from app.services.feishu_open_id_service import resolve_user_and_save
db: Optional[Session] = None
try:
db = SessionLocal()
# 自动保存/关联此应用的 open_id跨应用识别
resolved_uid = resolve_user_and_save(
db, app_id=settings.FEISHU_APP_ID or "",
open_id=open_id, union_id=union_id,
)
user = db.query(User).filter(User.feishu_open_id == open_id).first()
if not user and resolved_uid:
user = db.query(User).filter(User.id == resolved_uid).first()
if not user:
_reply_to_feishu(open_id, "你的账号未绑定平台用户,请先在平台绑定飞书。")
return
agent_id = user.feishu_default_agent_id
if not agent_id:
_reply_to_feishu(
open_id,
"你还没有设置飞书对话的默认 Agent。\n请先在平台设置:\n"
"POST /api/v1/feishu/default-agent {\"agent_id\": \"<你的AgentID>\"}",
)
return
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
_reply_to_feishu(open_id, f"默认 Agent (id={agent_id}) 已不存在,请重新设置。")
return
_reply_to_feishu(open_id, f"🤔 正在思考,请稍候...")
from app.agent_runtime import AgentRuntime, AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig
wc = agent.workflow_config or {}
nodes = wc.get("nodes", [])
system_prompt = agent.description or ""
model = "deepseek-v4-flash"
provider = "deepseek"
temperature = 0.7
max_iterations = 10
for n in nodes:
if n.get("type") not in ("agent", "llm", "template"):
continue
cfg = n.get("data", {}) if isinstance(n, dict) else getattr(n, "data", {})
system_prompt = cfg.get("system_prompt", "") or system_prompt
model = cfg.get("model", model)
provider = cfg.get("provider", provider)
temperature = float(cfg.get("temperature", temperature))
max_iterations = int(cfg.get("max_iterations", max_iterations))
break
config = AgentConfig(
name=agent.name or "agent",
system_prompt=system_prompt + (
f"\n\n## 系统信息\n"
f"你的 Agent ID 是: {agent.id}\n"
f"在调用 schedule_list、schedule_delete 等工具时,使用此 ID 作为 agent_id 参数。"
),
llm=AgentLLMConfig(
model=model,
provider=provider,
temperature=temperature,
max_iterations=max_iterations,
),
tools=AgentToolConfig(),
memory=AgentMemoryConfig(
max_history_messages=int(cfg.get("memory_max_history", 20)),
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
persist_to_db=bool(cfg.get("memory_persist", True)),
vector_memory_enabled=bool(cfg.get("memory_vector_enabled", True)),
learning_enabled=bool(cfg.get("memory_learning", True)),
),
user_id=user.id,
memory_scope_id=str(agent.id),
)
on_llm_call = _make_llm_logger(db, agent_id=str(agent.id), user_id=user.id)
runtime = AgentRuntime(config=config, on_llm_call=on_llm_call)
result = await runtime.run(text)
if result.content:
_reply_card(
open_id,
f"🤖 {agent.name}",
result.content.strip(),
status="success",
)
else:
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
logger.info(
"飞书 Agent 回复完成: open_id=%s agent=%s iterations=%d tools=%d",
open_id[:20], agent.name, result.iterations_used, result.tool_calls_made,
)
except Exception as e:
logger.error("飞书消息处理失败: %s", e)
try:
_reply_to_feishu(open_id, f"处理失败: {e!s}")
except Exception:
pass
finally:
if db:
db.close()
def _handle_message_internal(data):
"""同步入口 — 创建异步任务处理飞书消息。"""
# 去重WS 重连后可能重投已处理的消息
msg_id = _get_message_id(data)
if msg_id:
if msg_id in _processed_msg_ids:
logger.debug("跳过已处理消息: %s", msg_id)
return
_processed_msg_ids.append(msg_id)
# 记录 pending open_id用于绑定
open_id = _get_sender_open_id(data)
chat_type = _get_chat_type(data)
text = _get_message_text(data)
if open_id:
_pending_open_ids.append(open_id)
if len(_pending_open_ids) > 5:
_pending_open_ids.pop(0)
try:
with open("/tmp/feishu_open_id.txt", "w") as f:
f.write(open_id)
except Exception:
pass
if not open_id or chat_type != "p2p":
return
logger.info("飞书收到消息: open_id=%s text=%s", open_id[:20], text[:50] if text else "(空)")
if not text:
return
# 将实际处理委托给异步函数
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_handle_message_async(data))
else:
loop.run_until_complete(_handle_message_async(data))
except Exception as e:
logger.error("创建飞书消息处理任务失败: %s", e)
try:
_reply_to_feishu(open_id, f"处理失败: {e!s}")
except Exception:
pass
def _make_llm_logger(db, agent_id: Optional[str] = None, user_id: Optional[str] = None):
"""创建 LLM 调用日志回调。"""
def _log(metrics: dict):
try:
from app.models.agent_llm_log import AgentLLMLog
log = AgentLLMLog(
agent_id=agent_id,
session_id=metrics.get("session_id"),
user_id=user_id,
model=metrics.get("model", ""),
provider=metrics.get("provider"),
prompt_tokens=metrics.get("prompt_tokens", 0),
completion_tokens=metrics.get("completion_tokens", 0),
total_tokens=metrics.get("total_tokens", 0),
latency_ms=metrics.get("latency_ms", 0),
iteration_number=metrics.get("iteration_number", 0),
step_type=metrics.get("step_type"),
tool_name=metrics.get("tool_name"),
status=metrics.get("status", "success"),
error_message=metrics.get("error_message"),
)
db.add(log)
db.commit()
except Exception as e:
logger.warning("写入 AgentLLMLog 失败: %s", e)
return _log
def _build_event_handler():
"""构建事件处理器。"""
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
def on_message_receive(data):
"""处理 im.message.receive_v1 事件。"""
_handle_message_internal(data)
builder = EventDispatcherHandler.builder(
encrypt_key="",
verification_token=settings.FEISHU_VERIFICATION_TOKEN,
)
builder.register_p2_im_message_receive_v1(on_message_receive)
return builder.build()
async def start_ws_client():
"""在 async 上下文中启动飞书长连接(在主事件循环运行)。"""
if not settings.FEISHU_APP_ID or not settings.FEISHU_APP_SECRET:
logger.warning("飞书应用未配置,跳过长连接启动")
return
from lark_oapi.ws import Client as WSClient
handler = _build_event_handler()
client = WSClient(
app_id=settings.FEISHU_APP_ID,
app_secret=settings.FEISHU_APP_SECRET,
event_handler=handler,
auto_reconnect=True,
)
logger.info("飞书长连接客户端启动中...")
while True:
try:
await client._connect()
logger.info("飞书长连接已建立")
asyncio.ensure_future(client._ping_loop())
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("飞书长连接断开3秒后重连: %s", e)
await asyncio.sleep(3)
def get_pending_open_ids() -> List[str]:
"""获取待绑定的 open_id 列表。"""
return list(_pending_open_ids)
def clear_pending_open_ids():
"""清空待绑定的 open_id。"""
_pending_open_ids.clear()