Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	src/langbot/pkg/api/http/controller/groups/pipelines/pipelines.py
#	src/langbot/pkg/api/http/service/bot.py
#	src/langbot/pkg/provider/runners/localagent.py
#	src/langbot/templates/metadata/pipeline/ai.yaml
#	tests/unit_tests/api/service/test_bot_service.py
#	tests/unit_tests/provider/runners/test_difysvapi_runner.py
#	tests/unit_tests/utils/test_safe_regex.py
#	web/src/app/infra/entities/adapter-categories.ts
#	web/src/app/wizard/page.tsx
#	web/src/i18n/locales/en-US.ts
#	web/src/i18n/locales/ja-JP.ts
#	web/src/i18n/locales/zh-Hans.ts
#	web/tests/e2e/plugin-page-auth.spec.ts
This commit is contained in:
Hyu
2026-08-31 17:17:47 +08:00
67 changed files with 3604 additions and 255 deletions
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace
import json
import uuid
from langbot.pkg.api.http.service.bot import BotService
@@ -253,6 +254,26 @@ class TestBotServiceGetRuntimeBotInfo:
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={'api': {'webhook_prefix': 'https://bot.example.com'}}),
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
)
_set_discovered_adapters(ap, 'http_bot')
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'name': 'HTTP Bot',
'adapter': 'http_bot',
'adapter_config': {},
}
)
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
assert result['adapter_runtime_values']['webhook_full_url'] == ('https://bot.example.com/bots/http-bot-uuid')
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
"""Returns no webhook URL for non-webhook adapters like telegram."""
# Setup
@@ -656,6 +677,77 @@ class TestBotServiceListEventRouteStatuses:
assert result['stale_routes'] == []
class TestBotServiceHttpBotInboundTest:
async def test_sends_signed_message_through_public_ingress(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'adapter': 'http_bot',
'adapter_config': {
'signature_required': True,
'inbound_secret': 'test-secret',
},
'enable': True,
}
)
response = MagicMock(status=202)
session = MagicMock()
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
with (
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
patch(
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
new=AsyncMock(
return_value={
'code': 0,
'data': {
'session_id': 'wizard-session',
'accepted_message_id': 'in-message',
},
}
),
),
):
result = await service.send_http_bot_test_message(
WORKSPACE_UUID,
'http-bot-uuid',
'hello',
)
assert result['accepted_message_id'] == 'in-message'
request = session.post.call_args
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
payload = json.loads(request.kwargs['data'])
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
headers = request.kwargs['headers']
assert headers['X-LB-Timestamp']
assert headers['X-LB-Signature'].startswith('sha256=')
async def test_rejects_non_http_bot(self):
service = BotService(SimpleNamespace())
service.get_bot = AsyncMock(
return_value={
'uuid': 'telegram-bot',
'adapter': 'telegram',
'adapter_config': {},
'enable': True,
}
)
with pytest.raises(ValueError, match='only available for HTTP Bot'):
await service.send_http_bot_test_message(
WORKSPACE_UUID,
'telegram-bot',
'hello',
)
class TestBotServiceSendMessage:
"""Tests for send_message method."""
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
await service.get_models()
class TestSpaceServiceGetModelSelection:
"""Tests for availability-ranked model selection."""
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
async def test_preserves_selection_order_and_category_query(self, response_shape):
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
service = SpaceService(ap)
models = [
{
'uuid': 'best-model',
'model_id': 'best-chat-model',
'provider': 'provider-1',
'category': 'chat',
'status': 'active',
},
{
'uuid': 'fallback-model',
'model_id': 'fallback-chat-model',
'provider': 'provider-2',
'category': 'chat',
'status': 'active',
},
]
if response_shape == 'models-envelope':
data = {'models': models}
elif response_shape == 'availability-wrapper':
data = [
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
for index, model in enumerate(models)
]
else:
data = models
payload = {'code': 0, 'data': data}
mock_response = MagicMock(status=200)
with (
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
patch(
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
new=AsyncMock(return_value=payload),
),
):
session = MagicMock()
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
get_session.return_value = session
result = await service.get_model_selection('chat')
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
params={'category': 'chat'},
)
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
persistence = SimpleNamespace(
execute_async=AsyncMock(
side_effect=[
_create_mock_result(first_item=None),
_create_mock_result(first_item=local_model),
]
)
)
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={}),
persistence_mgr=persistence,
model_mgr=model_mgr,
)
service = SpaceService(ap)
service.get_model_selection = AsyncMock(
return_value=[
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
]
)
context = SimpleNamespace(
instance_uuid='instance',
workspace_uuid='workspace',
placement_generation=1,
principal=SimpleNamespace(),
entitlement_revision=0,
)
result = await service.get_recommended_chat_model(context)
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
service.get_model_selection.assert_awaited_once_with('chat')
model_mgr.sync_new_models_from_space.assert_awaited_once()
assert persistence.execute_async.await_count == 2
class TestSpaceServiceCreditsCache:
"""Tests for credits cache behavior."""
@@ -964,6 +964,7 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
sa.func.date_trunc('hour', sa.column('timestamp')),
sa.func.length(sa.literal('value')),
sa.func.nullif(sa.literal('value'), sa.literal('')),
sa.func.strftime('%Y-%m-%d %H:00', sa.column('timestamp')),
),
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
@@ -977,6 +978,19 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem
_validate_scoped_statement_call((statement,), {})
async def test_scoped_session_executes_sqlite_strftime() -> None:
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
try:
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
result = await uow.session.execute(
sa.select(sa.func.strftime('%Y-%m-%d %H:00', sa.literal('2026-08-28 03:45:00')))
)
assert result.scalar_one() == '2026-08-28 03:00'
finally:
await engine.dispose()
async def test_scoped_sql_rejects_public_execution_options() -> None:
statement = sa.select(sa.literal(1))
with pytest.raises(ScopedSessionTransactionError, match='execution options'):
+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
_LEGACY_SENSITIVE_WORD_PATTERN_COUNT = 70
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."""
words = [f'word{i}' for i in range(_LEGACY_SENSITIVE_WORD_PATTERN_COUNT)]
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."""
words = [f'word{i}' for i in range(_LEGACY_SENSITIVE_WORD_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():
words = ['alpha-secret'] + [f'word{i}' for i in range(_LEGACY_SENSITIVE_WORD_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,9 +1,11 @@
"""Tests for QQ Official keyboard payload helpers."""
"""Tests for QQ Official message and keyboard payload helpers."""
import asyncio
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -99,6 +101,12 @@ def _stream_test_adapter():
adapter.bot = MagicMock()
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
adapter.bot.send_private_text_msg = AsyncMock()
adapter.bot.send_group_text_msg = AsyncMock()
adapter.bot.send_private_markdown_msg = AsyncMock()
adapter.bot.send_group_markdown_msg = AsyncMock()
adapter.bot.send_channle_group_text_msg = AsyncMock()
adapter.bot.send_channle_private_text_msg = AsyncMock()
adapter.ap = None
adapter._stream_ctx = {}
adapter._stream_ctx_ts = {}
@@ -108,7 +116,7 @@ def _stream_test_adapter():
@pytest.mark.asyncio
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
async def test_qq_stream_replace_mode_sends_complete_snapshots():
adapter = _stream_test_adapter()
adapter._stream_ctx['message-1'] = {
'user_openid': 'user-1',
@@ -138,10 +146,109 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
'<think>one',
' two',
'<think>one two',
]
@pytest.mark.asyncio
async def test_qq_markdown_messages_use_markdown_payloads():
requests = []
def capture_request(request: httpx.Request) -> httpx.Response:
requests.append((str(request.url), json.loads(request.content)))
return httpx.Response(200, json={})
client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock())
client.access_token = 'access-token'
client.access_token_expiry_time = time.time() + 3600
client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request))
try:
await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2)
await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3)
finally:
await client.close()
assert requests == [
(
'https://api.sgroup.qq.com/v2/users/user-1/messages',
{'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'},
),
(
'https://api.sgroup.qq.com/v2/groups/group-1/messages',
{'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'},
),
]
@pytest.mark.asyncio
async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1')
adapter.bot.send_private_markdown_msg.assert_awaited_once_with(
user_openid='user-1',
content='# Hello',
msg_id='message-1',
event_id=None,
msg_seq=1,
)
adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
group_openid='group-1',
content='* Hello',
msg_id=None,
event_id='event-1',
msg_seq=1,
)
adapter.bot.send_private_text_msg.assert_not_awaited()
adapter.bot.send_group_text_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_defaults_to_plain_text_replies():
adapter = _stream_test_adapter()
adapter.config = {}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello')
adapter.bot.send_private_text_msg.assert_awaited_once()
adapter.bot.send_group_text_msg.assert_awaited_once()
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_does_not_affect_channel_messages():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
message = platform_message.MessageChain([platform_message.Plain(text='# Hello')])
channel_source = MagicMock()
channel_source.t = 'AT_MESSAGE_CREATE'
channel_source.channel_id = 'channel-1'
channel_source.d_id = 'message-1'
channel_event = MagicMock()
channel_event.source_platform_object = channel_source
await adapter.reply_message(channel_event, message)
dm_source = MagicMock()
dm_source.t = 'DIRECT_MESSAGE_CREATE'
dm_source.guild_id = 'guild-1'
dm_source.d_id = 'message-2'
dm_event = MagicMock()
dm_event.source_platform_object = dm_source
await adapter.reply_message(dm_event, message)
adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1')
adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2')
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
@@ -44,6 +44,86 @@ def test_webhook_dispatch_tasks_are_bounded():
assert len(client._dispatch_tasks) == 100
@pytest.mark.asyncio
async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
order = []
logger = types.SimpleNamespace(
debug=Mock(),
error=Mock(),
warning=Mock(),
)
client = WecomBotWsClient('bot-id', 'secret', logger)
async def parse_message(*args, **kwargs):
del args, kwargs
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
async def reply_stream(*args, **kwargs):
del args, kwargs
order.append('initial-frame')
return {}
async def dispatch_event(event):
del event
order.append('pipeline-dispatch')
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
client.reply_stream = reply_stream
client._dispatch_event = dispatch_event
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
assert order == ['initial-frame', 'pipeline-dispatch']
@pytest.mark.asyncio
async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
dispatched = []
class Logger:
def __init__(self):
self.warnings = []
async def debug(self, message):
del message
async def error(self, message):
raise AssertionError(message)
async def warning(self, message):
self.warnings.append(message)
logger = Logger()
client = WecomBotWsClient('bot-id', 'secret', logger)
async def parse_message(*args, **kwargs):
del args, kwargs
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
async def reply_stream(*args, **kwargs):
del args, kwargs
raise ConnectionError('simulated reply failure')
async def dispatch_event(event):
dispatched.append(event)
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
client.reply_stream = reply_stream
client._dispatch_event = dispatch_event
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
assert len(dispatched) == 1
assert len(logger.warnings) == 1
assert 'simulated reply failure' in logger.warnings[0]
def test_extract_template_card_action_supports_nested_button_key():
task_id, event_key, card_type = extract_template_card_action(
{
@@ -1,3 +1,4 @@
import uuid
from types import SimpleNamespace
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['external_userid'] == 'external-user'
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
@@ -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"}}'
)
@@ -690,6 +690,19 @@ class TestGetPluginInfo:
connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin')
assert result == {'manifest': {'metadata': {'name': 'plugin'}}}
@pytest.mark.asyncio
async def test_returns_none_when_plugin_is_not_installed(self):
connector = create_mock_connector()
configure_handler(connector, AsyncMock())
connector._target_binding = AsyncMock(
side_effect=ValueError('Plugin author/plugin is not installed in this Workspace')
)
result = await connector.get_plugin_info('author', 'plugin')
assert result is None
connector.handler.get_plugin_info.assert_not_awaited()
class TestSetPluginConfig:
"""Tests for set_plugin_config method."""
@@ -91,3 +91,42 @@ def test_convert_messages_plain_string_content_untouched():
msg = provider_message.Message(role='user', content='just text')
out = req._convert_messages([msg])
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]
+40 -1
View File
@@ -58,7 +58,7 @@ async def test_matches_any_rejects_pattern_and_input_amplification():
@pytest.mark.asyncio
async def test_bundled_sensitive_words_fit_within_pattern_limit():
config_path = Path(__file__).parents[3] / 'src/langbot/templates/metadata/sensitive-words.json'
config = json.loads(config_path.read_text())
config = json.loads(config_path.read_text(encoding='utf-8'))
assert len(config['words']) <= safe_regex.MAX_PATTERN_COUNT
found, masked = await safe_regex.mask_patterns(
@@ -72,6 +72,45 @@ async def test_bundled_sensitive_words_fit_within_pattern_limit():
assert masked == '普通消息'
@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
async def test_mask_patterns_bounds_replacement_growth_and_masks_matches():
found, masked = await safe_regex.mask_patterns(
@@ -88,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
text = service._plain_text('Research & Development', link)
html = service._html('Research & Development', link)
assert 'LangBot Cloud' in text
assert 'LangBot' in text
assert 'LangBot Cloud' not in text
assert 'Research & Development' in text
assert '7 days' in text
assert link in text
@@ -103,3 +104,55 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry
assert 'Research &amp; Development' in html
assert 'expires in 7 days' in html
assert 'lbi_secret&amp;next=&lt;unsafe&gt;' in html
assert 'LangBot Cloud' not in html
async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_link():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret'
html = service._html("RockChinQ's Workspace", link)
assert 'https://docs.langbot.app/langbot-logo.png' in html
assert '>LangBot<' in html
assert 'Workspace invitation' in html
assert 'Open invitation link' in html
assert 'linear-gradient' not in html
assert 'box-shadow' not in html
assert 'border-top:4px solid' not in html
assert 'border:1px solid #dfe6f0' not in html
assert 'height="28"' in html
assert 'height="32"' in html
assert 'margin-top:32px' not in html
assert f'>{link}<' not in html
async def test_oss_smtp_configuration_delivers_the_generic_invitation_email():
service = InvitationDeliveryService(
_app(
{
'workspace': {
'invitations': {
'email': {
'provider': 'smtp',
'from': 'LangBot <noreply@example.com>',
'smtp': {'host': 'smtp.example.com'},
}
}
}
}
)
)
service._send_smtp = AsyncMock(return_value=True)
link = 'https://self-hosted.example/invitations/accept#token=lbi_secret'
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Self-hosted Workspace',
invitation_link=link,
)
assert result == InvitationDeliveryResult(status='sent', provider='smtp')
service._send_smtp.assert_awaited_once()
assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link)
assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link)