feat(wecom): add optional source block to interactive template cards for enhanced branding

This commit is contained in:
fdc310
2026-06-23 00:02:37 +08:00
parent 47d75082e8
commit f658f0ede5
3 changed files with 66 additions and 9 deletions
+28 -7
View File
@@ -770,7 +770,12 @@ async def parse_wecom_bot_message(
return message_data
def build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str, Any]:
def build_button_interaction_payload(
form_data: dict,
task_id: str,
*,
source: Optional[dict] = None,
) -> dict[str, Any]:
"""Build a `template_card` (button_interaction) WeCom payload.
Shared by both the webhook-mode client (returns the payload as the
@@ -784,6 +789,11 @@ def build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str,
task_id: Unique per-card identifier. WeCom requires this for
button_interaction. The click callback returns it as TaskId so we
can find the originating session.
source: Optional source header dict ``{icon_url, desc, desc_color}``
shown at the top of the card. WeCom accepts arbitrary HTTPS
URLs for ``icon_url`` (unlike DingTalk Avatar which requires
a uploaded media id), so the LangBot logo URL can be passed
straight through.
Notes:
* ``button.key`` is set directly to the Dify ``action_id``. The click
@@ -828,7 +838,7 @@ def build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str,
}
)
card = {
card: dict[str, Any] = {
'card_type': 'button_interaction',
'main_title': {
'title': node_title,
@@ -837,6 +847,8 @@ def build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str,
'button_list': button_list,
'task_id': task_id,
}
if source:
card['source'] = source
return {
'msgtype': 'template_card',
'template_card': card,
@@ -882,6 +894,15 @@ class WecomBotClient:
self._feedback_callback: Optional[Callable] = None
self._card_action_callback: Optional[Callable] = None
# Optional `source` block injected into every interactive template_card
# the client builds. Set via `set_card_source` from the adapter after
# reading config. Format: {icon_url, desc, desc_color}.
self.card_source: Optional[dict] = None
def set_card_source(self, source: Optional[dict]) -> None:
"""Set the `source` header dict injected into every
button_interaction template_card. Pass None to clear."""
self.card_source = source
def set_feedback_callback(self, callback: Callable) -> None:
"""设置反馈回调函数。
@@ -934,11 +955,11 @@ class WecomBotClient:
'stream': stream_payload,
}
@staticmethod
def _build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str, Any]:
"""Class-level shim — delegates to module-level builder so ws_client
can reuse the exact same payload shape without importing the class."""
return build_button_interaction_payload(form_data, task_id)
def _build_button_interaction_payload(self, form_data: dict, task_id: str) -> dict[str, Any]:
"""Class-level shim — delegates to module-level builder and auto-
injects the client's configured `source` block so every card emitted
through this client carries the LangBot header."""
return build_button_interaction_payload(form_data, task_id, source=self.card_source)
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""对响应进行加密封装并返回给企业微信。
+23 -1
View File
@@ -118,6 +118,10 @@ class WecomBotWsClient:
# Signature mirrors the http-mode WecomBotClient:
# async def callback(session, action_id, task_id, raw_event) -> None
self._card_action_callback: Optional[Callable] = None
# Optional `source` block injected into every interactive
# template_card the client builds via `push_form_pause`. Set via
# `set_card_source` from the adapter after reading config.
self.card_source: Optional[dict] = None
# ── Public API ──────────────────────────────────────────────────
@@ -274,6 +278,11 @@ class WecomBotWsClient:
"""
self._card_action_callback = callback
def set_card_source(self, source: Optional[dict]) -> None:
"""Set the `source` block injected into every interactive
template_card pushed via `push_form_pause`. Pass None to clear."""
self.card_source = source
async def push_form_pause(
self, msg_id: str, form_data: dict, task_id: Optional[str] = None
) -> tuple[bool, Optional[str], Optional[str]]:
@@ -309,7 +318,7 @@ class WecomBotWsClient:
}
self._task_id_by_msg[msg_id] = task_id
card_payload = build_button_interaction_payload(form_data, task_id)
card_payload = build_button_interaction_payload(form_data, task_id, source=self.card_source)
try:
await self.reply_template_card(req_id, card_payload)
except Exception:
@@ -390,6 +399,19 @@ class WecomBotWsClient:
if not is_final and content == self._stream_last_content.get(msg_id):
return True
# Skip empty/whitespace-only chunks — the runner injects a
# zero-width space ('') as a pass-through when workflow_paused
# fires without any preceding LLM output. WeCom renders that
# as an empty bubble that sits before the form card; skip it.
# NOTE: Python str.strip() does NOT strip , so we use
# a regex that treats any character with Unicode category Zs
# (separator space) or Cf (format char like ZWS) as blank.
if not is_final:
import re as _re
if not _re.sub(r'[\s]', '', content):
return True
# Generate feedback_id for final chunk
feedback_id = ''
if is_final:
+15 -1
View File
@@ -343,6 +343,19 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if hasattr(self.bot, 'set_card_action_callback'):
self.bot.set_card_action_callback(self._on_card_action)
# Hand the client a `source` block so every interactive
# template_card it emits carries the LangBot logo + name at the
# top — the WeCom analogue of DingTalk's Avatar header.
# Always on; icon_url accepts plain HTTPS URLs (no upload needed).
if hasattr(self.bot, 'set_card_source'):
self.bot.set_card_source(
{
'icon_url': 'https://raw.githubusercontent.com/RockChinQ/LangBot/master/res/logo-blue.png',
'desc': 'LangBot',
'desc_color': 0,
}
)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -566,7 +579,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
import secrets as _secrets
task_id = f'dify-{_secrets.token_hex(12)}'
payload = build_button_interaction_payload(form_data, task_id)
source = getattr(self.bot, 'card_source', None)
payload = build_button_interaction_payload(form_data, task_id, source=source)
# Register task_id → form_data so the click callback can find it.
# user_id / chat_id are required so _on_card_action can route the