mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 21:44:20 +00:00
feat(wecom): add optional source block to interactive template cards for enhanced branding
This commit is contained in:
@@ -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]:
|
||||
"""对响应进行加密封装并返回给企业微信。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user