374 lines
13 KiB
Python
374 lines
13 KiB
Python
|
|
"""审批原生运行时 — 通过渠道原生交互式卡片渲染审批 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"}
|