Compare commits

...

11 Commits

Author SHA1 Message Date
dadachann 70ad04ce25 Merge master into deploy/prod 2026-08-24 06:54:21 +00:00
Hyu f0ee57c1e0 style(email): use generic LangBot invitation branding (#2466)
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-08-24 14:53:59 +08:00
dadachann 0bab5ddf4c Merge master into deploy/prod 2026-08-24 06:15:09 +00:00
Hyu a45e27e76e style(cloud): redesign workspace invitation email (#2464)
* style(cloud): redesign workspace invitation email

* fix(email): harden Outlook spacing and text contrast

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-08-24 14:14:56 +08:00
dadachann b791caeb98 Merge master into deploy/prod 2026-08-24 04:53:51 +00:00
Hyu 536fcdf29f fix(web): restore plugin page SDK loading (#2435)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-24 12:40:50 +08:00
dadachann dbea5150af Merge master into deploy/prod 2026-08-24 04:35:30 +00:00
QuasarRyan c87548c0b9 feat(qqofficial): add markdown reply rendering (#2459) 2026-08-24 11:59:23 +08:00
dadachann 0c06c05938 Merge master into deploy/prod 2026-08-24 03:54:53 +00:00
QuasarRyan 79634772da fix(qqofficial): send complete stream snapshots (#2458) 2026-08-24 11:49:22 +08:00
dadachann 7e0a72b104 Merge master into deploy/prod for Cloud rollout 2026-08-24 03:18:22 +00:00
8 changed files with 443 additions and 64 deletions
+63
View File
@@ -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,
+50 -29
View File
@@ -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
@@ -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;">Youre 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;">Youre 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;">&nbsp;</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;">&nbsp;</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&nbsp;&rarr;</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>'''
@@ -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
@@ -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 &amp; Development' in html assert 'Research &amp; Development' in html
assert 'expires in 7 days' in html assert 'expires in 7 days' in html
assert 'lbi_secret&amp;next=&lt;unsafe&gt;' 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)
+24 -2
View File
@@ -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,
); );
} }
+58 -4
View File
@@ -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);
}); });