Compare commits

...

6 Commits

Author SHA1 Message Date
leonoxo 855ae2bdba fix(line): map LINE mentions to At elements so the at-bot rule works (#2478)
The LINE adapter passed text through as a single Plain component,
ignoring the mention payload (mentions[].index/length/isSelf) that the
Line Messaging API includes in the webhook. As a result:

- At(target=bot_account_id) never appeared in the message chain, so the
  'at-bot' group respond rule silently dropped every @bot mention.
- The bot only replied when the message happened to match the prefix
  rule (e.g. starting with 'ai').

Now LINEMessageConverter reads message.message.mention and builds the
chain per mention position:

- Bot mention (isSelf) -> At(target=bot_account_id) so AtBotRule matches
  the same way as other adapters (dingtalk/lark etc.).
- Other mentions -> At(target=<line user id>, display=<mention text>).
  At.__str__ already prepends '@', so the display text carries no
  double '@' and the rendered text (prefix/regexp rules, quotes,
  session context) is byte-identical to before.
- Missing/out-of-bounds mentions are skipped defensively.

target2yiri becomes an instance method (like wechatpad/aiocqhttp) so
the converters can hold bot_account_id; LINEAdapter passes it in from
its own config.
2026-08-27 18:38:27 +08:00
fishzjp b66db86bff fix(provider): stringify MCP tool results for OpenAI-compatible APIs (#2476)
execute_func_call returns list[ContentElement] for MCP tools, but the
runner assigned that list directly to the tool-message content. The
OpenAI chat-completions spec requires tool-message content to be a
string, so OpenAI-compatible endpoints return HTTP 500 when the raw
list is sent.

Serialize the list to a string before building the tool message, using
ContentElement.__str__ which returns the text payload for text elements
and a human-readable placeholder for images and files. Fixes #2457.
2026-08-27 18:19:04 +08:00
fishzjp 95b8736e93 fix(provider): tolerate trimmed image parts in litellm message conversion (#2475)
SessionManager clears image_base64 on past turns to save memory, and
exclude_none serialization drops the hollowed field entirely, so a
replayed history part can arrive as {'type': 'image_base64'} with no
payload. The converter accessed the missing key unconditionally and
raised KeyError on every turn after an image was sent.

Prefer the base64 payload when present, fall back to an image_url that
survived on the same element, and drop hollow parts otherwise (same
strategy as the existing file-part handling). Fixes #2469.
2026-08-27 18:07:43 +08:00
Yang cabde423a1 Fix wecomcs open_kfid msgid (#2449)
* Update wecomcs.py

fix bug wecomcs send_message open_kfid
event.receiver_id  is open_kfid

* Update wecomcs.py

fix bug msgid exceeds the 32-byte limit

* test(wecomcs): cover bounded message IDs and images

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-27 17:30:49 +08:00
ciri667 08307790e5 fix(cntfilter): allow legacy sensitive-word lists over 64 patterns (#2467)
* fix(cntfilter): allow legacy sensitive-word lists over 64 patterns

Legacy sensitive-words.json files shipped ~70 rules. After v4.10.7,
BanWordFilter treated the 64-pattern safe_regex cap as a hard failure
and blocked every message. Raise the cap only on the sensitive-word
path, keep the 50ms CPU budget, and truncate oversized lists with a
one-time warning.

Fixes #2443

* fix(cntfilter): reject oversized sensitive-word lists

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-25 23:53:41 +08:00
leonoxo 777fe1f20b fix(line): use stable source id for session identity (#2398)
LINEEventConverter.target2yiri() built Friend.id/Group.id from
event.message.id, which is unique per message. Every incoming message
therefore mapped to a new session key, so LINE users and groups lost
conversation context on every turn.

Use event.source.user_id/group_id/room_id instead, matching the stable
identifiers other adapters (e.g. Telegram) use for session identity.
Falls back to the group/room id when user_id is absent, per LINE's
documented behavior for some group/room members.
2026-08-25 12:30:29 +08:00
14 changed files with 877 additions and 29 deletions
@@ -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='',
) )
+61 -12
View File
@@ -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,
+10 -3
View File
@@ -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)
+13 -4
View File
@@ -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,
) )
+113
View File
@@ -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
+39
View File
@@ -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(