PMFlow/backend/app/services/feishu_integration.py

1874 lines
76 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.

"""
Feishu Integration Service - 飞书集成服务
功能:
1. 接收飞书群聊消息事件
2. AI 判断消息是否为有价值的信号
3. 自动创建信号并关联到项目
4. 推送通知给相关角色
飞书开放平台配置:
- App ID: 从飞书开放平台获取
- App Secret: 从飞书开放平台获取
- 事件接收: 统一使用长连接模式
"""
import asyncio
import json
import hashlib
import re
import time
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
import httpx
from sqlalchemy import and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..models import (
User,
Project,
Signal,
ProjectSignalLink,
Integration,
FeishuReplyOutbox,
AIProcessingLog,
)
from ..database import AsyncSessionLocal
from .notification_service import NotificationService
from .signal_capture import ProactiveSignalCaptureService
from .llm import LLMService
from .prompt_profile_service import prompt_profile_service
CN_TZ = timezone(timedelta(hours=8))
WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
OUTBOX_MAX_ATTEMPTS = 3
OUTBOX_STALE_SENDING_SECONDS = 120
class FeishuIntegrationService:
"""飞书集成服务"""
def __init__(self, db: AsyncSession):
self.db = db
# 从数据库获取飞书配置
self.app_id = None
self.app_secret = None
self.encrypt_key = None
self.verification_token = None
self.event_mode = "long_connection"
self.tenant_id = 1
@staticmethod
def _normalize_event_mode(_: Optional[str] = None) -> str:
return "long_connection"
async def load_config(self, tenant_id: int = 1):
"""从数据库加载飞书配置"""
self.tenant_id = tenant_id
result = await self.db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'feishu',
Integration.is_active.is_(True),
)
)
integration = result.scalar_one_or_none()
if integration:
if isinstance(integration.config, str):
config = json.loads(integration.config) if integration.config else {}
else:
config = integration.config or {}
self.app_id = config.get('app_id')
self.app_secret = config.get('app_secret')
self.encrypt_key = None
self.verification_token = None
self.event_mode = self._normalize_event_mode(config.get('event_mode'))
else:
self.app_id = None
self.app_secret = None
self.encrypt_key = None
self.verification_token = None
self.event_mode = 'long_connection'
def is_configured(self) -> bool:
"""检查是否已配置"""
return bool(self.app_id and self.app_secret)
async def save_config(
self,
app_id: str,
app_secret: str,
encrypt_key: str,
verification_token: str,
event_mode: str = "long_connection",
tenant_id: int = 1
):
"""保存飞书配置到数据库"""
mode = self._normalize_event_mode(event_mode)
config = {
'app_id': app_id,
'app_secret': app_secret,
'encrypt_key': "",
'verification_token': "",
'event_mode': mode,
}
# 查找或创建集成记录
result = await self.db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'feishu'
)
)
integration = result.scalar_one_or_none()
if integration:
integration.config = config
integration.is_active = True
else:
integration = Integration(
type='feishu',
config=config,
is_active=True,
tenant_id=tenant_id
)
self.db.add(integration)
await self.db.commit()
# 更新内存配置
self.app_id = app_id
self.app_secret = app_secret
self.encrypt_key = None
self.verification_token = None
self.event_mode = mode
self.tenant_id = tenant_id
async def handle_message_event(
self,
event_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
处理飞书消息事件
支持的事件类型:
- message.receive_v1: 收到群聊消息
- im.message.receive_v1: 收到群聊消息(schema 2.0)
"""
try:
event_type = (event_data.get('type') or '').strip()
# 飞书 schema 2.0 会返回 im.message.receive_v1
if event_type in {'message.receive_v1', 'im.message.receive_v1'}:
return await self._handle_message_receive(event_data)
return {
'code': 0,
'msg': f'Event type {event_type} not handled'
}
except Exception as e:
print(f"[Feishu] Handle event error: {e}")
return {
'code': 1,
'msg': str(e)
}
async def _handle_message_receive(
self,
event: Dict[str, Any]
) -> Dict[str, Any]:
"""处理收到的消息事件"""
state_trace_id = f"feishu-sm-{uuid4().hex[:12]}"
ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu"
message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {}
chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {}
sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {}
message_id = (message.get("message_id") or "").strip() or "unknown"
self._emit_state_transition(
trace_id=state_trace_id,
state="RECEIVED",
message_id=message_id,
message_type=str(message.get("message_type") or "unknown"),
)
sender_type = str(sender.get("sender_type") or "").strip().lower()
if sender_type in {"app", "bot"}:
return {'code': 0, 'msg': 'Ignore bot/app message'}
mentioned_bot_names = self._extract_mentioned_bot_names(message)
# 解析消息内容与附件元数据
message_payload = self._extract_message_payload(event)
message_content = str(message_payload.get("text") or "").strip() or None
message_attachments = (
message_payload.get("attachments")
if isinstance(message_payload.get("attachments"), list)
else []
)
await self._record_ingress_trace(
ingress_source=ingress_source,
trace_id=state_trace_id,
message_id=message_id,
event=event,
message_content=message_content or "",
)
if not message_content:
print(
f"[Feishu] message.receive_v1 ignored: no text content, "
f"message_type={message.get('message_type')}, keys={list(message.keys())}"
)
chat_id = chat.get('chat_id') or message.get('chat_id') or ''
if mentioned_bot_names and chat_id:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=(sender.get('sender_id', {}) or {}).get('open_id'),
mention_name=(sender.get('sender_name') or sender.get('name') or '你'),
text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。",
trace_id=state_trace_id,
)
self._emit_state_transition(
trace_id=state_trace_id,
state="REPLIED",
route="empty_text_prompt",
)
return {'code': 0, 'msg': 'No text content'}
# 获取发送者信息
sender_id = sender.get('sender_id', {}) if isinstance(sender.get('sender_id', {}), dict) else {}
sender_open_id = (sender_id.get('open_id') or "").strip() or None
sender_name = await self._resolve_sender_display_name(
sender=sender,
sender_id=sender_id,
)
# 获取群聊信息
chat_id = chat.get('chat_id') or message.get('chat_id') or ''
chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group"
captured_at = self._extract_message_datetime(message)
occurred_at_iso = (
captured_at.isoformat() if isinstance(captured_at, datetime) else None
)
message_attachments = self._normalize_ingress_attachment_records(
message_attachments,
occurred_at=occurred_at_iso,
source_message_id=(message.get("message_id") or "").strip() or None,
source_chat_id=chat_id or None,
)
# 使用信号捕获服务处理
capture_service = ProactiveSignalCaptureService(self.db)
try:
result = await capture_service.capture_from_feishu(
group_chat_id=chat_id,
message_content=message_content,
sender=sender_name,
tenant_id=self.tenant_id,
proposer_open_id=sender_open_id,
captured_at=captured_at,
mentioned_bot_names=mentioned_bot_names,
feishu_message_id=(message.get("message_id") or "").strip() or None,
chat_type=chat_type,
)
await self._persist_signal_attachments_from_ingress(
signal_id=result.get("signal_id"),
attachments=message_attachments,
)
except Exception as exc:
self._emit_state_transition(
trace_id=state_trace_id,
state="ERROR_REPLY",
reason="capture_exception",
error=str(exc),
)
if chat_id:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=self._build_error_reply_text(),
trace_id=state_trace_id,
)
self._emit_state_transition(
trace_id=state_trace_id,
state="REPLIED",
route="error_template",
)
return {
"code": 0,
"msg": "error_handled",
"data": {
"status": "error",
"classification": "error",
"ai_trace_id": state_trace_id,
},
}
print(
"[Feishu] Message processed: "
f"status={result.get('status')}, "
f"classification={result.get('classification')}, "
f"linked_projects={result.get('linked_project_ids', [])}, "
f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, "
f"ai_trace_id={result.get('ai_trace_id')}"
)
scene = self._classify_message_scene(capture_result=result)
self._emit_state_transition(
trace_id=state_trace_id,
state="CLASSIFIED",
scene=scene,
status=str(result.get("status") or "unknown"),
classification=str(result.get("classification") or "unknown"),
)
if str(result.get("status") or "").lower() != "error":
self._emit_state_transition(
trace_id=state_trace_id,
state="COMMITTED",
ai_trace_id=str(result.get("ai_trace_id") or ""),
)
reply_route = "none"
if scene == "execute":
if self._is_duplicate_capture_result(result):
self._emit_state_transition(
trace_id=state_trace_id,
state="ROUTED",
route="duplicate_suppressed",
)
else:
self._emit_state_transition(
trace_id=state_trace_id,
state="ROUTED",
route="tool_first_execute",
)
deterministic_reply = str(result.get("direct_reply_text") or "").strip()
if not deterministic_reply:
deterministic_reply = self._build_execution_receipt_reply(
message_content=message_content,
capture_result=result,
)
deterministic_reply, guard_reason = self._guard_execute_reply_text_with_reason(
deterministic_reply,
trace_id=state_trace_id,
)
if guard_reason:
asyncio.create_task(
self._record_execute_guard_alert(
trace_id=state_trace_id,
message_id=message_id,
message_content=message_content,
capture_result=result,
guard_reason=guard_reason,
)
)
if not deterministic_reply and str(result.get("status") or "").lower() == "error":
deterministic_reply = self._build_error_reply_text()
if chat_id and deterministic_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=deterministic_reply,
trace_id=state_trace_id,
)
reply_route = "execute_template"
elif scene == "qa":
self._emit_state_transition(
trace_id=state_trace_id,
state="ROUTED",
route="qa_consult",
)
if chat_id and bool(result.get("direct_reply_required")):
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=str(result.get("direct_reply_text") or "已收到,正在处理。"),
trace_id=state_trace_id,
)
reply_route = "qa_direct"
elif (
chat_id
and self._should_reply_to_mention(
mentioned_bot_names=mentioned_bot_names,
message_content=message_content,
)
):
await self._reply_with_active_role_prompt(
chat_id=chat_id,
sender_open_id=sender_open_id,
sender_name=sender_name,
message_content=message_content,
capture_result=result,
trace_id=state_trace_id,
)
reply_route = "qa_llm"
else:
self._emit_state_transition(
trace_id=state_trace_id,
state="ERROR_REPLY",
reason="scene_error",
capture_status=str(result.get("status") or "unknown"),
)
if chat_id:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=self._build_error_reply_text(),
trace_id=state_trace_id,
)
reply_route = "error_template"
if reply_route != "none":
self._emit_state_transition(
trace_id=state_trace_id,
state="REPLIED",
route=reply_route,
)
await self._record_message_trace(
trace_id=state_trace_id,
message_id=message_id,
chat_type=chat_type,
message_content=message_content,
capture_result=result,
reply_source=reply_route,
)
return {
'code': 0,
'msg': 'success',
'data': {
'status': result.get('status'),
'classification': result.get('classification'),
'linked_project_ids': result.get('linked_project_ids', []),
'auto_tasks_count': len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else []),
'ai_trace_id': result.get('ai_trace_id'),
'state_trace_id': state_trace_id,
'scene': scene,
},
}
async def _record_ingress_trace(
self,
*,
ingress_source: str,
trace_id: str,
message_id: str,
event: Dict[str, Any],
message_content: str,
) -> None:
try:
message = event.get("message", {}) if isinstance(event.get("message", {}), dict) else {}
chat = event.get("chat", {}) if isinstance(event.get("chat", {}), dict) else {}
sender = event.get("sender", {}) if isinstance(event.get("sender", {}), dict) else {}
sender_id = sender.get("sender_id", {}) if isinstance(sender.get("sender_id", {}), dict) else {}
sender_name = (
str(sender.get("sender_name") or sender.get("name") or "").strip()
or str(sender_id.get("open_id") or "").strip()
or "unknown"
)
chat_id = str(chat.get("chat_id") or message.get("chat_id") or "").strip() or None
chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip() or None
async with AsyncSessionLocal() as db:
db.add(
AIProcessingLog(
tenant_id=int(self.tenant_id or 1),
feishu_message_id=(message_id or "")[:128] or None,
feishu_item_id=None,
raw_message=(message_content or "")[:8000] or None,
ai_raw_output=json.dumps(
{
"trace_id": trace_id,
"ingress_source": ingress_source,
"event_type": str(event.get("type") or "")[:120],
"chat_id": chat_id,
"chat_type": chat_type,
"sender_name": sender_name[:200],
},
ensure_ascii=False,
)[:120000],
project_score=None,
signal_confidence=None,
level="ingress",
normalized_text=(message_content or "")[:8000] or None,
route=ingress_source[:80],
intent=None,
executor="feishu_ingress",
operation_code=None,
operation_result={
"message_type": str(message.get("message_type") or "")[:80],
"chat_id": chat_id,
"chat_type": chat_type,
"sender_name": sender_name[:200],
},
reply_source=None,
routing_decision="event_received",
routing_reason=f"Feishu ingress via {ingress_source}",
reasoning_bullets=[
f"trace_id={trace_id}",
f"message_id={message_id}",
f"source={ingress_source}",
],
processing_status="success",
error_message=None,
duration_ms=0,
)
)
await db.commit()
except Exception as exc:
print(f"[Feishu] ingress trace write failed: trace_id={trace_id}, error={exc}")
@staticmethod
def _classify_message_scene(*, capture_result: Dict[str, Any]) -> str:
if not isinstance(capture_result, dict):
return "error"
status = str(capture_result.get("status") or "").strip().lower()
classification = str(capture_result.get("classification") or "").strip().lower()
if status in {"captured", "ignored"}:
return "execute"
if status == "answered" or classification in {"question", "qa"}:
return "qa"
if status == "error" or classification == "error":
return "error"
return "qa"
@staticmethod
def _build_error_reply_text() -> str:
return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。"
@staticmethod
def _emit_state_transition(trace_id: str, state: str, **payload: Any) -> None:
safe_trace = str(trace_id or "unknown")
safe_state = str(state or "unknown")
details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else ""
print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}")
async def _reply_with_active_role_prompt(
self,
*,
chat_id: str,
sender_open_id: Optional[str],
sender_name: str,
message_content: str,
capture_result: Dict[str, Any],
trace_id: Optional[str] = None,
) -> None:
"""被 @ 时回复一条有人味的执行反馈。"""
deterministic_time_reply = self._build_time_truth_reply(message_content)
if deterministic_time_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=deterministic_time_reply,
trace_id=trace_id,
)
return
execution_receipt = self._build_execution_receipt_reply(
message_content=message_content,
capture_result=capture_result,
)
execution_receipt = self._guard_execute_reply_text(
execution_receipt,
trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"),
)
if execution_receipt:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=execution_receipt,
trace_id=trace_id,
)
return
butler_reply = self._build_butler_decision_reply(capture_result=capture_result)
if butler_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=butler_reply,
trace_id=trace_id,
)
return
try:
active_role = await prompt_profile_service.get_active_role(self.db, tenant_id=self.tenant_id)
role = active_role.get("role") if isinstance(active_role, dict) else {}
role_name = str((role or {}).get("role_name") or "产品经理(任务分派)").strip()
system_prompt = str((role or {}).get("system_prompt") or "").strip()
if len(system_prompt) < 20:
system_prompt = (
"你是 PMFlow 的任务分派产品经理。"
"请用简洁、专业、有温度的中文回复用户,先确认需求,再给可执行下一步。"
)
status = str(capture_result.get("status") or "unknown")
classification = str(capture_result.get("classification") or "unknown")
reason = str(capture_result.get("reason") or "").strip()
ai_trace_id = str(capture_result.get("ai_trace_id") or "").strip()
linked_project_ids = capture_result.get("linked_project_ids", [])
linked_projects_text = ", ".join([str(x) for x in linked_project_ids[:3]]) if isinstance(linked_project_ids, list) else ""
now_cn = datetime.now(CN_TZ)
now_cn_text = (
f"{now_cn.year}年{now_cn.month}月{now_cn.day}日 "
f"{WEEKDAY_CN[now_cn.weekday()]} {now_cn.strftime('%H:%M:%S')}"
)
prompt = f"""
{system_prompt}
你正在飞书群里以「{role_name}」身份回复用户。请基于以下上下文,生成一条 35-140 字中文回复:
- 当前系统时间(北京时间):{now_cn_text}
- 提及用户:{sender_name}
- 用户消息:{(message_content or '')[:600]}
- 系统处理状态:{status}
- 分类结果:{classification}
- 处理原因:{reason or "无"}
- 处理追踪ID:{ai_trace_id or "无"}
- 关联项目ID:{linked_projects_text or '无'}
回复要求:
1. 语气自然、专业,避免机器口吻。
2. 先确认你已处理,再给 1-3 条下一步建议。
3. 若信息不足,请明确要补充什么。
4. 如果用户询问“日期/今天/时间/几点/星期”,必须基于“当前系统时间(北京时间)”直接回答,不得编造历史日期。
5. 不要输出 Markdown,不要输出 JSON。
只输出最终回复正文。
""".strip()
llm = LLMService()
raw_reply = await llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="feishu_humanized_reply",
temperature=0.4,
max_tokens=180,
)
reply_text = self._clean_reply_text(raw_reply)
if (not reply_text) or self._looks_like_provider_error(reply_text):
reply_text = self._fallback_reply_text(capture_result)
except Exception as exc:
print(f"[Feishu] generate mention reply failed: {exc}")
reply_text = self._fallback_reply_text(capture_result)
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=reply_text,
trace_id=trace_id,
)
async def _safe_reply_text(
self,
*,
chat_id: str,
text: str,
mention_open_id: Optional[str] = None,
mention_name: Optional[str] = None,
trace_id: Optional[str] = None,
) -> None:
try:
plain_text = self._normalize_outgoing_reply_text(text)
mention_prefix = self._build_mention_prefix(
open_id=mention_open_id,
display_name=mention_name,
)
final_text = f"{mention_prefix}{plain_text}".strip()
outbox_id = await self._enqueue_reply_outbox(
chat_id=chat_id,
message_text=final_text,
trace_id=trace_id,
)
asyncio.create_task(self._dispatch_outbox_entry(outbox_id=outbox_id))
except Exception as exc:
print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}")
try:
plain_text = self._normalize_outgoing_reply_text(text)
mention_prefix = self._build_mention_prefix(
open_id=mention_open_id,
display_name=mention_name,
)
await self.send_message_to_group(chat_id=chat_id, message=f"{mention_prefix}{plain_text}".strip())
except Exception as send_exc:
print(f"[Feishu] send fallback failed: chat_id={chat_id}, error={send_exc}")
async def _enqueue_reply_outbox(
self,
*,
chat_id: str,
message_text: str,
trace_id: Optional[str],
) -> int:
async with AsyncSessionLocal() as db:
row = FeishuReplyOutbox(
tenant_id=int(self.tenant_id or 1),
trace_id=(trace_id or "")[:64] or None,
chat_id=(chat_id or "")[:128],
message_text=(message_text or "")[:2000] or "已收到,正在处理。",
status="pending",
attempts=0,
next_retry_at=None,
)
db.add(row)
await db.commit()
await db.refresh(row)
return int(row.id)
async def _dispatch_outbox_entry(self, *, outbox_id: int) -> None:
await self.__class__._dispatch_outbox_entry_by_id(outbox_id=outbox_id)
@classmethod
async def _dispatch_outbox_entry_by_id(cls, *, outbox_id: int) -> None:
for _ in range(OUTBOX_MAX_ATTEMPTS + 1):
now = datetime.utcnow()
should_wait_seconds = 0.0
async with AsyncSessionLocal() as db:
row = (
await db.execute(
select(FeishuReplyOutbox).where(
FeishuReplyOutbox.id == int(outbox_id)
)
)
).scalar_one_or_none()
if row is None:
return
status = str(row.status or "").lower()
if status == "sent":
return
if status == "failed":
return
if status == "sending":
stale_seconds = 0.0
marker = row.updated_at or row.created_at
if isinstance(marker, datetime):
try:
marker_naive = marker.replace(tzinfo=None) if marker.tzinfo else marker
stale_seconds = max(0.0, (now - marker_naive).total_seconds())
except Exception:
stale_seconds = 0.0
if stale_seconds >= OUTBOX_STALE_SENDING_SECONDS:
row.status = "retrying"
row.next_retry_at = now
await db.commit()
else:
return
if row.next_retry_at and row.next_retry_at > now:
should_wait_seconds = max(0.2, min(30.0, (row.next_retry_at - now).total_seconds()))
else:
row.status = "sending"
row.attempts = int(row.attempts or 0) + 1
row.last_error = None
await db.commit()
if should_wait_seconds > 0:
await asyncio.sleep(should_wait_seconds)
continue
send_error: Optional[str] = None
async with AsyncSessionLocal() as db_send:
row = (
await db_send.execute(
select(FeishuReplyOutbox).where(
FeishuReplyOutbox.id == int(outbox_id)
)
)
).scalar_one_or_none()
if row is None:
return
try:
service = FeishuIntegrationService(db_send)
await service.load_config(tenant_id=int(row.tenant_id))
await service.send_message_to_group(
chat_id=str(row.chat_id or ""),
message=str(row.message_text or ""),
)
row.status = "sent"
row.sent_at = datetime.utcnow()
row.next_retry_at = None
row.last_error = None
await db_send.commit()
return
except Exception as exc:
send_error = str(exc)
async with AsyncSessionLocal() as db_retry:
row = (
await db_retry.execute(
select(FeishuReplyOutbox).where(
FeishuReplyOutbox.id == int(outbox_id)
)
)
).scalar_one_or_none()
if row is None:
return
attempts = int(row.attempts or 0)
if attempts >= OUTBOX_MAX_ATTEMPTS:
row.status = "failed"
row.next_retry_at = None
else:
row.status = "retrying"
delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1)))
row.next_retry_at = datetime.utcnow() + timedelta(seconds=delay_seconds)
row.last_error = (send_error or "unknown_send_error")[:1000]
await db_retry.commit()
if attempts >= OUTBOX_MAX_ATTEMPTS:
return
await asyncio.sleep(min(5, 2 * max(1, attempts)))
@classmethod
async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int:
now = datetime.utcnow()
fetch_limit = max(1, min(200, int(limit)))
async with AsyncSessionLocal() as db:
due_rows = (
await db.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.status.in_(("pending", "retrying")),
or_(
FeishuReplyOutbox.next_retry_at.is_(None),
FeishuReplyOutbox.next_retry_at <= now,
),
)
.order_by(FeishuReplyOutbox.id.asc())
.limit(fetch_limit)
)
).scalars().all()
if not due_rows:
return 0
await asyncio.gather(
*(cls._dispatch_outbox_entry_by_id(outbox_id=int(outbox_id)) for outbox_id in due_rows),
return_exceptions=True,
)
return len(due_rows)
@staticmethod
def _clean_reply_text(value: Optional[str]) -> str:
text = str(value or "").strip()
if text.startswith("```"):
text = text.replace("```", "").strip()
if text.lower().startswith("json"):
text = text[4:].strip()
return text[:500]
@classmethod
def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str:
text = cls._clean_reply_text(value)
if (not text) or cls._looks_like_provider_error(text):
return cls._build_error_reply_text()
return text[:500]
@staticmethod
def _looks_like_provider_error(text: str) -> bool:
lowered = str(text or "").strip().lower()
if not lowered:
return False
error_signals = (
"[error]",
"request was aborted",
"request aborted",
"connection aborted",
"operation aborted",
"context canceled",
"context cancelled",
"cancelled",
"canceled",
"rate limit",
"too many requests",
"429",
"quota",
"insufficient_quota",
"insufficient quota",
"api key",
"authentication failed",
"service unavailable",
"upstream error",
"upstream request",
"bad gateway",
"gateway timeout",
"internal server error",
"connection reset",
"econnreset",
"socket hang up",
)
if any(flag in lowered for flag in error_signals):
return True
if len(lowered) <= 160:
compact = lowered.replace("\n", " ").strip()
ascii_chars = sum(1 for ch in compact if ord(ch) < 128)
ascii_ratio = ascii_chars / max(1, len(compact))
generic_error_tokens = ("error", "failed", "abort", "timeout", "exception")
if ascii_ratio >= 0.9 and any(token in compact for token in generic_error_tokens):
return True
return False
@staticmethod
def _fallback_reply_text(capture_result: Dict[str, Any]) -> str:
status = str(capture_result.get("status") or "")
classification = str(capture_result.get("classification") or "")
if status == "captured":
if classification in {"project", "new_project"}:
return "我已收到需求并完成初步识别,已按项目级信息入队处理。你可以继续补充目标、范围和时间,我会进一步细化分派。"
return "我已收到需求并完成初步识别,已进入信号处理流程。若你希望我直接分派任务,请补充目标、优先级和期望时间。"
return "我已收到消息并开始处理。若你希望我直接分派任务,请补充目标、优先级和时间要求。"
@staticmethod
def _build_butler_decision_reply(capture_result: Dict[str, Any]) -> Optional[str]:
if not isinstance(capture_result, dict):
return None
status = str(capture_result.get("status") or "").strip().lower()
if status != "captured":
return None
results = capture_result.get("results")
if not isinstance(results, list) or not results:
return None
auto_results = [row for row in results if str((row or {}).get("route_status") or "") == "auto_processed"]
pending_results = [row for row in results if str((row or {}).get("route_status") or "") == "pending"]
classification = str(capture_result.get("classification") or "signal").strip().lower()
signal_id = str(capture_result.get("signal_id") or "").strip()
linked_project_ids = capture_result.get("linked_project_ids") or []
project_hint = ""
if isinstance(linked_project_ids, list) and linked_project_ids:
project_hint = f",并关联项目 #{linked_project_ids[0]}"
if pending_results:
if classification in {"project", "new_project"}:
return (
"我已先完成项目候选入队,当前有不确定项需要你确认:"
"1) 归属项目/是否新建 2) 优先级 3) 截止时间。"
"确认后我会自动继续推进。"
)
return (
"我已先完成信号入队,当前判断存在不确定项(多半是项目归属或范围边界)。"
"请你补充:归属项目、优先级、期望完成时间,我会立即自动续跑。"
)
if auto_results:
if classification in {"project", "new_project"}:
return (
f"已自动完成项目流处理{project_hint}。"
"后续我会继续按管家流程拆解与分派,除非你要求人工接管。"
)
if signal_id:
return (
f"已自动完成信号流处理(信号#{signal_id}{project_hint})。"
"这是明确需求场景,我会继续自动推进后续动作。"
)
return "已自动完成信号流处理。后续我会继续自动推进,只有不确定项才会向你确认。"
return None
@staticmethod
def _build_execution_receipt_reply(
*,
message_content: Optional[str],
capture_result: Dict[str, Any],
) -> Optional[str]:
"""
对“已执行”场景优先给简短回执,避免 LLM 输出教程式长文或二次处理建议。
"""
if not isinstance(capture_result, dict):
return None
status = str(capture_result.get("status") or "").strip().lower()
if status not in {"captured", "ignored"}:
return None
if status == "ignored":
reason = str(capture_result.get("reason") or "").strip().lower()
if reason == "duplicate_filtered":
return "这条消息我已处理过,未重复入库。"
if reason in {"non_requirement_noise", "empty_or_emoji_only"}:
return "这条消息不是可执行需求,我没有入库。请直接说要新增什么信号或任务。"
return "这条消息已忽略(未入库)。如需执行,请明确目标、对象和动作。"
text = str(message_content or "").strip()
strict_keywords = ("单独信号源", "单个信号源", "只入库", "仅入库", "不需要二次处理")
strict_mode = any(k in text for k in strict_keywords)
primary_route_status = ""
results = capture_result.get("results")
if isinstance(results, list) and results:
first = results[0] if isinstance(results[0], dict) else {}
primary_route_status = str((first or {}).get("route_status") or "").strip().lower()
if primary_route_status == "auto_done" or primary_route_status == "auto_processed":
return "已自动处理完成并入库,后续动作已继续自动推进。"
if primary_route_status == "confirmed":
return "已按确认结果执行并入库。"
if primary_route_status in {"queued_for_decision", "pending"}:
clarification_message = str(capture_result.get("clarification_message") or "").strip()
if clarification_message:
return clarification_message
return "已完成入库,当前进入待确认队列。"
if primary_route_status == "dismissed":
return "该消息已记录,但当前标记为忽略,不会继续执行。"
if primary_route_status == "failed":
return "未执行:受控执行器拒绝了这次动作,请补充缺失字段或检查项目上下文。"
if primary_route_status == "undone":
return "上一次自动结果已撤销,当前不会继续执行。"
signal_id = str(capture_result.get("signal_id") or "").strip()
linked_project_ids = capture_result.get("linked_project_ids")
project_id: Optional[str] = None
if isinstance(linked_project_ids, list) and linked_project_ids:
project_id = str(linked_project_ids[0]).strip() or None
if not project_id:
candidate = capture_result.get("created_project_id")
if candidate is not None:
project_id = str(candidate).strip() or None
if signal_id and project_id:
if strict_mode:
return f"已按你的要求仅执行入库:信号#{signal_id},项目#{project_id}。"
return f"已执行完成:信号#{signal_id} 已入库并关联项目#{project_id}。"
if signal_id:
if strict_mode:
return f"已按你的要求仅执行入库:信号#{signal_id}。"
return f"已执行完成:信号#{signal_id} 已入库。"
if project_id:
if strict_mode:
return f"已按你的要求执行:项目#{project_id} 已关联。"
return f"已执行完成:已关联项目#{project_id}。"
return "已执行完成。"
@staticmethod
def _detect_forbidden_execute_keyword(text: Optional[str]) -> Optional[str]:
lowered = str(text or "").strip().lower()
if not lowered:
return None
forbidden = (
"curl",
"调用方式",
"接口教程",
"api 调用",
"请求示例",
"postman",
"sdk 调用",
)
for token in forbidden:
if token in lowered:
return token
return None
@classmethod
def _contains_forbidden_execute_keywords(cls, text: Optional[str]) -> bool:
return cls._detect_forbidden_execute_keyword(text) is not None
def _guard_execute_reply_text_with_reason(
self,
text: Optional[str],
*,
trace_id: str,
) -> tuple[Optional[str], Optional[str]]:
if not text:
return text, None
hit = self._detect_forbidden_execute_keyword(text)
if not hit:
return text, None
self._emit_state_transition(
trace_id=trace_id,
state="ROUTED",
route="execute_template_guard",
reason="forbidden_keyword_detected",
keyword=hit,
)
return (
"已执行完成并入库。如需我继续动作,请直接补充目标、对象和时限。",
hit,
)
def _guard_execute_reply_text(self, text: Optional[str], *, trace_id: str) -> Optional[str]:
sanitized, _ = self._guard_execute_reply_text_with_reason(text=text, trace_id=trace_id)
return sanitized
async def _record_execute_guard_alert(
self,
*,
trace_id: str,
message_id: str,
message_content: str,
capture_result: Dict[str, Any],
guard_reason: str,
) -> None:
try:
async with AsyncSessionLocal() as db:
db.add(
AIProcessingLog(
tenant_id=int(self.tenant_id or 1),
feishu_message_id=(message_id or "")[:128] or None,
feishu_item_id=None,
raw_message=(message_content or "")[:8000] or None,
ai_raw_output=json.dumps(
{
"trace_id": trace_id,
"guard_reason": guard_reason,
"status": capture_result.get("status"),
"classification": capture_result.get("classification"),
},
ensure_ascii=False,
)[:120000],
project_score=None,
signal_confidence=None,
level="execute_guard",
routing_decision="execute_reply_guard_triggered",
routing_reason=f"执行态回复命中禁词({guard_reason}),已切换模板模式。",
reasoning_bullets=[
"执行态不允许输出调用教程类文本。",
"本次回复已被熔断为固定模板。",
f"trace_id={trace_id}",
],
processing_status="success",
error_message=None,
duration_ms=0,
)
)
recipients = (
await db.execute(
select(User.id).where(
and_(
User.tenant_id == int(self.tenant_id or 1),
User.is_active.is_(True),
)
).limit(5)
)
).scalars().all()
await NotificationService(db).create_notifications(
tenant_id=int(self.tenant_id or 1),
user_ids=[int(uid) for uid in recipients],
category="alert",
title="执行态回复熔断已触发",
content=(
f"trace_id={trace_id}\n"
f"message_id={message_id}\n"
f"原因:命中禁词 {guard_reason}\n"
"系统已自动切换为固定模板回复。"
)[:2000],
source="feishu_guard",
entity_type="feishu_message",
entity_id=None,
target_url="/ai-logs",
commit=False,
)
await db.commit()
except Exception as exc:
print(f"[Feishu] execute guard alert write failed: trace_id={trace_id}, error={exc}")
async def _record_message_trace(
self,
*,
trace_id: str,
message_id: str,
chat_type: str,
message_content: str,
capture_result: Dict[str, Any],
reply_source: str,
) -> None:
try:
async with AsyncSessionLocal() as db:
db.add(
AIProcessingLog(
tenant_id=int(self.tenant_id or 1),
feishu_message_id=(message_id or "")[:128] or None,
feishu_item_id=None,
raw_message=(str(capture_result.get("normalized_text") or message_content) or "")[:8000] or None,
ai_raw_output=json.dumps(
{
"trace_id": trace_id,
"chat_type": chat_type,
"normalized_text": str(capture_result.get("normalized_text") or message_content)[:2000],
"route": capture_result.get("route"),
"db_mutations": capture_result.get("db_mutations") or [],
"reply_source": reply_source or "none",
"reply_text": str(capture_result.get("direct_reply_text") or "")[:500],
},
ensure_ascii=False,
)[:120000],
project_score=None,
signal_confidence=None,
level="reply_trace",
routing_decision=str(capture_result.get("route") or "reply_trace")[:64],
routing_reason="飞书消息 reply trace",
reasoning_bullets=[
f"chat_type={chat_type}",
f"reply_source={reply_source or 'none'}",
f"trace_id={trace_id}",
],
processing_status="success",
error_message=None,
duration_ms=0,
)
)
await db.commit()
except Exception as exc:
print(f"[Feishu] message trace write failed: trace_id={trace_id}, error={exc}")
@staticmethod
def _build_mention_prefix(*, open_id: Optional[str], display_name: Optional[str]) -> str:
user_open_id = str(open_id or "").strip()
if not user_open_id:
return ""
safe_name = str(display_name or "你").replace("<", "").replace(">", "").strip()[:24] or "你"
return f'<at user_id="{user_open_id}">{safe_name}</at> '
@staticmethod
def _is_duplicate_capture_result(result: Dict[str, Any]) -> bool:
if not isinstance(result, dict):
return False
status = str(result.get("status") or "").strip().lower()
if status != "ignored":
return False
reason = str(result.get("reason") or "").strip().lower()
classification = str(result.get("classification") or "").strip().lower()
return reason == "duplicate_filtered" or classification == "duplicate"
@staticmethod
def _should_reply_to_mention(*, mentioned_bot_names: List[str], message_content: Optional[str]) -> bool:
# 仅在明确 @PMFlow(而不是 @all / @其他成员)时触发自动回复
normalized_names = {str(x or "").strip().lower() for x in mentioned_bot_names if str(x or "").strip()}
if any(name in {"pmflow", "_user_1"} for name in normalized_names):
return True
# 兼容文本中的 @ 语法(不同消息类型返回格式不一致)
text = str(message_content or "").strip().lower()
if not text:
return False
if "@pmflow" in text or "@_user_1" in text:
return True
return False
@staticmethod
def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]:
text = str(message_content or "").strip().lower()
if not text:
return None
time_keywords = ("今天", "日期", "时间", "几号", "几点", "星期", "周几", "当前日期")
if not any(keyword in text for keyword in time_keywords):
return None
now_cn = datetime.now(CN_TZ)
today_text = (
f"{now_cn.year}年{now_cn.month}月{now_cn.day}日"
f"({WEEKDAY_CN[now_cn.weekday()]}){now_cn.strftime('%H:%M:%S')}"
)
if "2023年11月7日" in text or "2023-11-07" in text:
return (
f"我这边已校准为北京时间 {today_text}。"
"你看到“2023年11月7日”通常是客户端缓存或本机时区异常,"
"建议刷新会话并检查系统时区是否为 Asia/Shanghai。"
)
return (
f"我这边当前北京时间是 {today_text}。"
"如果你的界面显示不一致,优先检查设备时区和飞书客户端缓存。"
)
async def _resolve_sender_display_name(
self,
*,
sender: Dict[str, Any],
sender_id: Dict[str, Any],
) -> str:
sender_name = (sender.get("sender_name") or "").strip()
if sender_name:
return sender_name
for key in ("name", "en_name"):
value = (sender.get(key) or "").strip()
if value:
return value
open_id = (sender_id.get("open_id") or "").strip()
if open_id:
remote_name = await self._fetch_user_name_by_open_id(open_id)
if remote_name:
return remote_name
return f"飞书用户({open_id[:8]})"
user_id = (sender_id.get("user_id") or "").strip()
if user_id:
return f"飞书用户({user_id[:8]})"
return "未知用户"
async def _fetch_user_name_by_open_id(self, open_id: str) -> Optional[str]:
safe_open_id = (open_id or "").strip()
if not safe_open_id:
return None
try:
access_token = await self._get_tenant_access_token()
except Exception as exc:
print(f"[Feishu] fetch user name skipped: token unavailable: {exc}")
return None
url = f"https://open.feishu.cn/open-apis/contact/v3/users/{safe_open_id}"
headers = {"Authorization": f"Bearer {access_token}"}
params = {"user_id_type": "open_id"}
try:
async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client:
resp = await client.get(url, headers=headers, params=params)
resp.raise_for_status()
payload = resp.json() or {}
if payload.get("code") != 0:
print(
"[Feishu] fetch user name failed: "
f"code={payload.get('code')} msg={payload.get('msg')}"
)
return None
user = payload.get("data", {}).get("user", {}) if isinstance(payload.get("data", {}), dict) else {}
name = (user.get("name") or user.get("en_name") or "").strip()
return name or None
except Exception as exc:
print(f"[Feishu] fetch user name failed: {exc}")
return None
@staticmethod
def _extract_message_datetime(message: Dict[str, Any]) -> Optional[datetime]:
if not isinstance(message, dict):
return None
raw = message.get("create_time") or message.get("update_time")
if raw is None:
return None
try:
ts = int(str(raw).strip())
if ts > 10_000_000_000:
ts = ts / 1000.0
return datetime.fromtimestamp(ts, tz=timezone.utc)
except Exception:
return None
@staticmethod
def _extract_mentioned_bot_names(message: Dict[str, Any]) -> List[str]:
if not isinstance(message, dict):
return []
mentions = message.get("mentions", [])
names: List[str] = []
if isinstance(mentions, list):
for item in mentions:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
if name not in names:
names.append(name)
content = message.get("content")
text = ""
if isinstance(content, dict):
text = str(content.get("text") or "")
elif isinstance(content, str):
try:
parsed = json.loads(content)
if isinstance(parsed, dict):
text = str(parsed.get("text") or "")
else:
text = content
except Exception:
text = content
if text:
for raw in re.findall(r"<at[^>]*>([^<]+)</at>", text, flags=re.IGNORECASE):
name = str(raw or "").strip()
if name and name not in names:
names.append(name)
for raw in re.findall(r"@([^\s@]+)", text):
name = str(raw or "").strip()
if name and name not in names:
names.append(name)
return names
@staticmethod
def _build_attachment_placeholder_text(attachments: List[Dict[str, Any]]) -> str:
if not attachments:
return ""
names: List[str] = []
for item in attachments[:3]:
attachment_type = str(item.get("attachment_type") or "附件").strip()
file_name = str(item.get("file_name") or "").strip()
names.append(file_name or attachment_type)
suffix = "、".join(names)
return f"[附件消息] {suffix}" if suffix else "[附件消息]"
@staticmethod
def _normalize_ingress_attachment_records(
items: List[Dict[str, Any]],
*,
occurred_at: Optional[str] = None,
source_message_id: Optional[str] = None,
source_chat_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
seen_ids: set[str] = set()
for idx, raw in enumerate(items):
if not isinstance(raw, dict):
continue
attachment_type = str(raw.get("attachment_type") or raw.get("type") or "file").strip().lower()
if attachment_type in {"img", "picture"}:
attachment_type = "image"
if attachment_type in {"doc", "docs"}:
attachment_type = "document"
if attachment_type not in {"image", "file", "media", "document"}:
attachment_type = "file"
file_key = str(raw.get("file_key") or "").strip() or None
image_key = str(raw.get("image_key") or "").strip() or None
media_id = str(raw.get("media_id") or "").strip() or None
download_url = str(raw.get("download_url") or raw.get("url") or "").strip() or None
attachment_id = (
str(raw.get("attachment_id") or raw.get("id") or file_key or image_key or media_id or "")
.strip()
)
if not attachment_id:
attachment_id = f"attachment-{idx + 1}"
if not any([file_key, image_key, media_id, download_url]):
continue
if attachment_id in seen_ids:
continue
seen_ids.add(attachment_id)
file_size = raw.get("file_size") if raw.get("file_size") is not None else raw.get("size")
try:
file_size = int(str(file_size).strip()) if file_size is not None else None
except (TypeError, ValueError):
file_size = None
file_name = (
str(raw.get("file_name") or raw.get("name") or raw.get("title") or "").strip()
or f"{attachment_type}-{attachment_id}"
)
mime_type = str(raw.get("mime_type") or raw.get("mime") or "").strip() or None
normalized.append(
{
"id": attachment_id,
"attachment_id": attachment_id,
"attachment_type": attachment_type,
"file_name": file_name,
"mime_type": mime_type,
"file_size": file_size,
"file_key": file_key,
"image_key": image_key,
"media_id": media_id,
"download_url": download_url,
"source": str(raw.get("source") or "feishu").strip() or "feishu",
"occurred_at": str(raw.get("occurred_at") or occurred_at or "").strip() or None,
"source_message_id": str(raw.get("source_message_id") or source_message_id or "").strip() or None,
"source_chat_id": str(raw.get("source_chat_id") or source_chat_id or "").strip() or None,
}
)
return normalized
def _extract_message_payload(self, event: Dict[str, Any]) -> Dict[str, Any]:
"""提取消息文本与附件元数据。"""
message = event.get("message", {})
if not isinstance(message, dict) and isinstance(event.get("event", {}), dict):
message = event.get("event", {}).get("message", {})
if not isinstance(message, dict):
return {"text": None, "attachments": [], "message_type": ""}
message_type = str(message.get("message_type") or "").strip().lower()
content = message.get("content", "")
content_json: Dict[str, Any] = {}
if isinstance(content, dict):
content_json = content
elif isinstance(content, str) and content.strip():
try:
parsed = json.loads(content)
if isinstance(parsed, dict):
content_json = parsed
except Exception:
if message_type == "text":
return {"text": content.strip() or None, "attachments": [], "message_type": message_type}
text_parts: List[str] = []
attachments: List[Dict[str, Any]] = []
if message_type == "text":
text = str(content_json.get("text") or "").strip()
return {"text": text or None, "attachments": [], "message_type": message_type}
if message_type == "post":
post = content_json.get("post", {}) if isinstance(content_json, dict) else {}
zh_cn = post.get("zh_cn", {}) if isinstance(post, dict) else {}
elements = zh_cn.get("content", []) if isinstance(zh_cn, dict) else []
if (not elements) and isinstance(content_json, dict):
direct_elements = content_json.get("content", [])
if isinstance(direct_elements, list):
elements = direct_elements
for row in elements:
row_items = row if isinstance(row, list) else [row]
for elem in row_items:
if not isinstance(elem, dict):
continue
tag = str(elem.get("tag") or "").strip().lower()
if tag == "text":
text = str(elem.get("text") or "").strip()
if text:
text_parts.append(text)
elif tag in {"img", "image"}:
attachments.append(
{
"attachment_type": "image",
"image_key": elem.get("image_key"),
"file_name": elem.get("title") or "图片附件",
}
)
elif tag in {"file", "media"}:
attachments.append(
{
"attachment_type": "media" if tag == "media" else "file",
"file_key": elem.get("file_key"),
"media_id": elem.get("media_id"),
"file_name": elem.get("file_name") or elem.get("title"),
"file_size": elem.get("file_size"),
}
)
elif message_type in {"image", "file", "media"}:
attachments.append(
{
"attachment_type": message_type,
"image_key": content_json.get("image_key"),
"file_key": content_json.get("file_key"),
"media_id": content_json.get("media_id"),
"file_name": content_json.get("file_name") or content_json.get("title"),
"file_size": content_json.get("file_size"),
"mime_type": content_json.get("file_type") or content_json.get("mime_type"),
}
)
text = str(content_json.get("text") or content_json.get("caption") or "").strip()
if text:
text_parts.append(text)
elif isinstance(content_json, dict):
guessed_keys = {
"image_key": content_json.get("image_key"),
"file_key": content_json.get("file_key"),
"media_id": content_json.get("media_id"),
"download_url": content_json.get("download_url"),
}
if any(v for v in guessed_keys.values()):
attachments.append(
{
"attachment_type": "file",
"image_key": guessed_keys.get("image_key"),
"file_key": guessed_keys.get("file_key"),
"media_id": guessed_keys.get("media_id"),
"download_url": guessed_keys.get("download_url"),
"file_name": content_json.get("file_name") or content_json.get("title"),
"file_size": content_json.get("file_size"),
"mime_type": content_json.get("mime_type"),
}
)
text = str(content_json.get("text") or "").strip()
if text:
text_parts.append(text)
normalized_attachments = self._normalize_ingress_attachment_records(attachments)
merged_text = " ".join([part for part in text_parts if part]).strip()
if not merged_text and normalized_attachments:
merged_text = self._build_attachment_placeholder_text(normalized_attachments)
return {
"text": merged_text or None,
"attachments": normalized_attachments,
"message_type": message_type,
}
def _extract_message_content(self, event: Dict[str, Any]) -> Optional[str]:
payload = self._extract_message_payload(event)
text = str(payload.get("text") or "").strip()
return text or None
async def _persist_signal_attachments_from_ingress(
self,
*,
signal_id: Any,
attachments: List[Dict[str, Any]],
) -> None:
if not attachments:
return
try:
signal_int = int(str(signal_id).strip())
except (TypeError, ValueError, AttributeError):
return
signal = (
await self.db.execute(
select(Signal).where(
and_(
Signal.tenant_id == int(self.tenant_id or 1),
Signal.id == signal_int,
)
)
)
).scalar_one_or_none()
if signal is None:
return
try:
payload = signal.strategic_analysis
strategic: Dict[str, Any] = {}
if isinstance(payload, dict):
strategic = dict(payload)
elif isinstance(payload, str) and payload.strip():
parsed = json.loads(payload)
if isinstance(parsed, dict):
strategic = parsed
source_context = strategic.get("source_context")
if not isinstance(source_context, dict):
source_context = {}
existing = source_context.get("attachments")
existing_rows = existing if isinstance(existing, list) else []
merged = self._normalize_ingress_attachment_records(
[*(row for row in existing_rows if isinstance(row, dict)), *attachments]
)
if not merged:
return
source_context["attachments"] = merged
strategic["source_context"] = source_context
signal.strategic_analysis = json.dumps(strategic, ensure_ascii=False)
await self.db.commit()
except Exception as exc:
print(f"[Feishu] persist signal attachments failed: signal_id={signal_int}, error={exc}")
try:
await self.db.rollback()
except Exception:
pass
async def send_message_to_group(
self,
chat_id: str,
message: str
) -> Dict[str, Any]:
"""
发送消息到飞书群聊
用于推送通知、简报等
"""
if not self.is_configured():
raise ValueError("飞书未配置")
# 获取 tenant_access_token
access_token = await self._get_tenant_access_token()
# 发送消息 API
url = "https://open.feishu.cn/open-apis/im/v1/messages"
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
message_data = {
'receive_id': chat_id,
'msg_type': 'text',
'content': json.dumps({'text': message})
}
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
resp = await client.post(
url,
headers=headers,
params={'receive_id_type': 'chat_id'},
json=message_data,
)
payload: Dict[str, Any]
try:
payload = resp.json()
except Exception:
payload = {"http_status": resp.status_code, "raw": resp.text[:500]}
if resp.status_code >= 400:
raise ValueError(
f"飞书发送失败(http={resp.status_code}): {payload}"
)
if int(payload.get("code", -1)) != 0:
raise ValueError(
f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}"
)
return payload
async def _get_tenant_access_token(self) -> str:
"""获取 tenant_access_token"""
if not self.app_id or not self.app_secret:
raise ValueError("飞书未配置")
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
headers = {'Content-Type': 'application/json'}
data = {
'app_id': self.app_id,
'app_secret': self.app_secret
}
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
try:
resp = await client.post(url, headers=headers, json=data)
except httpx.RequestError as exc:
raise ValueError(f"访问飞书 auth 接口失败: {exc}") from exc
try:
result = resp.json()
except Exception:
result = {"http_status": resp.status_code, "raw": resp.text[:500]}
if resp.status_code >= 400:
raise ValueError(
f"获取 tenant_access_token 失败(http={resp.status_code}): {result}"
)
if result.get('code') == 0 and result.get('tenant_access_token'):
return result.get('tenant_access_token')
raise ValueError(
"获取 tenant_access_token 失败: "
f"code={result.get('code')} msg={result.get('msg') or result.get('message')} payload={result}"
)
async def download_attachment_bytes(self, attachment: Dict[str, Any]) -> Dict[str, Any]:
"""通过飞书开放平台代理下载附件。"""
if not self.is_configured():
raise ValueError("飞书未配置")
access_token = await self._get_tenant_access_token()
attachment_type = str(attachment.get("attachment_type") or "file").strip().lower()
file_name = str(attachment.get("file_name") or "").strip() or None
media_type = str(attachment.get("mime_type") or "").strip() or None
image_key = str(attachment.get("image_key") or "").strip()
file_key = str(attachment.get("file_key") or "").strip()
media_id = str(attachment.get("media_id") or "").strip()
download_url = str(attachment.get("download_url") or "").strip()
url = ""
if download_url:
url = download_url
elif attachment_type == "image" and image_key:
url = f"https://open.feishu.cn/open-apis/im/v1/images/{image_key}"
elif file_key:
url = f"https://open.feishu.cn/open-apis/im/v1/files/{file_key}"
elif media_id:
url = f"https://open.feishu.cn/open-apis/im/v1/files/{media_id}"
if not url:
raise ValueError("附件缺少可下载 key,无法代理下载")
headers = {"Authorization": f"Bearer {access_token}"}
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
try:
resp = await client.get(url, headers=headers, follow_redirects=True)
except httpx.RequestError as exc:
raise ValueError(f"飞书附件下载请求失败: {exc}") from exc
if resp.status_code >= 400:
body_preview = resp.text[:300] if resp.text else ""
raise ValueError(
f"飞书附件下载失败(http={resp.status_code}): {body_preview}"
)
content = resp.content or b""
if not content:
raise ValueError("飞书附件下载为空内容")
response_content_type = str(resp.headers.get("content-type") or "").strip()
content_type = media_type or response_content_type or "application/octet-stream"
return {
"content": content,
"content_type": content_type,
"file_name": file_name,
}
async def send_rich_card(
self,
chat_id: str,
title: str,
content: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
发送富文本卡片消息
用于发送格式化的简报、通知等
"""
if not self.is_configured():
raise ValueError("飞书未配置")
access_token = await self._get_tenant_access_token()
url = "https://open.feishu.cn/open-apis/im/v1/messages"
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
# 构造卡片内容
card_content = {
'config': {'wide_screen_mode': True},
'header': {
'title': {
'content': title,
'tag': 'plain_text'
}
},
'elements': []
}
for item in content:
if item.get('type') == 'text':
card_content['elements'].append({
'tag': 'div',
'text': {
'content': item.get('text', ''),
'tag': 'lark_md'
}
})
elif item.get('type') == 'divider':
card_content['elements'].append({'tag': 'hr'})
message_data = {
'receive_id': chat_id,
'msg_type': 'interactive',
'content': json.dumps(card_content)
}
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
resp = await client.post(
url,
headers=headers,
params={'receive_id_type': 'chat_id'},
json=message_data,
)
payload: Dict[str, Any]
try:
payload = resp.json()
except Exception:
payload = {"http_status": resp.status_code, "raw": resp.text[:500]}
if resp.status_code >= 400:
raise ValueError(
f"飞书发送失败(http={resp.status_code}): {payload}"
)
if int(payload.get("code", -1)) != 0:
raise ValueError(
f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}"
)
return payload
# 全局服务实例
_feishu_service: Optional[FeishuIntegrationService] = None
async def dispatch_pending_feishu_replies(limit: int = 30) -> int:
"""后台补偿发送:扫描 pending/retrying outbox 并触发派发。"""
return await FeishuIntegrationService.dispatch_pending_outbox_entries(limit=limit)
def get_feishu_service(db: AsyncSession) -> FeishuIntegrationService:
"""获取飞书服务实例"""
return FeishuIntegrationService(db)