mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-30 14:17:14 +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
|
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.
|
"""Build a `template_card` (button_interaction) WeCom payload.
|
||||||
|
|
||||||
Shared by both the webhook-mode client (returns the payload as the
|
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
|
task_id: Unique per-card identifier. WeCom requires this for
|
||||||
button_interaction. The click callback returns it as TaskId so we
|
button_interaction. The click callback returns it as TaskId so we
|
||||||
can find the originating session.
|
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:
|
Notes:
|
||||||
* ``button.key`` is set directly to the Dify ``action_id``. The click
|
* ``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',
|
'card_type': 'button_interaction',
|
||||||
'main_title': {
|
'main_title': {
|
||||||
'title': node_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,
|
'button_list': button_list,
|
||||||
'task_id': task_id,
|
'task_id': task_id,
|
||||||
}
|
}
|
||||||
|
if source:
|
||||||
|
card['source'] = source
|
||||||
return {
|
return {
|
||||||
'msgtype': 'template_card',
|
'msgtype': 'template_card',
|
||||||
'template_card': card,
|
'template_card': card,
|
||||||
@@ -882,6 +894,15 @@ class WecomBotClient:
|
|||||||
|
|
||||||
self._feedback_callback: Optional[Callable] = None
|
self._feedback_callback: Optional[Callable] = None
|
||||||
self._card_action_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:
|
def set_feedback_callback(self, callback: Callable) -> None:
|
||||||
"""设置反馈回调函数。
|
"""设置反馈回调函数。
|
||||||
@@ -934,11 +955,11 @@ class WecomBotClient:
|
|||||||
'stream': stream_payload,
|
'stream': stream_payload,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
def _build_button_interaction_payload(self, form_data: dict, task_id: str) -> dict[str, Any]:
|
||||||
def _build_button_interaction_payload(form_data: dict, task_id: str) -> dict[str, Any]:
|
"""Class-level shim — delegates to module-level builder and auto-
|
||||||
"""Class-level shim — delegates to module-level builder so ws_client
|
injects the client's configured `source` block so every card emitted
|
||||||
can reuse the exact same payload shape without importing the class."""
|
through this client carries the LangBot header."""
|
||||||
return build_button_interaction_payload(form_data, task_id)
|
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]:
|
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:
|
# Signature mirrors the http-mode WecomBotClient:
|
||||||
# async def callback(session, action_id, task_id, raw_event) -> None
|
# async def callback(session, action_id, task_id, raw_event) -> None
|
||||||
self._card_action_callback: Optional[Callable] = 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 ──────────────────────────────────────────────────
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -274,6 +278,11 @@ class WecomBotWsClient:
|
|||||||
"""
|
"""
|
||||||
self._card_action_callback = callback
|
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(
|
async def push_form_pause(
|
||||||
self, msg_id: str, form_data: dict, task_id: Optional[str] = None
|
self, msg_id: str, form_data: dict, task_id: Optional[str] = None
|
||||||
) -> tuple[bool, Optional[str], Optional[str]]:
|
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||||
@@ -309,7 +318,7 @@ class WecomBotWsClient:
|
|||||||
}
|
}
|
||||||
self._task_id_by_msg[msg_id] = task_id
|
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:
|
try:
|
||||||
await self.reply_template_card(req_id, card_payload)
|
await self.reply_template_card(req_id, card_payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -390,6 +399,19 @@ class WecomBotWsClient:
|
|||||||
if not is_final and content == self._stream_last_content.get(msg_id):
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
return True
|
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
|
# Generate feedback_id for final chunk
|
||||||
feedback_id = ''
|
feedback_id = ''
|
||||||
if is_final:
|
if is_final:
|
||||||
|
|||||||
@@ -343,6 +343,19 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if hasattr(self.bot, 'set_card_action_callback'):
|
if hasattr(self.bot, 'set_card_action_callback'):
|
||||||
self.bot.set_card_action_callback(self._on_card_action)
|
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(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -566,7 +579,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
import secrets as _secrets
|
import secrets as _secrets
|
||||||
|
|
||||||
task_id = f'dify-{_secrets.token_hex(12)}'
|
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.
|
# 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
|
# user_id / chat_id are required so _on_card_action can route the
|
||||||
|
|||||||
Reference in New Issue
Block a user