From f658f0ede502371baf71301f81b69f2eafdc5169 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Tue, 23 Jun 2026 00:02:37 +0800 Subject: [PATCH] feat(wecom): add optional source block to interactive template cards for enhanced branding --- src/langbot/libs/wecom_ai_bot_api/api.py | 35 +++++++++++++++---- .../libs/wecom_ai_bot_api/ws_client.py | 24 ++++++++++++- src/langbot/pkg/platform/sources/wecombot.py | 16 ++++++++- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 1ed006dae..8bea7bbe6 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -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]: """对响应进行加密封装并返回给企业微信。 diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index 64e79eac1..892ab03a4 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -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: diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index 84cde168b..883ea3501 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -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