mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 16:36:07 +00:00
feat: Refactor input handling and feedback messages across multiple adapters
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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}',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == ['✅ 已填写 comment:looks good', '✅ 已选择 choice:B']
|
||||
assert lines == ['✅ comment:looks good', '✅ choice:B']
|
||||
|
||||
|
||||
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:你好',
|
||||
'✅ xiala:or',
|
||||
'✅ files:2 file(s)',
|
||||
]
|
||||
|
||||
|
||||
@@ -193,4 +193,4 @@ def test_lark_completed_input_lines_display_select_value_from_object():
|
||||
}
|
||||
)
|
||||
|
||||
assert lines == ['✅ 已选择 xiala:**B**']
|
||||
assert lines == ['✅ xiala:B']
|
||||
|
||||
@@ -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'}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user