ForcePilot/backend/package/yuxi/channel/approval/native_runtime.py
Kris 79ee930957 feat(approval): 新增审批流程核心模块
实现了完整的审批功能链路,包含审批请求投递、模板渲染、回复解析、引擎管理以及原生交互式卡片支持
2026-05-21 10:22:45 +08:00

374 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""审批原生运行时 — 通过渠道原生交互式卡片渲染审批 UI
相比 delivery.py 的纯文本投递native_runtime 使用渠道的交互式卡片消息
渲染审批按钮,提供更好的用户体验。
支持的渠道卡片格式:
- 飞书: interactive card (lark_md + button actions)
- 钉钉: action_card (Markdown + btns)
- 其他渠道: 降级为纯文本投递
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import Any
from yuxi.channel.protocols import (
ApprovalDecision,
ApprovalRequest,
ApprovalResult,
)
from yuxi.channel.plugins.registry import ChannelPluginRegistry
logger = logging.getLogger(__name__)
FEISHU = "feishu"
DINGTALK = "dingtalk"
DEFAULT_TIMEOUT_MINUTES = 5
DEFAULT_TIMEOUT = 300
POLL_INTERVAL = 3
@dataclass
class NativeApprovalCard:
channel_type: str
card_content: dict
request_id: str
def to_dict(self) -> dict:
return {
"channel_type": self.channel_type,
"card_content": self.card_content,
"request_id": self.request_id,
}
class NativeApprovalRenderer:
def render_card(self, request: ApprovalRequest, channel_type: str) -> NativeApprovalCard:
if channel_type == FEISHU:
card = _build_feishu_approval_card(request)
elif channel_type == DINGTALK:
card = _build_dingtalk_approval_card(request)
else:
card = _build_fallback_card(request)
return NativeApprovalCard(
channel_type=channel_type,
card_content=card,
request_id=request.id,
)
def render_resolved_card(self, result: ApprovalResult, request: ApprovalRequest | None, channel_type: str) -> dict:
if channel_type == FEISHU:
return _build_feishu_resolved_card(result, request)
elif channel_type == DINGTALK:
return _build_dingtalk_resolved_card(result, request)
return _build_fallback_resolved_card(result, request)
async def deliver_native_approval(
self,
request: ApprovalRequest,
approver_peer_ids: list[str],
*,
timeout: float = DEFAULT_TIMEOUT,
) -> ApprovalResult:
"""通过原生交互式卡片投递审批请求。
1. 构建渠道原生卡片
2. 通过 send_card 发送给审批人
3. 轮询等待审批结果
4. 获得结果后更新卡片为已处理状态
若渠道不支持 send_card降级为纯文本投递。
"""
from yuxi.channel.approval.delivery import deliver_approval_request
plugin = ChannelPluginRegistry.get(request.channel_type)
if plugin is None:
return _native_expired(request.id, "渠道插件未注册")
if not _supports_card(plugin):
logger.info("渠道 %s 不支持卡片消息,降级为纯文本投递", request.channel_type)
return await deliver_approval_request(request, approver_peer_ids, timeout=timeout)
card = self.render_card(request, request.channel_type)
sent_message_ids: dict[str, str] = {}
for peer_id in approver_peer_ids:
try:
raw = await plugin.send_card(peer_id, card.card_content)
msg_id = _extract_msg_id(raw)
if msg_id:
sent_message_ids[peer_id] = msg_id
except Exception:
logger.exception(
"发送审批卡片失败: channel=%s, peer=%s",
request.channel_type,
peer_id,
)
if not sent_message_ids:
return _native_expired(request.id, "所有审批通知发送失败")
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
try:
while loop.time() < deadline:
await asyncio.sleep(POLL_INTERVAL)
if callable(getattr(plugin, "check_approval_status", None)):
result = await plugin.check_approval_status({}, request.id)
if result is not None:
resolved_card = self.render_resolved_card(result, request, request.channel_type)
await _edit_cards(plugin, sent_message_ids, resolved_card)
return result
expired = _native_expired(request.id, "审批等待超时")
resolved_card = self.render_resolved_card(expired, request, request.channel_type)
await _edit_cards(plugin, sent_message_ids, resolved_card)
return expired
except asyncio.CancelledError:
cancelled = ApprovalResult(
request_id=request.id,
decision=ApprovalDecision.CANCELLED,
decided_by="system",
reason="审批被取消",
)
resolved_card = self.render_resolved_card(cancelled, request, request.channel_type)
await _edit_cards(plugin, sent_message_ids, resolved_card)
raise
_native_renderer = NativeApprovalRenderer()
def get_native_renderer() -> NativeApprovalRenderer:
return _native_renderer
def _supports_card(plugin: Any) -> bool:
has_send = callable(getattr(plugin, "send_card", None))
has_edit = (
callable(getattr(plugin, "edit_card", None))
or callable(getattr(plugin, "update_card", None))
)
return has_send and has_edit
def _extract_msg_id(raw: Any) -> str | None:
if raw is None:
return None
if isinstance(raw, str):
return raw
if isinstance(raw, dict):
return raw.get("msg_id") or raw.get("message_id")
return None
async def _edit_cards(plugin: Any, sent_message_ids: dict[str, str], resolved_card: dict) -> None:
edit_fn = getattr(plugin, "edit_card", None) or getattr(plugin, "update_card", None)
if edit_fn is None:
return
for peer_id, msg_id in sent_message_ids.items():
try:
await edit_fn(peer_id, msg_id, resolved_card)
except Exception:
logger.debug("更新审批卡片失败: %s/%s", peer_id, msg_id)
def _native_expired(request_id: str, reason: str) -> ApprovalResult:
return ApprovalResult(
request_id=request_id,
decision=ApprovalDecision.EXPIRED,
decided_by="system",
reason=reason,
)
# ── 飞书交互式卡片 ──────────────────────────────────────
def _build_feishu_approval_card(request: ApprovalRequest) -> dict:
timeout_minutes = DEFAULT_TIMEOUT_MINUTES
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": "🔐 审批请求"},
"template": "blue",
},
"elements": [
{
"tag": "div",
"fields": [
{"is_short": True, "text": {"tag": "lark_md", "content": f"**请求 ID**\n`{request.id[:12]}...`"}},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**操作类型**\n{request.action.value}"}},
],
},
{
"tag": "div",
"text": {"tag": "lark_md", "content": f"**描述**\n{request.description}"},
},
{
"tag": "div",
"fields": [
{
"is_short": True,
"text": {"tag": "lark_md", "content": f"**发起人**\n{request.initiator_peer_id}"},
},
{"is_short": True, "text": {"tag": "lark_md", "content": f"**有效期**\n{timeout_minutes} 分钟"}},
],
},
{"tag": "hr"},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "lark_md", "content": "✅ 批准"},
"type": "primary",
"value": f'{{"action":"approve","req":"{request.id}"}}',
},
{
"tag": "button",
"text": {"tag": "lark_md", "content": "❌ 拒绝"},
"type": "danger",
"value": f'{{"action":"deny","req":"{request.id}"}}',
},
],
},
],
}
def _build_feishu_resolved_card(result: ApprovalResult, request: ApprovalRequest | None) -> dict:
if result.decision == ApprovalDecision.APPROVED:
template = "green"
title = "✅ 审批已通过"
reason_text = f"**通过原因**: {result.reason}" if result.reason else ""
elif result.decision == ApprovalDecision.DENIED:
template = "red"
title = "❌ 审批已拒绝"
reason_text = f"**拒绝原因**: {result.reason}" if result.reason else ""
elif result.decision == ApprovalDecision.CANCELLED:
template = "grey"
title = "🚫 审批已取消"
reason_text = f"**取消原因**: {result.reason}" if result.reason else ""
else:
template = "grey"
title = "⏰ 审批已过期"
reason_text = f"**原因**: {result.reason}" if result.reason else ""
elements: list[dict] = [
{
"tag": "div",
"text": {"tag": "lark_md", "content": f"请求 ID: `{result.request_id[:12]}...`"},
},
]
if result.decided_by:
elements.append(
{
"tag": "div",
"text": {"tag": "lark_md", "content": f"决定人: {result.decided_by}"},
}
)
if reason_text:
elements.append(
{
"tag": "div",
"text": {"tag": "lark_md", "content": reason_text},
}
)
if request:
elements.insert(
0,
{
"tag": "div",
"text": {"tag": "lark_md", "content": f"原请求: {request.description}"},
},
)
return {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": template,
},
"elements": elements,
}
# ── 钉钉 Action Card ────────────────────────────────────
def _build_dingtalk_approval_card(request: ApprovalRequest) -> dict:
timeout_minutes = DEFAULT_TIMEOUT_MINUTES
markdown = (
f"## 🔐 审批请求\n\n"
f"**请求 ID**: `{request.id[:12]}...`\n\n"
f"**操作类型**: {request.action.value}\n\n"
f"**描述**: {request.description}\n\n"
f"**发起人**: {request.initiator_peer_id}\n\n"
f"**有效期**: {timeout_minutes} 分钟\n\n"
f"> 请点击下方按钮做出决定"
)
return {
"title": "🔐 审批请求",
"text": markdown,
"btnOrientation": "1",
"btns": [
{
"title": "✅ 批准",
"actionURL": f"dingtalk://yuxi/approval/approve?req={request.id}",
},
{
"title": "❌ 拒绝",
"actionURL": f"dingtalk://yuxi/approval/deny?req={request.id}",
},
],
}
def _build_dingtalk_resolved_card(result: ApprovalResult, request: ApprovalRequest | None) -> dict:
if result.decision == ApprovalDecision.APPROVED:
title = "✅ 审批已通过"
elif result.decision == ApprovalDecision.DENIED:
title = "❌ 审批已拒绝"
elif result.decision == ApprovalDecision.CANCELLED:
title = "🚫 审批已取消"
else:
title = "⏰ 审批已过期"
lines = [f"## {title}", "", f"**请求 ID**: `{result.request_id[:12]}...`"]
if result.decided_by:
lines.append(f"**决定人**: {result.decided_by}")
if result.reason:
lines.append(f"**原因**: {result.reason}")
markdown = "\n\n".join(lines)
return {"title": title, "text": markdown}
# ── 降级卡片(纯文本格式的卡片消息)────────────────────
def _build_fallback_card(request: ApprovalRequest) -> dict:
from yuxi.channel.approval.templates import get_template_renderer
renderer = get_template_renderer("default")
text = renderer.render_request(request)
return {"text": text, "type": "plain_text"}
def _build_fallback_resolved_card(result: ApprovalResult, request: ApprovalRequest | None) -> dict:
from yuxi.channel.approval.templates import get_template_renderer
renderer = get_template_renderer("default")
text = renderer.render_result(result, request)
return {"text": text, "type": "plain_text"}