mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
fix(wecombot): improve event routing and sandbox attachment delivery
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -174,6 +174,8 @@ spec:
|
||||
supported_events:
|
||||
- message.received
|
||||
- feedback.received
|
||||
- wecombot.enter_chat
|
||||
- wecombot.template_card_event
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
|
||||
@@ -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 '<host_root>/default'. Relative paths are resolved under host_root.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = '<think>plan</think>I will create a file.'
|
||||
last = '<think>file created</think>Here 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
|
||||
|
||||
@@ -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 = '<think>plan</think>Creating your file.'
|
||||
final = '<think>done</think>Your 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('<think>') == 1
|
||||
assert final_text.count('Your file is ready.') == 1
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -90,6 +90,14 @@ const DEBUG_EVENT_DEFINITIONS: Record<string, DebugEventDefinition> = {
|
||||
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: {} },
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-[200px] justify-between text-sm font-normal px-3"
|
||||
>
|
||||
<span className="truncate">{currentLabel()}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t('bots.searchTarget')} className="h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('bots.noTargetFound')}</CommandEmpty>
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetAgent')}>
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`agent:${a.uuid}:${a.name}`}
|
||||
onSelect={() => select(encodeTarget('agent', a.uuid || ''))}
|
||||
>
|
||||
<Bot className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('agent', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<div className="relative w-[200px] shrink-0">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'h-8 w-full justify-between text-sm font-normal px-3',
|
||||
selectedTarget && 'pr-10',
|
||||
)}
|
||||
{pipelines.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetPipeline')}>
|
||||
{pipelines.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`pipeline:${a.uuid}:${a.name}`}
|
||||
onSelect={() =>
|
||||
select(encodeTarget('pipeline', a.uuid || ''))
|
||||
}
|
||||
>
|
||||
<Workflow className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('pipeline', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
>
|
||||
<span className="truncate">{currentLabel()}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t('bots.searchTarget')} className="h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('bots.noTargetFound')}</CommandEmpty>
|
||||
{agents.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetAgent')}>
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`agent:${a.uuid}:${a.name}`}
|
||||
onSelect={() => select(encodeTarget('agent', a.uuid || ''))}
|
||||
>
|
||||
<Bot className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('agent', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{pipelines.length > 0 && (
|
||||
<CommandGroup heading={t('bots.targetPipeline')}>
|
||||
{pipelines.map((a) => (
|
||||
<CommandItem
|
||||
key={a.uuid}
|
||||
value={`pipeline:${a.uuid}:${a.name}`}
|
||||
onSelect={() =>
|
||||
select(encodeTarget('pipeline', a.uuid || ''))
|
||||
}
|
||||
>
|
||||
<Workflow className="mr-2 size-3.5 shrink-0" />
|
||||
<span className="truncate">{targetLabel(a)}</span>
|
||||
{current === encodeTarget('pipeline', a.uuid || '') && (
|
||||
<Check className="ml-auto size-3.5 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedTarget && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 size-6"
|
||||
>
|
||||
<Link
|
||||
to={`/home/agents?id=${encodeURIComponent(selectedTarget.uuid || '')}&tab=config`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('bots.pluginSubscriptions.configure')}
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('bots.pluginSubscriptions.configure')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "担当者が受付を開始、停止、または一時停止しました。",
|
||||
|
||||
@@ -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: "Оператор начал, остановил или приостановил приём обращений.",
|
||||
|
||||
@@ -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: "เจ้าหน้าที่เริ่ม หยุด หรือพักการให้บริการ",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "接待人员开始、停止接待或暂时挂起。",
|
||||
|
||||
@@ -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: "接待人員開始、停止接待或暫時掛起。",
|
||||
|
||||
Reference in New Issue
Block a user