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