diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index d72ffe22b..207315d28 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -1658,6 +1658,8 @@ class WecomBotClient: self._card_action_callback: Optional[Callable] = None self._stream_last_content: dict[str, str] = {} self._dispatch_tasks: set[asyncio.Task] = set() + self._welcome_replies: dict[str, asyncio.Future] = {} + self._handled_form_tasks: dict[str, float] = {} # 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}. @@ -1764,7 +1766,12 @@ class WecomBotClient: event: 由企业微信消息转换的内部事件对象。 """ try: - await self._handle_message(event) + if event.msgtype == 'event': + kind = 'template_card_event' if extract_wecom_event_type(event) == 'template_card_event' else 'event' + for handler in self._message_handlers.get(kind, []): + await handler(event) + else: + await self._handle_message(event) except Exception: await self.logger.error(traceback.format_exc()) @@ -1996,11 +2003,48 @@ class WecomBotClient: if event_type == 'template_card_event': return await self._handle_template_card_event(msg_json, nonce) + if event_type == 'enter_chat': + return await self._handle_enter_chat(msg_json, nonce) + + if msg_json.get('msgtype') == 'event': + event = wecombotevent.WecomBotEvent({**msg_json, 'type': msg_json.get('chattype', 'single')}) + if not self._start_dispatch_task(event): + return Response('Busy', status=503) + return await self._encrypt_and_reply({}, nonce) + if msg_json.get('msgtype') == 'stream': return await self._handle_post_followup_response(msg_json, nonce) return await self._handle_post_initial_response(msg_json, nonce) + async def reply_welcome(self, message_id: str, content: str) -> dict: + """Complete the pending HTTP welcome response before its deadline.""" + future = self._welcome_replies.get(message_id) + if future is None or future.done(): + raise ValueError('WeCom welcome reply window has expired or was already used') + future.set_result({'msgtype': 'text', 'text': {'content': content}}) + return {'ok': True} + + async def _handle_enter_chat(self, msg_json: dict[str, Any], nonce: str): + message_id = str(msg_json.get('msgid') or '') + if not message_id or message_id in self._welcome_replies: + return await self._encrypt_and_reply({}, nonce) + future = asyncio.get_running_loop().create_future() + self._welcome_replies[message_id] = future + try: + event = wecombotevent.WecomBotEvent({**msg_json, 'type': msg_json.get('chattype', 'single')}) + if not self._start_dispatch_task(event): + return Response('Busy', status=503) + try: + reply = await asyncio.wait_for(asyncio.shield(future), timeout=4.5) + except asyncio.TimeoutError: + reply = {} + return await self._encrypt_and_reply(reply, nonce) + finally: + self._welcome_replies.pop(message_id, None) + if not future.done(): + future.cancel() + async def _handle_template_card_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """Handle a button click on a button_interaction template_card. @@ -2014,10 +2058,29 @@ class WecomBotClient: await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}') + now = time.monotonic() + self._handled_form_tasks = { + key: seen for key, seen in self._handled_form_tasks.items() if now - seen < 3600 + } + if task_id in self._handled_form_tasks: + return await self._encrypt_and_reply({}, nonce) session = self.stream_sessions.get_session_by_task_id(task_id) if session is None: - await self.logger.warning(f'未找到 task_id={task_id} 对应的 session,按钮点击被丢弃') + if not task_id.startswith('dify-'): + event = wecombotevent.WecomBotEvent( + { + **msg_json, + 'type': msg_json.get('chattype', 'single'), + 'msgtype': 'event', + 'eventtype': 'template_card_event', + } + ) + if not self._start_dispatch_task(event): + return Response('Busy', status=503) else: + self._handled_form_tasks[task_id] = now + if len(self._handled_form_tasks) > 2048: + self._handled_form_tasks.pop(next(iter(self._handled_form_tasks))) if self._card_action_callback is not None: try: await self._card_action_callback(session, event_key, task_id, msg_json) diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index d61659950..9daf15900 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -150,6 +150,7 @@ class WecomBotWsClient: # context on click). # task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id} self._pending_forms_by_task: dict[str, dict] = {} + self._handled_form_tasks: dict[str, float] = {} # Reverse: msg_id -> task_id (for cleanup when stream finishes). self._task_id_by_msg: dict[str, str] = {} # Optional card-action callback registered by the adapter. @@ -324,6 +325,12 @@ class WecomBotWsClient: } return await self._send_reply(req_id, body) + async def reply_welcome(self, req_id: str, content: str) -> Optional[dict]: + """Reply to enter_chat using the dedicated welcome command.""" + return await self._send_reply( + req_id, {'msgtype': 'text', 'text': {'content': content}}, cmd=CMD_RESPOND_WELCOME + ) + async def reply_text(self, req_id: str, content: str) -> Optional[dict]: """Send a non-streaming text reply. @@ -986,6 +993,7 @@ class WecomBotWsClient: await self.logger.debug(f'Received event_callback event_type={event_type}') message_data = { + **body, 'msgtype': 'event', 'type': body.get('chattype', 'single'), 'event': event_info, @@ -1069,15 +1077,35 @@ class WecomBotWsClient: f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}' ) + now = time.monotonic() + self._handled_form_tasks = {key: seen for key, seen in self._handled_form_tasks.items() if now - seen < 3600} + if task_id in self._handled_form_tasks: + return pending = self._pending_forms_by_task.get(task_id) if pending is None: - await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored') + # Expired managed forms must not start a new business execution. + if task_id.startswith('dify-'): + return + event = wecombotevent.WecomBotEvent( + { + **body, + 'type': body.get('chattype', 'single'), + 'msgtype': 'event', + 'eventtype': 'template_card_event', + 'req_id': frame.get('headers', {}).get('req_id', ''), + } + ) + for handler in self._message_handlers.get('template_card_event', []): + await handler(event) return if time.monotonic() - float(pending.get('created_at', 0.0)) > _PENDING_FORM_TTL_SECONDS: self._drop_pending_form_task(task_id, pending) await self.logger.warning(f'Pending form expired for task_id={task_id} (ws)') return + self._handled_form_tasks[task_id] = now + if len(self._handled_form_tasks) > 2048: + self._handled_form_tasks.pop(next(iter(self._handled_form_tasks))) req_id_for_update = frame.get('headers', {}).get('req_id', '') form_data = pending.get('form_data', {}) or {} selections = extract_template_card_selections(tce, form_data) @@ -1125,6 +1153,9 @@ class WecomBotWsClient: self._drop_pending_form_task(task_id, pending) def _drop_pending_form_task(self, task_id: str, pending: dict) -> None: + self._handled_form_tasks[task_id] = time.monotonic() + if len(self._handled_form_tasks) > 2048: + self._handled_form_tasks.pop(next(iter(self._handled_form_tasks))) self._pending_forms_by_task.pop(task_id, None) msg_id = pending.get('msg_id', '') if msg_id: diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index c558cca50..cf4bef4fe 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -577,6 +577,19 @@ class BoxService: *, skip_host_mount_validation: bool = False, ) -> dict: + result = await self._execute_spec_payload_result( + spec_payload, query, skip_host_mount_validation=skip_host_mount_validation + ) + return self._serialize_result(result) + + async def _execute_spec_payload_result( + self, + spec_payload: dict, + query: pipeline_query.Query, + *, + skip_host_mount_validation: bool = False, + ) -> BoxExecutionResult: + """Execute within the bound Workspace and preserve the raw result for file transfers.""" if not self._available: raise BoxError( 'Box runtime is not available. Configure an available Box backend before using Box features.' @@ -635,7 +648,7 @@ class BoxService: f'query_id={query.query_id} ' f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}' ) - return self._serialize_result(result) + return result def resolve_box_session_id(self, query: pipeline_query.Query) -> str: """Use the Box explicitly bound by the current Runner invocation.""" @@ -1206,16 +1219,17 @@ class BoxService: async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: """Fallback: read the outbox over the exec channel (E2B / remote). - Uses ``client.execute`` directly (bypassing ``_serialize_result``) - so stdout is NOT truncated by ``output_limit_chars`` - the raw - base64 payload can be far larger than the 4000-char display limit. + Use the same Workspace-scoped execution path as tools, retaining raw + stdout because base64 payloads can exceed the display truncation limit. """ import json as _json target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}' - max_file_bytes = self._EXEC_FALLBACK_MAX_BYTES + # Outbound bytes travel in raw stdout, not argv. Use the regular + # attachment limits rather than the smaller inbound command limit. + max_file_bytes = self._ATTACHMENT_MAX_BYTES max_files = self._ATTACHMENT_MAX_FILES - max_total_bytes = max_file_bytes * max_files + max_total_bytes = self._ATTACHMENT_MAX_TOTAL_BYTES max_scan_entries = 1000 script = ( 'import base64, json, os\n' @@ -1248,11 +1262,11 @@ class BoxService: ' continue\n' ' size = entry.stat(follow_symlinks=False).st_size\n' ' if size > max_file_bytes or total_bytes + size > max_total_bytes:\n' - ' continue\n' + " raise RuntimeError('Sandbox output attachment exceeds the export size limit')\n" " with open(entry.path, 'rb') as f:\n" ' data = f.read(max_file_bytes + 1)\n' ' if len(data) > max_file_bytes or total_bytes + len(data) > max_total_bytes:\n' - ' continue\n' + " raise RuntimeError('Sandbox output attachment exceeds the export size limit')\n" ' except OSError:\n' ' continue\n' ' rel = os.path.relpath(entry.path, target)\n' @@ -1269,17 +1283,26 @@ class BoxService: } if 'extra_mounts' not in spec_payload: spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query) - try: - spec = self.build_spec(spec_payload) - result = await self.client.execute(spec) - except Exception: - return [] + result = await self._execute_spec_payload_result(spec_payload, query) if not result.ok: - return [] + if 'Sandbox output attachment exceeds the export size limit' in (result.stderr or ''): + raise BoxValidationError( + f'Sandbox output attachments exceed the export limit ' + f'({max_file_bytes} bytes per file, {max_total_bytes} bytes total); files were retained' + ) + raise BoxValidationError(f'Failed to read sandbox outbox (exit code {result.exit_code})') try: - return _json.loads(str(result.stdout or '').strip().splitlines()[-1]) - except Exception: - return [] + entries = _json.loads(str(result.stdout or '').strip().splitlines()[-1]) + if not isinstance(entries, list) or any( + not isinstance(entry, dict) + or not isinstance(entry.get('name'), str) + or not isinstance(entry.get('b64'), str) + for entry in entries + ): + raise ValueError('Invalid attachment list') + return entries + except (ValueError, IndexError) as exc: + raise BoxValidationError('Sandbox outbox returned invalid attachment data') from exc async def _clear_outbox(self, query: pipeline_query.Query, host_dir: str | None) -> None: """Empty the per-query outbox after collection. diff --git a/src/langbot/pkg/pipeline/process/stream_results.py b/src/langbot/pkg/pipeline/process/stream_results.py index 7e5e889d6..35e0dd09a 100644 --- a/src/langbot/pkg/pipeline/process/stream_results.py +++ b/src/langbot/pkg/pipeline/process/stream_results.py @@ -15,13 +15,24 @@ async def coalesce_stream_results( # Completion snapshots can add attachments and usage to the final # delta. Deliver that richer result once, before closing the card. content = pending.all_content if pending.all_content is not None else pending.content + # Multi-round runners stream a cumulative transcript, but complete + # with only the last model round. Keep the transcript on the same + # card while taking attachments and metadata from the completion. + final_round = ( + isinstance(content, str) + and isinstance(result.content, str) + and bool(result.content) + and content.endswith(result.content) + ) same_message = ( isinstance(result, Message) and result.role == pending.role - and result.tool_calls == pending.tool_calls - and (not content or result.content == content) + and (result.tool_calls or []) == (pending.tool_calls or []) + and (not content or result.content == content or final_round) ) - if not same_message: + if same_message and final_round and content != result.content: + result = result.model_copy(update={'content': content}) + elif not same_message: yield pending pending = None snapshot = '' diff --git a/src/langbot/pkg/platform/adapters/wecombot/adapter.py b/src/langbot/pkg/platform/adapters/wecombot/adapter.py index 1bbd7b179..90ee727e7 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations from langbot.pkg.platform.sources.wecombot import WecomBotAdapter as LegacyWecomBotAdapter import asyncio +import base64 import time import traceback import typing @@ -47,6 +48,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo _group_cache: dict[str, platform_entities.UserGroup] = {} _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {} _stream_to_monitoring_msg: dict[str, tuple[str, float]] = {} + _business_event_ids: dict[tuple[str, str], float] = {} _STREAM_MAPPING_TTL: int = 600 class Config: @@ -103,6 +105,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo return [ 'message.received', 'feedback.received', + 'wecombot.enter_chat', + 'wecombot.template_card_event', 'platform.specific', ] @@ -131,7 +135,29 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo _join_text_components = staticmethod(LegacyWecomBotAdapter._join_text_components) _iter_media_components = staticmethod(LegacyWecomBotAdapter._iter_media_components) - _send_media = staticmethod(LegacyWecomBotAdapter._send_media) + + @staticmethod + async def _send_media(bot, req_id: str, item: dict) -> bool: + """Require upload and delivery acknowledgements instead of dropping attachments.""" + kind = item['type'] + payload = item.get('base64') or '' + if payload.startswith('data:'): + payload = payload.split(',', 1)[-1] + try: + data = base64.b64decode(payload, validate=True) + except ValueError as exc: + raise ValueError(f'Invalid {kind} attachment data') from exc + if not data: + raise ValueError(f'{kind} attachment has no data to upload') + result = await bot.upload_media(data, item.get('name') or f'attachment.{kind}', media_type=kind) + media_id = result.get('media_id') if isinstance(result, dict) else None + if not media_id: + raise RuntimeError(f'WeCom {kind} upload failed: no media ID returned') + ack = await getattr(bot, f'reply_{kind}')(req_id, media_id) + if ack is None or ack.get('errcode', 0) != 0: + code = ack.get('errcode') if isinstance(ack, dict) else 'no acknowledgement' + raise RuntimeError(f'WeCom {kind} delivery failed: {code}') + return True async def send_message( self, @@ -145,6 +171,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo raise NotSupportedError(f'send_message:{target_type}') items = await WecomBotMessageConverter.yiri2target(message) content = self._join_text_components(items) + if list(self._iter_media_components(items)): + raise NotSupportedError('send_message:media requires an originating message callback') raw = await self.bot.send_message(str(target_id), content) return platform_events.MessageResult(raw={'result': raw}) @@ -156,6 +184,22 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo if context.get('event_type') == 'message.received' and message_id: source = await self.get_message(target_type, target_id, message_id) return await self.reply_message(source, message) + if context.get('event_type') == 'wecombot.enter_chat': + data = context.get('data') or {} + raw_event = data.get('data') or {} + if time.time() - float(data.get('timestamp') or 0) >= 5: + raise ValueError('WeCom welcome replies must complete within 5 seconds of entering the chat') + items = await WecomBotMessageConverter.yiri2target(message) + if list(self._iter_media_components(items)): + raise NotSupportedError('welcome_reply:media') + content = self._join_text_components(items) + callback_id = data.get('message_id') if self.config.get('enable-webhook') else raw_event.get('req_id') + if not callback_id: + raise ValueError('WeCom welcome reply is missing its callback ID') + raw = await self.bot.reply_welcome(callback_id, content) + if raw is None: + raise RuntimeError('WeCom welcome reply was not acknowledged') + return platform_events.MessageResult(raw={'result': raw}) return await self.send_message(target_type, target_id, message) async def reply_message( @@ -168,6 +212,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo if not isinstance(event, WecomBotEvent): raise ValueError('WeComBot reply_message requires a WecomBotEvent source object') items = await WecomBotMessageConverter.yiri2target(message) + if self.config.get('enable-webhook') and list(self._iter_media_components(items)): + raise NotSupportedError('reply_media:webhook_mode; use WebSocket mode to deliver files') content = self._join_text_components(items) raw = None from .stream_text import format_stream_text @@ -196,6 +242,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo if not isinstance(event, WecomBotEvent): raise ValueError('WeComBot reply_message_chunk requires a WecomBotEvent source object') items = await WecomBotMessageConverter.yiri2target(message) + if is_final and self.config.get('enable-webhook') and list(self._iter_media_components(items)): + raise NotSupportedError('reply_media:webhook_mode; use WebSocket mode to deliver files') from .stream_text import format_stream_text content = format_stream_text(self._join_text_components(items)) @@ -293,10 +341,13 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo interaction_event = interaction_event_from_native(event) if interaction_event is not None: await self._dispatch_eba_event(interaction_event) + else: + await self._handle_native_event(event) except Exception: await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}') async def _handle_native_event(self, event: WecomBotEvent): + key = None try: if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: legacy_event = await self.event_converter.target2legacy(event) @@ -305,9 +356,24 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo eba_event = await self.event_converter.target2yiri(event) if eba_event: + if eba_event.type in {'wecombot.enter_chat', 'wecombot.template_card_event'}: + message_id = eba_event.message_id + now = time.monotonic() + self._business_event_ids = { + key: seen for key, seen in self._business_event_ids.items() if now - seen < 600 + } + key = (eba_event.type, message_id) + if message_id and key in self._business_event_ids: + return + if message_id: + self._business_event_ids[key] = now + if len(self._business_event_ids) > 2048: + self._business_event_ids.pop(next(iter(self._business_event_ids))) self._cache_event(eba_event) await self._dispatch_eba_event(eba_event) except Exception: + if key is not None: + self._business_event_ids.pop(key, None) await self.logger.error(f'Error in wecombot native event: {traceback.format_exc()}') async def _handle_feedback(self, **kwargs): diff --git a/src/langbot/pkg/platform/adapters/wecombot/event_converter.py b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py index 31fefb559..a99508813 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/event_converter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/event_converter.py @@ -50,6 +50,36 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter): ) async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event: + from langbot.libs.wecom_ai_bot_api.api import ( + extract_wecom_event_type, + extract_template_card_event_payload, + extract_template_card_action, + ) + + event_type = extract_wecom_event_type(event) or event.get('eventtype') + if event_type in {'enter_chat', 'template_card_event'}: + group = event.get('chattype', event.type) == 'group' + fields = dict( + adapter_name=ADAPTER_NAME, + user=platform_entities.User(id=event.userid, nickname=event.username), + chat_id=str(event.chatid if group else event.userid), + chat_type=platform_entities.ChatType.GROUP if group else platform_entities.ChatType.PRIVATE, + message_id=event.message_id or '', + timestamp=float(event.get('create_time') or time.time()), + data=dict(event), + source_platform_object=event, + ) + if event_type == 'enter_chat': + return platform_events.WecomBotEnterChatEvent(**fields) + card = extract_template_card_event_payload(event) + task_id, event_key, card_type = extract_template_card_action(card) + return platform_events.WecomBotTemplateCardEvent( + **fields, + task_id=task_id, + event_key=event_key, + card_type=card_type, + selected_items=card.get('selected_items') or card.get('SelectedItems') or [], + ) if event.type in {'single', 'group'} and event.msgtype != 'event': return await self.message_to_eba(event) return self.platform_specific( diff --git a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml index 1c1c8213f..d5b7c1eeb 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml @@ -174,6 +174,8 @@ spec: supported_events: - message.received - feedback.received + - wecombot.enter_chat + - wecombot.template_card_event - platform.specific supported_apis: diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 856563e39..c04226b07 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -408,7 +408,7 @@ box: workspace_quota_mb: 0 readiness_cache_sec: 15 local: - profile: 'default' + profile: 'default' # Network enabled by default. Use 'offline' to disable network by default. image: '' # Custom local sandbox image. Leave empty to use the profile default. host_root: './data/box' # Base host directory for local workspace mounts. Docker deployments should override this with an absolute host path. default_workspace: '' # Defaults to '/default'. Relative paths are resolved under host_root. diff --git a/tests/unit_tests/box/test_attachment_host_capabilities.py b/tests/unit_tests/box/test_attachment_host_capabilities.py index 48acd2862..27aabc7de 100644 --- a/tests/unit_tests/box/test_attachment_host_capabilities.py +++ b/tests/unit_tests/box/test_attachment_host_capabilities.py @@ -21,3 +21,122 @@ async def test_host_without_openat_uses_sandbox_attachment_channel(monkeypatch, assert await service.collect_outbound_attachments(query) == [] service._read_outbox_via_exec.assert_awaited_once_with(query) service._clear_outbox.assert_awaited_once_with(query, None) + + +@pytest.mark.asyncio +async def test_outbox_exec_preserves_workspace_and_full_attachment(monkeypatch, tmp_path): + import base64 + import json + from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus + + monkeypatch.setattr('os.supports_dir_fd', set()) + client = Mock(spec=BoxRuntimeClient) + content = base64.b64encode(b'file content' * 1000).decode() + client.execute = AsyncMock( + return_value=BoxExecutionResult( + session_id='person_test_user', + backend_name='docker', + status=BoxExecutionStatus.COMPLETED, + exit_code=0, + stdout=json.dumps([{'name': 'report.txt', 'b64': content}]), + stderr='', + duration_ms=1, + ) + ) + service = BoxService(make_app(Mock(), [str(tmp_path)]), client=client, output_limit_chars=100) + service._available = True + service._tenant_workspace = Mock(return_value=str(tmp_path)) + service._clear_outbox = AsyncMock() + query = make_query() + attachments = await service.collect_outbound_attachments(query) + assert attachments == [{'type': 'File', 'name': 'report.txt', 'base64': content}] + call = client.execute.await_args + assert call.kwargs['action_context'].workspace_uuid == query.workspace_uuid + assert call.kwargs['action_context'].instance_uuid == query.instance_uuid + assert call.args[0].host_path == str(tmp_path) + assert call.args[0].session_id == 'person_test_user' + service._clear_outbox.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['rpc', 'command', 'invalid_json']) +async def test_outbox_read_failure_keeps_files(monkeypatch, failure): + from langbot_plugin.box.errors import BoxValidationError + from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus + + monkeypatch.setattr('os.supports_dir_fd', set()) + client = Mock(spec=BoxRuntimeClient) + client.execute = AsyncMock( + return_value=BoxExecutionResult( + session_id='person_test_user', + backend_name='docker', + status=BoxExecutionStatus.COMPLETED, + exit_code=1 if failure == 'command' else 0, + stdout='not json', + stderr='', + duration_ms=1, + ) + ) + if failure == 'rpc': + client.execute.side_effect = BoxValidationError('missing context') + service = BoxService(make_app(Mock()), client=client) + service._available = True + service._clear_outbox = AsyncMock() + with pytest.raises(BoxValidationError): + await service.collect_outbound_attachments(make_query()) + service._clear_outbox.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('oversized', [False, True]) +async def test_real_outbox_reader_handles_image_above_old_256k_limit(tmp_path, monkeypatch, oversized): + import asyncio + import base64 + import sys + from langbot_plugin.box.errors import BoxValidationError + from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus + + monkeypatch.setattr('os.supports_dir_fd', set()) + service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient)) + service._available = True + service.OUTBOX_MOUNT_DIR = tmp_path.as_posix() + if oversized: + service._ATTACHMENT_MAX_BYTES = 300000 + folder = tmp_path / 'query-42' + folder.mkdir() + payload = b'\x89PNG\r\n\x1a\n' + b'x' * (343154 - 8) + (folder / 'sunset.png').write_bytes(payload) + + async def execute_script(spec, query): + script = spec['cmd'].split('\n', 1)[1].rsplit('\nLBPY', 1)[0] + process = await asyncio.create_subprocess_exec( + sys.executable, + '-c', + script, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + return BoxExecutionResult( + session_id='person_test_user', + backend_name='test', + status=BoxExecutionStatus.COMPLETED, + exit_code=process.returncode, + stdout=stdout.decode(), + stderr=stderr.decode(), + duration_ms=1, + ) + + service._execute_spec_payload_result = execute_script + service._clear_outbox = AsyncMock() + if oversized: + with pytest.raises(BoxValidationError): + await service.collect_outbound_attachments(make_query()) + service._clear_outbox.assert_not_awaited() + assert (folder / 'sunset.png').exists() + else: + attachments = await service.collect_outbound_attachments(make_query()) + assert len(attachments) == 1 + assert attachments[0]['type'] == 'Image' + assert base64.b64decode(attachments[0]['base64'].split(',', 1)[1]) == payload + service._clear_outbox.assert_awaited_once() diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 655eba7e1..52fcbafa3 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -934,20 +934,21 @@ async def test_truncate_stderr_independently(): @pytest.mark.asyncio -async def test_profile_default_provides_defaults(): +@pytest.mark.parametrize('profile_name,network', [('default', BoxNetworkMode.ON), ('offline', BoxNetworkMode.OFF)]) +async def test_profile_default_provides_defaults(profile_name, network): """When tool call omits network/image, profile defaults are used.""" logger = Mock() backend = FakeBackend(logger) runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300) - service = BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + service = BoxService(make_app(logger, profile=profile_name), client=_InProcessBoxRuntimeClient(logger, runtime)) await service.initialize() result = await service.execute_tool({'command': 'echo hi'}, make_query(30)) assert result['ok'] is True spec = backend.start_specs[0] - profile = BUILTIN_PROFILES['default'] - assert spec.network == BoxNetworkMode.OFF + profile = BUILTIN_PROFILES[profile_name] + assert spec.network == network assert spec.image == profile.image assert spec.timeout_sec == profile.timeout_sec @@ -962,14 +963,14 @@ async def test_profile_unlocked_field_can_be_overridden(): await service.initialize() result = await service.execute_spec_payload( - {'cmd': 'echo hi', 'timeout_sec': 60, 'network': 'on', 'session_id': 'person_test_user'}, + {'cmd': 'echo hi', 'timeout_sec': 60, 'network': 'off', 'session_id': 'person_test_user'}, make_query(31), ) assert result['ok'] is True spec = backend.start_specs[0] assert spec.timeout_sec == 60 - assert spec.network == BoxNetworkMode.ON + assert spec.network == BoxNetworkMode.OFF @pytest.mark.asyncio @@ -1037,10 +1038,15 @@ def test_unknown_profile_raises_error(): def test_builtin_profiles_are_consistent(): """Basic sanity check on all built-in profiles.""" assert 'default' in BUILTIN_PROFILES + assert 'offline' in BUILTIN_PROFILES assert 'offline_readonly' in BUILTIN_PROFILES assert 'network_basic' in BUILTIN_PROFILES assert 'network_extended' in BUILTIN_PROFILES + default = BUILTIN_PROFILES['default'] + assert default.network == BoxNetworkMode.ON + assert BUILTIN_PROFILES['offline'] == default.model_copy(update={'name': 'offline', 'network': BoxNetworkMode.OFF}) + offline = BUILTIN_PROFILES['offline_readonly'] assert offline.network == BoxNetworkMode.OFF assert offline.host_path_mode == BoxHostMountMode.READ_ONLY @@ -2025,7 +2031,8 @@ class TestInboundOutboundRoundTrip: calls = [] - async def fake_client_execute(spec): + async def fake_client_execute(spec, *, action_context=None): + assert action_context.workspace_uuid == query.workspace_uuid cmd = spec.cmd calls.append(cmd) if 'os.scandir' in cmd: @@ -2068,7 +2075,8 @@ class TestInboundOutboundRoundTrip: calls = [] - async def fake_client_execute(spec): + async def fake_client_execute(spec, *, action_context=None): + assert action_context.workspace_uuid == query.workspace_uuid cmd = spec.cmd calls.append(cmd) if 'os.scandir' in cmd: diff --git a/tests/unit_tests/pipeline/test_stream_results.py b/tests/unit_tests/pipeline/test_stream_results.py index 3643ec2d1..7ddd7af3c 100644 --- a/tests/unit_tests/pipeline/test_stream_results.py +++ b/tests/unit_tests/pipeline/test_stream_results.py @@ -89,3 +89,24 @@ async def test_cumulative_stream_ends_with_last_snapshot_even_when_final_is_blan ] assert [item.all_content for item in results] == ['hel', 'hello', 'hello'] assert results[-1].is_final + + +@pytest.mark.asyncio +async def test_multi_round_completion_keeps_one_transcript_and_attachments(): + first = 'planI will create a file.' + last = 'file createdHere is your file.' + completed = Message(role='assistant', content=last) + completed.attachments = MessageChain([File(name='report.txt', base64='aGVsbG8=')]) + results = [ + item + async for item in coalesce_stream_results( + source( + MessageChunk(role='assistant', content=first, is_final=False), + MessageChunk(role='assistant', content=first + last, is_final=True), + completed, + ) + ) + ] + assert len(results) == 2 + assert results[-1].content == first + last + assert results[-1].attachments is completed.attachments diff --git a/tests/unit_tests/platform/test_wecombot_eba_adapter.py b/tests/unit_tests/platform/test_wecombot_eba_adapter.py index 12d3386e3..4e8fccc9b 100644 --- a/tests/unit_tests/platform/test_wecombot_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecombot_eba_adapter.py @@ -330,3 +330,170 @@ async def test_wecombot_webhook_mode_rejects_proactive_send(): await adapter.send_message( 'person', 'user-1', platform_message.MessageChain([platform_message.Plain(text='hi')]) ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('kind', ['enter_chat', 'template_card_event']) +async def test_business_events_preserve_identity_payload_and_deduplicate(kind): + from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient + + adapter = make_adapter() + listener = AsyncMock() + adapter.listeners[platform_events.EBAEvent] = listener + client = WecomBotWsClient('bot-id', 'secret', DummyLogger()) + client.on_message('event')(adapter._handle_native_event) + client.on_message('template_card_event')(adapter._handle_interaction_event) + body = { + 'msgid': 'business-event-1', + 'msgtype': 'event', + 'chattype': 'single', + 'from': {'userid': 'visitor-1', 'alias': 'Visitor'}, + 'create_time': 1700000000, + 'event': { + 'eventtype': kind, + 'template_card_event': { + 'task_id': 'business-card', + 'event_key': 'approve', + 'card_type': 'button_interaction', + 'selected_items': [{'question_key': 'choice', 'option_ids': ['yes']}], + }, + }, + } + frame = {'headers': {'req_id': 'callback-1'}, 'body': body} + await client._handle_event_callback(frame) + await client._handle_event_callback(frame) + listener.assert_awaited_once() + event = listener.await_args.args[0] + assert event.type == 'wecombot.' + kind + assert event.user.id == 'visitor-1' + assert event.chat_id == 'visitor-1' + assert event.timestamp == 1700000000 + assert event.data['req_id'] == 'callback-1' + assert event.data['event'] == body['event'] + assert type(event).model_validate(event.model_dump()).type == event.type + if kind == 'template_card_event': + assert event.event_key == 'approve' + assert event.selected_items[0]['option_ids'] == ['yes'] + + +@pytest.mark.asyncio +async def test_managed_card_callback_does_not_start_business_event(): + adapter = make_adapter() + listener = AsyncMock() + adapter.listeners[platform_events.EBAEvent] = listener + await adapter._handle_interaction_event( + WecomBotEvent( + { + 'type': 'single', + 'msgtype': 'event', + 'userid': 'user-1', + 'event': { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'task_id': 'lbi-managed', + 'event_key': 'lbi:token:a:0', + }, + }, + } + ) + ) + listener.assert_awaited_once() + event = listener.await_args.args[0] + assert event.type == 'platform.specific' + assert event.action == 'interaction.submitted' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('webhook', [False, True]) +async def test_enter_chat_reply_uses_welcome_callback_and_checks_deadline(webhook): + import time + + adapter = make_adapter(webhook) + adapter.bot.reply_welcome = AsyncMock(return_value={'ok': True}) + context = { + 'event_type': 'wecombot.enter_chat', + 'data': { + 'timestamp': time.time(), + 'message_id': 'entry-1', + 'data': {'req_id': 'request-1'}, + }, + } + message = platform_message.MessageChain([platform_message.Plain(text='Welcome')]) + await adapter.reply_to_event(context=context, target_type='person', target_id='user-1', message=message) + adapter.bot.reply_welcome.assert_awaited_once_with('entry-1' if webhook else 'request-1', 'Welcome') + context['data']['timestamp'] -= 10 + with pytest.raises(ValueError, match='5 seconds'): + await adapter.reply_to_event(context=context, target_type='person', target_id='user-1', message=message) + + +@pytest.mark.asyncio +async def test_file_attachment_is_uploaded_and_delivered_after_stream(): + adapter = make_adapter() + adapter.bot.upload_media = AsyncMock(return_value={'media_id': 'file-1'}) + adapter.bot.reply_file = AsyncMock(return_value={'errcode': 0}) + source = await adapter.event_converter.target2yiri(wecombot_event()) + message = platform_message.MessageChain( + [ + platform_message.Plain(text='Here is your file'), + platform_message.File(name='report.txt', base64='aGVsbG8='), + ] + ) + await adapter.reply_message_chunk(source, None, message, is_final=True) + adapter.bot.upload_media.assert_awaited_once_with(b'hello', 'report.txt', media_type='file') + adapter.bot.reply_file.assert_awaited_once_with('req-1', 'file-1') + + +@pytest.mark.asyncio +@pytest.mark.parametrize('failure', ['upload', 'reply', 'timeout']) +async def test_file_delivery_errors_are_not_silent(failure): + adapter = make_adapter() + adapter.bot.upload_media = AsyncMock(return_value=None if failure == 'upload' else {'media_id': 'file-1'}) + adapter.bot.reply_file = AsyncMock(return_value=None if failure == 'timeout' else {'errcode': 40001}) + source = await adapter.event_converter.target2yiri(wecombot_event()) + with pytest.raises(RuntimeError, match='WeCom file'): + await adapter.reply_message( + source, + platform_message.MessageChain( + [ + platform_message.File(name='report.txt', base64='aGVsbG8='), + ] + ), + ) + + +@pytest.mark.asyncio +async def test_multi_round_file_completion_finalizes_once_without_text_fallback(): + from langbot.pkg.pipeline.process.stream_results import coalesce_stream_results + from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk + + adapter = make_adapter() + adapter.bot.upload_media = AsyncMock(return_value={'media_id': 'file-1'}) + adapter.bot.reply_file = AsyncMock(return_value={'errcode': 0}) + source = await adapter.event_converter.target2yiri(wecombot_event()) + prefix = 'planCreating your file.' + final = 'doneYour file is ready.' + completed = Message(role='assistant', content=final) + completed.attachments = platform_message.MessageChain( + [ + platform_message.File(name='report.txt', base64='aGVsbG8='), + ] + ) + + async def results(): + yield MessageChunk(role='assistant', content=prefix) + yield MessageChunk(role='assistant', content=prefix + final, tool_calls=[], is_final=True) + yield completed + + async for result in coalesce_stream_results(results()): + await adapter.reply_message_chunk( + source, + None, + result.get_content_platform_message_chain(), + is_final=isinstance(result, Message) or result.is_final, + ) + assert sum(call.kwargs['is_final'] for call in adapter.bot.push_stream_chunk.await_args_list) == 1 + adapter.bot.reply_text.assert_not_awaited() + adapter.bot.reply_file.assert_awaited_once_with('req-1', 'file-1') + final_text = adapter.bot.push_stream_chunk.await_args_list[-1].args[1] + assert final_text.count('') == 1 + assert final_text.count('Your file is ready.') == 1 diff --git a/tests/unit_tests/platform/test_wecombot_template_card.py b/tests/unit_tests/platform/test_wecombot_template_card.py index f430f7080..d27a6650c 100644 --- a/tests/unit_tests/platform/test_wecombot_template_card.py +++ b/tests/unit_tests/platform/test_wecombot_template_card.py @@ -569,3 +569,132 @@ def test_action_stage_only_shows_content_after_fields_without_placeholders(): assert card['sub_title_text'] == '请选择操作' assert '{{#$output.' not in card['sub_title_text'] assert [button['text'] for button in card['button_list']] == ['yes', 'no'] + + +@pytest.mark.asyncio +async def test_webhook_enter_chat_returns_welcome_without_message_stream(): + from unittest.mock import AsyncMock + from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotClient('', '', '', logger, unified_mode=True) + client._encrypt_and_reply = AsyncMock(side_effect=lambda reply, nonce: reply) + ordinary = AsyncMock() + client.on_message('single')(ordinary) + + async def welcome(event): + assert isinstance(event, WecomBotEvent) + await client.reply_welcome(event.message_id, 'Welcome') + + client.on_message('event')(welcome) + result = await client._handle_enter_chat( + { + 'msgid': 'entry-1', + 'msgtype': 'event', + 'chattype': 'single', + 'from': {'userid': 'visitor'}, + 'event': {'eventtype': 'enter_chat'}, + }, + 'nonce', + ) + assert result == {'msgtype': 'text', 'text': {'content': 'Welcome'}} + ordinary.assert_not_awaited() + assert not client._welcome_replies + with pytest.raises(ValueError, match='expired'): + await client.reply_welcome('entry-1', 'too late') + await client.close() + + +@pytest.mark.asyncio +async def test_webhook_unmanaged_card_dispatches_but_expired_dify_does_not(): + import asyncio + from unittest.mock import AsyncMock + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotClient('', '', '', logger, unified_mode=True) + client._encrypt_and_reply = AsyncMock(return_value={}) + handler = AsyncMock() + client.on_message('template_card_event')(handler) + for task_id in ['business-card', 'dify-expired']: + await client._handle_template_card_event( + { + 'msgid': task_id, + 'msgtype': 'event', + 'chattype': 'single', + 'from': {'userid': 'visitor'}, + 'event': { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'task_id': task_id, + 'event_key': 'approve', + }, + }, + }, + 'nonce', + ) + await asyncio.gather(*client._dispatch_tasks) + handler.assert_awaited_once() + assert handler.await_args.args[0].userid == 'visitor' + await client.close() + + +@pytest.mark.asyncio +async def test_ws_welcome_uses_dedicated_command(): + from unittest.mock import AsyncMock + + client = WecomBotWsClient('bot-id', 'secret', object()) + client._send_reply = AsyncMock(return_value={}) + await client.reply_welcome('req-1', 'Welcome') + client._send_reply.assert_awaited_once_with( + 'req-1', {'msgtype': 'text', 'text': {'content': 'Welcome'}}, cmd='aibot_respond_welcome_msg' + ) + + +@pytest.mark.asyncio +async def test_expired_custom_managed_card_never_becomes_business_event(): + from unittest.mock import AsyncMock + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotWsClient('bot-id', 'secret', logger) + handler = AsyncMock() + client.on_message('template_card_event')(handler) + client._drop_pending_form_task('custom-managed-task', {}) + body = { + 'msgtype': 'event', + 'event': { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'task_id': 'custom-managed-task', + 'event_key': 'approve', + }, + }, + } + await client._handle_template_card_event_frame({'headers': {'req_id': 'r1'}}, body) + handler.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_webhook_welcome_timeout_cleans_pending_reply(monkeypatch): + import asyncio + from unittest.mock import AsyncMock + + logger = types.SimpleNamespace(info=AsyncMock(), debug=AsyncMock(), error=AsyncMock(), warning=AsyncMock()) + client = WecomBotClient('', '', '', logger, unified_mode=True) + client._encrypt_and_reply = AsyncMock(side_effect=lambda reply, nonce: reply) + + async def timeout(awaitable, timeout): + awaitable.cancel() + raise asyncio.TimeoutError + + monkeypatch.setattr(asyncio, 'wait_for', timeout) + result = await client._handle_enter_chat( + { + 'msgid': 'no-reply', + 'msgtype': 'event', + 'event': {'eventtype': 'enter_chat'}, + }, + 'nonce', + ) + assert result == {} + assert not client._welcome_replies + await client.close() diff --git a/web/src/app/home/agents/components/debug-event-data.ts b/web/src/app/home/agents/components/debug-event-data.ts index b51b96077..369499015 100644 --- a/web/src/app/home/agents/components/debug-event-data.ts +++ b/web/src/app/home/agents/components/debug-event-data.ts @@ -90,6 +90,14 @@ const DEBUG_EVENT_DEFINITIONS: Record = { messageField: 'text', }, 'message.deleted': deletion, + 'wecombot.enter_chat': { + fields: [user], + defaults: { chat_id: 'debug-user', chat_type: 'private', message_id: 'debug-entry', data: {} }, + }, + 'wecombot.template_card_event': { + fields: [user], + defaults: { chat_id: 'debug-user', chat_type: 'private', message_id: 'debug-card-click', task_id: 'debug-card', event_key: 'confirm', card_type: 'button_interaction', selected_items: [], data: {} }, + }, 'wecomcs.enter_session': { fields: [user], defaults: { open_kfid: 'debug-kf', external_userid: 'debug-user', chat_id: 'debug-user|debug-kf', scene: 'debug', scene_param: '', welcome_code: '', wechat_channels: {} }, diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index 990b0dd3c..223d8824a 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -9,6 +9,8 @@ import { useState, } from 'react'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { cn } from '@/lib/utils'; import type { TFunction } from 'i18next'; import { UseFormReturn } from 'react-hook-form'; import { @@ -30,6 +32,7 @@ import { Play, RefreshCw, Shield, + Settings2, Trash2, UserCheck, UserMinus, @@ -495,6 +498,10 @@ function TargetCombobox({ const [open, setOpen] = useState(false); const pipelineAllowed = isMessageEventPattern(binding.event_pattern); const targetType = binding.target_type || 'agent'; + const selectedTarget = + targetType !== 'discard' + ? agentOptions.find((agent) => agent.uuid === binding.target_uuid) + : undefined; const current = targetType === 'discard' @@ -545,63 +552,92 @@ function TargetCombobox({ } return ( - - - - - - - - - {t('bots.noTargetFound')} - {agents.length > 0 && ( - - {agents.map((a) => ( - select(encodeTarget('agent', a.uuid || ''))} - > - - {targetLabel(a)} - {current === encodeTarget('agent', a.uuid || '') && ( - - )} - - ))} - +
+ + + + + + + + + {t('bots.noTargetFound')} + {agents.length > 0 && ( + + {agents.map((a) => ( + select(encodeTarget('agent', a.uuid || ''))} + > + + {targetLabel(a)} + {current === encodeTarget('agent', a.uuid || '') && ( + + )} + + ))} + + )} + {pipelines.length > 0 && ( + + {pipelines.map((a) => ( + + select(encodeTarget('pipeline', a.uuid || '')) + } + > + + {targetLabel(a)} + {current === encodeTarget('pipeline', a.uuid || '') && ( + + )} + + ))} + + )} + + + + + {selectedTarget && ( + + + + + + {t('bots.pluginSubscriptions.configure')} + + + )} +
); } diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index 29b39de44..ddfa72710 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -390,9 +390,10 @@ const BotSessionMonitor = forwardRef< }, [selectedSessionId, messagePage, loadMessages]); useEffect(() => { + if (loadingMessages || messageError) return; if (messages.length === 0 && toolCalls.length === 0) return; - // Wait for DOM to render the new messages before scrolling - requestAnimationFrame(() => { + // The timeline is hidden until all message data has finished loading. + const frame = requestAnimationFrame(() => { const container = messagesContainerRef.current; if (container) { const viewport = container.querySelector( @@ -402,7 +403,8 @@ const BotSessionMonitor = forwardRef< scrollTarget.scrollTop = scrollTarget.scrollHeight; } }); - }, [messages, toolCalls]); + return () => cancelAnimationFrame(frame); + }, [loadingMessages, messageError, messages, toolCalls]); const parseMessageChain = (content: string): MessageChainComponent[] => { try { diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 438d63c4b..b5c071468 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1003,6 +1003,8 @@ const enUS = { eventWildcard: 'All events', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "User entered bot chat", + wecombot_template_card_event: "Template card interaction", wecomcs_enter_session: "Customer entered conversation", wecomcs_msg_send_fail: "Customer service message failed", wecomcs_servicer_status_change: "Representative availability changed", @@ -1025,6 +1027,8 @@ const enUS = { platform_specific: 'Platform-specific event', }, eventDescriptions: { + wecombot_enter_chat: "A user opened the bot chat. Use this event for welcome workflows.", + wecombot_template_card_event: "A user clicked or submitted a template card. Managed form callbacks are handled separately.", wecomcs_enter_session: "A customer opened a service link; includes entry source and welcome code.", wecomcs_msg_send_fail: "WeChat reported an outgoing message failure, with message ID and failure type.", wecomcs_servicer_status_change: "A representative started, stopped or suspended service.", diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 4e3e60381..cc05428a1 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1022,6 +1022,8 @@ const esES = { eventWildcard: 'Todos los eventos', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "Usuario entró al chat del bot", + wecombot_template_card_event: "Interacción con tarjeta", wecomcs_enter_session: "Cliente entró en la conversación", wecomcs_msg_send_fail: "Fallo de envío de atención al cliente", wecomcs_servicer_status_change: "Disponibilidad del agente modificada", @@ -1044,6 +1046,8 @@ const esES = { platform_specific: 'Evento específico de la plataforma', }, eventDescriptions: { + wecombot_enter_chat: "Un usuario abrió el chat del bot. Úsalo para flujos de bienvenida.", + wecombot_template_card_event: "Un usuario pulsó o envió una tarjeta. Las respuestas de formularios gestionados se procesan por separado.", wecomcs_enter_session: "Un cliente abrió el enlace de atención; incluye origen y código de bienvenida.", wecomcs_msg_send_fail: "WeChat notificó un fallo de envío con el ID y el tipo de error.", wecomcs_servicer_status_change: "Un agente inició, detuvo o suspendió la atención.", diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index f9e812c83..6a51d3817 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1008,6 +1008,8 @@ const jaJP = { eventWildcard: 'すべてのイベント', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "ボットとのチャットに参加", + wecombot_template_card_event: "テンプレートカード操作", wecomcs_enter_session: "顧客が会話に参加", wecomcs_msg_send_fail: "カスタマーサービスの送信失敗", wecomcs_servicer_status_change: "担当者の受付状態変更", @@ -1030,6 +1032,8 @@ const jaJP = { platform_specific: 'プラットフォーム固有イベント', }, eventDescriptions: { + wecombot_enter_chat: "ユーザーがボットのチャットを開きました。歓迎や案内のフローに使用できます。", + wecombot_template_card_event: "ユーザーがカードをクリックまたは送信しました。管理対象フォームの応答は別途処理します。", wecomcs_enter_session: "顧客がリンクから会話を開きました。流入元と歓迎メッセージ用コードを含みます。", wecomcs_msg_send_fail: "WeChat が送信失敗を通知しました。メッセージ ID と失敗種別を含みます。", wecomcs_servicer_status_change: "担当者が受付を開始、停止、または一時停止しました。", diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 345d0d708..482830ede 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1018,6 +1018,8 @@ const ruRU = { eventWildcard: 'Все события', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "Пользователь открыл чат бота", + wecombot_template_card_event: "Взаимодействие с карточкой", wecomcs_enter_session: "Клиент вошёл в диалог", wecomcs_msg_send_fail: "Ошибка отправки сообщения поддержки", wecomcs_servicer_status_change: "Статус оператора изменён", @@ -1040,6 +1042,8 @@ const ruRU = { platform_specific: 'Событие конкретной платформы', }, eventDescriptions: { + wecombot_enter_chat: "Пользователь открыл чат бота. Событие подходит для сценариев приветствия.", + wecombot_template_card_event: "Пользователь нажал или отправил карточку. Ответы управляемых форм обрабатываются отдельно.", wecomcs_enter_session: "Клиент открыл ссылку поддержки; событие содержит источник и код приветствия.", wecomcs_msg_send_fail: "WeChat сообщил об ошибке отправки с ID сообщения и типом ошибки.", wecomcs_servicer_status_change: "Оператор начал, остановил или приостановил приём обращений.", diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 0820b20bb..508b9624e 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -986,6 +986,8 @@ const thTH = { eventWildcard: 'ทุกเหตุการณ์', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "ผู้ใช้เข้าสู่แชตบอต", + wecombot_template_card_event: "การโต้ตอบกับการ์ด", wecomcs_enter_session: "ลูกค้าเข้าสู่การสนทนา", wecomcs_msg_send_fail: "ส่งข้อความบริการลูกค้าไม่สำเร็จ", wecomcs_servicer_status_change: "สถานะเจ้าหน้าที่เปลี่ยนแปลง", @@ -1008,6 +1010,8 @@ const thTH = { platform_specific: 'เหตุการณ์เฉพาะแพลตฟอร์ม', }, eventDescriptions: { + wecombot_enter_chat: "ผู้ใช้เปิดแชตบอต ใช้สำหรับขั้นตอนต้อนรับและแนะนำ", + wecombot_template_card_event: "ผู้ใช้คลิกหรือส่งการ์ด การตอบกลับแบบฟอร์มที่ระบบจัดการจะประมวลผลแยกกัน", wecomcs_enter_session: "ลูกค้าเปิดลิงก์บริการ พร้อมข้อมูลแหล่งที่มาและรหัสต้อนรับ", wecomcs_msg_send_fail: "WeChat แจ้งการส่งล้มเหลว พร้อม ID ข้อความและประเภทข้อผิดพลาด", wecomcs_servicer_status_change: "เจ้าหน้าที่เริ่ม หยุด หรือพักการให้บริการ", diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 67435c25f..b1554257a 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1003,6 +1003,8 @@ const viVN = { eventWildcard: 'Tất cả sự kiện', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "Người dùng vào cuộc trò chuyện với bot", + wecombot_template_card_event: "Tương tác thẻ mẫu", wecomcs_enter_session: "Khách hàng vào hội thoại", wecomcs_msg_send_fail: "Gửi tin nhắn hỗ trợ thất bại", wecomcs_servicer_status_change: "Trạng thái nhân viên thay đổi", @@ -1025,6 +1027,8 @@ const viVN = { platform_specific: 'Sự kiện riêng của nền tảng', }, eventDescriptions: { + wecombot_enter_chat: "Người dùng mở cuộc trò chuyện với bot. Dùng cho quy trình chào mừng.", + wecombot_template_card_event: "Người dùng nhấp hoặc gửi thẻ. Phản hồi biểu mẫu do hệ thống quản lý được xử lý riêng.", wecomcs_enter_session: "Khách hàng mở liên kết hỗ trợ; gồm nguồn truy cập và mã chào mừng.", wecomcs_msg_send_fail: "WeChat thông báo gửi thất bại, gồm ID tin nhắn và loại lỗi.", wecomcs_servicer_status_change: "Nhân viên bắt đầu, dừng hoặc tạm dừng tiếp nhận.", diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index a40353291..9a3a56181 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -946,6 +946,8 @@ const zhHans = { eventWildcard: '全部事件', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "用户进入机器人会话", + wecombot_template_card_event: "模板卡片交互", wecomcs_enter_session: "客户进入会话", wecomcs_msg_send_fail: "客服消息发送失败", wecomcs_servicer_status_change: "接待人员状态变更", @@ -968,6 +970,8 @@ const zhHans = { platform_specific: '平台特定事件', }, eventDescriptions: { + wecombot_enter_chat: "用户进入机器人会话,可用于欢迎和引导流程。", + wecombot_template_card_event: "用户点击或提交模板卡片;已有交互表单的回调单独处理。", wecomcs_enter_session: "客户通过客服链接进入会话,可获取来源和欢迎语凭据。", wecomcs_msg_send_fail: "微信通知客服消息发送失败,包含消息 ID 和失败类型。", wecomcs_servicer_status_change: "接待人员开始、停止接待或暂时挂起。", diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 87b730ac8..d19b6f8b0 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -945,6 +945,8 @@ const zhHant = { eventWildcard: '全部事件', eventNamespaceWildcard: '{{namespace}}.*', eventNames: { + wecombot_enter_chat: "使用者進入機器人會話", + wecombot_template_card_event: "範本卡片互動", wecomcs_enter_session: "客戶進入會話", wecomcs_msg_send_fail: "客服訊息傳送失敗", wecomcs_servicer_status_change: "接待人員狀態變更", @@ -967,6 +969,8 @@ const zhHant = { platform_specific: '平台特定事件', }, eventDescriptions: { + wecombot_enter_chat: "使用者進入機器人會話,可用於歡迎和引導流程。", + wecombot_template_card_event: "使用者點擊或提交範本卡片;已有互動表單的回呼單獨處理。", wecomcs_enter_session: "客戶透過客服連結進入會話,可取得來源和歡迎語憑證。", wecomcs_msg_send_fail: "微信通知客服訊息傳送失敗,包含訊息 ID 和失敗類型。", wecomcs_servicer_status_change: "接待人員開始、停止接待或暫時掛起。",