refactor(api): 使用常量替代硬编码字符串 - 在health_check接口中使用ApiResponseKey.STATUS和ApiStatus.OK常量 - 替换硬编码的状态返回值为枚举常量 refactor(core): 配置模块错误信息统一使用常量 - 从constants模块导入ConfigErrorDetail并替换CORS_ORIGINS和LEGACY_ALLOWED_QUERIES的验证错误信息 - 配置类中的默认值使用constants中定义的常量 feat(constants): 添加API响应、安全错误和配置错误常量类 - 新增ApiResponseKey用于API状态键名 - 新增ApiStatus用于API状态值 - 新增SecurityErrorDetail用于安全认证错误详情 - 新增ConfigErrorDetail用于配置验证错误详情 - 添加DEFAULT_MODEL_PROVIDER和DEFAULT_OPENCLAW_ACTION_JSON常量 refactor(security): 安全认证模块使用错误常量 - 将硬编码的安全错误信息替换为SecurityErrorDetail常量 - 包括API密钥、审批密钥和审计密钥的相关错误信息 refactor(ai-agent): AI代理适配器改进错误处理 - 将HTTP状态码替换为FastAPI状态常量 - 添加OpenClaw工具和操作的错误常量 - 修复健康检查和工具调用中的状态码比较逻辑 - 添加AIToolAuditKey用于工具审计键名 feat(ai-agent): 扩展AI代理常量定义 - 新增AIToolAuditKey用于工具审计字段 - 添加OpenClaw相关的错误常量如OPENCLAW_CHAT_PROVIDER_REQUIRED等 - 添加UNSUPPORTED_AI_SKILL_TEMPLATE模板字符串 refactor(approvals): 审批模块常量化重构 - 新增ApprovalPayloadKey用于审批载荷字段 - 添加approval_action函数和APPROVAL_ACTION_SEPARATOR分隔符 - 使用常量替换字面量值 feat(audit): 审计模块新增飞书事件动作类型 - 添加FEISHU_WEBHOOK_EVENT和FEISHU_LONG_CONNECTION_EVENT审计动作 refactor(business): 业务模块全面常量化 - 新增BusinessDomain枚举包含所有业务域 - 添加BusinessResponseKey、BusinessPayloadKey等常量类 - 重构DOMAIN_MODELS为frozenset以提高性能 - 添加normalize_domain等辅助函数用于域标准化 - 使用常量替换路由和业务服务中的硬编码字符串 - 添加业务错误常量和字段验证模板 refactor(feishu): 飞书客户端错误处理优化 - 将HTTP状态码替换为FastAPI标准状态常量 - 改进错误处理的一致性 refactor(approvals): 审批服务使用新常量结构 - 使用ApprovalPayloadKey常量重构载荷字段 - 使用approval_action函数统一动作命名格式 - 优化高风险域判断逻辑 ```
261 lines
9.3 KiB
Python
261 lines
9.3 KiB
Python
import json
|
|
import re
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.constants import ActorValue
|
|
from app.core.config import get_settings
|
|
from app.modules.ai_agent.service import AIService
|
|
from app.modules.ai_agent.constants import AIResponseKey
|
|
from app.modules.audit.constants import AuditSource
|
|
from app.modules.feishu.constants import (
|
|
FEISHU_AI_REPLY_TITLE,
|
|
FEISHU_MENTION_PATTERN,
|
|
FEISHU_ZERO_WIDTH_SPACE,
|
|
FeishuCommandKey,
|
|
FeishuCommandName,
|
|
FeishuCommandResultKey,
|
|
FeishuPayloadKey,
|
|
FeishuReplyType,
|
|
)
|
|
from app.modules.reports.constants import ReportResponseKey
|
|
from app.modules.feishu.service import FeishuService
|
|
from app.modules.reports.service import ReportService
|
|
from app.modules.risk.constants import RiskSummaryKey
|
|
from app.modules.risk.service import RiskService
|
|
|
|
DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报")
|
|
PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报")
|
|
ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance")
|
|
RISK_KEYWORDS = ("风险", "预警", "risk")
|
|
AI_COMMAND_PREFIXES = ("问 ", "ai ", "AI ", "/ask ")
|
|
RISK_TITLE = "风险预警"
|
|
DEFAULT_AI_PROMPT = "请说明你能做什么。"
|
|
|
|
|
|
def _parse_content_text(content: Any) -> str:
|
|
"""Extract plain command text from a Feishu message content payload."""
|
|
|
|
if isinstance(content, dict):
|
|
return str(
|
|
content.get(FeishuPayloadKey.TEXT)
|
|
or content.get(FeishuPayloadKey.CONTENT)
|
|
or ""
|
|
)
|
|
if not isinstance(content, str):
|
|
return ""
|
|
try:
|
|
data = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
return content
|
|
if isinstance(data, dict):
|
|
return str(
|
|
data.get(FeishuPayloadKey.TEXT)
|
|
or data.get(FeishuPayloadKey.CONTENT)
|
|
or ""
|
|
)
|
|
return content
|
|
|
|
|
|
def _clean_command_text(text: str) -> str:
|
|
"""Remove mentions and invisible characters from Feishu command text."""
|
|
|
|
text = re.sub(FEISHU_MENTION_PATTERN, "", text or "")
|
|
text = text.replace(FEISHU_ZERO_WIDTH_SPACE, "")
|
|
return text.strip()
|
|
|
|
|
|
def _command_result(
|
|
command: FeishuCommandName,
|
|
reply_type: FeishuReplyType,
|
|
title: str,
|
|
content: str,
|
|
provider_response: dict[str, Any] | None = None,
|
|
lines: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
result: dict[str, Any] = {
|
|
FeishuCommandResultKey.COMMAND: command,
|
|
FeishuCommandResultKey.REPLY_TYPE: reply_type,
|
|
FeishuCommandResultKey.TITLE: title,
|
|
FeishuCommandResultKey.CONTENT: content,
|
|
FeishuCommandResultKey.PROVIDER_RESPONSE: provider_response,
|
|
}
|
|
if lines is not None:
|
|
result[FeishuCommandResultKey.LINES] = lines
|
|
return result
|
|
|
|
|
|
class FeishuCommandService:
|
|
"""Route Feishu text commands to reports, risk summaries, or AI replies."""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.feishu = FeishuService(db)
|
|
|
|
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
|
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
|
if not message:
|
|
return None
|
|
text = _clean_command_text(_parse_content_text(message.get(FeishuPayloadKey.CONTENT)))
|
|
if not text:
|
|
return None
|
|
sender = event.get(FeishuPayloadKey.SENDER) or {}
|
|
sender_id = sender.get(FeishuPayloadKey.SENDER_ID) or {}
|
|
actor = (
|
|
sender_id.get(FeishuPayloadKey.OPEN_ID)
|
|
or sender_id.get(FeishuPayloadKey.USER_ID)
|
|
or ActorValue.FEISHU
|
|
)
|
|
return {
|
|
FeishuCommandKey.TEXT: text,
|
|
FeishuCommandKey.CHAT_ID: message.get(FeishuCommandKey.CHAT_ID),
|
|
FeishuCommandKey.ACTOR: actor,
|
|
}
|
|
|
|
def handle_text(
|
|
self,
|
|
text: str,
|
|
chat_id: str | None = None,
|
|
actor: str = ActorValue.FEISHU,
|
|
auto_reply: bool = True,
|
|
) -> dict[str, Any]:
|
|
command_text = _clean_command_text(text)
|
|
lowered = command_text.lower()
|
|
provider_response: dict[str, Any] | None = None
|
|
|
|
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
|
|
report = ReportService(self.db).daily_brief()
|
|
if auto_reply:
|
|
provider_response = self._send_card_if_configured(
|
|
chat_id,
|
|
report[ReportResponseKey.TITLE],
|
|
report[ReportResponseKey.LINES],
|
|
actor,
|
|
)
|
|
return _command_result(
|
|
FeishuCommandName.DAILY_BRIEF,
|
|
FeishuReplyType.CARD,
|
|
report[ReportResponseKey.TITLE],
|
|
report[ReportResponseKey.CONTENT],
|
|
provider_response,
|
|
report[ReportResponseKey.LINES],
|
|
)
|
|
|
|
if any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS):
|
|
report = ReportService(self.db).project_weekly()
|
|
if auto_reply:
|
|
provider_response = self._send_card_if_configured(
|
|
chat_id,
|
|
report[ReportResponseKey.TITLE],
|
|
report[ReportResponseKey.LINES],
|
|
actor,
|
|
)
|
|
return _command_result(
|
|
FeishuCommandName.PROJECT_WEEKLY,
|
|
FeishuReplyType.CARD,
|
|
report[ReportResponseKey.TITLE],
|
|
report[ReportResponseKey.CONTENT],
|
|
provider_response,
|
|
report[ReportResponseKey.LINES],
|
|
)
|
|
|
|
if any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS):
|
|
report = ReportService(self.db).attendance_summary()
|
|
if auto_reply:
|
|
provider_response = self._send_card_if_configured(
|
|
chat_id,
|
|
report[ReportResponseKey.TITLE],
|
|
report[ReportResponseKey.LINES],
|
|
actor,
|
|
)
|
|
return _command_result(
|
|
FeishuCommandName.ATTENDANCE_SUMMARY,
|
|
FeishuReplyType.CARD,
|
|
report[ReportResponseKey.TITLE],
|
|
report[ReportResponseKey.CONTENT],
|
|
provider_response,
|
|
report[ReportResponseKey.LINES],
|
|
)
|
|
|
|
if any(keyword in command_text for keyword in RISK_KEYWORDS):
|
|
summary = RiskService(self.db).summary()
|
|
lines = [
|
|
f"- 综合风险等级:{summary[RiskSummaryKey.RISK_LEVEL]}",
|
|
f"- 风险分:{summary[RiskSummaryKey.RISK_SCORE]}",
|
|
f"- 逾期任务:{len(summary[RiskSummaryKey.OVERDUE_TASKS])}",
|
|
f"- 延期项目:{len(summary[RiskSummaryKey.DELAYED_PROJECTS])}",
|
|
f"- 超预算项目:{len(summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}",
|
|
f"- 资金风险账户:{len(summary[RiskSummaryKey.FUND_RISKS])}",
|
|
f"- 供应商风险:{len(summary[RiskSummaryKey.SUPPLIER_RISKS])}",
|
|
f"- 打开风险事件:{len(summary[RiskSummaryKey.OPEN_EVENTS])}",
|
|
]
|
|
if auto_reply:
|
|
provider_response = self._send_card_if_configured(
|
|
chat_id,
|
|
RISK_TITLE,
|
|
lines,
|
|
actor,
|
|
)
|
|
return _command_result(
|
|
FeishuCommandName.RISK_SUMMARY,
|
|
FeishuReplyType.CARD,
|
|
RISK_TITLE,
|
|
"\n".join(lines),
|
|
provider_response,
|
|
lines,
|
|
)
|
|
|
|
prompt = command_text
|
|
for prefix in AI_COMMAND_PREFIXES:
|
|
if command_text.startswith(prefix):
|
|
prompt = command_text[len(prefix) :].strip()
|
|
break
|
|
if not prompt:
|
|
prompt = DEFAULT_AI_PROMPT
|
|
ai_result = AIService(self.db).ask(
|
|
prompt,
|
|
context={},
|
|
actor=actor,
|
|
source=AuditSource.FEISHU,
|
|
)
|
|
content = ai_result[AIResponseKey.ANSWER]
|
|
is_explicit_ai = any(
|
|
command_text.startswith(prefix) or lowered.startswith(prefix)
|
|
for prefix in AI_COMMAND_PREFIXES
|
|
)
|
|
if auto_reply:
|
|
provider_response = self._send_text_if_configured(chat_id, content, actor)
|
|
return _command_result(
|
|
FeishuCommandName.AI_ASK if is_explicit_ai else FeishuCommandName.FALLBACK_AI,
|
|
FeishuReplyType.TEXT,
|
|
FEISHU_AI_REPLY_TITLE,
|
|
content,
|
|
provider_response,
|
|
)
|
|
|
|
def _send_card_if_configured(
|
|
self,
|
|
chat_id: str | None,
|
|
title: str,
|
|
lines: list[str],
|
|
actor: str,
|
|
) -> dict[str, Any] | None:
|
|
settings = get_settings()
|
|
if not (settings.feishu_app_id and settings.feishu_app_secret):
|
|
return None
|
|
card = FeishuService.build_basic_card(title, lines)
|
|
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
|
|
|
|
def _send_text_if_configured(
|
|
self,
|
|
chat_id: str | None,
|
|
text: str,
|
|
actor: str,
|
|
) -> dict[str, Any] | None:
|
|
settings = get_settings()
|
|
if not (settings.feishu_app_id and settings.feishu_app_secret):
|
|
return None
|
|
return self.feishu.send_text(text, receive_id=chat_id, actor=actor)
|