mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-28 05:07:14 +00:00
feat(tenancy): add Workspace multi-tenant foundation (#2353)
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,133 @@
|
||||
import quart
|
||||
import mimetypes
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import mimetypes
|
||||
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.api.http.context import RequestContext
|
||||
from langbot.pkg.core.errors import TaskCapacityError
|
||||
from langbot.pkg.utils import httpclient, importutil
|
||||
|
||||
from ... import group
|
||||
from langbot.pkg.utils import importutil
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class _AdapterSessionScope:
|
||||
"""Immutable tenant and principal binding for a credential exchange."""
|
||||
|
||||
instance_uuid: str
|
||||
workspace_uuid: str
|
||||
placement_generation: int
|
||||
principal_type: str
|
||||
account_uuid: str | None
|
||||
api_key_uuid: str | None
|
||||
|
||||
@classmethod
|
||||
def from_request_context(cls, request_context: RequestContext) -> '_AdapterSessionScope':
|
||||
principal = request_context.principal
|
||||
return cls(
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
principal_type=principal.principal_type.value,
|
||||
account_uuid=principal.account_uuid,
|
||||
api_key_uuid=principal.api_key_uuid,
|
||||
)
|
||||
|
||||
def matches(self, request_context: RequestContext) -> bool:
|
||||
"""Return whether a request is from the exact initiating tenant principal."""
|
||||
|
||||
return self == self.from_request_context(request_context)
|
||||
|
||||
|
||||
def _bind_session_scope(session: dict, request_context: RequestContext) -> None:
|
||||
session['scope'] = _AdapterSessionScope.from_request_context(request_context)
|
||||
|
||||
|
||||
def _get_owned_session(
|
||||
sessions: dict[str, dict],
|
||||
session_id: str,
|
||||
request_context: RequestContext,
|
||||
) -> dict | None:
|
||||
"""Resolve a session without revealing sessions owned by another scope."""
|
||||
|
||||
session = sessions.get(session_id)
|
||||
scope = session.get('scope') if session is not None else None
|
||||
if not isinstance(scope, _AdapterSessionScope) or not scope.matches(request_context):
|
||||
return None
|
||||
return session
|
||||
|
||||
|
||||
def _pop_owned_session(
|
||||
sessions: dict[str, dict],
|
||||
session_id: str,
|
||||
request_context: RequestContext,
|
||||
) -> dict | None:
|
||||
"""Remove an owned session without allowing cross-scope cancellation."""
|
||||
|
||||
session = _get_owned_session(sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return None
|
||||
return sessions.pop(session_id, None)
|
||||
|
||||
|
||||
_MAX_ADAPTER_SESSIONS = 100
|
||||
_MAX_ADAPTER_SESSIONS_PER_WORKSPACE = 10
|
||||
|
||||
|
||||
def _start_adapter_session_task(
|
||||
ap,
|
||||
coro,
|
||||
*,
|
||||
adapter: str,
|
||||
session_id: str,
|
||||
request_context: RequestContext,
|
||||
) -> asyncio.Task | None:
|
||||
"""Attach one credential exchange to tenant admission and app shutdown."""
|
||||
|
||||
try:
|
||||
wrapper = ap.task_mgr.create_user_task(
|
||||
coro,
|
||||
kind='platform-adapter-credential-exchange',
|
||||
name=f'{adapter}-credential-{session_id}',
|
||||
label=f'{adapter} credential exchange',
|
||||
instance_uuid=request_context.instance_uuid,
|
||||
workspace_uuid=request_context.workspace_uuid,
|
||||
placement_generation=request_context.placement_generation,
|
||||
)
|
||||
except TaskCapacityError:
|
||||
coro.close()
|
||||
return None
|
||||
return wrapper.task
|
||||
|
||||
|
||||
def _make_room_for_session(
|
||||
sessions: dict[str, dict],
|
||||
request_context: RequestContext,
|
||||
) -> None:
|
||||
"""Bound credential-exchange sessions globally and per workspace."""
|
||||
|
||||
workspace_uuid = request_context.workspace_uuid
|
||||
owned = [
|
||||
(session_id, session)
|
||||
for session_id, session in sessions.items()
|
||||
if getattr(session.get('scope'), 'workspace_uuid', None) == workspace_uuid
|
||||
]
|
||||
evict_workspace_session = len(owned) >= _MAX_ADAPTER_SESSIONS_PER_WORKSPACE
|
||||
evict_global_session = len(sessions) >= _MAX_ADAPTER_SESSIONS
|
||||
if not evict_workspace_session and not evict_global_session:
|
||||
return
|
||||
|
||||
candidates = owned if evict_workspace_session else list(sessions.items())
|
||||
session_id, _ = min(
|
||||
candidates,
|
||||
key=lambda item: float(item[1].get('created_at', 0.0)),
|
||||
)
|
||||
session = sessions.pop(session_id, None)
|
||||
task = session.get('task') if session is not None else None
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
|
||||
|
||||
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
|
||||
@@ -84,8 +209,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/lark/create-app', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/lark/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -106,6 +231,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'error': None,
|
||||
'created_at': time.time(),
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_make_room_for_session(_create_app_sessions, request_context)
|
||||
_create_app_sessions[session_id] = session
|
||||
|
||||
def on_qr_code(info):
|
||||
@@ -137,7 +264,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
|
||||
task = asyncio.create_task(run_registration())
|
||||
task = _start_adapter_session_task(
|
||||
self.ap,
|
||||
run_registration(),
|
||||
adapter='lark',
|
||||
session_id=session_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if task is None:
|
||||
_create_app_sessions.pop(session_id, None)
|
||||
return self.http_status(429, -1, 'Too many active credential exchanges')
|
||||
session['task'] = task
|
||||
|
||||
# Wait for QR code to be ready (max 10 seconds)
|
||||
@@ -160,10 +296,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/lark/create-app/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll registration status."""
|
||||
session = _create_app_sessions.get(session_id)
|
||||
_cleanup_expired_sessions()
|
||||
session = _get_owned_session(_create_app_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -179,10 +320,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/lark/create-app/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a registration session."""
|
||||
session = _create_app_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_create_app_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -206,8 +353,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/weixin/login', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/weixin/login', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -229,6 +376,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'error': None,
|
||||
'created_at': time.time(),
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_make_room_for_session(_weixin_login_sessions, request_context)
|
||||
_weixin_login_sessions[session_id] = session
|
||||
|
||||
client = OpenClawWeixinClient(
|
||||
@@ -267,7 +416,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
task = asyncio.create_task(run_login())
|
||||
task = _start_adapter_session_task(
|
||||
self.ap,
|
||||
run_login(),
|
||||
adapter='weixin',
|
||||
session_id=session_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if task is None:
|
||||
_weixin_login_sessions.pop(session_id, None)
|
||||
return self.http_status(429, -1, 'Too many active credential exchanges')
|
||||
session['task'] = task
|
||||
|
||||
# Wait for QR code to be ready (max 10 seconds)
|
||||
@@ -290,10 +448,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/weixin/login/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll WeChat login status."""
|
||||
session = _weixin_login_sessions.get(session_id)
|
||||
_cleanup_expired_weixin_sessions()
|
||||
session = _get_owned_session(_weixin_login_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -317,10 +480,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/weixin/login/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a WeChat login session."""
|
||||
session = _weixin_login_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_weixin_login_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -344,8 +513,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/dingtalk/create-app', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/dingtalk/create-app', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -368,6 +537,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'device_code': None,
|
||||
'interval': 5,
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_make_room_for_session(_dingtalk_sessions, request_context)
|
||||
_dingtalk_sessions[session_id] = session
|
||||
|
||||
async def run_device_flow():
|
||||
@@ -380,7 +551,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
json={'source': 'langbot'},
|
||||
) as resp:
|
||||
try:
|
||||
data = await resp.json()
|
||||
data = await httpclient.read_json_limited(resp)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
session['status'] = 'error'
|
||||
session['error'] = 'Invalid response from DingTalk service'
|
||||
@@ -397,7 +568,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
json={'nonce': nonce},
|
||||
) as resp:
|
||||
try:
|
||||
data = await resp.json()
|
||||
data = await httpclient.read_json_limited(resp)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
session['status'] = 'error'
|
||||
session['error'] = 'Invalid response from DingTalk service'
|
||||
@@ -428,7 +599,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
json={'device_code': device_code},
|
||||
) as poll_resp:
|
||||
try:
|
||||
poll_data = await poll_resp.json()
|
||||
poll_data = await httpclient.read_json_limited(poll_resp)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
continue
|
||||
|
||||
@@ -464,7 +635,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
|
||||
task = asyncio.create_task(run_device_flow())
|
||||
task = _start_adapter_session_task(
|
||||
self.ap,
|
||||
run_device_flow(),
|
||||
adapter='dingtalk',
|
||||
session_id=session_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if task is None:
|
||||
_dingtalk_sessions.pop(session_id, None)
|
||||
return self.http_status(429, -1, 'Too many active credential exchanges')
|
||||
session['task'] = task
|
||||
|
||||
# Wait for QR code to be ready (max 10 seconds)
|
||||
@@ -491,11 +671,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/dingtalk/create-app/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll DingTalk Device Flow status."""
|
||||
_cleanup_expired_dingtalk_sessions()
|
||||
session = _dingtalk_sessions.get(session_id)
|
||||
session = _get_owned_session(_dingtalk_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -511,10 +695,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/dingtalk/create-app/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a DingTalk Device Flow session."""
|
||||
session = _dingtalk_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_dingtalk_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -538,8 +728,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/wecombot/create-bot', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/wecombot/create-bot', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
|
||||
import uuid
|
||||
import time
|
||||
@@ -563,6 +753,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'scode': None,
|
||||
'task': None,
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_make_room_for_session(_wecombot_sessions, request_context)
|
||||
_wecombot_sessions[session_id] = session
|
||||
|
||||
async def run_qr_flow():
|
||||
@@ -574,7 +766,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
|
||||
) as resp:
|
||||
try:
|
||||
data = await resp.json()
|
||||
data = await httpclient.read_json_limited(resp)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
session['status'] = 'error'
|
||||
session['error'] = 'Invalid response from WeCom service'
|
||||
@@ -601,7 +793,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
f'{WECOM_QC_QUERY_URL}?scode={scode}',
|
||||
) as poll_resp:
|
||||
try:
|
||||
poll_data = await poll_resp.json()
|
||||
poll_data = await httpclient.read_json_limited(poll_resp)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
continue
|
||||
|
||||
@@ -628,7 +820,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
|
||||
task = asyncio.create_task(run_qr_flow())
|
||||
task = _start_adapter_session_task(
|
||||
self.ap,
|
||||
run_qr_flow(),
|
||||
adapter='wecombot',
|
||||
session_id=session_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if task is None:
|
||||
_wecombot_sessions.pop(session_id, None)
|
||||
return self.http_status(429, -1, 'Too many active credential exchanges')
|
||||
session['task'] = task
|
||||
|
||||
# Wait for QR code to be ready (max 10 seconds)
|
||||
@@ -655,11 +856,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/wecombot/create-bot/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll WeComBot creation status."""
|
||||
_cleanup_expired_wecombot_sessions()
|
||||
session = _wecombot_sessions.get(session_id)
|
||||
session = _get_owned_session(_wecombot_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -675,10 +880,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/wecombot/create-bot/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a WeComBot creation session."""
|
||||
session = _wecombot_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_wecombot_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
@@ -702,8 +913,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
|
||||
@self.route('/qqofficial/bind', methods=['POST'])
|
||||
async def _() -> str:
|
||||
@self.route('/qqofficial/bind', methods=['POST'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Start QQ Official QR binding. Returns session_id + QR URL.
|
||||
|
||||
Flow: generate a local AES-256 key, register it with
|
||||
@@ -739,6 +950,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
'bind_key_bytes': bind_key_bytes,
|
||||
'interval': 2,
|
||||
}
|
||||
_bind_session_scope(session, request_context)
|
||||
_make_room_for_session(_qqofficial_sessions, request_context)
|
||||
_qqofficial_sessions[session_id] = session
|
||||
|
||||
async def run_qr_binding():
|
||||
@@ -752,7 +965,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
headers={'Accept': 'application/json'},
|
||||
) as resp:
|
||||
try:
|
||||
data = await resp.json(content_type=None)
|
||||
data = await httpclient.read_json_limited(resp)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
session['status'] = 'error'
|
||||
session['error'] = 'Invalid response from QQ bind service'
|
||||
@@ -790,7 +1003,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
headers={'Accept': 'application/json'},
|
||||
) as poll_resp:
|
||||
try:
|
||||
poll_data = await poll_resp.json(content_type=None)
|
||||
poll_data = await httpclient.read_json_limited(poll_resp)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
continue
|
||||
|
||||
@@ -843,7 +1056,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
|
||||
task = asyncio.create_task(run_qr_binding())
|
||||
task = _start_adapter_session_task(
|
||||
self.ap,
|
||||
run_qr_binding(),
|
||||
adapter='qqofficial',
|
||||
session_id=session_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
if task is None:
|
||||
_qqofficial_sessions.pop(session_id, None)
|
||||
return self.http_status(429, -1, 'Too many active credential exchanges')
|
||||
session['task'] = task
|
||||
|
||||
# Wait up to 10s for the QR URL to be ready before responding.
|
||||
@@ -870,11 +1092,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/qqofficial/bind/status/<session_id>', methods=['GET'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/qqofficial/bind/status/<session_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Poll QQ Official QR binding status."""
|
||||
_cleanup_expired_qqofficial_sessions()
|
||||
session = _qqofficial_sessions.get(session_id)
|
||||
session = _get_owned_session(_qqofficial_sessions, session_id, request_context)
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
@@ -892,10 +1118,16 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
|
||||
async def _(session_id: str) -> str:
|
||||
@self.route(
|
||||
'/qqofficial/bind/<session_id>',
|
||||
methods=['DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(session_id: str, request_context: RequestContext) -> str:
|
||||
"""Cancel and clean up a QQ Official QR binding session."""
|
||||
session = _qqofficial_sessions.pop(session_id, None)
|
||||
session = _pop_owned_session(_qqofficial_sessions, session_id, request_context)
|
||||
if session is None:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
return self.success(data={})
|
||||
|
||||
@@ -1,45 +1,95 @@
|
||||
import quart
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ....authz import Permission, has_permission
|
||||
from ....context import RequestContext
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('bots', '/api/v1/platform/bots')
|
||||
class BotsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'bots': await self.ap.bot_service.get_bots()})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
bot_uuid = await self.ap.bot_service.create_bot(json_data)
|
||||
return self.success(data={'uuid': bot_uuid})
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
|
||||
return self.success(
|
||||
data={
|
||||
'bots': await self.ap.bot_service.get_bots(
|
||||
request_context,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
||||
if bot is None:
|
||||
return self.http_status(404, -1, 'bot not found')
|
||||
return self.success(data={'bot': bot})
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.bot_service.update_bot(bot_uuid, json_data)
|
||||
return self.success()
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.bot_service.delete_bot(bot_uuid)
|
||||
return self.success()
|
||||
@self.route(
|
||||
'',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data)
|
||||
return self.success(data={'uuid': bot_uuid})
|
||||
|
||||
@self.route('/<bot_uuid>/logs', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
@self.route(
|
||||
'/<bot_uuid>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
include_secret = has_permission(request_context, Permission.RESOURCE_MANAGE)
|
||||
bot = await self.ap.bot_service.get_runtime_bot_info(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
if bot is None:
|
||||
return self.http_status(404, -1, 'bot not found')
|
||||
return self.success(data={'bot': bot})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>',
|
||||
methods=['PUT', 'DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
if quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data)
|
||||
else:
|
||||
await self.ap.bot_service.delete_bot(request_context, bot_uuid)
|
||||
return self.success()
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/logs',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
from_index = json_data.get('from_index', -1)
|
||||
max_count = json_data.get('max_count', 10)
|
||||
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
||||
logs, total_count = await self.ap.bot_service.list_event_logs(
|
||||
request_context, bot_uuid, from_index, max_count
|
||||
)
|
||||
return self.success(data={'logs': logs, 'total_count': total_count})
|
||||
|
||||
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
@self.route(
|
||||
'/<bot_uuid>/send_message',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.API_KEY,
|
||||
permission=Permission.RUNTIME_OPERATE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
target_type = json_data.get('target_type')
|
||||
target_id = json_data.get('target_id')
|
||||
@@ -54,37 +104,51 @@ class BotsRouterGroup(group.RouterGroup):
|
||||
if target_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
||||
|
||||
try:
|
||||
await self.ap.bot_service.send_message(bot_uuid, target_type, target_id, message_chain_data)
|
||||
return self.success(data={'sent': True})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
||||
|
||||
# ============ Bot Admins ============
|
||||
|
||||
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(bot_uuid: str) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
|
||||
return self.success(data={'admins': admins})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
launcher_type = json_data.get('launcher_type', '').strip()
|
||||
launcher_id = str(json_data.get('launcher_id', '')).strip()
|
||||
if not launcher_type or not launcher_id:
|
||||
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
|
||||
try:
|
||||
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
|
||||
return self.success(data={'id': admin_id})
|
||||
except Exception as e:
|
||||
return self.http_status(409, -1, str(e))
|
||||
await self.ap.bot_service.send_message(
|
||||
request_context,
|
||||
bot_uuid,
|
||||
target_type,
|
||||
target_id,
|
||||
message_chain_data,
|
||||
)
|
||||
return self.success(data={'sent': True})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(bot_uuid: str, admin_id: int) -> str:
|
||||
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
admins = await self.ap.bot_service.get_bot_admins(request_context, bot_uuid)
|
||||
return self.success(data={'admins': admins})
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, request_context: RequestContext) -> str:
|
||||
json_data = await quart.request.json
|
||||
launcher_type = json_data.get('launcher_type', '').strip()
|
||||
launcher_id = str(json_data.get('launcher_id', '')).strip()
|
||||
if not launcher_type or not launcher_id:
|
||||
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
|
||||
try:
|
||||
admin_id = await self.ap.bot_service.add_bot_admin(
|
||||
request_context, bot_uuid, launcher_type, launcher_id
|
||||
)
|
||||
return self.success(data={'id': admin_id})
|
||||
except IntegrityError as e:
|
||||
return self.http_status(409, -1, str(e))
|
||||
|
||||
@self.route(
|
||||
'/<bot_uuid>/admins/<int:admin_id>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
)
|
||||
async def _(bot_uuid: str, admin_id: int, request_context: RequestContext) -> str:
|
||||
await self.ap.bot_service.delete_bot_admin(request_context, bot_uuid, admin_id)
|
||||
return self.success()
|
||||
|
||||
Reference in New Issue
Block a user