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:
RockChinQ
2026-07-30 21:43:35 +08:00
committed by GitHub
parent 463b120923
commit e1ac5e0fc8
468 changed files with 78320 additions and 13137 deletions
@@ -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()