mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 20:27:13 +00:00
Compare commits
5 Commits
bb366779af
...
f0ee57c1e0
| Author | SHA1 | Date | |
|---|---|---|---|
| f0ee57c1e0 | |||
| a45e27e76e | |||
| 536fcdf29f | |||
| c87548c0b9 | |||
| 79634772da |
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||
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(
|
||||
self,
|
||||
group_openid: str,
|
||||
|
||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
content_type = content.get('type', 'text')
|
||||
|
||||
if content_type == 'text':
|
||||
if target_type == 'c2c':
|
||||
await self.bot.send_private_text_msg(
|
||||
if target_type in {'c2c', 'group'}:
|
||||
await self._send_c2c_or_group_text_reply(
|
||||
target_type,
|
||||
target_id,
|
||||
content['content'],
|
||||
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,
|
||||
msg_id=qq_official_event.d_id,
|
||||
)
|
||||
|
||||
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):
|
||||
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(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||
ctx['session_started'] = True
|
||||
|
||||
# 发送内容 = 全量累积文本
|
||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
||||
# - 首次:发送全部文本,建立会话
|
||||
# - 后续:只能发送新增部分(append 行为)
|
||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
||||
if not content_to_send and not is_final:
|
||||
# `replace` mode requires every update to contain the previously
|
||||
# delivered content as its prefix. `sent_length` only tells us whether
|
||||
# a non-final snapshot has new content; it must not truncate the
|
||||
# content sent to QQ.
|
||||
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
|
||||
return
|
||||
content_to_send = ctx['accumulated_text']
|
||||
|
||||
input_state = 10 if is_final else 1
|
||||
|
||||
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
return
|
||||
|
||||
try:
|
||||
if target_type == 'c2c':
|
||||
await self.bot.send_private_text_msg(
|
||||
user_openid=target_id,
|
||||
content=text,
|
||||
event_id=event_id,
|
||||
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,
|
||||
)
|
||||
await self._send_c2c_or_group_text_reply(
|
||||
target_type,
|
||||
target_id,
|
||||
text,
|
||||
event_id=event_id,
|
||||
msg_seq=msg_seq,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||
|
||||
|
||||
@@ -95,6 +95,18 @@ spec:
|
||||
type: boolean
|
||||
required: true
|
||||
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
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
|
||||
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
|
||||
@staticmethod
|
||||
def _plain_text(workspace_name: str, invitation_link: str) -> str:
|
||||
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'Accept invitation: {invitation_link}\n\n'
|
||||
'This secure invitation expires in 7 days and can only be accepted by the email address '
|
||||
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',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>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
<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);">
|
||||
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
|
||||
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
|
||||
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">You’re invited</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:36px;">
|
||||
<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>
|
||||
<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>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
|
||||
<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></tr></table>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</td></tr>
|
||||
<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>
|
||||
</table>
|
||||
</td></tr>
|
||||
<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} in LangBot.</div>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
|
||||
<tr>
|
||||
<td align="center" style="padding:48px 16px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 4px 20px;">
|
||||
<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;">
|
||||
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td style="padding:42px 42px 38px;">
|
||||
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
|
||||
<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>
|
||||
<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 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>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & Development' in html
|
||||
assert 'expires in 7 days' 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, {
|
||||
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(
|
||||
@@ -724,6 +745,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
): Promise<string> {
|
||||
return this.getAuthenticatedObjectURL(
|
||||
`/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({
|
||||
status: 200,
|
||||
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',
|
||||
);
|
||||
|
||||
const pluginFrame = page.frameLocator('iframe');
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('iframe')
|
||||
.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||
).toBeVisible();
|
||||
await pluginFrame.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(pluginFrame.locator('body')).toHaveAttribute(
|
||||
'data-saved',
|
||||
'true',
|
||||
);
|
||||
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||
expect(pageSdkRequests).toBe(1);
|
||||
expect(pageApiRequests).toBe(1);
|
||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user