feat(ai_agent): 完善AI适配器和服务功能

- 添加OpenClaw和Hermes健康检查接口
- 实现OpenClaw工具调用功能
- 重构AI适配器使用常量定义
- 增加AI技能系统支持
- 更新配置文件中的默认模型提供者设置

refactor(scheduler): 使用常量替换硬编码值

- 将硬编码的actor值替换为ActorValue常量
- 将receive_id_type替换为FeishuReceiveIdType枚举

refactor(audit): 统一审计日志常量使用

- 将硬编码的actor、source、risk_level等值替换为对应常量
- 更新审核服务中的状态和操作常量引用

refactor(approvals): 标准化审批模块常量使用

- 将applicant默认值替换为ActorValue.API常量
- 使用ApprovalStatus常量替代硬编码状态值
- 更新审核操作常量引用
```
This commit is contained in:
2026-07-06 00:02:03 +08:00
parent d82116d637
commit aa81fc5321
36 changed files with 2328 additions and 337 deletions

View File

@@ -5,7 +5,20 @@ from typing import Any
import httpx
from fastapi import HTTPException
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
from app.core.config import get_settings
from app.modules.feishu.constants import (
FEISHU_AUTH_MISSING,
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS,
FEISHU_MESSAGE_PATH,
FEISHU_RECEIVE_ID_MISSING,
FEISHU_SUCCESS_CODE,
FEISHU_TENANT_TOKEN_PATH,
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS,
FeishuMessageType,
FeishuPayloadKey,
FeishuReceiveIdType,
)
class FeishuClient:
@@ -21,23 +34,26 @@ class FeishuClient:
def _get_tenant_access_token(self) -> str:
if not self._is_configured():
raise HTTPException(status_code=503, detail="Feishu app credentials are not configured")
raise HTTPException(status_code=503, detail=FEISHU_AUTH_MISSING)
if self._tenant_access_token and time.time() < self._token_expires_at:
return self._tenant_access_token
url = f"{self.settings.feishu_base_url}/auth/v3/tenant_access_token/internal"
url = f"{self.settings.feishu_base_url}{FEISHU_TENANT_TOKEN_PATH}"
payload = {
"app_id": self.settings.feishu_app_id,
"app_secret": self.settings.feishu_app_secret,
FeishuPayloadKey.APP_ID: self.settings.feishu_app_id,
FeishuPayloadKey.APP_SECRET: self.settings.feishu_app_secret,
}
with httpx.Client(timeout=20) as client:
response = client.post(url, json=payload)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise HTTPException(status_code=502, detail={"feishu_error": data})
self._tenant_access_token = data["tenant_access_token"]
self._token_expires_at = time.time() + int(data.get("expire", 7200)) - 300
if data.get(FeishuPayloadKey.CODE) != FEISHU_SUCCESS_CODE:
raise HTTPException(status_code=502, 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)
)
self._token_expires_at = time.time() + expire_seconds - FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS
return self._tenant_access_token
def send_message(
@@ -48,13 +64,13 @@ class FeishuClient:
content: dict[str, Any],
) -> dict[str, Any]:
token = self._get_tenant_access_token()
url = f"{self.settings.feishu_base_url}/im/v1/messages"
headers = {"Authorization": f"Bearer {token}"}
params = {"receive_id_type": receive_id_type}
url = f"{self.settings.feishu_base_url}{FEISHU_MESSAGE_PATH}"
headers = {HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(token=token)}
params = {FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type}
payload = {
"receive_id": receive_id,
"msg_type": msg_type,
"content": json.dumps(content, ensure_ascii=False),
FeishuPayloadKey.RECEIVE_ID: receive_id,
FeishuPayloadKey.MESSAGE_TYPE: msg_type,
FeishuPayloadKey.CONTENT: json.dumps(content, ensure_ascii=False),
}
with httpx.Client(timeout=20) as client:
response = client.post(url, headers=headers, params=params, json=payload)
@@ -66,26 +82,31 @@ class FeishuClient:
self,
text: str,
receive_id: str | None = None,
receive_id_type: str = "chat_id",
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
) -> dict:
chat_id = receive_id or self.settings.feishu_default_chat_id
if not chat_id:
raise HTTPException(
status_code=400,
detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required",
detail=FEISHU_RECEIVE_ID_MISSING,
)
return self.send_message(chat_id, receive_id_type, "text", {"text": text})
return self.send_message(
chat_id,
receive_id_type,
FeishuMessageType.TEXT,
{FeishuPayloadKey.TEXT: text},
)
def send_card(
self,
card: dict[str, Any],
receive_id: str | None = None,
receive_id_type: str = "chat_id",
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
) -> dict:
chat_id = receive_id or self.settings.feishu_default_chat_id
if not chat_id:
raise HTTPException(
status_code=400,
detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required",
detail=FEISHU_RECEIVE_ID_MISSING,
)
return self.send_message(chat_id, receive_id_type, "interactive", card)
return self.send_message(chat_id, receive_id_type, FeishuMessageType.INTERACTIVE, card)

View File

@@ -4,12 +4,24 @@ 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 FeishuCommandKey
from app.modules.feishu.service import FeishuService
from app.modules.reports.service import ReportService
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."""
@@ -52,25 +64,25 @@ class FeishuCommandService:
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 "feishu"
actor = sender_id.get("open_id") or sender_id.get("user_id") or ActorValue.FEISHU
return {
"text": text,
"chat_id": message.get("chat_id"),
"actor": actor,
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 = "feishu",
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 ["日报", "晨报", "经营日报", "经营晨报"]):
if any(keyword in command_text for keyword in DAILY_REPORT_KEYWORDS):
report = ReportService(self.db).daily_brief()
result = {
"command": "daily_brief",
@@ -89,7 +101,7 @@ class FeishuCommandService:
result["provider_response"] = provider_response
return result
if any(keyword in command_text for keyword in ["周报", "项目周报"]):
if any(keyword in command_text for keyword in PROJECT_WEEKLY_KEYWORDS):
report = ReportService(self.db).project_weekly()
result = {
"command": "project_weekly",
@@ -108,7 +120,7 @@ class FeishuCommandService:
result["provider_response"] = provider_response
return result
if any(keyword in command_text for keyword in ["打卡", "考勤", "attendance"]):
if any(keyword in command_text for keyword in ATTENDANCE_KEYWORDS):
report = ReportService(self.db).attendance_summary()
result = {
"command": "attendance_summary",
@@ -127,7 +139,7 @@ class FeishuCommandService:
result["provider_response"] = provider_response
return result
if any(keyword in command_text for keyword in ["风险", "预警", "risk"]):
if any(keyword in command_text for keyword in RISK_KEYWORDS):
summary = RiskService(self.db).summary()
lines = [
f"- 综合风险等级:{summary['risk_level']}",
@@ -142,25 +154,38 @@ class FeishuCommandService:
result = {
"command": "risk_summary",
"reply_type": "card",
"title": "风险预警",
"title": RISK_TITLE,
"content": "\n".join(lines),
"lines": lines,
}
if auto_reply:
provider_response = self._send_card_if_configured(chat_id, "风险预警", lines, actor)
provider_response = self._send_card_if_configured(
chat_id,
RISK_TITLE,
lines,
actor,
)
result["provider_response"] = provider_response
return result
prompt = command_text
for prefix in ["", "ai ", "AI ", "/ask "]:
for prefix in AI_COMMAND_PREFIXES:
if command_text.startswith(prefix):
prompt = command_text[len(prefix) :].strip()
break
if not prompt:
prompt = "请说明你能做什么。"
ai_result = AIService(self.db).ask(prompt, context={}, actor=actor, source="feishu")
content = ai_result["answer"]
is_explicit_ai = lowered.startswith(("ai ", "/ask")) or command_text.startswith("")
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
)
result = {
"command": "ai_ask" if is_explicit_ai else "fallback_ai",
"reply_type": "text",

View File

@@ -0,0 +1,40 @@
from enum import StrEnum
class FeishuReceiveIdType(StrEnum):
CHAT_ID = "chat_id"
class FeishuMessageType(StrEnum):
TEXT = "text"
INTERACTIVE = "interactive"
class FeishuPayloadKey(StrEnum):
RECEIVE_ID = "receive_id"
RECEIVE_ID_TYPE = "receive_id_type"
MESSAGE_TYPE = "msg_type"
CONTENT = "content"
TEXT = "text"
CODE = "code"
TENANT_ACCESS_TOKEN = "tenant_access_token"
EXPIRE = "expire"
APP_ID = "app_id"
APP_SECRET = "app_secret"
FEISHU_ERROR = "feishu_error"
CARD = "card"
class FeishuCommandKey(StrEnum):
TEXT = "text"
CHAT_ID = "chat_id"
ACTOR = "actor"
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
FEISHU_MESSAGE_PATH = "/im/v1/messages"
FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
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

View File

@@ -2,8 +2,11 @@ from typing import Any
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.modules.audit.constants import AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.constants import FeishuCommandKey
from app.modules.feishu.service import FeishuService
@@ -24,8 +27,8 @@ class FeishuEventService:
self.feishu.verify_event(payload)
self.feishu.audit.log(
AuditLogCreate(
actor="feishu",
source="feishu",
actor=ActorValue.FEISHU,
source=AuditSource.FEISHU,
action=f"{source}_event",
request_payload=payload,
response_payload={"accepted": True},
@@ -35,9 +38,9 @@ class FeishuEventService:
if not command:
return {"ok": True, "handled": False}
result = self.commands.handle_text(
command["text"],
chat_id=command["chat_id"],
actor=command["actor"],
command[FeishuCommandKey.TEXT],
chat_id=command[FeishuCommandKey.CHAT_ID],
actor=command[FeishuCommandKey.ACTOR],
auto_reply=auto_reply,
)
return {"ok": True, "handled": True, "result": result}

View File

@@ -2,19 +2,22 @@ from typing import Any
from pydantic import BaseModel, Field
from app.core.constants import ActorValue
from app.modules.feishu.constants import FeishuReceiveIdType
class FeishuTextMessage(BaseModel):
receive_id: str | None = Field(
default=None,
description="chat_id or open_id depending on type.",
)
receive_id_type: str = "chat_id"
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
text: str
class FeishuCardMessage(BaseModel):
receive_id: str | None = None
receive_id_type: str = "chat_id"
receive_id_type: str = FeishuReceiveIdType.CHAT_ID
card: dict[str, Any]
@@ -35,7 +38,7 @@ class FeishuSendResult(BaseModel):
class FeishuCommandRequest(BaseModel):
text: str
chat_id: str | None = None
actor: str = "api"
actor: str = ActorValue.API
auto_reply: bool = False

View File

@@ -3,10 +3,13 @@ from typing import Any
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.config import get_settings
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
class FeishuService:
@@ -32,19 +35,19 @@ class FeishuService:
self,
text: str,
receive_id: str | None = None,
receive_id_type: str = "chat_id",
actor: str = "system",
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
actor: str = ActorValue.SYSTEM,
) -> dict[str, Any]:
result = self.client.send_text(text, receive_id, receive_id_type)
self.audit.log(
AuditLogCreate(
actor=actor,
source="feishu",
action="send_text",
source=AuditSource.FEISHU,
action=AuditAction.FEISHU_SEND_TEXT,
request_payload={
"receive_id": receive_id,
"receive_id_type": receive_id_type,
"text": text,
FeishuPayloadKey.RECEIVE_ID: receive_id,
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
FeishuPayloadKey.TEXT: text,
},
response_payload=result,
)
@@ -55,19 +58,19 @@ class FeishuService:
self,
card: dict[str, Any],
receive_id: str | None = None,
receive_id_type: str = "chat_id",
actor: str = "system",
receive_id_type: str = FeishuReceiveIdType.CHAT_ID,
actor: str = ActorValue.SYSTEM,
) -> dict[str, Any]:
result = self.client.send_card(card, receive_id, receive_id_type)
self.audit.log(
AuditLogCreate(
actor=actor,
source="feishu",
action="send_card",
source=AuditSource.FEISHU,
action=AuditAction.FEISHU_SEND_CARD,
request_payload={
"receive_id": receive_id,
"receive_id_type": receive_id_type,
"card": card,
FeishuPayloadKey.RECEIVE_ID: receive_id,
FeishuPayloadKey.RECEIVE_ID_TYPE: receive_id_type,
FeishuPayloadKey.CARD: card,
},
response_payload=result,
)
@@ -80,6 +83,12 @@ class FeishuService:
"config": {"wide_screen_mode": True},
"header": {"title": {"tag": "plain_text", "content": title}},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": "\n".join(lines) or "暂无数据"}}
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": "\n".join(lines) or "暂无数据",
},
}
],
}