mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 04:16:07 +00:00
feat(dingtalk): add feedback functionality to cards with 👍/👎 buttons
- Implemented feedback state management for cards, allowing users to provide feedback via thumbs up/down buttons. - Enhanced card rendering to include feedback buttons when appropriate. - Registered feedback listeners to handle feedback events and update card states accordingly. - Updated the card template to support dynamic button rendering for feedback actions. - Improved error handling and logging for feedback actions and card updates.
This commit is contained in:
@@ -18,9 +18,16 @@ OUTPUT = Path('src/langbot/templates/dingtalk_human_input_card.json')
|
|||||||
def markdown_block(node_id, variable='content'):
|
def markdown_block(node_id, variable='content'):
|
||||||
"""A MarkdownBlock whose content is bound to a global variable.
|
"""A MarkdownBlock whose content is bound to a global variable.
|
||||||
|
|
||||||
Unlike BaseText (whose `text` field requires editor-side manual binding),
|
Critical: `content.varType: "markdown"` must be set, otherwise DingTalk
|
||||||
MarkdownBlock's `content` accepts `variableValue` binding at JSON load
|
silently fails to render the bound variable (the card body stays blank
|
||||||
time — the imported template renders the variable straight away.
|
even though the variable is supplied via cardParamMap). The working
|
||||||
|
reference template in I:\\下载\\dingtalk_1782055283543.json confirms
|
||||||
|
this — its MarkdownBlock has the same varType marker.
|
||||||
|
|
||||||
|
isStreaming is left `false` because the adapter writes the variable via
|
||||||
|
`update_card_data` (the full-card PUT endpoint), not the streaming
|
||||||
|
`card/streaming` endpoint. Setting `isStreaming: true` here conflicts
|
||||||
|
with that path and can suppress the rendered body.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
'componentName': 'MarkdownBlock',
|
'componentName': 'MarkdownBlock',
|
||||||
@@ -28,16 +35,25 @@ def markdown_block(node_id, variable='content'):
|
|||||||
'props': {
|
'props': {
|
||||||
'mdVer': 0,
|
'mdVer': 0,
|
||||||
'icon': {'type': 'icon', 'icon': '', 'iconType': 'emoji'},
|
'icon': {'type': 'icon', 'icon': '', 'iconType': 'emoji'},
|
||||||
'content': {'variable': variable, 'variableType': 'global', 'type': 'variableValue'},
|
'content': {
|
||||||
|
'variable': variable,
|
||||||
|
'variableType': 'global',
|
||||||
|
'type': 'variableValue',
|
||||||
|
'varType': 'markdown',
|
||||||
|
},
|
||||||
'visible': {
|
'visible': {
|
||||||
'type': 'dynamicVisible',
|
'type': 'dynamicVisible',
|
||||||
'value': True,
|
'value': True,
|
||||||
'valueType': 'fixed',
|
'valueType': 'fixed',
|
||||||
'condition': {'op': 'and', 'conditions': []},
|
'condition': {'op': 'and', 'conditions': []},
|
||||||
},
|
},
|
||||||
'isStreaming': True,
|
'isStreaming': False,
|
||||||
'enableLinkStatPoint': False,
|
'enableLinkStatPoint': False,
|
||||||
'linkStatPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
|
'linkStatPoint': {
|
||||||
|
'type': 'dynamicString',
|
||||||
|
'content': 'Page_InteractiveCard__Click_markdownOpenlink',
|
||||||
|
'i18n': False,
|
||||||
|
},
|
||||||
'linkStatPointParams': [],
|
'linkStatPointParams': [],
|
||||||
'marginTop': 6,
|
'marginTop': 6,
|
||||||
'marginBottom': 6,
|
'marginBottom': 6,
|
||||||
@@ -406,6 +422,82 @@ def build_editor_data():
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Empty containers for flowStatus=2 (writing) and flowStatus=4 (doing).
|
||||||
|
# AICardContainer expects placeholders to exist for every enabled state;
|
||||||
|
# without them, the renderer can refuse to advance to flowStatus=3 (done)
|
||||||
|
# and the card body stays empty. They render nothing visible because
|
||||||
|
# they have no content children, but their presence satisfies the
|
||||||
|
# state-machine validation.
|
||||||
|
def _empty_status_container(node_id, status):
|
||||||
|
return {
|
||||||
|
'componentName': 'AICardStatusContainer',
|
||||||
|
'id': node_id,
|
||||||
|
'props': {
|
||||||
|
'status': status,
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'enableExtend': False,
|
||||||
|
'autoFoldConfig': {
|
||||||
|
'needFold': True,
|
||||||
|
'heightLimit': 480,
|
||||||
|
'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey',
|
||||||
|
},
|
||||||
|
'innerOffset': 0,
|
||||||
|
'enableCollapse': False,
|
||||||
|
'margin': -2,
|
||||||
|
},
|
||||||
|
'title': f'状态{status}占位',
|
||||||
|
'hidden': False,
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [
|
||||||
|
{
|
||||||
|
'componentName': 'AICardContent',
|
||||||
|
'id': f'{node_id}_content',
|
||||||
|
'props': {
|
||||||
|
'marginLeft': 0,
|
||||||
|
'marginRight': 0,
|
||||||
|
'marginTop': 0,
|
||||||
|
'marginBottom': 0,
|
||||||
|
'visible': {
|
||||||
|
'type': 'dynamicVisible',
|
||||||
|
'value': True,
|
||||||
|
'valueType': 'fixed',
|
||||||
|
'condition': {'op': 'and', 'conditions': []},
|
||||||
|
},
|
||||||
|
'innerOffset': 0,
|
||||||
|
'disabledWhileForward': False,
|
||||||
|
'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
|
||||||
|
'statPointParams': [
|
||||||
|
{
|
||||||
|
'type': 'fixed',
|
||||||
|
'variable': '',
|
||||||
|
'value': '',
|
||||||
|
'name': '',
|
||||||
|
'variableType': 'global',
|
||||||
|
'id': '1',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'margin': -2,
|
||||||
|
'transformToEventChain': False,
|
||||||
|
'enableStatPoint': False,
|
||||||
|
},
|
||||||
|
'hidden': False,
|
||||||
|
'title': '',
|
||||||
|
'isLocked': False,
|
||||||
|
'condition': True,
|
||||||
|
'conditionGroup': '',
|
||||||
|
'children': [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
writing_state = _empty_status_container('node_status_writing', 2)
|
||||||
|
doing_state = _empty_status_container('node_status_doing', 4)
|
||||||
|
|
||||||
root = {
|
root = {
|
||||||
'componentName': 'AICardContainer',
|
'componentName': 'AICardContainer',
|
||||||
'id': 'node_root',
|
'id': 'node_root',
|
||||||
@@ -415,8 +507,12 @@ def build_editor_data():
|
|||||||
'marginTop': 0,
|
'marginTop': 0,
|
||||||
'marginBottom': 0,
|
'marginBottom': 0,
|
||||||
'enablePending': True,
|
'enablePending': True,
|
||||||
'enableWriting': False,
|
# writing/doing must be enabled so AICardContainer recognises
|
||||||
'enableDoing': False,
|
# flowStatus transitions through 2/4 — without this, the
|
||||||
|
# working reference template (I:\\下载\\dingtalk_1782055283543.json)
|
||||||
|
# never reaches the done state and the body stays empty.
|
||||||
|
'enableWriting': True,
|
||||||
|
'enableDoing': True,
|
||||||
'enableFailed': True,
|
'enableFailed': True,
|
||||||
'summaryContent': {'type': 'variableValue', 'variableType': 'global', 'variable': ''},
|
'summaryContent': {'type': 'variableValue', 'variableType': 'global', 'variable': ''},
|
||||||
'enableTitle': False,
|
'enableTitle': False,
|
||||||
@@ -449,7 +545,7 @@ def build_editor_data():
|
|||||||
'isLocked': False,
|
'isLocked': False,
|
||||||
'condition': True,
|
'condition': True,
|
||||||
'conditionGroup': '',
|
'conditionGroup': '',
|
||||||
'children': [pending_state, done_state, failed_state],
|
'children': [pending_state, writing_state, doing_state, done_state, failed_state],
|
||||||
}
|
}
|
||||||
|
|
||||||
btns_var = {
|
btns_var = {
|
||||||
@@ -552,7 +648,13 @@ def build_editor_data():
|
|||||||
return {
|
return {
|
||||||
'schemaVersion': '3.0.0',
|
'schemaVersion': '3.0.0',
|
||||||
'schema': {
|
'schema': {
|
||||||
'config': {'update_multi': True, 'streaming_mode': True},
|
# Match the working reference template — leaving config null lets
|
||||||
|
# DingTalk pick defaults. Explicit `streaming_mode: true` would
|
||||||
|
# make the renderer wait for chunks on the streaming endpoint
|
||||||
|
# (PUT /v1.0/card/streaming), which our adapter does NOT use —
|
||||||
|
# it pushes content via update_card_data, so streaming_mode=true
|
||||||
|
# leaves the body empty.
|
||||||
|
'config': None,
|
||||||
'componentsMap': components_map,
|
'componentsMap': components_map,
|
||||||
'componentsTree': [root],
|
'componentsTree': [root],
|
||||||
'i18n': {},
|
'i18n': {},
|
||||||
@@ -629,7 +731,16 @@ def build_editor_data():
|
|||||||
'hsfList': [],
|
'hsfList': [],
|
||||||
'lwpList': [],
|
'lwpList': [],
|
||||||
'pageData': {},
|
'pageData': {},
|
||||||
'extension': {'extendType': 'AI', 'aiStatusList': [3, 1, 5], 'fileTypeList': []},
|
'extension': {
|
||||||
|
'extendType': 'AI',
|
||||||
|
# All 5 statuses listed — must mirror the enableX flags on
|
||||||
|
# AICardContainer. The working reference template's extension
|
||||||
|
# includes 2 (writing) and 4 (doing); omitting them while
|
||||||
|
# enableWriting/enableDoing are true makes the renderer reject
|
||||||
|
# transitions and leaves the card body empty.
|
||||||
|
'aiStatusList': [3, 1, 5, 2, 4],
|
||||||
|
'fileTypeList': [],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
@@ -190,9 +191,30 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# by _paint_form_on_card so the post-pause form keeps the streamed
|
# by _paint_form_on_card so the post-pause form keeps the streamed
|
||||||
# context above the new prompt.
|
# context above the new prompt.
|
||||||
active_turn_text: dict
|
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.
|
||||||
|
listeners: typing.Dict[
|
||||||
|
typing.Type[platform_events.Event],
|
||||||
|
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||||
|
]
|
||||||
ap: typing.Any = None
|
ap: typing.Any = None
|
||||||
bot_uuid: str = ''
|
bot_uuid: 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__'
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
required_keys = [
|
required_keys = [
|
||||||
'client_id',
|
'client_id',
|
||||||
@@ -219,6 +241,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
card_state={},
|
card_state={},
|
||||||
active_turn_card={},
|
active_turn_card={},
|
||||||
active_turn_text={},
|
active_turn_text={},
|
||||||
|
pending_monitoring_msg={},
|
||||||
|
feedback_state={},
|
||||||
bot_account_id=bot_account_id,
|
bot_account_id=bot_account_id,
|
||||||
bot=bot,
|
bot=bot,
|
||||||
listeners={},
|
listeners={},
|
||||||
@@ -290,6 +314,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
card_instance, card_instance_id = chat_card_entry
|
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 '[]'
|
||||||
|
)
|
||||||
if content:
|
if content:
|
||||||
if form_template_id:
|
if form_template_id:
|
||||||
# The card content has already been written via
|
# The card content has already been written via
|
||||||
@@ -305,10 +337,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
out_track_id=card_instance_id,
|
out_track_id=card_instance_id,
|
||||||
card_param_map={
|
card_param_map={
|
||||||
'content': content,
|
'content': content,
|
||||||
'btns': '[]',
|
'btns': feedback_btns_json,
|
||||||
'flowStatus': '3' if is_final else '1',
|
'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:
|
except Exception:
|
||||||
if self.ap is not None:
|
if self.ap is not None:
|
||||||
self.ap.logger.exception('DingTalk: update card content failed')
|
self.ap.logger.exception('DingTalk: update card content failed')
|
||||||
@@ -317,11 +356,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if is_final:
|
if is_final:
|
||||||
if form_template_id and not content:
|
if form_template_id and not content:
|
||||||
# Empty final chunk still needs to leave the card with
|
# Empty final chunk still needs to leave the card with
|
||||||
# flowStatus=3 so the spinner stops.
|
# 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
|
||||||
try:
|
try:
|
||||||
await self.bot.update_card_data(
|
await self.bot.update_card_data(
|
||||||
out_track_id=card_instance_id,
|
out_track_id=card_instance_id,
|
||||||
card_param_map={'flowStatus': '3'},
|
card_param_map=empty_final_params,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -386,6 +429,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if session_key:
|
if session_key:
|
||||||
self.active_turn_card[session_key] = out_track_id
|
self.active_turn_card[session_key] = out_track_id
|
||||||
self.active_turn_text[session_key] = ''
|
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
|
return True
|
||||||
|
|
||||||
# Legacy chat-card path (no form template).
|
# Legacy chat-card path (no form template).
|
||||||
@@ -437,6 +485,21 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.bot.on_message('FriendMessage')(on_message)
|
self.bot.on_message('FriendMessage')(on_message)
|
||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
self.bot.on_message('GroupMessage')(on_message)
|
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):
|
async def run_async(self):
|
||||||
await self.bot.start()
|
await self.bot.start()
|
||||||
@@ -571,6 +634,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'node_title': node_title,
|
'node_title': node_title,
|
||||||
'form_content': form_content,
|
'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)
|
btns = self._build_btns(actions, out_track_id)
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
@@ -635,6 +703,92 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
)
|
)
|
||||||
return btns
|
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(
|
async def _send_form_card(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -769,6 +923,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if session_key:
|
if session_key:
|
||||||
self.active_turn_card[session_key] = out_track_id
|
self.active_turn_card[session_key] = out_track_id
|
||||||
self.active_turn_text[session_key] = ''
|
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
|
return entry
|
||||||
|
|
||||||
async def send_message_text_form(
|
async def send_message_text_form(
|
||||||
@@ -839,13 +999,21 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
or (params.get('actionId') or '').strip()
|
or (params.get('actionId') or '').strip()
|
||||||
or (params.get('id') or '').strip()
|
or (params.get('id') or '').strip()
|
||||||
)
|
)
|
||||||
|
if not raw_action_id:
|
||||||
|
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)
|
state = self.card_state.get(out_track_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}')
|
await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}')
|
||||||
return
|
return
|
||||||
if not raw_action_id:
|
|
||||||
await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}')
|
|
||||||
return
|
|
||||||
|
|
||||||
actions = state.get('actions', []) or []
|
actions = state.get('actions', []) or []
|
||||||
action_id = raw_action_id
|
action_id = raw_action_id
|
||||||
@@ -965,6 +1133,71 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# Once consumed, drop the state — the runner clears _PENDING_FORMS too.
|
# Once consumed, drop the state — the runner clears _PENDING_FORMS too.
|
||||||
self.card_state.pop(out_track_id, None)
|
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(
|
async def _mark_card_resolved(
|
||||||
self,
|
self,
|
||||||
out_track_id: str,
|
out_track_id: str,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user