mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-22 21:44:20 +00:00
fix: add Avatar component to dingtalk_human_input_card.json for enhanced user interaction
This commit is contained in:
@@ -220,6 +220,56 @@ def button_group(node_id):
|
||||
}
|
||||
|
||||
|
||||
def avatar(node_id, *, name='LangBot', image_variable='bot_avatar'):
|
||||
"""Avatar component in `userInfo` mode — renders the bot's avatar
|
||||
image and nickname as a header row above the response content.
|
||||
Mirrors the layout from `I:\\下载\\dingtalk_1782120006374.json` where
|
||||
Avatar sits at the top of the done-state AICardContent.
|
||||
|
||||
`imageUrl` is bound to a global variable (default `bot_avatar`) so
|
||||
the adapter can populate it at runtime with a DingTalk media id
|
||||
(``@xxx``) obtained from the /media/upload endpoint. DingTalk's
|
||||
Avatar.imageUrl resolver rejects external URLs — it only accepts
|
||||
DingTalk-hosted media ids, so this binding is the only path to
|
||||
a custom avatar.
|
||||
"""
|
||||
return {
|
||||
'componentName': 'Avatar',
|
||||
'id': node_id,
|
||||
'props': {
|
||||
'imageUrl': {
|
||||
'value': '',
|
||||
'valueType': 'variable',
|
||||
'type': 'dynamicImage',
|
||||
'variable': image_variable,
|
||||
'variableType': 'global',
|
||||
},
|
||||
'name': {'i18n': False, 'type': 'dynamicString', 'content': name},
|
||||
'sizeType': 'Standard',
|
||||
'size': 'extraSmall',
|
||||
'customSize': 48,
|
||||
'marginLeft': 12,
|
||||
'marginRight': 12,
|
||||
'marginTop': 6,
|
||||
'marginBottom': 6,
|
||||
'visible': {
|
||||
'type': 'dynamicVisible',
|
||||
'value': True,
|
||||
'valueType': 'fixed',
|
||||
'condition': {'op': 'and', 'conditions': []},
|
||||
},
|
||||
'mode': 'userInfo',
|
||||
'margin': -2,
|
||||
'innerOffset': 0,
|
||||
},
|
||||
'title': '头像',
|
||||
'hidden': False,
|
||||
'isLocked': False,
|
||||
'condition': True,
|
||||
'conditionGroup': '',
|
||||
}
|
||||
|
||||
|
||||
def build_editor_data():
|
||||
component_names = [
|
||||
'AIPending',
|
||||
@@ -229,6 +279,7 @@ def build_editor_data():
|
||||
'AICardContainer',
|
||||
'ButtonGroup',
|
||||
'MarkdownBlock',
|
||||
'Avatar',
|
||||
]
|
||||
components_map = [
|
||||
{
|
||||
@@ -344,6 +395,7 @@ def build_editor_data():
|
||||
'condition': True,
|
||||
'conditionGroup': '',
|
||||
'children': [
|
||||
avatar('node_avatar', name='LangBot'),
|
||||
markdown_block('node_text_content', variable='content'),
|
||||
button_group('node_btn_group'),
|
||||
],
|
||||
@@ -722,6 +774,15 @@ def build_editor_data():
|
||||
'disabled': True,
|
||||
'visible': False,
|
||||
},
|
||||
{
|
||||
'id': 'bot_avatar',
|
||||
'type': 'string',
|
||||
'name': 'bot_avatar',
|
||||
'description': '机器人头像 DingTalk 媒体 ID(@xxx 格式,启动时由 /media/upload 拿到)',
|
||||
'private': False,
|
||||
'editorVarType': 'variables',
|
||||
'disabled': False,
|
||||
},
|
||||
btns_var,
|
||||
],
|
||||
'formList': [],
|
||||
|
||||
@@ -2,7 +2,9 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from typing import Awaitable, Callable, Optional
|
||||
@@ -57,6 +59,12 @@ class DingTalkClient:
|
||||
self.access_token_expiry_time = ''
|
||||
self.markdown_card = markdown_card
|
||||
self.logger = logger
|
||||
# Legacy access_token used by the OLD oapi.dingtalk.com endpoints
|
||||
# (e.g. /media/upload, which is the only documented way to get an
|
||||
# `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0
|
||||
# token doesn't work there — different auth domain.
|
||||
self.legacy_access_token = ''
|
||||
self.legacy_access_token_expiry_time: typing.Optional[float] = None
|
||||
self._stopped = False # Flag to control the event loop
|
||||
|
||||
async def _on_card_action(self, payload: dict) -> None:
|
||||
@@ -760,6 +768,96 @@ class DingTalkClient:
|
||||
await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}')
|
||||
return False
|
||||
|
||||
async def get_legacy_access_token(self) -> Optional[str]:
|
||||
"""Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a
|
||||
different auth domain from the v1.0 token cached in
|
||||
``self.access_token`` — only the legacy token authorises the
|
||||
``/media/upload`` endpoint that returns an ``@xxx`` media_id
|
||||
consumable by card components like Avatar.imageUrl.
|
||||
|
||||
Returns the token string on success, None on failure. Caches
|
||||
with a 60s safety margin before the documented 7200s expiry.
|
||||
"""
|
||||
now = time.time()
|
||||
if (
|
||||
self.legacy_access_token
|
||||
and self.legacy_access_token_expiry_time
|
||||
and now < self.legacy_access_token_expiry_time
|
||||
):
|
||||
return self.legacy_access_token
|
||||
|
||||
url = 'https://oapi.dingtalk.com/gettoken'
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0)
|
||||
data = response.json() if response.status_code == 200 else {}
|
||||
if data.get('errcode') == 0 and data.get('access_token'):
|
||||
self.legacy_access_token = data['access_token']
|
||||
expires_in = int(data.get('expires_in', 7200))
|
||||
self.legacy_access_token_expiry_time = now + expires_in - 60
|
||||
return self.legacy_access_token
|
||||
if self.logger:
|
||||
await self.logger.error(
|
||||
f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}'
|
||||
)
|
||||
except Exception:
|
||||
_stdout_logger.exception('DingTalk legacy gettoken error')
|
||||
if self.logger:
|
||||
await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}')
|
||||
return None
|
||||
|
||||
async def upload_image_media(self, file_path: str) -> Optional[str]:
|
||||
"""Upload an image file to DingTalk media storage and return the
|
||||
``@xxx`` media_id, which can be passed straight into card variables
|
||||
like Avatar.imageUrl. Endpoint:
|
||||
|
||||
POST https://oapi.dingtalk.com/media/upload?access_token=…&type=image
|
||||
|
||||
Returns the media_id on success, None on any failure (caller
|
||||
should handle a None gracefully — DingTalk falls back to a
|
||||
default avatar when imageUrl is empty/unknown).
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
if self.logger:
|
||||
await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}')
|
||||
return None
|
||||
|
||||
token = await self.get_legacy_access_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
url = 'https://oapi.dingtalk.com/media/upload'
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
file_name = os.path.basename(file_path)
|
||||
# Best-effort content-type guess; DingTalk accepts the major image
|
||||
# mime types and otherwise infers from the bytes.
|
||||
ext = os.path.splitext(file_name)[1].lower().lstrip('.')
|
||||
mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get(
|
||||
ext, 'application/octet-stream'
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
params={'access_token': token, 'type': 'image'},
|
||||
files={'media': (file_name, file_bytes, mime)},
|
||||
timeout=30.0,
|
||||
)
|
||||
data = response.json() if response.status_code == 200 else {}
|
||||
if data.get('errcode') == 0 and data.get('media_id'):
|
||||
_stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id'])
|
||||
return data['media_id']
|
||||
if self.logger:
|
||||
await self.logger.error(
|
||||
f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}'
|
||||
)
|
||||
except Exception:
|
||||
_stdout_logger.exception('DingTalk upload_image_media error')
|
||||
if self.logger:
|
||||
await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}')
|
||||
return None
|
||||
|
||||
async def start(self):
|
||||
"""启动 WebSocket 连接,监听消息"""
|
||||
self._stopped = False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import pathlib
|
||||
import traceback
|
||||
import typing
|
||||
import uuid
|
||||
@@ -191,14 +191,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# by _paint_form_on_card so the post-pause form keeps the streamed
|
||||
# context above the new prompt.
|
||||
active_turn_text: dict
|
||||
# user_msg_id → monitoring_message_id, populated by
|
||||
# on_monitoring_message_created and drained into feedback_state when
|
||||
# a card is created for that user message.
|
||||
pending_monitoring_msg: dict
|
||||
# out_track_id → {monitoring_msg_id, session_id, user_id, voted}.
|
||||
# Marks a card as feedback-enabled. _on_card_action looks here first
|
||||
# for the synthetic feedback action ids and emits a FeedbackEvent.
|
||||
feedback_state: dict
|
||||
# event_type → callback. The abstract base class doesn't declare this,
|
||||
# so we must do it here or pydantic silently drops `listeners={}` in
|
||||
# super().__init__ and any access raises AttributeError.
|
||||
@@ -208,12 +200,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
]
|
||||
ap: typing.Any = None
|
||||
bot_uuid: str = ''
|
||||
# DingTalk media_id (`@xxx` format) for the bot avatar image, fetched
|
||||
# on adapter startup by uploading the bundled LangBot logo via the
|
||||
# legacy /media/upload endpoint. Empty string when the upload hasn't
|
||||
# run yet or failed — the template's Avatar then falls back to its
|
||||
# default (initials of `name`).
|
||||
bot_avatar_media_id: str = ''
|
||||
|
||||
# Synthetic action ids the user can't collide with — Dify actions are
|
||||
# external strings, ours are namespaced. ClassVar so pydantic doesn't
|
||||
# treat these constants as model fields.
|
||||
FEEDBACK_ACTION_UP: typing.ClassVar[str] = '__lb_feedback_up__'
|
||||
FEEDBACK_ACTION_DOWN: typing.ClassVar[str] = '__lb_feedback_down__'
|
||||
# Path to the LangBot logo bundled in the repo (`res/logo-blue.png`),
|
||||
# resolved relative to this file. Updated to find the file even when
|
||||
# LangBot is installed as a package or run from a different cwd.
|
||||
_LOGO_PATH: typing.ClassVar[pathlib.Path] = pathlib.Path(__file__).resolve().parents[5] / 'res' / 'logo-blue.png'
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
required_keys = [
|
||||
@@ -241,8 +238,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
card_state={},
|
||||
active_turn_card={},
|
||||
active_turn_text={},
|
||||
pending_monitoring_msg={},
|
||||
feedback_state={},
|
||||
bot_account_id=bot_account_id,
|
||||
bot=bot,
|
||||
listeners={},
|
||||
@@ -314,14 +309,9 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return
|
||||
|
||||
card_instance, card_instance_id = chat_card_entry
|
||||
# When this is the final chunk on a feedback-eligible card,
|
||||
# paint 👍/👎 into the `btns` slot. Skip for non-final chunks
|
||||
# and for non-form-template (legacy) cards which don't carry a
|
||||
# `btns` slot in their template.
|
||||
inject_feedback = is_final and bool(form_template_id) and card_instance_id in self.feedback_state
|
||||
feedback_btns_json = (
|
||||
json.dumps(self._build_feedback_btns(card_instance_id), ensure_ascii=False) if inject_feedback else '[]'
|
||||
)
|
||||
# btns is reserved exclusively for Dify form-action buttons.
|
||||
# The template renders an Avatar header above the markdown
|
||||
# content; no feedback buttons get injected here.
|
||||
if content:
|
||||
if form_template_id:
|
||||
# The card content has already been written via
|
||||
@@ -335,19 +325,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
try:
|
||||
await self.bot.update_card_data(
|
||||
out_track_id=card_instance_id,
|
||||
card_param_map={
|
||||
'content': content,
|
||||
'btns': feedback_btns_json,
|
||||
'flowStatus': '3' if is_final else '1',
|
||||
},
|
||||
card_param_map=self._card_params(
|
||||
content=content,
|
||||
btns='[]',
|
||||
flowStatus='3' if is_final else '1',
|
||||
),
|
||||
)
|
||||
# Cache the latest written content so the feedback
|
||||
# click handler can re-send it alongside the
|
||||
# disabled-buttons update — DingTalk update_card_data
|
||||
# with a partial cardParamMap clears unspecified
|
||||
# variables, so a btns-only update wipes the card body.
|
||||
if inject_feedback:
|
||||
self.feedback_state[card_instance_id]['last_content'] = content
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.exception('DingTalk: update card content failed')
|
||||
@@ -356,15 +339,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if is_final:
|
||||
if form_template_id and not content:
|
||||
# Empty final chunk still needs to leave the card with
|
||||
# flowStatus=3 so the spinner stops. Paint feedback btns
|
||||
# here too — earlier chunks already wrote the content.
|
||||
empty_final_params: dict = {'flowStatus': '3'}
|
||||
if inject_feedback:
|
||||
empty_final_params['btns'] = feedback_btns_json
|
||||
# flowStatus=3 so the spinner stops.
|
||||
try:
|
||||
await self.bot.update_card_data(
|
||||
out_track_id=card_instance_id,
|
||||
card_param_map=empty_final_params,
|
||||
card_param_map=self._card_params(flowStatus='3'),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -417,7 +396,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
out_track_id=out_track_id,
|
||||
open_space_id=open_space_id,
|
||||
is_group=is_group,
|
||||
card_param_map={'content': '', 'btns': '[]', 'flowStatus': '1'},
|
||||
card_param_map=self._card_params(content='', btns='[]', flowStatus='1'),
|
||||
callback_type='STREAM',
|
||||
)
|
||||
except Exception:
|
||||
@@ -429,11 +408,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if session_key:
|
||||
self.active_turn_card[session_key] = out_track_id
|
||||
self.active_turn_text[session_key] = ''
|
||||
# Register for feedback so the final streaming chunk can paint
|
||||
# 👍/👎 buttons. If the turn later pauses for human input,
|
||||
# _paint_form_on_card un-registers this so the form-prompt card
|
||||
# doesn't accidentally show feedback buttons.
|
||||
self._register_feedback_card(out_track_id, event)
|
||||
return True
|
||||
|
||||
# Legacy chat-card path (no form template).
|
||||
@@ -485,25 +459,38 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.bot.on_message('FriendMessage')(on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
self.bot.on_message('GroupMessage')(on_message)
|
||||
elif event_type == platform_events.FeedbackEvent:
|
||||
# Stored only; _on_card_action looks it up by type when a
|
||||
# feedback action id arrives.
|
||||
self.listeners[platform_events.FeedbackEvent] = callback
|
||||
|
||||
async def on_monitoring_message_created(self, query, monitoring_message_id: str):
|
||||
"""Pipeline hook: stash monitoring_message_id keyed by the user's
|
||||
inbound message id so the card created for this turn can carry it
|
||||
forward as FeedbackEvent.stream_id."""
|
||||
try:
|
||||
user_msg_id = query.message_event.message_chain.message_id
|
||||
if user_msg_id:
|
||||
self.pending_monitoring_msg[str(user_msg_id)] = monitoring_message_id
|
||||
except Exception as exc:
|
||||
await self.logger.debug(f'DingTalk: failed to map monitoring message: {exc}')
|
||||
|
||||
async def run_async(self):
|
||||
# Upload the bundled LangBot logo so the card Avatar can render
|
||||
# via DingTalk's media CDN — external URLs (e.g. raw.githubusercontent)
|
||||
# are blocked by DingTalk's Avatar.imageUrl resolver. Non-fatal if
|
||||
# the upload fails: cards still render without an avatar image.
|
||||
if self._LOGO_PATH.exists():
|
||||
media_id = await self.bot.upload_image_media(str(self._LOGO_PATH))
|
||||
if media_id:
|
||||
self.bot_avatar_media_id = media_id
|
||||
if self.ap is not None:
|
||||
self.ap.logger.info(f'DingTalk bot avatar uploaded: media_id={media_id}')
|
||||
else:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.warning('DingTalk bot avatar upload failed; card will use default')
|
||||
else:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.warning(f'DingTalk bot avatar source not found: {self._LOGO_PATH}')
|
||||
await self.bot.start()
|
||||
|
||||
def _card_params(self, **extra) -> dict:
|
||||
"""Build a cardParamMap dict that always carries `bot_avatar`
|
||||
(when uploaded) alongside whatever caller-specific params. The
|
||||
bot_avatar key gets dropped on every update_card_data call —
|
||||
DingTalk wipes unspecified template variables, so re-sending it
|
||||
on each update is mandatory."""
|
||||
params = {}
|
||||
if self.bot_avatar_media_id:
|
||||
params['bot_avatar'] = self.bot_avatar_media_id
|
||||
params.update(extra)
|
||||
return params
|
||||
|
||||
async def kill(self) -> bool:
|
||||
await self.bot.stop()
|
||||
return True
|
||||
@@ -634,11 +621,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'node_title': node_title,
|
||||
'form_content': form_content,
|
||||
}
|
||||
# This card is becoming a form-prompt card; thumbs feedback would
|
||||
# collide visually with the action buttons (same `btns` slot) and
|
||||
# semantically (feedback is for the answer, not an intermediate
|
||||
# prompt). Drop the registration.
|
||||
self.feedback_state.pop(out_track_id, None)
|
||||
|
||||
btns = self._build_btns(actions, out_track_id)
|
||||
parts: list[str] = []
|
||||
@@ -659,11 +641,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
try:
|
||||
await self.bot.update_card_data(
|
||||
out_track_id=out_track_id,
|
||||
card_param_map={
|
||||
'content': display_content,
|
||||
'btns': json.dumps(btns, ensure_ascii=False),
|
||||
'flowStatus': '3',
|
||||
},
|
||||
card_param_map=self._card_params(
|
||||
content=display_content,
|
||||
btns=json.dumps(btns, ensure_ascii=False),
|
||||
flowStatus='3',
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
@@ -703,92 +685,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
)
|
||||
return btns
|
||||
|
||||
@classmethod
|
||||
def _build_feedback_btns(
|
||||
cls,
|
||||
out_track_id: str,
|
||||
voted: typing.Optional[str] = None,
|
||||
) -> list:
|
||||
"""Two-button feedback pair posted to the `btns` slot.
|
||||
|
||||
The bundled `dingtalk_human_input_card.json` template's btns
|
||||
ButtonGroup schema only declares ``text/color/status/event`` —
|
||||
extra fields like ``icon`` are silently dropped by DingTalk's
|
||||
renderer, so the buttons stay text-only. Emoji is inlined into
|
||||
``text`` for a glanceable like/dislike marker.
|
||||
|
||||
* Pre-click (``voted`` is None): 👍 in `blue`, 👎 in `gray`; both
|
||||
clickable.
|
||||
* Post-click (``voted`` matches one action id): the clicked button
|
||||
keeps its color, the other goes flat `gray`; both `disabled`.
|
||||
"""
|
||||
items = [
|
||||
('👍 有帮助', cls.FEEDBACK_ACTION_UP, 'blue'),
|
||||
('👎 无帮助', cls.FEEDBACK_ACTION_DOWN, 'gray'),
|
||||
]
|
||||
btns = []
|
||||
for text, action_id, default_color in items:
|
||||
if voted is None:
|
||||
color = default_color
|
||||
status = 'normal'
|
||||
else:
|
||||
color = default_color if action_id == voted else 'gray'
|
||||
status = 'disabled'
|
||||
btns.append(
|
||||
{
|
||||
'text': text,
|
||||
'color': color,
|
||||
'status': status,
|
||||
'event': {
|
||||
'type': 'sendCardRequest',
|
||||
'params': {
|
||||
'actionId': action_id,
|
||||
'params': {'action_id': action_id, 'out_track_id': out_track_id},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
return btns
|
||||
|
||||
def _register_feedback_card(
|
||||
self,
|
||||
out_track_id: str,
|
||||
message_source: platform_events.MessageEvent,
|
||||
) -> None:
|
||||
"""Mark a card as eligible for thumbs feedback by capturing session
|
||||
+ user + monitoring info. Pulls the monitoring_message_id from
|
||||
``pending_monitoring_msg`` keyed by the inbound DingTalk message id;
|
||||
falls back to empty for synthetic events (resumed-workflow cards),
|
||||
in which case the FeedbackEvent still fires but without a stream
|
||||
id correlation."""
|
||||
if not out_track_id or message_source is None:
|
||||
return
|
||||
user_msg_id = ''
|
||||
monitoring_msg_id = ''
|
||||
spo = getattr(message_source, 'source_platform_object', None)
|
||||
if spo is not None:
|
||||
inc = getattr(spo, 'incoming_message', None)
|
||||
if inc is not None:
|
||||
user_msg_id = str(getattr(inc, 'message_id', '') or '')
|
||||
if user_msg_id:
|
||||
monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, '')
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
session_id = f'group_{message_source.group.id}'
|
||||
user_id = str(message_source.sender.id)
|
||||
elif isinstance(message_source, platform_events.FriendMessage):
|
||||
session_id = f'person_{message_source.sender.id}'
|
||||
user_id = str(message_source.sender.id)
|
||||
else:
|
||||
return
|
||||
self.feedback_state[out_track_id] = {
|
||||
'monitoring_msg_id': monitoring_msg_id,
|
||||
'session_id': session_id,
|
||||
'user_id': user_id,
|
||||
'user_msg_id': user_msg_id,
|
||||
'created_at': time.time(),
|
||||
'voted': False,
|
||||
}
|
||||
|
||||
async def _send_form_card(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -863,11 +759,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
out_track_id=out_track_id,
|
||||
open_space_id=open_space_id,
|
||||
is_group=is_group,
|
||||
card_param_map={
|
||||
'content': display_content,
|
||||
'btns': json.dumps(btns, ensure_ascii=False),
|
||||
'flowStatus': '3',
|
||||
},
|
||||
card_param_map=self._card_params(
|
||||
content=display_content,
|
||||
btns=json.dumps(btns, ensure_ascii=False),
|
||||
flowStatus='3',
|
||||
),
|
||||
callback_type='STREAM',
|
||||
)
|
||||
except Exception:
|
||||
@@ -894,9 +790,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
out_track_id = uuid.uuid4().hex
|
||||
open_space_id, is_group = self._derive_open_space(message_source)
|
||||
if form_template_id:
|
||||
card_param_map = {'content': '', 'btns': '[]', 'flowStatus': '1'}
|
||||
card_param_map = self._card_params(content='', btns='[]', flowStatus='1')
|
||||
card_data_config = None
|
||||
else:
|
||||
# Legacy chat-card template doesn't carry a `bot_avatar`
|
||||
# variable, so don't decorate the param map here.
|
||||
card_param_map = {'content': '', 'query': '...'}
|
||||
card_data_config = {'autoLayout': self.config.get('card_auto_layout', False)}
|
||||
try:
|
||||
@@ -923,12 +821,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if session_key:
|
||||
self.active_turn_card[session_key] = out_track_id
|
||||
self.active_turn_text[session_key] = ''
|
||||
# Resumed cards are eligible for feedback too — the synthetic
|
||||
# message_source has no source_platform_object so the helper
|
||||
# records empty user_msg_id/monitoring_msg_id; FeedbackEvent fires
|
||||
# without a stream_id correlation.
|
||||
if form_template_id:
|
||||
self._register_feedback_card(out_track_id, message_source)
|
||||
return entry
|
||||
|
||||
async def send_message_text_form(
|
||||
@@ -1003,13 +895,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}')
|
||||
return
|
||||
|
||||
# Feedback path: handle 👍/👎 clicks before form-action lookup so a
|
||||
# feedback click on a card that also happens to be tracked in
|
||||
# card_state (it shouldn't, but defensively) is still routed right.
|
||||
if raw_action_id in (self.FEEDBACK_ACTION_UP, self.FEEDBACK_ACTION_DOWN):
|
||||
await self._handle_feedback_action(out_track_id, raw_action_id, payload)
|
||||
return
|
||||
|
||||
state = self.card_state.get(out_track_id)
|
||||
if state is None:
|
||||
await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}')
|
||||
@@ -1133,71 +1018,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# Once consumed, drop the state — the runner clears _PENDING_FORMS too.
|
||||
self.card_state.pop(out_track_id, None)
|
||||
|
||||
async def _handle_feedback_action(
|
||||
self,
|
||||
out_track_id: str,
|
||||
action_id: str,
|
||||
payload: dict,
|
||||
) -> None:
|
||||
"""Process a 👍/👎 click: dispatch FeedbackEvent to the registered
|
||||
listener (LangBot's monitoring service records it), then repaint
|
||||
the card's buttons in `disabled` state so the click is visibly
|
||||
acknowledged and not re-issued."""
|
||||
state = self.feedback_state.get(out_track_id)
|
||||
if state is None:
|
||||
await self.logger.warning(f'DingTalk: feedback action {action_id} for unknown out_track_id={out_track_id}')
|
||||
return
|
||||
if state.get('voted'):
|
||||
# Already voted — ignore re-clicks (DingTalk may still deliver
|
||||
# them despite the disabled status, depending on the client).
|
||||
return
|
||||
|
||||
feedback_type = 1 if action_id == self.FEEDBACK_ACTION_UP else 2
|
||||
feedback_content = '有帮助' if feedback_type == 1 else '无帮助'
|
||||
|
||||
listener = self.listeners.get(platform_events.FeedbackEvent)
|
||||
if listener is not None:
|
||||
feedback_event = platform_events.FeedbackEvent(
|
||||
feedback_id=str(uuid.uuid4()),
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=feedback_content,
|
||||
user_id=state.get('user_id') or payload.get('user_id') or '',
|
||||
session_id=state.get('session_id', ''),
|
||||
message_id=state.get('user_msg_id', ''),
|
||||
stream_id=state.get('monitoring_msg_id', '') or None,
|
||||
source_platform_object=payload,
|
||||
)
|
||||
try:
|
||||
await listener(feedback_event, self)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.exception('DingTalk: feedback listener raised')
|
||||
|
||||
state['voted'] = action_id
|
||||
# Repaint the same `btns` slot: clicked button keeps its color +
|
||||
# disabled (marks the user's choice), other goes gray + disabled.
|
||||
# CRITICAL: DingTalk update_card_data with a partial cardParamMap
|
||||
# clears unspecified template variables — sending only `btns` wipes
|
||||
# the card body. Always re-send content + flowStatus too.
|
||||
card_param_map: dict = {
|
||||
'btns': json.dumps(
|
||||
self._build_feedback_btns(out_track_id, voted=action_id),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
'flowStatus': '3',
|
||||
}
|
||||
last_content = state.get('last_content')
|
||||
if last_content:
|
||||
card_param_map['content'] = last_content
|
||||
try:
|
||||
await self.bot.update_card_data(
|
||||
out_track_id=out_track_id,
|
||||
card_param_map=card_param_map,
|
||||
)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
self.ap.logger.exception('DingTalk: grey out feedback buttons failed')
|
||||
|
||||
async def _mark_card_resolved(
|
||||
self,
|
||||
out_track_id: str,
|
||||
@@ -1224,11 +1044,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
try:
|
||||
await self.bot.update_card_data(
|
||||
out_track_id=out_track_id,
|
||||
card_param_map={
|
||||
'content': content,
|
||||
'btns': '[]',
|
||||
'flowStatus': '3',
|
||||
},
|
||||
card_param_map=self._card_params(
|
||||
content=content,
|
||||
btns='[]',
|
||||
flowStatus='3',
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
if self.ap is not None:
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user