mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 12:47:13 +00:00
Compare commits
6 Commits
f0ee57c1e0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 855ae2bdba | |||
| b66db86bff | |||
| 95b8736e93 | |||
| cabde423a1 | |||
| 08307790e5 | |||
| 777fe1f20b |
@@ -295,6 +295,34 @@ class WecomCSClient:
|
|||||||
raise Exception('Failed to send message')
|
raise Exception('Failed to send message')
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@_bounded_token_retry
|
||||||
|
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
|
||||||
|
payload = {
|
||||||
|
'touser': external_userid,
|
||||||
|
'open_kfid': open_kfid,
|
||||||
|
'msgid': msgid,
|
||||||
|
'msgtype': 'image',
|
||||||
|
'image': {
|
||||||
|
'media_id': media_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
|
||||||
|
data = await httpclient.parse_json_response(response)
|
||||||
|
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
|
||||||
|
if data['errcode'] != 0:
|
||||||
|
await self.logger.error(f'发送图片失败:{data}')
|
||||||
|
raise Exception('Failed to send image message')
|
||||||
|
return data
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
"""处理回调请求(独立端口模式,使用全局 request)。"""
|
||||||
return await self._handle_callback_internal(request)
|
return await self._handle_callback_internal(request)
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ from .. import entities
|
|||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
from ....utils.safe_regex import SafeRegexError, mask_patterns
|
||||||
|
|
||||||
|
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
|
||||||
|
# default safe_regex per-call cap of 64 and used to fail-close every message.
|
||||||
|
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
|
||||||
|
_MAX_SENSITIVE_WORD_PATTERNS = 256
|
||||||
|
|
||||||
|
|
||||||
@filter_model.filter_class('ban-word-filter')
|
@filter_model.filter_class('ban-word-filter')
|
||||||
class BanWordFilter(filter_model.ContentFilter):
|
class BanWordFilter(filter_model.ContentFilter):
|
||||||
@@ -14,12 +19,17 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
|
||||||
|
words = self.ap.sensitive_meta.data.get('words') or []
|
||||||
|
mask = self.ap.sensitive_meta.data['mask']
|
||||||
|
mask_word = self.ap.sensitive_meta.data['mask_word']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
found, message = await mask_patterns(
|
found, current = await mask_patterns(
|
||||||
self.ap.sensitive_meta.data['words'],
|
words,
|
||||||
message,
|
message,
|
||||||
mask=self.ap.sensitive_meta.data['mask'],
|
mask=mask,
|
||||||
mask_word=self.ap.sensitive_meta.data['mask_word'],
|
mask_word=mask_word,
|
||||||
|
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
|
||||||
)
|
)
|
||||||
except SafeRegexError as exc:
|
except SafeRegexError as exc:
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
@@ -31,7 +41,7 @@ class BanWordFilter(filter_model.ContentFilter):
|
|||||||
|
|
||||||
return entities.FilterResult(
|
return entities.FilterResult(
|
||||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||||
replacement=message,
|
replacement=current,
|
||||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||||
console_notice='',
|
console_notice='',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from linebot.v3.webhooks import (
|
|||||||
ImageMessageContent,
|
ImageMessageContent,
|
||||||
VideoMessageContent,
|
VideoMessageContent,
|
||||||
AudioMessageContent,
|
AudioMessageContent,
|
||||||
|
UserMentionee,
|
||||||
)
|
)
|
||||||
|
|
||||||
# from linebot import WebhookParser
|
# from linebot import WebhookParser
|
||||||
@@ -58,15 +59,19 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
return content_list
|
return content_list
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, bot_account_id: str = ''):
|
||||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
self.bot_account_id = bot_account_id
|
||||||
|
|
||||||
|
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
|
||||||
lb_msg_list = []
|
lb_msg_list = []
|
||||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||||
|
|
||||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||||
|
|
||||||
if isinstance(message.message, TextMessageContent):
|
if isinstance(message.message, TextMessageContent):
|
||||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
lb_msg_list.extend(
|
||||||
|
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
|
||||||
|
)
|
||||||
elif isinstance(message.message, AudioMessageContent):
|
elif isinstance(message.message, AudioMessageContent):
|
||||||
pass
|
pass
|
||||||
elif isinstance(message.message, VideoMessageContent):
|
elif isinstance(message.message, VideoMessageContent):
|
||||||
@@ -86,22 +91,60 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||||
return platform_message.MessageChain(lb_msg_list)
|
return platform_message.MessageChain(lb_msg_list)
|
||||||
|
|
||||||
|
def _build_text_components(self, text: str, mention) -> list:
|
||||||
|
"""Build message components from text, inserting At components for mentions.
|
||||||
|
|
||||||
|
LINE provides mention positions (index/length) and is_self per mentionee in the
|
||||||
|
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
|
||||||
|
'at-bot' group respond rule work for LINE, consistent with other adapters.
|
||||||
|
"""
|
||||||
|
components: list = []
|
||||||
|
if not mention or not mention.mentionees:
|
||||||
|
if text:
|
||||||
|
components.append(platform_message.Plain(text=text))
|
||||||
|
return components
|
||||||
|
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
|
||||||
|
cursor = 0
|
||||||
|
for start, end, mentionee in segments:
|
||||||
|
if start < cursor:
|
||||||
|
start, end = cursor, min(end, len(text))
|
||||||
|
if start < cursor or end <= start or end > len(text):
|
||||||
|
continue
|
||||||
|
if start > cursor:
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:start]))
|
||||||
|
if isinstance(mentionee, UserMentionee):
|
||||||
|
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
|
||||||
|
if not target:
|
||||||
|
target = text[start:end]
|
||||||
|
else:
|
||||||
|
target = text[start:end]
|
||||||
|
# At.__str__ already prepends '@', so strip one from the LINE text token.
|
||||||
|
display = text[start:end].lstrip('@')
|
||||||
|
components.append(platform_message.At(target=str(target), display=display))
|
||||||
|
cursor = end
|
||||||
|
if cursor < len(text):
|
||||||
|
components.append(platform_message.Plain(text=text[cursor:]))
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
def __init__(self, bot_account_id: str = ''):
|
||||||
|
self.bot_account_id = bot_account_id
|
||||||
|
self.message_converter = LINEMessageConverter(bot_account_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
event: platform_events.MessageEvent,
|
event: platform_events.MessageEvent,
|
||||||
) -> MessageEvent:
|
) -> MessageEvent:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
async def target2yiri(self, event, bot_client) -> platform_events.Event:
|
||||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
message_chain = await self.message_converter.target2yiri(event, bot_client)
|
||||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
|
||||||
|
|
||||||
if event.source.type == 'user':
|
if event.source.type == 'user':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
id=event.message.id,
|
id=event.source.user_id,
|
||||||
nickname=event.source.user_id,
|
nickname=event.source.user_id,
|
||||||
remark='',
|
remark='',
|
||||||
),
|
),
|
||||||
@@ -110,13 +153,19 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
source_platform_object=event,
|
source_platform_object=event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# 'group' and 'room' sources carry the stable chat id under different
|
||||||
|
# field names; user_id may be absent for some members, so fall back
|
||||||
|
# to the group/room id rather than the per-message id.
|
||||||
|
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
|
||||||
|
member_id = event.source.user_id or group_id
|
||||||
|
|
||||||
return platform_events.GroupMessage(
|
return platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.event.sender.sender_id.open_id,
|
id=member_id,
|
||||||
member_name=event.event.sender.sender_id.union_id,
|
member_name=member_id,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.message.id,
|
id=group_id,
|
||||||
name='',
|
name='',
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
@@ -163,8 +212,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
listeners={},
|
listeners={},
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
seq=1,
|
seq=1,
|
||||||
event_converter=LINEEventConverter(),
|
event_converter=LINEEventConverter(bot_account_id),
|
||||||
message_converter=LINEMessageConverter(),
|
message_converter=LINEMessageConverter(bot_account_id),
|
||||||
line_webhook=line_webhook,
|
line_webhook=line_webhook,
|
||||||
parser=parser,
|
parser=parser,
|
||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
)
|
)
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'{event.receiver_id}|u{event.user_id}',
|
||||||
nickname=nickname,
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
msgid = f'{uuid.uuid4().hex}'
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_text_msg(
|
await self.bot.send_text_msg(
|
||||||
open_kfid=open_kfid,
|
open_kfid=open_kfid,
|
||||||
@@ -205,6 +205,13 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
msgid=msgid,
|
msgid=msgid,
|
||||||
content=content['content'],
|
content=content['content'],
|
||||||
)
|
)
|
||||||
|
elif content['type'] == 'image':
|
||||||
|
await self.bot.send_image_msg(
|
||||||
|
open_kfid=open_kfid,
|
||||||
|
external_userid=external_userid,
|
||||||
|
msgid=msgid,
|
||||||
|
media_id=content['media_id'],
|
||||||
|
)
|
||||||
|
|
||||||
def set_bot_uuid(self, bot_uuid: str):
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
|
|||||||
@@ -747,9 +747,24 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
converted_parts = []
|
converted_parts = []
|
||||||
for part in content:
|
for part in content:
|
||||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||||
part['image_url'] = {'url': part['image_base64']}
|
# History trimming (SessionManager) clears image_base64
|
||||||
|
# on past turns and exclude_none serialization drops
|
||||||
|
# the key entirely, so the replayed part may carry no
|
||||||
|
# payload. Prefer the base64 payload; fall back to an
|
||||||
|
# image_url that survived on the same element; drop
|
||||||
|
# hollow parts instead of raising KeyError (#2469).
|
||||||
|
image_b64 = part.get('image_base64')
|
||||||
|
fallback_url = None
|
||||||
|
if not image_b64:
|
||||||
|
raw_image_url = part.get('image_url')
|
||||||
|
if isinstance(raw_image_url, dict):
|
||||||
|
fallback_url = raw_image_url.get('url')
|
||||||
|
if image_b64 or fallback_url:
|
||||||
|
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||||
part['type'] = 'image_url'
|
part['type'] = 'image_url'
|
||||||
del part['image_base64']
|
part.pop('image_base64', None)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
# OpenAI-compatible chat models reject non-image file parts
|
# OpenAI-compatible chat models reject non-image file parts
|
||||||
# (audio/document base64 or url). These originate from Voice /
|
# (audio/document base64 or url). These originate from Voice /
|
||||||
# File attachments — including ones replayed from conversation
|
# File attachments — including ones replayed from conversation
|
||||||
|
|||||||
@@ -619,7 +619,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
and len(func_ret) > 0
|
and len(func_ret) > 0
|
||||||
and isinstance(func_ret[0], provider_message.ContentElement)
|
and isinstance(func_ret[0], provider_message.ContentElement)
|
||||||
):
|
):
|
||||||
tool_content = func_ret
|
# OpenAI-compatible APIs require tool-message content to be a
|
||||||
|
# string; a raw list of ContentElement causes HTTP 500 (#2457).
|
||||||
|
tool_content = '\n'.join(str(ce) for ce in func_ret)
|
||||||
else:
|
else:
|
||||||
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
tool_content = json.dumps(func_ret, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ class SafeRegexTimeoutError(SafeRegexError):
|
|||||||
"""Raised when the regex engine exhausts the operation CPU budget."""
|
"""Raised when the regex engine exhausts the operation CPU budget."""
|
||||||
|
|
||||||
|
|
||||||
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
|
def _validate_patterns(
|
||||||
|
patterns: Sequence[str],
|
||||||
|
*,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
if max_pattern_count < 1:
|
||||||
|
raise ValueError('max_pattern_count must be positive')
|
||||||
|
if len(patterns) > max_pattern_count:
|
||||||
|
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
|
||||||
normalized = tuple(patterns)
|
normalized = tuple(patterns)
|
||||||
if len(normalized) > MAX_PATTERN_COUNT:
|
|
||||||
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
|
|
||||||
for pattern in normalized:
|
for pattern in normalized:
|
||||||
if not isinstance(pattern, str):
|
if not isinstance(pattern, str):
|
||||||
raise SafeRegexError('Regex patterns must be strings')
|
raise SafeRegexError('Regex patterns must be strings')
|
||||||
@@ -115,8 +121,9 @@ def _mask_patterns_sync(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
|
max_pattern_count: int,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
normalized_patterns = _validate_patterns(patterns)
|
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
|
||||||
_validate_input(value)
|
_validate_input(value)
|
||||||
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
|
||||||
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
|
||||||
@@ -162,6 +169,7 @@ async def mask_patterns(
|
|||||||
mask: str,
|
mask: str,
|
||||||
mask_word: str,
|
mask_word: str,
|
||||||
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
|
||||||
|
max_pattern_count: int = MAX_PATTERN_COUNT,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
"""Apply untrusted masking patterns with bounded CPU and output growth."""
|
||||||
|
|
||||||
@@ -174,4 +182,5 @@ async def mask_patterns(
|
|||||||
mask=mask,
|
mask=mask,
|
||||||
mask_word=mask_word,
|
mask_word=mask_word,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
max_pattern_count=max_pattern_count,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""BanWordFilter regression tests for legacy sensitive-word lists.
|
||||||
|
|
||||||
|
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
|
||||||
|
the previous default list (~70 patterns). The filter must keep applying those
|
||||||
|
rules instead of blocking every message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.factories import FakeApp
|
||||||
|
|
||||||
|
|
||||||
|
def _load_banwords():
|
||||||
|
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||||
|
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
|
||||||
|
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
|
||||||
|
safe_regex = import_module('langbot.pkg.utils.safe_regex')
|
||||||
|
return banwords, entities, safe_regex
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
|
||||||
|
banwords, entities, _ = _load_banwords()
|
||||||
|
app = FakeApp()
|
||||||
|
app.sensitive_meta = Mock()
|
||||||
|
app.sensitive_meta.data = {
|
||||||
|
'words': words,
|
||||||
|
'mask': mask,
|
||||||
|
'mask_word': mask_word,
|
||||||
|
}
|
||||||
|
return banwords.BanWordFilter(app), entities, app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
|
||||||
|
"""A pre-v4.10.7 word list must not fail closed on every message."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.PASS
|
||||||
|
assert result.replacement == 'hello there, nothing banned'
|
||||||
|
assert result.user_notice == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
|
||||||
|
"""Words past the first 64-pattern batch must still be applied."""
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide secret-token now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert 'secret-token' not in result.replacement
|
||||||
|
assert '[hidden]' in result.replacement
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_word_list_masks_match_in_first_batch():
|
||||||
|
_, _, safe_regex = _load_banwords()
|
||||||
|
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'alpha-secret is here')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.MASKED
|
||||||
|
assert result.replacement == '[hidden] is here'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_sensitive_word_regex_still_blocks():
|
||||||
|
filt, entities, _ = _filter_with_words(['(unclosed'])
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'any message')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oversized_word_list_is_blocked():
|
||||||
|
"""Configured rules must never be silently skipped when the list is oversized."""
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
|
||||||
|
filt, entities, _ = _filter_with_words(words)
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'hello there, nothing banned')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
|
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
|
||||||
|
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_match_beyond_total_cap_cannot_bypass_filter():
|
||||||
|
banwords, _, _ = _load_banwords()
|
||||||
|
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
|
||||||
|
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
|
||||||
|
|
||||||
|
result = await filt.process(Mock(), 'please hide late-secret now')
|
||||||
|
|
||||||
|
assert result.level == entities.ResultLevel.BLOCK
|
||||||
|
assert result.replacement == ''
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from linebot.v3.webhooks import TextMessageContent, UserMentionee, AllMentionee
|
||||||
|
|
||||||
|
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||||
|
from langbot.pkg.platform.sources import line
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
BOT_ACCOUNT_ID = 'line-bot-account'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(
|
||||||
|
*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi', mention=None
|
||||||
|
):
|
||||||
|
event = MagicMock()
|
||||||
|
event.timestamp = 1700000000000
|
||||||
|
message = MagicMock(spec=TextMessageContent)
|
||||||
|
message.id = message_id
|
||||||
|
message.text = text
|
||||||
|
message.mention = mention
|
||||||
|
event.message = message
|
||||||
|
event.message.webhook_event_id = f'webhook-{message_id}'
|
||||||
|
event.message.timestamp = event.timestamp
|
||||||
|
|
||||||
|
source = MagicMock()
|
||||||
|
source.type = source_type
|
||||||
|
source.user_id = user_id
|
||||||
|
if group_id is not None:
|
||||||
|
source.group_id = group_id
|
||||||
|
if room_id is not None:
|
||||||
|
source.room_id = room_id
|
||||||
|
event.source = source
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _make_converter(bot_account_id: str = BOT_ACCOUNT_ID) -> line.LINEEventConverter:
|
||||||
|
return line.LINEEventConverter(bot_account_id=bot_account_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_message_launcher_id_stable_across_messages() -> None:
|
||||||
|
"""Two distinct messages from the same LINE user must resolve to the same
|
||||||
|
sender id, otherwise every message starts a brand new session (context loss).
|
||||||
|
"""
|
||||||
|
converter = _make_converter()
|
||||||
|
event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
|
||||||
|
event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
|
||||||
|
|
||||||
|
result1 = await converter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await converter.target2yiri(event2, bot_client=None)
|
||||||
|
|
||||||
|
assert result1.sender.id == 'U-stable-user'
|
||||||
|
assert result1.sender.id == result2.sender.id
|
||||||
|
assert result1.sender.id != event1.message.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_message_uses_group_id_not_message_id() -> None:
|
||||||
|
converter = _make_converter()
|
||||||
|
event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
|
||||||
|
event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
|
||||||
|
|
||||||
|
result1 = await converter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await converter.target2yiri(event2, bot_client=None)
|
||||||
|
|
||||||
|
assert result1.sender.group.id == 'G-stable-group'
|
||||||
|
assert result1.sender.group.id == result2.sender.group.id
|
||||||
|
assert result1.sender.id == 'U-member'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
|
||||||
|
|
||||||
|
result = await converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
assert result.sender.group.id == 'R-stable-room'
|
||||||
|
assert result.sender.id == 'R-stable-room'
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
|
||||||
|
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
|
||||||
|
return [c for c in chain if isinstance(c, platform_message.At)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_mention_keeps_plain_text() -> None:
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
assert _plain_texts(chain) == ['hello world']
|
||||||
|
assert _ats(chain) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
|
||||||
|
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
|
||||||
|
group respond rule matches (previously the mention was lost and the message
|
||||||
|
was silently dropped in groups with at-only rules).
|
||||||
|
"""
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@BOT hey',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert len(ats) == 1
|
||||||
|
assert ats[0].target == BOT_ACCOUNT_ID
|
||||||
|
assert _plain_texts(chain) == [' hey']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_other_user_mention_keeps_display_text() -> None:
|
||||||
|
"""Mentions of other users keep their display text in the message string,
|
||||||
|
so prefix/regexp rules that match the raw '@Name ...' text still work.
|
||||||
|
"""
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@Alice hello',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert len(ats) == 1
|
||||||
|
assert ats[0].target == 'U-other'
|
||||||
|
# str() of the At component falls back to display when set
|
||||||
|
assert str(chain) == '@Alice hello'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_mention_triggers_atbot_rule() -> None:
|
||||||
|
"""End-to-end: a group message that @mentions the bot must be accepted by
|
||||||
|
the at-bot respond rule (this is the regression that silently dropped
|
||||||
|
'@bot' messages in LINE groups).
|
||||||
|
"""
|
||||||
|
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||||
|
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@RAIQt hi',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
query = MagicMock()
|
||||||
|
query.adapter = MagicMock()
|
||||||
|
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||||
|
|
||||||
|
rule = AtBotRule(ap=MagicMock())
|
||||||
|
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||||
|
|
||||||
|
assert result.matching is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
|
||||||
|
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
|
||||||
|
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
query = MagicMock()
|
||||||
|
query.adapter = MagicMock()
|
||||||
|
query.adapter.bot_account_id = BOT_ACCOUNT_ID
|
||||||
|
|
||||||
|
rule = AtBotRule(ap=MagicMock())
|
||||||
|
result = await rule.match(str(chain), chain, {'at': True}, query)
|
||||||
|
|
||||||
|
assert result.matching is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_at_all_mention_preserved_as_at_component() -> None:
|
||||||
|
mention = MagicMock()
|
||||||
|
mention.mentionees = [
|
||||||
|
AllMentionee(type='all', index=0, length=4),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@All hello',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert len(ats) == 1
|
||||||
|
assert str(chain) == '@All hello'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_mentions_sorted_by_position() -> None:
|
||||||
|
mention = MagicMock()
|
||||||
|
# Intentionally out of order to exercise sorting
|
||||||
|
mention.mentionees = [
|
||||||
|
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
|
||||||
|
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
|
||||||
|
]
|
||||||
|
converter = _make_converter()
|
||||||
|
event = _make_event(
|
||||||
|
source_type='group',
|
||||||
|
user_id='U-member',
|
||||||
|
group_id='G1',
|
||||||
|
message_id='m1',
|
||||||
|
text='@aaa mid @bbb tail',
|
||||||
|
mention=mention,
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = await converter.message_converter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
ats = _ats(chain)
|
||||||
|
assert [a.target for a in ats] == ['U-a', 'U-b']
|
||||||
|
assert str(chain) == '@aaa mid @bbb tail'
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
@@ -49,7 +50,29 @@ async def test_send_message_sends_text_to_customer_service_user():
|
|||||||
assert kwargs['open_kfid'] == 'kf-test'
|
assert kwargs['open_kfid'] == 'kf-test'
|
||||||
assert kwargs['external_userid'] == 'external-user'
|
assert kwargs['external_userid'] == 'external-user'
|
||||||
assert kwargs['content'] == 'hello'
|
assert kwargs['content'] == 'hello'
|
||||||
assert kwargs['msgid'].startswith('langbot_')
|
assert len(kwargs['msgid'].encode()) <= 32
|
||||||
|
assert uuid.UUID(hex=kwargs['msgid']).hex == kwargs['msgid']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_sends_image_to_customer_service_user():
|
||||||
|
adapter = make_adapter()
|
||||||
|
adapter.bot_account_id = 'kf-test'
|
||||||
|
adapter.bot = SimpleNamespace(
|
||||||
|
get_media_id=AsyncMock(return_value='media-id'),
|
||||||
|
send_image_msg=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
message = platform_message.MessageChain([platform_message.Image(base64='aW1hZ2U=')])
|
||||||
|
|
||||||
|
await adapter.send_message('person', 'uexternal-user', message)
|
||||||
|
|
||||||
|
adapter.bot.send_image_msg.assert_awaited_once()
|
||||||
|
kwargs = adapter.bot.send_image_msg.await_args.kwargs
|
||||||
|
assert kwargs['open_kfid'] == 'kf-test'
|
||||||
|
assert kwargs['external_userid'] == 'external-user'
|
||||||
|
assert kwargs['media_id'] == 'media-id'
|
||||||
|
assert len(kwargs['msgid'].encode()) <= 32
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_image_msg_posts_customer_service_image_payload() -> None:
|
||||||
|
captured_request: httpx.Request | None = None
|
||||||
|
|
||||||
|
def handle_request(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal captured_request
|
||||||
|
captured_request = request
|
||||||
|
return httpx.Response(200, json={'errcode': 0})
|
||||||
|
|
||||||
|
client = WecomCSClient(
|
||||||
|
corpid='corp-id',
|
||||||
|
secret='secret',
|
||||||
|
token='token',
|
||||||
|
EncodingAESKey='encoding-key',
|
||||||
|
logger=None,
|
||||||
|
unified_mode=True,
|
||||||
|
)
|
||||||
|
client.access_token = 'access-token'
|
||||||
|
client._http_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await client.send_image_msg(
|
||||||
|
open_kfid='kf-test',
|
||||||
|
external_userid='external-user',
|
||||||
|
msgid='a' * 32,
|
||||||
|
media_id='media-id',
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
assert captured_request is not None
|
||||||
|
assert captured_request.url.path == '/cgi-bin/kf/send_msg'
|
||||||
|
assert captured_request.url.params['access_token'] == 'access-token'
|
||||||
|
assert captured_request.method == 'POST'
|
||||||
|
assert captured_request.read().decode() == (
|
||||||
|
'{"touser":"external-user","open_kfid":"kf-test","msgid":"'
|
||||||
|
+ 'a' * 32
|
||||||
|
+ '","msgtype":"image","image":{"media_id":"media-id"}}'
|
||||||
|
)
|
||||||
@@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched():
|
|||||||
msg = provider_message.Message(role='user', content='just text')
|
msg = provider_message.Message(role='user', content='just text')
|
||||||
out = req._convert_messages([msg])
|
out = req._convert_messages([msg])
|
||||||
assert out[0]['content'] == 'just text'
|
assert out[0]['content'] == 'just text'
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_messages_replayed_image_without_base64_does_not_crash():
|
||||||
|
"""Replayed image parts hollowed out by history trimming must not raise KeyError (#2469).
|
||||||
|
|
||||||
|
SessionManager clears image_base64 on past turns, and URL-less platform
|
||||||
|
images never had a URL, so the replayed part serializes as
|
||||||
|
{'type': 'image_base64'} with no payload keys. The hollow part should be
|
||||||
|
dropped while the sibling text part survives.
|
||||||
|
"""
|
||||||
|
req = _make_requester()
|
||||||
|
image = provider_message.ContentElement.from_image_base64('data:image/jpeg;base64,AAAA')
|
||||||
|
# Simulate SessionManager.trim_conversation_messages clearing binary payloads.
|
||||||
|
image.image_base64 = None
|
||||||
|
msg = provider_message.Message(
|
||||||
|
role='user',
|
||||||
|
content=[
|
||||||
|
provider_message.ContentElement.from_text('describe the photo'),
|
||||||
|
image,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
out = req._convert_messages([msg])
|
||||||
|
assert [p.get('type') for p in out[0]['content']] == ['text']
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_messages_replayed_image_with_url_falls_back_to_url():
|
||||||
|
"""When base64 was trimmed but image_url survived, rebuild the OpenAI image_url part from the URL."""
|
||||||
|
req = _make_requester()
|
||||||
|
image = provider_message.ContentElement(
|
||||||
|
type='image_base64',
|
||||||
|
image_base64=None,
|
||||||
|
image_url=provider_message.ImageURLContentObject(url='https://example.com/pic.jpg'),
|
||||||
|
)
|
||||||
|
msg = provider_message.Message(role='user', content=[image])
|
||||||
|
out = req._convert_messages([msg])
|
||||||
|
parts = out[0]['content']
|
||||||
|
assert [p.get('type') for p in parts] == ['image_url']
|
||||||
|
assert parts[0]['image_url'] == {'url': 'https://example.com/pic.jpg'}
|
||||||
|
assert 'image_base64' not in parts[0]
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Regression tests for tool-message content serialization (#2457).
|
||||||
|
|
||||||
|
MCP tools return ``list[ContentElement]`` from ``execute_func_call``.
|
||||||
|
The runner must serialize that list to a string before placing it in a
|
||||||
|
``role='tool'`` message, because the OpenAI chat-completions spec
|
||||||
|
requires tool-message content to be a string. Sending the raw list
|
||||||
|
causes OpenAI-compatible endpoints to return HTTP 500.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
|
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||||
|
|
||||||
|
from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||||
|
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||||
|
|
||||||
|
|
||||||
|
class _ToolCallProvider:
|
||||||
|
"""Non-streaming provider: round 1 issues a tool call, round 2 returns text."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.requests: list[dict] = []
|
||||||
|
|
||||||
|
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||||
|
self.requests.append({'messages': list(messages)})
|
||||||
|
|
||||||
|
if len(self.requests) == 1:
|
||||||
|
return provider_message.Message(
|
||||||
|
role='assistant',
|
||||||
|
content='Let me search that.',
|
||||||
|
tool_calls=[
|
||||||
|
provider_message.ToolCall(
|
||||||
|
id='call-mcp-1',
|
||||||
|
type='function',
|
||||||
|
function=provider_message.FunctionCall(
|
||||||
|
name='duckduckgo_search',
|
||||||
|
arguments=json.dumps({'query': 'swift'}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return provider_message.Message(role='assistant', content='Done.')
|
||||||
|
|
||||||
|
|
||||||
|
class _ToolCallStreamProvider:
|
||||||
|
"""Streaming variant of _ToolCallProvider."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.requests: list[dict] = []
|
||||||
|
|
||||||
|
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||||
|
self.requests.append({'messages': list(messages)})
|
||||||
|
|
||||||
|
async def _stream():
|
||||||
|
if len(self.requests) == 1:
|
||||||
|
yield provider_message.MessageChunk(
|
||||||
|
role='assistant',
|
||||||
|
content='Let me search that.',
|
||||||
|
tool_calls=[
|
||||||
|
provider_message.ToolCall(
|
||||||
|
id='call-mcp-1',
|
||||||
|
type='function',
|
||||||
|
function=provider_message.FunctionCall(
|
||||||
|
name='duckduckgo_search',
|
||||||
|
arguments=json.dumps({'query': 'swift'}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
yield provider_message.MessageChunk(
|
||||||
|
role='assistant',
|
||||||
|
content='Done.',
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _stream()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_query(stream: bool = False) -> pipeline_query.Query:
|
||||||
|
adapter = AsyncMock()
|
||||||
|
adapter.is_stream_output_supported = AsyncMock(return_value=stream)
|
||||||
|
|
||||||
|
query = pipeline_query.Query.model_construct(
|
||||||
|
query_id='mcp-tool-query',
|
||||||
|
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||||
|
launcher_id=12345,
|
||||||
|
sender_id=12345,
|
||||||
|
message_chain=[],
|
||||||
|
message_event=None,
|
||||||
|
adapter=adapter,
|
||||||
|
pipeline_uuid='pipeline-uuid',
|
||||||
|
bot_uuid='bot-uuid',
|
||||||
|
pipeline_config={
|
||||||
|
'ai': {
|
||||||
|
'runner': {'runner': 'local-agent'},
|
||||||
|
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||||
|
},
|
||||||
|
'output': {'misc': {'remove-think': False}},
|
||||||
|
},
|
||||||
|
prompt=SimpleNamespace(messages=[]),
|
||||||
|
messages=[],
|
||||||
|
user_message=provider_message.Message(role='user', content='search swift'),
|
||||||
|
use_funcs=[SimpleNamespace(name='duckduckgo_search')],
|
||||||
|
use_llm_model_uuid='test-model-uuid',
|
||||||
|
variables={},
|
||||||
|
)
|
||||||
|
object.__setattr__(
|
||||||
|
query,
|
||||||
|
'_execution_context',
|
||||||
|
ExecutionContext(
|
||||||
|
instance_uuid='instance-test',
|
||||||
|
workspace_uuid='workspace-test',
|
||||||
|
placement_generation=1,
|
||||||
|
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def _make_app(provider, func_ret) -> SimpleNamespace:
|
||||||
|
"""Build a minimal app whose tool_mgr returns *func_ret*."""
|
||||||
|
model = SimpleNamespace(
|
||||||
|
provider=provider,
|
||||||
|
model_entity=SimpleNamespace(
|
||||||
|
uuid='test-model-uuid',
|
||||||
|
name='test-model',
|
||||||
|
abilities=['func_call'],
|
||||||
|
extra_args={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SimpleNamespace(
|
||||||
|
logger=Mock(),
|
||||||
|
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||||
|
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(return_value=func_ret)),
|
||||||
|
rag_mgr=SimpleNamespace(),
|
||||||
|
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||||
|
skill_mgr=SimpleNamespace(
|
||||||
|
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||||
|
detect_skill_activation=AsyncMock(return_value=None),
|
||||||
|
build_activation_prompt=Mock(return_value=None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The actual shape returned by MCP tools: a list of ContentElement objects.
|
||||||
|
_MCP_FUNC_RET = [
|
||||||
|
provider_message.ContentElement.from_text('Title: Swift - Wikipedia\nURL: https://en.wikipedia.org/wiki/Swift'),
|
||||||
|
provider_message.ContentElement.from_text('Title: Swift Programming Language\nURL: https://swift.org'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_message_content_is_string_not_list():
|
||||||
|
"""Non-streaming: tool message content must be a string (#2457).
|
||||||
|
|
||||||
|
Before the fix, ``func_ret`` (a ``list[ContentElement]``) was assigned
|
||||||
|
to ``tool_content`` as-is, so the tool message carried a list instead
|
||||||
|
of a string, causing OpenAI-compatible APIs to return 500.
|
||||||
|
"""
|
||||||
|
provider = _ToolCallProvider()
|
||||||
|
app = _make_app(provider, _MCP_FUNC_RET)
|
||||||
|
runner = LocalAgentRunner(app, pipeline_config={})
|
||||||
|
query = _make_query(stream=False)
|
||||||
|
|
||||||
|
results = [msg async for msg in runner.run(query)]
|
||||||
|
|
||||||
|
tool_msgs = [m for m in results if m.role == 'tool']
|
||||||
|
assert len(tool_msgs) == 1
|
||||||
|
|
||||||
|
# The content must be a string, not a list.
|
||||||
|
assert isinstance(tool_msgs[0].content, str), (
|
||||||
|
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||||
|
)
|
||||||
|
# And it should contain the text of both ContentElements.
|
||||||
|
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||||
|
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_message_content_is_string_in_stream():
|
||||||
|
"""Streaming: same regression check for the streaming path (#2457)."""
|
||||||
|
provider = _ToolCallStreamProvider()
|
||||||
|
app = _make_app(provider, _MCP_FUNC_RET)
|
||||||
|
runner = LocalAgentRunner(app, pipeline_config={})
|
||||||
|
query = _make_query(stream=True)
|
||||||
|
|
||||||
|
results = [msg async for msg in runner.run(query)]
|
||||||
|
|
||||||
|
tool_msgs = [m for m in results if m.role == 'tool']
|
||||||
|
assert len(tool_msgs) == 1
|
||||||
|
|
||||||
|
assert isinstance(tool_msgs[0].content, str), (
|
||||||
|
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
|
||||||
|
)
|
||||||
|
assert 'Swift - Wikipedia' in tool_msgs[0].content
|
||||||
|
assert 'Swift Programming Language' in tool_msgs[0].content
|
||||||
@@ -53,6 +53,45 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mask_patterns_honors_explicit_pattern_count_cap():
|
||||||
|
patterns = ['a'] * (safe_regex.MAX_PATTERN_COUNT + 6)
|
||||||
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
patterns,
|
||||||
|
'hello',
|
||||||
|
mask='*',
|
||||||
|
mask_word='',
|
||||||
|
max_pattern_count=len(patterns),
|
||||||
|
)
|
||||||
|
assert found is False
|
||||||
|
assert masked == 'hello'
|
||||||
|
|
||||||
|
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||||
|
await safe_regex.mask_patterns(
|
||||||
|
patterns,
|
||||||
|
'hello',
|
||||||
|
mask='*',
|
||||||
|
mask_word='',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mask_patterns_rejects_oversized_sequence_before_copying_it():
|
||||||
|
class OversizedPatterns(list):
|
||||||
|
def __iter__(self):
|
||||||
|
raise AssertionError('oversized patterns must not be materialized')
|
||||||
|
|
||||||
|
patterns = OversizedPatterns(['a'] * (safe_regex.MAX_PATTERN_COUNT + 1))
|
||||||
|
|
||||||
|
with pytest.raises(safe_regex.SafeRegexLimitError):
|
||||||
|
await safe_regex.mask_patterns(
|
||||||
|
patterns,
|
||||||
|
'hello',
|
||||||
|
mask='*',
|
||||||
|
mask_word='',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
|
||||||
found, masked = await safe_regex.mask_patterns(
|
found, masked = await safe_regex.mask_patterns(
|
||||||
|
|||||||
Reference in New Issue
Block a user