1994 lines
81 KiB
Python
1994 lines
81 KiB
Python
"""
|
||
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_, text, update
|
||
from sqlalchemy.exc import IntegrityError
|
||
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 ..config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY
|
||
from .notification_service import NotificationService
|
||
from .signal_capture import ProactiveSignalCaptureService
|
||
from .llm import LLMService
|
||
|
||
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"
|
||
|
||
@staticmethod
|
||
def _is_postgresql_session(db: AsyncSession) -> bool:
|
||
try:
|
||
bind = db.get_bind()
|
||
except Exception:
|
||
return False
|
||
dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower()
|
||
return dialect_name.startswith("postgres")
|
||
|
||
@staticmethod
|
||
async def _notify_outbox_ready(db: AsyncSession, *, outbox_id: Optional[int]) -> None:
|
||
"""仅在 PostgreSQL 下发送 outbox ready 事件。"""
|
||
if not FeishuIntegrationService._is_postgresql_session(db):
|
||
return
|
||
try:
|
||
payload = str(int(outbox_id))[:64] if outbox_id is not None else ""
|
||
await db.execute(
|
||
text("SELECT pg_notify('feishu_outbox_ready', :payload)"),
|
||
{"payload": payload},
|
||
)
|
||
except Exception as exc:
|
||
print(f"[Feishu] outbox notify failed: outbox_id={outbox_id}, error={exc}")
|
||
|
||
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,
|
||
source_message_id=message_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,
|
||
source_message_id=message_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,
|
||
source_message_id=message_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,
|
||
source_message_id=message_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,
|
||
source_message_id=message_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,
|
||
source_message_id=message_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,
|
||
source_message_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,
|
||
source_message_id=source_message_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,
|
||
source_message_id=source_message_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,
|
||
source_message_id=source_message_id,
|
||
)
|
||
return
|
||
|
||
try:
|
||
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 = PROMPT_FEISHU_HUMANIZED_REPLY.format(
|
||
now_cn_text=now_cn_text,
|
||
sender_name=sender_name,
|
||
user_message=(message_content or "")[:600],
|
||
status=status,
|
||
classification=classification,
|
||
reason=(reason or "无"),
|
||
ai_trace_id=(ai_trace_id or "无"),
|
||
linked_projects_text=(linked_projects_text or "无"),
|
||
)
|
||
|
||
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 self._is_capability_question(message_content):
|
||
reply_text = self._strip_identity_lead(reply_text)
|
||
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,
|
||
source_message_id=source_message_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,
|
||
source_message_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()
|
||
await self._enqueue_reply_outbox(
|
||
chat_id=chat_id,
|
||
message_text=final_text,
|
||
trace_id=trace_id,
|
||
source_message_id=source_message_id,
|
||
)
|
||
except Exception as exc:
|
||
print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}")
|
||
# 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。
|
||
return
|
||
|
||
async def _enqueue_reply_outbox(
|
||
self,
|
||
*,
|
||
chat_id: str,
|
||
message_text: str,
|
||
trace_id: Optional[str],
|
||
source_message_id: Optional[str],
|
||
) -> int:
|
||
async with AsyncSessionLocal() as db:
|
||
normalized_source_message_id = (source_message_id or "")[:128] or None
|
||
normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。"
|
||
reply_hash = self._build_reply_hash(
|
||
source_message_id=normalized_source_message_id,
|
||
message_text=normalized_message_text,
|
||
)
|
||
if reply_hash:
|
||
sent_exists = (
|
||
await db.execute(
|
||
select(FeishuReplyOutbox.id)
|
||
.where(
|
||
FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1),
|
||
FeishuReplyOutbox.reply_hash == reply_hash,
|
||
FeishuReplyOutbox.status == "sent",
|
||
)
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if sent_exists is not None:
|
||
return int(sent_exists)
|
||
row = FeishuReplyOutbox(
|
||
tenant_id=int(self.tenant_id or 1),
|
||
trace_id=(trace_id or "")[:64] or None,
|
||
source_message_id=normalized_source_message_id,
|
||
chat_id=(chat_id or "")[:128],
|
||
message_text=normalized_message_text,
|
||
reply_hash=reply_hash,
|
||
status="pending",
|
||
attempts=0,
|
||
next_retry_at=None,
|
||
)
|
||
db.add(row)
|
||
try:
|
||
await db.flush()
|
||
await self._notify_outbox_ready(db, outbox_id=int(row.id) if row.id is not None else None)
|
||
await db.commit()
|
||
except IntegrityError:
|
||
await db.rollback()
|
||
if reply_hash:
|
||
sent_exists = (
|
||
await db.execute(
|
||
select(FeishuReplyOutbox.id)
|
||
.where(
|
||
FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1),
|
||
FeishuReplyOutbox.reply_hash == reply_hash,
|
||
FeishuReplyOutbox.status == "sent",
|
||
)
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if sent_exists is not None:
|
||
return int(sent_exists)
|
||
raise
|
||
await db.refresh(row)
|
||
return int(row.id)
|
||
|
||
@classmethod
|
||
async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int:
|
||
fetch_limit = max(1, min(200, int(limit)))
|
||
dispatched = 0
|
||
for _ in range(fetch_limit):
|
||
claimed = await cls._claim_next_outbox_entry(now=datetime.utcnow())
|
||
if claimed is None:
|
||
break
|
||
await cls._dispatch_claimed_outbox_entry(claimed=claimed)
|
||
dispatched += 1
|
||
return dispatched
|
||
|
||
@classmethod
|
||
async def _claim_next_outbox_entry(cls, *, now: datetime) -> Optional[dict[str, Any]]:
|
||
stale_before = now - timedelta(seconds=OUTBOX_STALE_SENDING_SECONDS)
|
||
async with AsyncSessionLocal() as db:
|
||
await db.execute(
|
||
update(FeishuReplyOutbox)
|
||
.where(
|
||
FeishuReplyOutbox.status == "sending",
|
||
FeishuReplyOutbox.updated_at <= stale_before,
|
||
)
|
||
.values(status="retrying", next_retry_at=now)
|
||
)
|
||
candidate_id = (
|
||
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())
|
||
.with_for_update(skip_locked=True)
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if candidate_id is None:
|
||
await db.commit()
|
||
return None
|
||
|
||
claimed = (
|
||
await db.execute(
|
||
update(FeishuReplyOutbox)
|
||
.where(
|
||
FeishuReplyOutbox.id == int(candidate_id),
|
||
FeishuReplyOutbox.status.in_(("pending", "retrying")),
|
||
or_(
|
||
FeishuReplyOutbox.next_retry_at.is_(None),
|
||
FeishuReplyOutbox.next_retry_at <= now,
|
||
),
|
||
)
|
||
.values(
|
||
status="sending",
|
||
attempts=FeishuReplyOutbox.attempts + 1,
|
||
last_error=None,
|
||
next_retry_at=None,
|
||
)
|
||
.returning(
|
||
FeishuReplyOutbox.id,
|
||
FeishuReplyOutbox.tenant_id,
|
||
FeishuReplyOutbox.chat_id,
|
||
FeishuReplyOutbox.message_text,
|
||
FeishuReplyOutbox.reply_hash,
|
||
FeishuReplyOutbox.attempts,
|
||
)
|
||
)
|
||
).mappings().first()
|
||
await db.commit()
|
||
if claimed is None:
|
||
return None
|
||
return dict(claimed)
|
||
|
||
@classmethod
|
||
async def _dispatch_claimed_outbox_entry(cls, *, claimed: dict[str, Any]) -> None:
|
||
outbox_id = int(claimed.get("id") or 0)
|
||
if outbox_id <= 0:
|
||
return
|
||
tenant_id = int(claimed.get("tenant_id") or 1)
|
||
reply_hash = str(claimed.get("reply_hash") or "").strip()
|
||
if reply_hash:
|
||
async with AsyncSessionLocal() as db_check:
|
||
sent_duplicate = (
|
||
await db_check.execute(
|
||
select(FeishuReplyOutbox.id)
|
||
.where(
|
||
FeishuReplyOutbox.id != outbox_id,
|
||
FeishuReplyOutbox.tenant_id == tenant_id,
|
||
FeishuReplyOutbox.reply_hash == reply_hash,
|
||
FeishuReplyOutbox.status == "sent",
|
||
)
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if sent_duplicate is not None:
|
||
async with AsyncSessionLocal() as db_skip:
|
||
row = (
|
||
await db_skip.execute(
|
||
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is not None and str(row.status or "").lower() == "sending":
|
||
row.status = "sent"
|
||
row.sent_at = datetime.utcnow()
|
||
row.next_retry_at = None
|
||
row.last_error = f"dedupe_skip_sent:{int(sent_duplicate)}"
|
||
await db_skip.commit()
|
||
return
|
||
|
||
send_error: Optional[str] = None
|
||
try:
|
||
async with AsyncSessionLocal() as db_send:
|
||
service = FeishuIntegrationService(db_send)
|
||
await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1))
|
||
await service.send_message_to_group(
|
||
chat_id=str(claimed.get("chat_id") or ""),
|
||
message=str(claimed.get("message_text") or ""),
|
||
)
|
||
except Exception as exc:
|
||
send_error = str(exc)
|
||
|
||
async with AsyncSessionLocal() as db_update:
|
||
row = (
|
||
await db_update.execute(
|
||
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is None:
|
||
return
|
||
if str(row.status or "").lower() != "sending":
|
||
return
|
||
attempts = int(row.attempts or claimed.get("attempts") or 0)
|
||
if send_error is None:
|
||
row.status = "sent"
|
||
row.sent_at = datetime.utcnow()
|
||
row.next_retry_at = None
|
||
row.last_error = None
|
||
await db_update.commit()
|
||
return
|
||
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_update.commit()
|
||
|
||
@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 _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]:
|
||
source = str(source_message_id or "").strip()
|
||
body = str(message_text or "").strip()
|
||
if not source or not body:
|
||
return None
|
||
raw = f"{source}{body}".encode("utf-8")
|
||
return hashlib.md5(raw, usedforsecurity=False).hexdigest()
|
||
|
||
@staticmethod
|
||
def _is_capability_question(message_content: Optional[str]) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(message_content or "").lower())
|
||
if not normalized:
|
||
return False
|
||
capability_tokens = (
|
||
"你是谁",
|
||
"你是什么",
|
||
"你能做什么",
|
||
"你可以做什么",
|
||
"系统能力",
|
||
"能力介绍",
|
||
"介绍下你",
|
||
)
|
||
return any(token in normalized for token in capability_tokens)
|
||
|
||
@staticmethod
|
||
def _strip_identity_lead(reply_text: str) -> str:
|
||
text = str(reply_text or "").strip()
|
||
if not text:
|
||
return text
|
||
lowered = text.lower()
|
||
forbidden_prefixes = (
|
||
"我是",
|
||
"我可以",
|
||
"我现在",
|
||
"是的,我现在",
|
||
"可以在这里和我",
|
||
)
|
||
forbidden_contains = (
|
||
"通过 feishu",
|
||
"群聊接入 openclaw",
|
||
"你可以直接在这里和我对话",
|
||
)
|
||
if any(lowered.startswith(prefix) for prefix in forbidden_prefixes) or any(token in lowered for token in forbidden_contains):
|
||
return ""
|
||
return text
|
||
|
||
@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)
|