2026-05-02 16:17:49 +08:00
|
|
|
|
"""飞书长连接事件监听 — 通过 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_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)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
db: Optional[Session] = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
db = SessionLocal()
|
|
|
|
|
|
user = db.query(User).filter(User.feishu_open_id == open_id).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"🤔 正在思考,请稍候...")
|
|
|
|
|
|
|
2026-05-02 21:44:47 +08:00
|
|
|
|
from app.agent_runtime import AgentRuntime, AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig
|
2026-05-02 16:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-05-02 21:44:47 +08:00
|
|
|
|
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
|
2026-05-02 16:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
config = AgentConfig(
|
|
|
|
|
|
name=agent.name or "agent",
|
|
|
|
|
|
system_prompt=system_prompt,
|
|
|
|
|
|
llm=AgentLLMConfig(
|
|
|
|
|
|
model=model,
|
|
|
|
|
|
provider=provider,
|
|
|
|
|
|
temperature=temperature,
|
|
|
|
|
|
max_iterations=max_iterations,
|
|
|
|
|
|
),
|
|
|
|
|
|
tools=AgentToolConfig(),
|
2026-05-02 21:44:47 +08:00
|
|
|
|
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)),
|
|
|
|
|
|
),
|
2026-05-02 16:17:49 +08:00
|
|
|
|
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()
|