```
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函数统一动作命名格式 - 优化高风险域判断逻辑 ```
This commit is contained in:
@@ -3,7 +3,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
|
||||
from app.core.config import get_settings
|
||||
@@ -34,7 +34,10 @@ class FeishuClient:
|
||||
|
||||
def _get_tenant_access_token(self) -> str:
|
||||
if not self._is_configured():
|
||||
raise HTTPException(status_code=503, detail=FEISHU_AUTH_MISSING)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=FEISHU_AUTH_MISSING,
|
||||
)
|
||||
if self._tenant_access_token and time.time() < self._token_expires_at:
|
||||
return self._tenant_access_token
|
||||
|
||||
@@ -48,7 +51,10 @@ class FeishuClient:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE:
|
||||
raise HTTPException(status_code=502, detail={FeishuPayloadKey.FEISHU_ERROR: data})
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={FeishuPayloadKey.FEISHU_ERROR: data},
|
||||
)
|
||||
self._tenant_access_token = data[FeishuPayloadKey.TENANT_ACCESS_TOKEN]
|
||||
expire_seconds = int(
|
||||
data.get(FeishuPayloadKey.EXPIRE, FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS)
|
||||
@@ -87,7 +93,7 @@ class FeishuClient:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_RECEIVE_ID_MISSING,
|
||||
)
|
||||
return self.send_message(
|
||||
@@ -106,7 +112,7 @@ class FeishuClient:
|
||||
chat_id = receive_id or self.settings.feishu_default_chat_id
|
||||
if not chat_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=FEISHU_RECEIVE_ID_MISSING,
|
||||
)
|
||||
return self.send_message(chat_id, receive_id_type, FeishuMessageType.INTERACTIVE, card)
|
||||
|
||||
@@ -9,9 +9,20 @@ 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 FeishuCommandKey
|
||||
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 = ("日报", "晨报", "经营日报", "经营晨报")
|
||||
@@ -27,7 +38,11 @@ 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("text") or content.get("content") or "")
|
||||
return str(
|
||||
content.get(FeishuPayloadKey.TEXT)
|
||||
or content.get(FeishuPayloadKey.CONTENT)
|
||||
or ""
|
||||
)
|
||||
if not isinstance(content, str):
|
||||
return ""
|
||||
try:
|
||||
@@ -35,18 +50,42 @@ def _parse_content_text(content: Any) -> str:
|
||||
except json.JSONDecodeError:
|
||||
return content
|
||||
if isinstance(data, dict):
|
||||
return str(data.get("text") or data.get("content") or "")
|
||||
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(r"@\S+", "", text or "")
|
||||
text = text.replace("\u200b", "")
|
||||
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."""
|
||||
|
||||
@@ -55,16 +94,20 @@ class FeishuCommandService:
|
||||
self.feishu = FeishuService(db)
|
||||
|
||||
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
event = payload.get("event") or {}
|
||||
message = event.get("message") or {}
|
||||
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("content")))
|
||||
text = _clean_command_text(_parse_content_text(message.get(FeishuPayloadKey.CONTENT)))
|
||||
if not text:
|
||||
return None
|
||||
sender = event.get("sender") or {}
|
||||
sender_id = sender.get("sender_id") or {}
|
||||
actor = sender_id.get("open_id") or sender_id.get("user_id") or ActorValue.FEISHU
|
||||
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),
|
||||
@@ -84,80 +127,70 @@ class FeishuCommandService:
|
||||
|
||||
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
|
||||
report = ReportService(self.db).daily_brief()
|
||||
result = {
|
||||
"command": "daily_brief",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
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()
|
||||
result = {
|
||||
"command": "project_weekly",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
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()
|
||||
result = {
|
||||
"command": "attendance_summary",
|
||||
"reply_type": "card",
|
||||
"title": report["title"],
|
||||
"content": report["content"],
|
||||
"lines": report["lines"],
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
report["title"],
|
||||
report["lines"],
|
||||
report[ReportResponseKey.TITLE],
|
||||
report[ReportResponseKey.LINES],
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
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['risk_level']}",
|
||||
f"- 风险分:{summary['risk_score']}",
|
||||
f"- 逾期任务:{len(summary['overdue_tasks'])}",
|
||||
f"- 延期项目:{len(summary['delayed_projects'])}",
|
||||
f"- 超预算项目:{len(summary['over_budget_projects'])}",
|
||||
f"- 资金风险账户:{len(summary['fund_risks'])}",
|
||||
f"- 供应商风险:{len(summary['supplier_risks'])}",
|
||||
f"- 打开风险事件:{len(summary['open_events'])}",
|
||||
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])}",
|
||||
]
|
||||
result = {
|
||||
"command": "risk_summary",
|
||||
"reply_type": "card",
|
||||
"title": RISK_TITLE,
|
||||
"content": "\n".join(lines),
|
||||
"lines": lines,
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_card_if_configured(
|
||||
chat_id,
|
||||
@@ -165,8 +198,14 @@ class FeishuCommandService:
|
||||
lines,
|
||||
actor,
|
||||
)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
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:
|
||||
@@ -186,16 +225,15 @@ class FeishuCommandService:
|
||||
command_text.startswith(prefix) or lowered.startswith(prefix)
|
||||
for prefix in AI_COMMAND_PREFIXES
|
||||
)
|
||||
result = {
|
||||
"command": "ai_ask" if is_explicit_ai else "fallback_ai",
|
||||
"reply_type": "text",
|
||||
"title": "AI 回复",
|
||||
"content": content,
|
||||
}
|
||||
if auto_reply:
|
||||
provider_response = self._send_text_if_configured(chat_id, content, actor)
|
||||
result["provider_response"] = provider_response
|
||||
return result
|
||||
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,
|
||||
|
||||
@@ -10,25 +10,44 @@ class FeishuMessageType(StrEnum):
|
||||
INTERACTIVE = "interactive"
|
||||
|
||||
|
||||
class FeishuEventSource(StrEnum):
|
||||
WEBHOOK = "webhook"
|
||||
LONG_CONNECTION = "long_connection"
|
||||
|
||||
|
||||
class FeishuPayloadKey(StrEnum):
|
||||
APP_ID = "app_id"
|
||||
APP_SECRET = "app_secret"
|
||||
CARD = "card"
|
||||
CHALLENGE = "challenge"
|
||||
CODE = "code"
|
||||
CONFIG = "config"
|
||||
CONTENT = "content"
|
||||
DIV = "div"
|
||||
ELEMENTS = "elements"
|
||||
EXPIRE = "expire"
|
||||
FEISHU_ERROR = "feishu_error"
|
||||
HEADER = "header"
|
||||
EVENT = "event"
|
||||
EVENT_ID = "event_id"
|
||||
EVENT_TYPE = "event_type"
|
||||
LARK_MARKDOWN = "lark_md"
|
||||
MESSAGE = "message"
|
||||
MESSAGE_ID = "message_id"
|
||||
MESSAGE_TYPE = "msg_type"
|
||||
OPEN_ID = "open_id"
|
||||
PLAIN_TEXT = "plain_text"
|
||||
RECEIVE_ID = "receive_id"
|
||||
RECEIVE_ID_TYPE = "receive_id_type"
|
||||
MESSAGE_TYPE = "msg_type"
|
||||
CONTENT = "content"
|
||||
TEXT = "text"
|
||||
CODE = "code"
|
||||
SENDER = "sender"
|
||||
SENDER_ID = "sender_id"
|
||||
TAG = "tag"
|
||||
TENANT_ACCESS_TOKEN = "tenant_access_token"
|
||||
EXPIRE = "expire"
|
||||
APP_ID = "app_id"
|
||||
APP_SECRET = "app_secret"
|
||||
FEISHU_ERROR = "feishu_error"
|
||||
CARD = "card"
|
||||
TEXT = "text"
|
||||
TITLE = "title"
|
||||
TOKEN = "token"
|
||||
USER_ID = "user_id"
|
||||
WIDE_SCREEN_MODE = "wide_screen_mode"
|
||||
|
||||
|
||||
class FeishuCommandKey(StrEnum):
|
||||
@@ -37,12 +56,57 @@ class FeishuCommandKey(StrEnum):
|
||||
ACTOR = "actor"
|
||||
|
||||
|
||||
class FeishuResponseKey(StrEnum):
|
||||
OK = "ok"
|
||||
ACCEPTED = "accepted"
|
||||
HANDLED = "handled"
|
||||
DUPLICATE = "duplicate"
|
||||
RESULT = "result"
|
||||
CHALLENGE = "challenge"
|
||||
PROVIDER_RESPONSE = "provider_response"
|
||||
|
||||
|
||||
class FeishuCommandResultKey(StrEnum):
|
||||
COMMAND = "command"
|
||||
REPLY_TYPE = "reply_type"
|
||||
TITLE = "title"
|
||||
CONTENT = "content"
|
||||
LINES = "lines"
|
||||
PROVIDER_RESPONSE = "provider_response"
|
||||
|
||||
|
||||
class FeishuCommandName(StrEnum):
|
||||
DAILY_BRIEF = "daily_brief"
|
||||
PROJECT_WEEKLY = "project_weekly"
|
||||
ATTENDANCE_SUMMARY = "attendance_summary"
|
||||
RISK_SUMMARY = "risk_summary"
|
||||
AI_ASK = "ai_ask"
|
||||
FALLBACK_AI = "fallback_ai"
|
||||
|
||||
|
||||
class FeishuReplyType(StrEnum):
|
||||
CARD = "card"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class FeishuEventReceiptKey(StrEnum):
|
||||
EVENT_KEY = "event_key"
|
||||
SOURCE = "source"
|
||||
EVENT_ID = "event_id"
|
||||
MESSAGE_ID = "message_id"
|
||||
|
||||
|
||||
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
|
||||
FEISHU_MESSAGE_PATH = "/im/v1/messages"
|
||||
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
|
||||
FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
|
||||
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
|
||||
FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
||||
FEISHU_SUCCESS_CODE = 0
|
||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
||||
FEISHU_WEBHOOK_EVENT_ACTION = "webhook_event"
|
||||
FEISHU_LONG_CONNECTION_EVENT_ACTION = "long_connection_event"
|
||||
FEISHU_AI_REPLY_TITLE = "AI 回复"
|
||||
FEISHU_EMPTY_CARD_TEXT = "暂无数据"
|
||||
FEISHU_MENTION_PATTERN = r"@\S+"
|
||||
FEISHU_ZERO_WIDTH_SPACE = "\u200b"
|
||||
|
||||
@@ -4,21 +4,22 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.audit.constants import AuditSource
|
||||
from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_LONG_CONNECTION_EVENT_ACTION,
|
||||
FEISHU_WEBHOOK_EVENT_ACTION,
|
||||
FeishuCommandKey,
|
||||
FeishuEventReceiptKey,
|
||||
FeishuEventSource,
|
||||
FeishuPayloadKey,
|
||||
FeishuResponseKey,
|
||||
)
|
||||
from app.modules.feishu.models import FeishuEventReceipt
|
||||
from app.modules.feishu.service import FeishuService
|
||||
|
||||
FEISHU_EVENT_ACTIONS = {
|
||||
"webhook": FEISHU_WEBHOOK_EVENT_ACTION,
|
||||
"long_connection": FEISHU_LONG_CONNECTION_EVENT_ACTION,
|
||||
FeishuEventSource.WEBHOOK: AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||
FeishuEventSource.LONG_CONNECTION: AuditAction.FEISHU_LONG_CONNECTION_EVENT,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,41 +34,57 @@ class FeishuEventService:
|
||||
def handle_event(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
source: str,
|
||||
source: str | FeishuEventSource,
|
||||
auto_reply: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
self.feishu.verify_event(payload)
|
||||
challenge = payload.get(FeishuPayloadKey.CHALLENGE)
|
||||
if challenge:
|
||||
return {FeishuResponseKey.CHALLENGE: challenge}
|
||||
source_value = _normalize_source(source)
|
||||
event_identity = _event_identity(payload, source)
|
||||
if event_identity and not self._register_event(event_identity):
|
||||
return {"ok": True, "handled": False, "duplicate": True}
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: False,
|
||||
FeishuResponseKey.DUPLICATE: True,
|
||||
}
|
||||
self.feishu.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=ActorValue.FEISHU,
|
||||
source=AuditSource.FEISHU,
|
||||
action=FEISHU_EVENT_ACTIONS.get(source, FEISHU_WEBHOOK_EVENT_ACTION),
|
||||
target_type=source,
|
||||
target_id=event_identity.get("event_key") if event_identity else None,
|
||||
action=FEISHU_EVENT_ACTIONS[source_value],
|
||||
target_type=source_value,
|
||||
target_id=(
|
||||
event_identity.get(FeishuEventReceiptKey.EVENT_KEY)
|
||||
if event_identity
|
||||
else None
|
||||
),
|
||||
request_payload=payload,
|
||||
response_payload={"accepted": True},
|
||||
response_payload={FeishuResponseKey.ACCEPTED: True},
|
||||
)
|
||||
)
|
||||
command = self.commands.extract_event_command(payload)
|
||||
if not command:
|
||||
return {"ok": True, "handled": False}
|
||||
return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False}
|
||||
result = self.commands.handle_text(
|
||||
command[FeishuCommandKey.TEXT],
|
||||
chat_id=command[FeishuCommandKey.CHAT_ID],
|
||||
actor=command[FeishuCommandKey.ACTOR],
|
||||
auto_reply=auto_reply,
|
||||
)
|
||||
return {"ok": True, "handled": True, "result": result}
|
||||
return {
|
||||
FeishuResponseKey.OK: True,
|
||||
FeishuResponseKey.HANDLED: True,
|
||||
FeishuResponseKey.RESULT: result,
|
||||
}
|
||||
|
||||
def _register_event(self, event_identity: dict[str, str | None]) -> bool:
|
||||
receipt = FeishuEventReceipt(
|
||||
event_key=str(event_identity["event_key"]),
|
||||
source=str(event_identity["source"]),
|
||||
event_id=event_identity.get("event_id"),
|
||||
message_id=event_identity.get("message_id"),
|
||||
event_key=str(event_identity[FeishuEventReceiptKey.EVENT_KEY]),
|
||||
source=str(event_identity[FeishuEventReceiptKey.SOURCE]),
|
||||
event_id=event_identity.get(FeishuEventReceiptKey.EVENT_ID),
|
||||
message_id=event_identity.get(FeishuEventReceiptKey.MESSAGE_ID),
|
||||
)
|
||||
self.db.add(receipt)
|
||||
try:
|
||||
@@ -78,7 +95,15 @@ class FeishuEventService:
|
||||
return True
|
||||
|
||||
|
||||
def _event_identity(payload: dict[str, Any], source: str) -> dict[str, str | None] | None:
|
||||
def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource:
|
||||
return FeishuEventSource(source)
|
||||
|
||||
|
||||
def _event_identity(
|
||||
payload: dict[str, Any],
|
||||
source: str | FeishuEventSource,
|
||||
) -> dict[str, str | None] | None:
|
||||
source_value = _normalize_source(source)
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
event = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||
message = event.get(FeishuPayloadKey.MESSAGE) or {}
|
||||
@@ -90,11 +115,11 @@ def _event_identity(payload: dict[str, Any], source: str) -> dict[str, str | Non
|
||||
event_type = header.get(FeishuPayloadKey.EVENT_TYPE)
|
||||
event_key = ":".join(
|
||||
str(part)
|
||||
for part in (source, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
for part in (source_value, event_type or FeishuPayloadKey.EVENT, stable_id)
|
||||
)
|
||||
return {
|
||||
"event_key": event_key,
|
||||
"source": source,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
"message_id": str(message_id) if message_id else None,
|
||||
FeishuEventReceiptKey.EVENT_KEY: event_key,
|
||||
FeishuEventReceiptKey.SOURCE: source_value,
|
||||
FeishuEventReceiptKey.EVENT_ID: str(event_id) if event_id else None,
|
||||
FeishuEventReceiptKey.MESSAGE_ID: str(message_id) if message_id else None,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import SessionLocal
|
||||
from app.modules.feishu.constants import FEISHU_DEFAULT_OPEN_API_DOMAIN, FeishuEventSource
|
||||
from app.modules.feishu.events import FeishuEventService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -13,7 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
def _sdk_domain(base_url: str) -> str:
|
||||
parsed = urlsplit(base_url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return "https://open.feishu.cn"
|
||||
return FEISHU_DEFAULT_OPEN_API_DOMAIN
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
@@ -35,7 +36,7 @@ def _handle_message_event(event: Any) -> None:
|
||||
try:
|
||||
result = FeishuEventService(db).handle_event(
|
||||
payload,
|
||||
source="long_connection",
|
||||
source=FeishuEventSource.LONG_CONNECTION,
|
||||
auto_reply=True,
|
||||
)
|
||||
logger.info("Handled Feishu long connection event: %s", result)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.modules.feishu.commands import FeishuCommandService
|
||||
from app.modules.feishu.constants import FeishuEventSource, FeishuPayloadKey, FeishuResponseKey
|
||||
from app.modules.feishu.events import FeishuEventService
|
||||
from app.modules.feishu.schemas import (
|
||||
FeishuCardMessage,
|
||||
@@ -22,11 +23,11 @@ async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
||||
"""Handle Feishu webhook challenge and text command events."""
|
||||
|
||||
payload = await request.json()
|
||||
service = FeishuService(db)
|
||||
service.verify_event(payload)
|
||||
if payload.get("challenge"):
|
||||
return {"challenge": payload["challenge"]}
|
||||
return FeishuEventService(db).handle_event(payload, source="webhook", auto_reply=True)
|
||||
return FeishuEventService(db).handle_event(
|
||||
payload,
|
||||
source=FeishuEventSource.WEBHOOK,
|
||||
auto_reply=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
@@ -41,7 +42,10 @@ def send_text(
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
return {
|
||||
FeishuResponseKey.OK: result.get(FeishuPayloadKey.CODE) == 0,
|
||||
FeishuResponseKey.PROVIDER_RESPONSE: result,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
|
||||
@@ -56,7 +60,10 @@ def send_card(
|
||||
receive_id_type=payload.receive_id_type,
|
||||
actor=principal.actor,
|
||||
)
|
||||
return {"ok": result.get("code") == 0, "provider_response": result}
|
||||
return {
|
||||
FeishuResponseKey.OK: result.get(FeishuPayloadKey.CODE) == 0,
|
||||
FeishuResponseKey.PROVIDER_RESPONSE: result,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -10,7 +10,13 @@ from app.modules.audit.constants import AuditAction, AuditSource
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.feishu.client import FeishuClient
|
||||
from app.modules.feishu.constants import FeishuPayloadKey, FeishuReceiveIdType
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_EMPTY_CARD_TEXT,
|
||||
FEISHU_INVALID_TOKEN,
|
||||
FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
||||
FeishuPayloadKey,
|
||||
FeishuReceiveIdType,
|
||||
)
|
||||
|
||||
|
||||
class FeishuService:
|
||||
@@ -24,17 +30,17 @@ class FeishuService:
|
||||
def verify_event(self, payload: dict[str, Any]) -> None:
|
||||
settings = get_settings()
|
||||
expected = settings.feishu_verification_token
|
||||
header = payload.get("header") or {}
|
||||
token = payload.get("token") or header.get("token")
|
||||
header = payload.get(FeishuPayloadKey.HEADER) or {}
|
||||
token = payload.get(FeishuPayloadKey.TOKEN) or header.get(FeishuPayloadKey.TOKEN)
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="FEISHU_VERIFICATION_TOKEN is required",
|
||||
detail=FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
||||
)
|
||||
if not token or not compare_digest(str(token), expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid Feishu token",
|
||||
detail=FEISHU_INVALID_TOKEN,
|
||||
)
|
||||
|
||||
def send_text(
|
||||
@@ -86,14 +92,19 @@ class FeishuService:
|
||||
@staticmethod
|
||||
def build_basic_card(title: str, lines: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"header": {"title": {"tag": "plain_text", "content": title}},
|
||||
"elements": [
|
||||
FeishuPayloadKey.CONFIG: {FeishuPayloadKey.WIDE_SCREEN_MODE: True},
|
||||
FeishuPayloadKey.HEADER: {
|
||||
FeishuPayloadKey.TITLE: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||
FeishuPayloadKey.CONTENT: title,
|
||||
}
|
||||
},
|
||||
FeishuPayloadKey.ELEMENTS: [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": "\n".join(lines) or "暂无数据",
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.DIV,
|
||||
FeishuPayloadKey.TEXT: {
|
||||
FeishuPayloadKey.TAG: FeishuPayloadKey.LARK_MARKDOWN,
|
||||
FeishuPayloadKey.CONTENT: "\n".join(lines) or FEISHU_EMPTY_CARD_TEXT,
|
||||
},
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user