feat: Refactor input handling and feedback messages across multiple adapters

This commit is contained in:
fdc310
2026-07-11 19:23:42 +08:00
parent 183fb87649
commit 6cae954a67
12 changed files with 77 additions and 95 deletions
+3 -2
View File
@@ -693,7 +693,7 @@ class QQOfficialClient:
target_type: str,
target_id: str,
markdown_content: str,
keyboard: dict,
keyboard: Optional[dict] = None,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
@@ -734,9 +734,10 @@ class QQOfficialClient:
body: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': markdown_content},
'keyboard': keyboard,
'msg_seq': msg_seq,
}
if keyboard and keyboard.get('content', {}).get('rows'):
body['keyboard'] = keyboard
if msg_id:
body['msg_id'] = msg_id
if event_id:
+4 -6
View File
@@ -1129,12 +1129,10 @@ def build_button_interaction_payload(
input_hint_lines = _wecom_input_hint_lines(form_data)
if input_hint_lines:
sub_title_parts.append('Fill these fields in chat before choosing an action:\n' + '\n'.join(input_hint_lines))
elif should_show_actions and actions:
sub_title_parts.append('Choose an action to continue.')
if overflow:
extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in overflow]
sub_title_parts.append(f'另有 {len(overflow)} 个选项不在按钮列表中,可直接回复 id:\n' + '\n'.join(extra_lines))
sub_title_text = '\n\n'.join(sub_title_parts) or '请选择一个操作以继续。'
sub_title_text = '\n\n'.join(sub_title_parts)
button_list = []
for idx, action in enumerate(visible_actions):
@@ -1452,8 +1450,8 @@ def build_button_interaction_update_card(
}
if action_key == clean_action_id:
button['style'] = _wecom_button_style(action, selected=True)
button['text'] = f'已选择:{button_title}'
button['replace_text'] = f'已选择:{button_title}'
button['text'] = f'{button_title}'
button['replace_text'] = f'{button_title}'
matched = True
button_list.append(button)
@@ -1463,7 +1461,7 @@ def build_button_interaction_update_card(
'text': action_title or clean_action_id,
'style': 1,
'key': clean_action_id,
'replace_text': f'已选择:{action_title or clean_action_id}',
'replace_text': f'{action_title or clean_action_id}',
}
)
+8 -18
View File
@@ -296,14 +296,8 @@ def _dingtalk_completed_input_lines(form_data: dict) -> list[str]:
value = inputs.get(field_name)
if value in (None, '', []):
continue
field_type = _dingtalk_field_type(field)
display_value = _dingtalk_display_input_value(field, value)
if field_type == 'select':
lines.append(f'✅ 已选择 {field_name}{display_value}')
elif field_type in {'file', 'file-list'}:
lines.append(f'✅ 已上传 {field_name}{display_value}')
else:
lines.append(f'✅ 已填写 {field_name}{display_value}')
lines.append(f'{field_name}{display_value}')
return lines
@@ -960,9 +954,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data)
if input_hint_lines:
parts.append('Fill these fields in chat before choosing an action:<br>' + '<br>'.join(input_hint_lines))
elif should_show_actions and actions:
parts.append('Choose an action to continue.')
display_content = '<br><br>'.join(parts) or '请选择一个操作以继续。'
display_content = '<br><br>'.join(parts)
try:
await self.bot.update_card_data(
@@ -1062,9 +1054,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data)
if input_hint_lines:
parts.append('Fill these fields in chat before choosing an action:<br>' + '<br>'.join(input_hint_lines))
elif should_show_actions and actions:
parts.append('Choose an action to continue.')
display_content = '<br><br>'.join(parts) or '请选择一个操作以继续。'
display_content = '<br><br>'.join(parts)
btns = self._build_btns(actions if should_show_actions else [], out_track_id)
@@ -1100,7 +1090,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Create a new card for resumed-workflow streaming output.
Used after a button click triggers a synthetic event the form
card stays put with the "已选择" notice, and a fresh card is
card stays put with the selection notice, and a fresh card is
spawned here for the LLM reply to stream into.
"""
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
@@ -1349,7 +1339,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return
# Visual feedback on the form card itself: keep the prompt visible,
# add a "已选择" line, remove the buttons. The resumed-workflow
# add a selection line, remove the buttons. The resumed-workflow
# output lives on a separate new card (lazy-created in
# reply_message_chunk on the synthetic event), so the form card
# stays put as a record of the user's selection.
@@ -1367,7 +1357,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Crucial: do NOT leave the form card's out_track_id in
# active_turn_card — otherwise create_message_card for the
# synthetic event would reuse it for the resume output, painting
# the LLM reply on top of the "已选择" notice. Clear it so the
# the LLM reply on top of the selection notice. Clear it so the
# resume goes through the lazy-create path and spawns a fresh card.
session_key = state.get('session_key', '')
if session_key and self.active_turn_card.get(session_key) == out_track_id:
@@ -1487,7 +1477,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) -> None:
"""Update the form card to acknowledge the user's selection.
Keeps the original prompt visible, adds a "已选择: X" notice, and
Keeps the original prompt visible, adds a selection notice, and
clears the buttons. The card stays as a permanent record of the
choice; the resumed workflow's output goes to a separate new card.
"""
@@ -1504,7 +1494,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
if completed_lines and not all(line in form_content for line in completed_lines):
parts.append('<hr>' + '<br>'.join(completed_lines))
parts.append(f'<hr>✅ 已选择:**{action_title}**')
parts.append(f'<hr>✅ {action_title}')
content = '<br><br>'.join(parts)
if self.ap is not None:
self.ap.logger.info(f'DingTalk _mark_card_resolved: out_track_id={out_track_id} action={action_title!r}')
+1 -2
View File
@@ -1396,7 +1396,6 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
body_parts: list[str] = []
if form_content:
body_parts.append(form_content)
body_parts.append('Please select an option below:')
embed_body = '\n\n'.join(body_parts)
# Discord embed.description has a 4096 char limit — defensive trim.
if len(embed_body) > 4000:
@@ -1589,7 +1588,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
stale: bool = False,
) -> None:
"""Disable all buttons on the form view and annotate the chosen
one mirrors DingTalk/Lark's in-card 已选择 feedback."""
one mirrors DingTalk/Lark's in-card selection feedback."""
try:
for child in view.children:
if not isinstance(child, discord_ui.Button):
+4 -10
View File
@@ -187,13 +187,7 @@ def _lark_completed_input_lines(form_data: dict) -> list[str]:
if value in (None, '', []):
continue
display_value = _lark_display_input_value(field, value)
field_type = _dify_field_type(field)
if field_type == 'select':
lines.append(f'✅ 已选择 {field_name}**{display_value}**')
elif field_type in {'file', 'file-list'}:
lines.append(f'✅ 已上传 {field_name}**{display_value}**')
else:
lines.append(f'✅ 已填写 {field_name}**{display_value}**')
lines.append(f'{field_name}{display_value}')
return lines
@@ -2014,7 +2008,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
Supports Dify form-action resume: when the runner yields a chunk with
``_resume_from_form=True``, the card transitions from buttons to a
grey "已选择" notice and a new ``streaming_txt_resume`` element is added
grey selection notice and a new ``streaming_txt_resume`` element is added
for subsequent resume chunks to stream into.
When ``_open_new_card=True`` on the final chunk, the existing card is
@@ -2051,7 +2045,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
if completed_lines and not all(line in stored_form_content for line in completed_lines):
notice_parts.append('---\n' + '\n'.join(completed_lines))
notice_parts.append(f'---\n已选择:**{action_title}**')
notice_parts.append(f'---\n{action_title}')
selected_notice = '\n\n'.join(notice_parts)
else:
selected_notice = ''
@@ -2379,7 +2373,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Update the entire card layout.
form_data show interactive buttons (initial Dify pause)
notice_text replace buttons with a grey "已选择" notice (resume transition)
notice_text replace buttons with a grey selection notice (resume transition)
resume_placeholder_text add a streaming_txt_resume markdown element
"""
form_data = form_data or {}
+10 -39
View File
@@ -842,51 +842,22 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
form_content = form_data.get('form_content') or ''
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
parts = [f'### {node_title}']
plain_parts = [node_title]
if form_content.strip():
parts.append(form_content.strip())
parts.append('请点击下方按钮选择:')
plain_parts.append(form_content.strip())
markdown_content = '\n\n'.join(parts)
plain_content = '\n\n'.join(plain_parts)
keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None
if is_field_step and not keyboard.get('content', {}).get('rows'):
field_parts = parts[:-1] if len(parts) > 1 else parts
text_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(field_parts))])
try:
await self.reply_message(message_source, text_msg)
except Exception:
await self.logger.error(f'QQ Official: field-step text send failed: {traceback.format_exc()}')
return
sender_id = ''
if source is not None:
sender_id = (
getattr(source, 'user_openid', None)
or getattr(source, 'member_openid', None)
or getattr(source, 'd_author_id', None)
or ''
)
if not sender_id and message_source.sender is not None:
sender_id = str(getattr(message_source.sender, 'id', '') or '')
self._pending_forms[session_key] = {
'form_data': form_data,
'msg_id': msg_id,
'sender_id': sender_id,
'target_type': target_type,
'target_id': target_id,
'source_event_t': source.t if source is not None else None,
'posted_at': time.time(),
}
await self.logger.info(
f'QQ Official: form field step posted session={session_key} '
f'field={form_data.get("_current_input_field")!r}'
)
return
if keyboard is None:
is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows')
if is_text_field_step:
keyboard = None
if keyboard is None and not is_text_field_step:
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
if not keyboard.get('content', {}).get('rows'):
if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step:
# No actions to render — fall back to plain text.
text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)])
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
try:
await self.reply_message(message_source, text_msg)
except Exception:
@@ -935,7 +906,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
await self.logger.error(
f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}'
)
text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)])
text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)])
try:
await self.reply_message(message_source, text_msg)
except Exception:
+5 -10
View File
@@ -304,7 +304,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
action_title = self._form_action_titles[query.data]
try:
original_text = query.message.text or ''
selected_text = f'{original_text}\n\n Selected: {action_title}'
selected_text = f'{original_text}\n\n{action_title}'
await query.edit_message_text(text=selected_text, reply_markup=None)
except Exception:
# If edit fails (e.g. message too long), just pass
@@ -687,15 +687,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
effective_message = update.effective_message
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
if is_select_field:
prompt = f'Please select {select_field}:'
elif is_field_step:
prompt = f'Please reply with a value for {current_field}.'
else:
prompt = 'Please select an action:'
text_lines = [f'[{node_title}] {prompt}']
heading = f'[{node_title}]'
text_lines = [heading]
if form_content:
text_lines.insert(0, form_content)
text_lines.append(form_content)
if oversized:
# callback_data exceeds Telegram's 64-byte limit — fall back to
@@ -722,7 +717,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# prompts even when they cannot read ordinary group messages.
'reply_markup': ForceReply(
selective=False,
input_field_placeholder=f'Enter {current_field}',
input_field_placeholder=current_field,
),
}
else:
@@ -93,7 +93,7 @@ def test_dingtalk_completed_input_lines_include_text_and_select_values():
}
)
assert lines == [' 已填写 commentlooks good', ' 已选择 choiceB']
assert lines == ['✅ commentlooks good', '✅ choiceB']
def test_dingtalk_clean_form_content_uses_all_input_defs():
@@ -109,9 +109,9 @@ def test_lark_completed_input_lines_include_text_select_and_files():
)
assert lines == [
' 已填写 us_input**你好**',
' 已选择 xiala**or**',
' 已上传 files**2 file(s)**',
'✅ us_input你好',
'✅ xialaor',
'✅ files2 file(s)',
]
@@ -193,4 +193,4 @@ def test_lark_completed_input_lines_display_select_value_from_object():
}
)
assert lines == [' 已选择 xiala**B**']
assert lines == ['✅ xialaB']
@@ -75,6 +75,7 @@ def _stream_test_adapter():
adapter.logger = AsyncMock()
adapter.bot = MagicMock()
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
adapter.ap = None
adapter._stream_ctx = {}
adapter._stream_ctx_ts = {}
@@ -142,6 +143,36 @@ async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
assert str(sent_chain) == 'Hello'
@pytest.mark.asyncio
async def test_qq_text_field_prompt_keeps_form_content():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
adapter = _stream_test_adapter()
adapter._pending_forms = {}
adapter._session_event_ids = {}
adapter._anchor_msg_seq = {}
source = MagicMock()
source.d_id = 'source-1'
source.t = 'C2C_MESSAGE_CREATE'
event = MagicMock()
event.source_platform_object = source
event.sender.id = 'user-1'
form_data = {
'_current_input_field': 'us_input',
'node_title': 'Manual input',
'form_content': '1234\nEnter your question',
'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}],
'actions': [{'id': 'yes', 'title': 'yes'}],
}
with patch.object(QQOfficialAdapter, '_resolve_target_from_event', return_value=('c2c', 'user-1')):
await adapter._handle_form_chunk(event, platform_message.MessageChain([]), form_data)
send_call = adapter.bot.send_markdown_keyboard.await_args.kwargs
assert send_call['markdown_content'] == '### Manual input\n\n1234\nEnter your question'
assert send_call['keyboard'] is None
@pytest.mark.asyncio
async def test_qq_select_click_enqueues_input_progress_query():
import langbot.pkg.core.app # noqa: F401
@@ -116,5 +116,8 @@ async def test_telegram_text_field_does_not_show_action_buttons():
args = bot.send_message.await_args.kwargs
assert isinstance(args['reply_markup'], ForceReply)
assert args['reply_markup'].selective is False
assert 'Please reply with a value for us_input.' in args['text']
assert args['reply_markup'].input_field_placeholder == 'us_input'
assert 'Please reply' not in args['text']
assert args['text'].startswith('[人工介入]')
assert 'us_input (paragraph)' in args['text']
assert adapter._form_action_titles == {}
@@ -81,10 +81,10 @@ def test_build_button_interaction_update_card_marks_clicked_button():
assert card['sub_title_text'] == 'Please choose one action.'
assert card['button_list'][0] == {'text': 'Approve', 'style': 2, 'key': 'approve'}
assert card['button_list'][1] == {
'text': '已选择:Reject',
'text': 'Reject',
'style': 1,
'key': 'reject',
'replace_text': '已选择:Reject',
'replace_text': 'Reject',
}
assert card['source'] == {'desc': 'LangBot'}