"""The confirmation card: what the employee reads before anything happens. Built with the platform's own card builder so it looks like every other Helix card, and carries a plain URL button rather than a Feishu callback button. That is not a workaround — it is the better shape here. A URL button opens a page that renders the full parameter snapshot with its digest, so "you approved what you saw" has somewhere to actually be shown, and it needs no new open-platform callback configuration (a Feishu app has one event URL, and card callbacks are a separate registration behind another app-version review). """ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from app.assistant.actions import AssistantAction from app.core.locale import DEFAULT_LANG, Lang from app.integrations.service import get_platform_setting from app.notifications import card_design from app.notifications.card_design import STATUS_PENDING from app.notifications.service import ( CardAction, CardFact, _build_interactive_card, ) CONFIRM_PATH = "/assistant/confirm" _BUTTON_DETAILS: dict[Lang, str] = { "zh": "查看完整内容", "en": "See full request", "ja": "全文を見る", } _BUTTON_CONFIRM: dict[Lang, str] = { "zh": "确认提交", "en": "Confirm and submit", "ja": "確認して送信", } _FOOTER: dict[Lang, str] = { "zh": "点开后可以看到完整内容;未确认前不会提交任何东西。", "en": "Opening shows the full request. Nothing is submitted until you confirm.", "ja": "開くと全文を確認できます。確認するまで何も送信されません。", } #: A ticket asks a human to do it by hand; a real flow submits the request the #: platform already knows how to run. Two different outcomes should not arrive #: wearing the same colour — the header is what the employee reads first. _TICKET_ACTION_KEY = "create_support_ticket" _HEADER_FLOW = "blue" _HEADER_TICKET = "wathet" _EXPIRY: dict[Lang, str] = { "zh": "此确认 {minutes} 分钟内有效,过期后再问我一次即可。", "en": "This offer is valid for {minutes} minutes — just ask me again after that.", "ja": "この確認は {minutes} 分間有効です。期限切れの場合はもう一度お申し付けください。", } #: The header's second line. Says what KIND of thing is being confirmed, so #: the title can be the thing itself rather than a category label. _SUBTITLE: dict[Lang, str] = { "zh": "待你确认 · 确认后才会提交", "en": "Your confirmation · nothing is submitted until you confirm", "ja": "確認待ち · 確認後に送信されます", } #: A card is a glance, not a form. Long free text is truncated here and shown in #: full on the confirmation page — the button is what leads to the real reading. _CARD_VALUE_MAX = 120 def _pick(table: dict[Lang, str], lang: Lang) -> str: return table.get(lang) or table[DEFAULT_LANG] def clip_value(value: str) -> str: """The card's truncation, exported so the confirm page can match it. The page shows the full value to the proposal's owner and this truncation to anyone else, which is what makes "the page discloses no more than the card" true rather than merely claimed. """ return _clip(value) def _clip(value: str) -> str: single_line = " ".join(value.split()) if len(single_line) <= _CARD_VALUE_MAX: return single_line return single_line[: _CARD_VALUE_MAX - 1] + "…" def confirm_path(*, token: str, digest: str) -> str: return f"{CONFIRM_PATH}?token={token}&h={digest}" async def confirm_url( db: AsyncSession, *, token: str, digest: str, ) -> str: """Absolute confirmation URL, from the same setting every card link uses. Resolved here rather than left relative because the card renderer drops a button whose URL is empty, and an unconfigured base would otherwise produce a card with no way to act on it. A missing setting degrades to the bare path — copy-pasteable — which is what the notification path does too. """ path = confirm_path(token=token, digest=digest) base = await get_platform_setting("app.frontend_base_url", None, session=db) if not base or not isinstance(base, str): return path return f"{base.rstrip('/')}{path}" def _native_confirm_button(label: str, proposal_id: str) -> dict: """A button Feishu posts back to us instead of opening a browser. ``value`` carries an id and nothing else: the caller's identity arrives in the verified envelope, so a captured payload is inert. The URL button stays alongside as the fallback for anyone whose client cannot run card callbacks, and as the place the full snapshot can still be read. Card 2.0 shape: the callback moved from a top-level ``value`` field into ``behaviors``. The value itself is unchanged and still arrives at ``event.action.value`` — which ``card_actions._action_value`` reads from either shape, so a card sent before this deploy keeps working when its button is finally tapped. """ from app.assistant.card_actions import ACTION_CONFIRM # noqa: PLC0415 — cycle return { "tag": "button", "text": {"tag": "plain_text", "content": label}, "type": "primary", "width": "fill", "behaviors": [{ "type": "callback", "value": {"action": ACTION_CONFIRM, "proposal": proposal_id}, }], } def build_confirmation_card( *, action: AssistantAction, params: dict[str, object], digest: str, url: str, lang: Lang, proposal_id: str | None = None, ) -> dict: """Render the offer. Every stored parameter appears — nothing is hidden.""" rows = action.summarise(params, lang) confirm_label = _pick(_BUTTON_CONFIRM, lang) from app.assistant.proposals import PROPOSAL_TTL_MINUTES # noqa: PLC0415 — cycle footer = "\n".join([ _pick(_EXPIRY, lang).format(minutes=PROPOSAL_TTL_MINUTES), _pick(_FOOTER, lang), ]) return _build_interactive_card( title=action.localized_title(lang), body_text=action.localized_intent(lang), url=url, button_label=confirm_label, is_en=lang == "en", lang=lang, # 待确认 —— the card is asking the employee for a decision, which is # the same state every approval card in the platform is in. Sharing # the status means sharing the badge and the header colour: the # assistant's cards stop looking like a different product. status=STATUS_PENDING, header_color=( _HEADER_TICKET if action.key == _TICKET_ACTION_KEY else None ) or card_design.tone_for(STATUS_PENDING).color, subtitle=_pick(_SUBTITLE, lang), facts=[ CardFact( label=row.localized(lang)["label"], value=_clip(row.localized(lang)["value"]), ) for row in rows ], extra_actions=[ CardAction(label=_pick(_BUTTON_DETAILS, lang), url=url, style="default"), ], # Index-aligned with ``extra_actions``: the renderer silently drops a # button whose resolved URL is empty, so this must be supplied when # calling the builder outside the notification pipeline. extra_action_urls=[url], # Confirm in place; the link becomes the secondary "see everything" # route rather than the only way to act. callback_actions=( [_native_confirm_button(confirm_label, proposal_id)] if proposal_id else None ), footer=footer, )