mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 04:37:13 +00:00
Compare commits
7 Commits
49d0aac210
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 08307790e5 | |||
| 777fe1f20b | |||
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da |
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
|||||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def _send_markdown_msg(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown message to a C2C user or QQ group."""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
await self.get_access_token()
|
||||||
|
|
||||||
|
if target_type == 'c2c':
|
||||||
|
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||||
|
elif target_type == 'group':
|
||||||
|
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported Markdown target type: {target_type}')
|
||||||
|
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
'msg_type': 2,
|
||||||
|
'markdown': {'content': content},
|
||||||
|
'msg_seq': msg_seq,
|
||||||
|
}
|
||||||
|
if msg_id:
|
||||||
|
data['msg_id'] = msg_id
|
||||||
|
if event_id:
|
||||||
|
data['event_id'] = event_id
|
||||||
|
|
||||||
|
async with self._http_client_context() as client:
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'QQBot {self.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
response = await client.post(url, headers=headers, json=data)
|
||||||
|
if response.status_code != 200:
|
||||||
|
response_data = await httpclient.parse_json_response(response)
|
||||||
|
await self.logger.error(f'Failed to send Markdown message: {response_data}')
|
||||||
|
raise ValueError(response)
|
||||||
|
|
||||||
|
async def send_private_markdown_msg(
|
||||||
|
self,
|
||||||
|
user_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown C2C message."""
|
||||||
|
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
|
async def send_group_markdown_msg(
|
||||||
|
self,
|
||||||
|
group_openid: str,
|
||||||
|
content: str,
|
||||||
|
msg_id: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Markdown QQ group message."""
|
||||||
|
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
|
||||||
|
|
||||||
async def send_group_text_msg(
|
async def send_group_text_msg(
|
||||||
self,
|
self,
|
||||||
group_openid: str,
|
group_openid: str,
|
||||||
|
|||||||
@@ -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='',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
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 +110,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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content_type = content.get('type', 'text')
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
if content_type == 'text':
|
if content_type == 'text':
|
||||||
if target_type == 'c2c':
|
if target_type in {'c2c', 'group'}:
|
||||||
await self.bot.send_private_text_msg(
|
await self._send_c2c_or_group_text_reply(
|
||||||
|
target_type,
|
||||||
target_id,
|
target_id,
|
||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
msg_id=qq_official_event.d_id,
|
||||||
)
|
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif content_type == 'image':
|
elif content_type == 'image':
|
||||||
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def _send_c2c_or_group_text_reply(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
msg_id: typing.Optional[str] = None,
|
||||||
|
event_id: typing.Optional[str] = None,
|
||||||
|
msg_seq: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Send a text reply using the configured C2C/group render mode."""
|
||||||
|
use_markdown = self.config.get('enable-markdown-rendering', False)
|
||||||
|
if target_type == 'c2c':
|
||||||
|
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
|
||||||
|
await send(
|
||||||
|
user_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
elif target_type == 'group':
|
||||||
|
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
|
||||||
|
await send(
|
||||||
|
group_openid=target_id,
|
||||||
|
content=content,
|
||||||
|
msg_id=msg_id,
|
||||||
|
event_id=event_id,
|
||||||
|
msg_seq=msg_seq,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||||
ctx['session_started'] = True
|
ctx['session_started'] = True
|
||||||
|
|
||||||
# 发送内容 = 全量累积文本
|
# `replace` mode requires every update to contain the previously
|
||||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
# delivered content as its prefix. `sent_length` only tells us whether
|
||||||
# - 首次:发送全部文本,建立会话
|
# a non-final snapshot has new content; it must not truncate the
|
||||||
# - 后续:只能发送新增部分(append 行为)
|
# content sent to QQ.
|
||||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||||
if not content_to_send and not is_final:
|
|
||||||
return
|
return
|
||||||
|
content_to_send = ctx['accumulated_text']
|
||||||
|
|
||||||
input_state = 10 if is_final else 1
|
input_state = 10 if is_final else 1
|
||||||
|
|
||||||
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_type == 'c2c':
|
await self._send_c2c_or_group_text_reply(
|
||||||
await self.bot.send_private_text_msg(
|
target_type,
|
||||||
user_openid=target_id,
|
target_id,
|
||||||
content=text,
|
text,
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
msg_seq=msg_seq,
|
msg_seq=msg_seq,
|
||||||
)
|
)
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
group_openid=target_id,
|
|
||||||
content=text,
|
|
||||||
event_id=event_id,
|
|
||||||
msg_seq=msg_seq,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ spec:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
- name: enable-markdown-rendering
|
||||||
|
label:
|
||||||
|
en_US: Enable Markdown Rendering
|
||||||
|
zh_Hans: 启用 Markdown 渲染
|
||||||
|
zh_Hant: 啟用 Markdown 渲染
|
||||||
|
description:
|
||||||
|
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
|
||||||
|
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
|
||||||
|
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
- name: webhook_url
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
||||||
return (
|
return (
|
||||||
'You have been invited to LangBot Cloud\n\n'
|
'You have been invited to join a Workspace in LangBot\n\n'
|
||||||
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
|
||||||
f'Accept invitation: {invitation_link}\n\n'
|
f'Accept invitation: {invitation_link}\n\n'
|
||||||
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
||||||
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Join {escaped_workspace} on LangBot Cloud</title>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Join {escaped_workspace} in LangBot</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
|
||||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
|
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||||
<tr><td align="center">
|
<tr>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
|
<td align="center" style="padding:48px 16px;">
|
||||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
<tr>
|
||||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
<td style="padding:0 4px 20px;">
|
||||||
</td></tr>
|
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
|
||||||
<tr><td style="padding:36px;">
|
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||||
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
|
</td>
|
||||||
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
|
</tr>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
<tr>
|
||||||
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
|
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||||
</td></tr></table>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
|
<tr>
|
||||||
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
|
<td style="padding:42px 42px 38px;">
|
||||||
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
|
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||||
</td></tr>
|
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">You’re invited to collaborate</h1>
|
||||||
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
|
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
|
||||||
</table>
|
|
||||||
</td></tr>
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 18px;">
|
||||||
|
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
|
||||||
|
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="28" style="height:28px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background:#2563eb;border-radius:8px;">
|
||||||
|
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||||
|
<tr><td height="32" style="height:32px;font-size:0;line-height:0;"> </td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-top:22px;">
|
||||||
|
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
|
||||||
|
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
|
||||||
|
Sent by LangBot<br>
|
||||||
|
If you were not expecting this invitation, you can safely ignore this email.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>'''
|
</html>'''
|
||||||
|
|||||||
@@ -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,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from linebot.v3.webhooks import TextMessageContent
|
||||||
|
|
||||||
|
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
|
||||||
|
from langbot.pkg.platform.sources import line
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi'):
|
||||||
|
event = MagicMock()
|
||||||
|
event.timestamp = 1700000000000
|
||||||
|
event.message = MagicMock(spec=TextMessageContent)
|
||||||
|
event.message.id = message_id
|
||||||
|
event.message.text = text
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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).
|
||||||
|
"""
|
||||||
|
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 line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await line.LINEEventConverter.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:
|
||||||
|
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 line.LINEEventConverter.target2yiri(event1, bot_client=None)
|
||||||
|
result2 = await line.LINEEventConverter.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:
|
||||||
|
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
|
||||||
|
|
||||||
|
result = await line.LINEEventConverter.target2yiri(event, bot_client=None)
|
||||||
|
|
||||||
|
assert result.sender.group.id == 'R-stable-room'
|
||||||
|
assert result.sender.id == 'R-stable-room'
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Tests for QQ Official keyboard payload helpers."""
|
"""Tests for QQ Official message and keyboard payload helpers."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
@@ -99,6 +101,12 @@ def _stream_test_adapter():
|
|||||||
adapter.bot = MagicMock()
|
adapter.bot = MagicMock()
|
||||||
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
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_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.ap = None
|
||||||
adapter._stream_ctx = {}
|
adapter._stream_ctx = {}
|
||||||
adapter._stream_ctx_ts = {}
|
adapter._stream_ctx_ts = {}
|
||||||
@@ -108,7 +116,7 @@ def _stream_test_adapter():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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_test_adapter()
|
||||||
adapter._stream_ctx['message-1'] = {
|
adapter._stream_ctx['message-1'] = {
|
||||||
'user_openid': 'user-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] == [
|
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
|
||||||
'<think>one',
|
'<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
|
@pytest.mark.asyncio
|
||||||
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -88,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
|
|||||||
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
|
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({}))
|
service = InvitationDeliveryService(_app({}))
|
||||||
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
|
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
|
||||||
|
|
||||||
text = service._plain_text('Research & Development', link)
|
text = service._plain_text('Research & Development', link)
|
||||||
html = service._html('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 'Research & Development' in text
|
||||||
assert '7 days' in text
|
assert '7 days' in text
|
||||||
assert link 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 & Development' in html
|
assert 'Research & Development' in html
|
||||||
assert 'expires in 7 days' in html
|
assert 'expires in 7 days' in html
|
||||||
assert 'lbi_secret&next=<unsafe>' in html
|
assert 'lbi_secret&next=<unsafe>' 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)
|
||||||
|
|||||||
@@ -710,11 +710,32 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAuthenticatedObjectURL(path: string): Promise<string> {
|
private async getAuthenticatedObjectURL(
|
||||||
|
path: string,
|
||||||
|
rewritePluginPageSdk = false,
|
||||||
|
): Promise<string> {
|
||||||
const response = await this.instance.get<Blob>(path, {
|
const response = await this.instance.get<Blob>(path, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
return URL.createObjectURL(response.data);
|
let blob = response.data;
|
||||||
|
if (rewritePluginPageSdk && blob.type.startsWith('text/html')) {
|
||||||
|
const apiBase =
|
||||||
|
this.instance.defaults.baseURL === '/'
|
||||||
|
? window.location.origin
|
||||||
|
: this.instance.defaults.baseURL?.replace(/\/$/, '');
|
||||||
|
const pageSdkUrl = `${apiBase}/api/v1/plugins/_sdk/page-sdk.js`;
|
||||||
|
const html = await blob.text();
|
||||||
|
blob = new Blob(
|
||||||
|
[
|
||||||
|
html.replace(
|
||||||
|
/(<script\b[^>]*\bsrc\s*=\s*)(["'])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\2/gi,
|
||||||
|
`$1$2${pageSdkUrl}$2`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ type: blob.type },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuthenticatedPluginAssetURL(
|
public getAuthenticatedPluginAssetURL(
|
||||||
@@ -724,6 +745,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return this.getAuthenticatedObjectURL(
|
return this.getAuthenticatedObjectURL(
|
||||||
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,55 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
|||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'text/html',
|
contentType: 'text/html',
|
||||||
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
|
body: `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>LangRAG Observability</h1>
|
||||||
|
<button id="save">Save</button>
|
||||||
|
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
|
||||||
|
<script>
|
||||||
|
document.querySelector('#save').addEventListener('click', async () => {
|
||||||
|
await window.langbot.api('/settings', { enabled: true }, 'POST');
|
||||||
|
document.body.dataset.saved = 'true';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let pageSdkRequests = 0;
|
||||||
|
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
|
||||||
|
pageSdkRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/javascript',
|
||||||
|
body: `window.langbot = {
|
||||||
|
api(endpoint, body, method) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const requestId = 'request-' + Date.now();
|
||||||
|
const handler = (event) => {
|
||||||
|
if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) {
|
||||||
|
window.removeEventListener('message', handler);
|
||||||
|
resolve(event.data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let pageApiRequests = 0;
|
||||||
|
await page.route(
|
||||||
|
'**/api/v1/plugins/langbot-team/LangRAG/page-api',
|
||||||
|
async (route) => {
|
||||||
|
pageApiRequests += 1;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: wrapped({ saved: true }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -78,11 +126,17 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
|
|||||||
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pluginFrame = page.frameLocator('iframe');
|
||||||
await expect(
|
await expect(
|
||||||
page
|
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||||
.frameLocator('iframe')
|
|
||||||
.getByRole('heading', { name: 'LangRAG Observability' }),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await pluginFrame.getByRole('button', { name: 'Save' }).click();
|
||||||
|
await expect(pluginFrame.locator('body')).toHaveAttribute(
|
||||||
|
'data-saved',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||||
|
expect(pageSdkRequests).toBe(1);
|
||||||
|
expect(pageApiRequests).toBe(1);
|
||||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user