diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 591767c7f..edea7b762 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -60,6 +60,19 @@ To inspect key identity and permissions, call `GET /api/v1/system/context` with ## Tool surface +Slack quick setup: create a disabled `slack-omni` bot draft first, then use +`start_slack_setup` with the user's App Configuration access/refresh tokens. +Choose `socket_mode=true` with a browser-reachable OAuth `redirect_url`, or +provide the public HTTPS `webhook_url` ending in `/bots/`. +Poll `get_slack_setup_status` for the Slack installation authorization URL; +the user must authorize installation. On success, apply the returned `config` +using the normal bot update flow. Socket Mode also requires a separately +generated `app_token` (`xapp-`, scope `connections:write`). Never log tokens. +`cancel_slack_setup` removes the temporary session without deleting the Slack +application. Sessions expire after 15 minutes and are bound to the initiating +Workspace, principal, and placement generation. Restarting a failed setup can +create another Slack application; inspect the returned `app_id` first. + The tools wrap the LangBot service layer. Current tools (v1): | Tool | Purpose | diff --git a/src/langbot/libs/slack_api/api.py b/src/langbot/libs/slack_api/api.py index 69d70b402..3ffcf04c0 100644 --- a/src/langbot/libs/slack_api/api.py +++ b/src/langbot/libs/slack_api/api.py @@ -5,6 +5,7 @@ from quart import Quart, jsonify, request from slack_sdk.web.async_client import AsyncWebClient from .slackevent import SlackEvent from typing import Callable +from collections import OrderedDict import langbot_plugin.api.entities.builtin.platform.events as platform_events _MAX_CALLBACK_BODY_BYTES = 1024 * 1024 @@ -30,6 +31,11 @@ class SlackClient: } self.bot_user_id = None # 避免机器人回复自己的消息 self.logger = logger + self.socket_client = None + self.socket_queue = asyncio.Queue(maxsize=100) + self.socket_seen = OrderedDict() + self.socket_stop = asyncio.Event() + self.socket_worker = None async def handle_callback_request(self): """处理回调请求(独立端口模式,使用全局 request)""" @@ -44,6 +50,8 @@ class SlackClient: Returns: 响应数据 """ + if self.socket_client is not None: + return jsonify({'error': 'Socket Mode is enabled'}), 409 return await self._handle_callback_internal(req) async def _handle_callback_internal(self, req): @@ -61,30 +69,80 @@ class SlackClient: if data['type'] == 'url_verification': return data['challenge'] - bot_user_id = data.get('event', {}).get('bot_id', '') - - if self.bot_user_id and bot_user_id == self.bot_user_id: - return jsonify({'status': 'ok'}) - - # 处理私信 - if data and data.get('event', {}).get('channel_type') in ['im']: - event = SlackEvent.from_payload(data) - await self._handle_message(event) - return jsonify({'status': 'ok'}) - - # 处理群聊 - if data.get('event', {}).get('type') == 'app_mention': - data.setdefault('event', {})['channel_type'] = 'channel' - event = SlackEvent.from_payload(data) - await self._handle_message(event) - return jsonify({'status': 'ok'}) - + await self.handle_payload(data) return jsonify({'status': 'ok'}) except Exception as e: await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') raise (e) + async def handle_payload(self, data): + """Dispatch the same event representation for HTTP and Socket Mode.""" + raw = data.get('event', {}) + if raw.get('bot_id') or raw.get('subtype') == 'bot_message': + return + if raw.get('channel_type') == 'im': + await self._handle_message(SlackEvent.from_payload(data)) + elif raw.get('type') == 'app_mention': + raw['channel_type'] = 'channel' + await self._handle_message(SlackEvent.from_payload(data)) + + async def _socket_request(self, client, req): + from slack_sdk.socket_mode.response import SocketModeResponse + + if req.type == 'events_api': + key = req.payload.get('event_id') or req.envelope_id + if key not in self.socket_seen: + try: + self.socket_queue.put_nowait(req.payload) + except asyncio.QueueFull: + # Leave unacknowledged so Slack can retry; do not grow tasks without bounds. + await self.logger.warning('Slack Socket Mode event queue is full') + return + self.socket_seen[key] = True + while len(self.socket_seen) > 4096: + self.socket_seen.popitem(last=False) + await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) + + async def run_socket(self, app_token): + from slack_sdk.socket_mode.aiohttp import SocketModeClient + + self.socket_stop.clear() + self.socket_client = SocketModeClient(app_token=app_token, web_client=self.client) + self.socket_client.socket_mode_request_listeners.append(self._socket_request) + + async def worker(): + while True: + payload = await self.socket_queue.get() + try: + await self.handle_payload(payload) + except Exception: + await self.logger.error('Slack Socket Mode event processing failed') + finally: + self.socket_queue.task_done() + + self.socket_worker = asyncio.create_task(worker()) + try: + await self.socket_client.connect() + await self.logger.info('Slack Socket Mode connected') + await self.socket_stop.wait() + finally: + await self.close_socket() + + async def close_socket(self): + self.socket_stop.set() + client, self.socket_client = self.socket_client, None + if client is not None: + await client.close() + worker, self.socket_worker = self.socket_worker, None + if worker is not None: + worker.cancel() + await asyncio.gather(worker, return_exceptions=True) + self.socket_seen.clear() + while not self.socket_queue.empty(): + self.socket_queue.get_nowait() + self.socket_queue.task_done() + async def _handle_message(self, event: SlackEvent): """ 处理消息事件。 diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index bcc81d855..92a3e2d26 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -158,6 +158,52 @@ def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str: @group.group_class('adapters', '/api/v1/platform/adapters') class AdaptersRouterGroup(group.RouterGroup): async def initialize(self) -> None: + from ....service.slack_setup import get_slack_setup, SlackSetupError + + slack_setup = get_slack_setup(self.ap) + + @self.route('/slack/setup', methods=['POST'], permission=Permission.RESOURCE_MANAGE, + auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def slack_start(request_context: RequestContext): + data = await quart.request.get_json() + if not isinstance(data, dict): + return self.http_status(400, -1, 'invalid_input') + try: + result = await slack_setup.start(request_context, **{ + key: data[key] for key in ('bot_uuid', 'access_token', 'refresh_token', 'webhook_url', + 'name', 'socket_mode', 'redirect_url') if key in data + }) + return self.success(data=result) + except (SlackSetupError, TypeError) as exc: + return self.http_status(400, -1, str(exc) if isinstance(exc, SlackSetupError) else 'invalid_input') + + @self.route('/slack/setup/', methods=['GET', 'DELETE'], + permission=Permission.RESOURCE_MANAGE, auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) + async def slack_status(session_id: str, request_context: RequestContext): + try: + slack_setup.owned(request_context, session_id) + if quart.request.method == 'DELETE': + slack_setup.discard(session_id) + return self.success(data={}) + response = await quart.make_response(self.success(data=slack_setup.status(request_context, session_id))) + response.headers['Cache-Control'] = 'no-store' + return response + except SlackSetupError: + return self.http_status(404, -1, 'session_not_found') + + @self.route('/slack/setup/callback', methods=['GET'], auth_type=group.AuthType.NONE) + async def slack_callback(): + try: + await slack_setup.finish(quart.request.args.get('state', ''), quart.request.args.get('code', ''), + quart.request.args.get('error')) + message = 'Slack authorization completed. Return to LangBot to finish setup. / Slack 授权完成,请返回 LangBot 完成配置。' + status = 200 + except SlackSetupError: + message = 'Slack authorization failed or expired. Return to LangBot for details. / Slack 授权失败或已过期,请返回 LangBot 查看详情。' + status = 400 + return quart.Response(message, status=status, content_type='text/plain; charset=utf-8', + headers={'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer'}) + @self.route('', methods=['GET']) async def _() -> str: return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()}) diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py index dbb9626cf..8499772cd 100644 --- a/src/langbot/pkg/api/http/controller/groups/webhooks.py +++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py @@ -31,6 +31,11 @@ class WebhookRouterGroup(group.RouterGroup): 适配器返回的响应 """ try: + setup = getattr(self.ap, 'slack_setup_service', None) + if setup is not None and not path and quart.request.method == 'POST': + challenge = await setup.challenge(bot_uuid, quart.request) + if challenge is not None: + return quart.jsonify(challenge) # Public ingress never accepts X-Workspace-Id. The opaque bot UUID # is resolved against the already-bound runtime resource, which # carries the trusted Workspace and placement generation. diff --git a/src/langbot/pkg/api/http/service/slack_setup.py b/src/langbot/pkg/api/http/service/slack_setup.py new file mode 100644 index 000000000..b5d7b1156 --- /dev/null +++ b/src/langbot/pkg/api/http/service/slack_setup.py @@ -0,0 +1,329 @@ +"""Short-lived Slack app provisioning and OAuth handoff sessions.""" + +import asyncio +import hashlib +import hmac +import ipaddress +import json +import re +import secrets +import time +from urllib.parse import urlencode, urlsplit + +import aiohttp + +from langbot.pkg.utils import httpclient + + +class SlackSetupError(ValueError): + pass + + +def callback_url(webhook_url: str, bot_uuid: str) -> str: + parsed = urlsplit(webhook_url) + suffix = f'/bots/{bot_uuid}' + if ( + parsed.scheme != 'https' + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or not parsed.path.endswith(suffix) + or parsed.hostname.lower() == 'localhost' + ): + raise SlackSetupError('invalid_webhook_url') + try: + if not ipaddress.ip_address(parsed.hostname).is_global: + raise SlackSetupError('invalid_webhook_url') + except ValueError as exc: + if isinstance(exc, SlackSetupError): + raise + return webhook_url[: -len(suffix)] + '/api/v1/platform/adapters/slack/setup/callback' + + +def manifest(name: str, webhook_url: str, redirect_url: str, *, events: bool, socket_mode=False) -> dict: + settings = {'socket_mode_enabled': socket_mode} + if events: + settings['event_subscriptions'] = { + **({} if socket_mode else {'request_url': webhook_url}), + 'bot_events': ['app_mention', 'message.im'], + } + return { + 'display_information': {'name': name}, + 'features': { + 'bot_user': {'display_name': name, 'always_online': False}, + 'app_home': {'messages_tab_enabled': True, 'messages_tab_read_only_enabled': False}, + }, + 'oauth_config': { + 'redirect_urls': [redirect_url], + 'scopes': { + 'bot': [ + 'app_mentions:read', + 'chat:write', + 'channels:history', + 'channels:read', + 'groups:history', + 'groups:read', + 'im:history', + 'im:read', + 'im:write', + 'mpim:history', + 'mpim:read', + 'users:read', + 'files:read', + 'files:write', + ] + }, + }, + 'settings': settings, + } + + +class SlackSetupService: + TTL = 900 + + def __init__(self, ap): + self.ap = ap + self.sessions = {} + + @staticmethod + def scope(context): + return (context.instance_uuid, context.workspace_uuid, context.placement_generation, context.principal) + + def owned(self, context, session_id): + session = self.sessions.get(session_id) + if not session or session['scope'] != self.scope(context): + raise SlackSetupError('session_not_found') + return session + + def discard(self, session_id): + session = self.sessions.pop(session_id, None) + if session: + session['timer'].cancel() + task = session.get('task') + if task and not task.done(): + task.cancel() + session.clear() + + async def api(self, method, token=None, **params): + headers = {'Authorization': f'Bearer {token}'} if token else {} + try: + async with httpclient.get_session().post( + f'https://slack.com/api/{method}', + data=params, + headers=headers, + timeout=aiohttp.ClientTimeout(total=25), + allow_redirects=False, + ) as response: + if response.status != 200: + raise SlackSetupError(f'slack_http_{response.status}') + data = await response.json() + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + raise SlackSetupError('slack_connection_failed') from exc + if not data.get('ok'): + # Never surface arbitrary upstream text or credentials in errors. + code = str(data.get('error', 'unknown_error')) + if not re.fullmatch(r'[a-z_]{1,80}', code): + code = 'unknown_error' + raise SlackSetupError(code) + return data + + async def start( + self, + context, + *, + bot_uuid, + access_token, + refresh_token, + webhook_url='', + name='LangBot', + socket_mode=False, + redirect_url='', + ): + if not all(isinstance(value, str) for value in (bot_uuid, access_token, refresh_token, webhook_url, name)): + raise SlackSetupError('invalid_input') + access_token, refresh_token, webhook_url, name = ( + access_token.strip(), + refresh_token.strip(), + webhook_url.strip(), + name.strip(), + ) + if not access_token or not refresh_token or not 1 <= len(name) <= 35: + raise SlackSetupError('invalid_input') + if not isinstance(socket_mode, bool): + raise SlackSetupError('invalid_input') + if socket_mode: + parsed = urlsplit(redirect_url) + if ( + not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or not parsed.path.endswith('/api/v1/platform/adapters/slack/setup/callback') + or not (parsed.scheme == 'https' or (parsed.scheme == 'http' and parsed.hostname == 'localhost')) + ): + raise SlackSetupError('invalid_redirect_url') + redirect = redirect_url + else: + redirect = callback_url(webhook_url, bot_uuid) + bot = await self.ap.bot_service.get_bot(context, bot_uuid) + if not bot or bot['adapter'] not in ('slack', 'slack-omni'): + raise SlackSetupError('slack_bot_not_found') + if ( + len(self.sessions) >= 100 + or sum(s['context'].workspace_uuid == context.workspace_uuid for s in self.sessions.values()) >= 10 + ): + raise SlackSetupError('too_many_sessions') + if any(s['bot_uuid'] == bot_uuid for s in self.sessions.values()): + raise SlackSetupError('setup_already_active') + sid = secrets.token_urlsafe(32) + session = { + 'scope': self.scope(context), + 'context': context, + 'status': 'creating', + 'bot_uuid': bot_uuid, + 'redirect_url': redirect, + 'webhook_url': webhook_url, + 'socket_mode': socket_mode, + 'timer': asyncio.get_running_loop().call_later(self.TTL, self.discard, sid), + } + self.sessions[sid] = session + coro = self._create(session, access_token, refresh_token, name, sid) + try: + wrapper = self.ap.task_mgr.create_user_task( + coro, + kind='platform-adapter-credential-exchange', + name=f'slack-setup-{sid}', + label='Slack app setup', + instance_uuid=context.instance_uuid, + workspace_uuid=context.workspace_uuid, + placement_generation=context.placement_generation, + ) + session['task'] = wrapper.task + except Exception: + coro.close() + self.discard(sid) + raise SlackSetupError('too_many_sessions') from None + return {'session_id': sid} + + async def _create(self, session, access, refresh, name, sid): + async def config_api(method, **params): + nonlocal access, refresh + try: + return await self.api(method, access, **params) + except SlackSetupError as exc: + if str(exc) not in ('token_expired', 'invalid_auth', 'token_revoked'): + raise + rotated = await self.api('tooling.tokens.rotate', refresh_token=refresh) + access, refresh = rotated['token'], rotated['refresh_token'] + return await self.api(method, access, **params) + + try: + base = manifest( + name, session['webhook_url'], session['redirect_url'], events=False, socket_mode=session['socket_mode'] + ) + result = await config_api('apps.manifest.create', manifest=json.dumps(base)) + session['app_id'] = result['app_id'] + session['credentials'] = result['credentials'] + # Register the signing secret before Slack validates the final event URL. + complete = manifest( + name, session['webhook_url'], session['redirect_url'], events=True, socket_mode=session['socket_mode'] + ) + await config_api('apps.manifest.update', app_id=result['app_id'], manifest=json.dumps(complete)) + session['authorize_url'] = 'https://slack.com/oauth/v2/authorize?' + urlencode( + { + 'client_id': result['credentials']['client_id'], + 'scope': ','.join(complete['oauth_config']['scopes']['bot']), + 'redirect_uri': session['redirect_url'], + 'state': sid, + } + ) + session['status'] = 'waiting' + except Exception as exc: + if session: + session['status'] = 'error' + session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'setup_failed' + session.pop('credentials', None) + finally: + access = refresh = '' + + def status(self, context, sid): + session = self.owned(context, sid) + result = {key: session[key] for key in ('status', 'app_id', 'authorize_url', 'error') if key in session} + if session['status'] == 'success': + result['config'] = session['config'] + return result + + async def finish(self, sid, code, error=None): + session = self.sessions.get(sid) + if not session or session['status'] != 'waiting': + raise SlackSetupError('session_not_found') + session['status'] = 'authorizing' + try: + if error or not code: + raise SlackSetupError('authorization_denied') + context = session['context'] + await self.ap.workspace_service.get_execution_binding( + context.workspace_uuid, + expected_generation=context.placement_generation, + ) + creds = session['credentials'] + result = await self.api( + 'oauth.v2.access', + client_id=creds['client_id'], + client_secret=creds['client_secret'], + code=code, + redirect_uri=session['redirect_url'], + ) + if result.get('app_id') != session['app_id'] or result.get('token_type') != 'bot': + raise SlackSetupError('unexpected_oauth_response') + session['config'] = { + 'bot_token': result['access_token'], + 'signing_secret': creds['signing_secret'], + 'socket_mode': session['socket_mode'], + } + session['status'] = 'success' + except Exception as exc: + if session: + session['status'] = 'error' + session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'authorization_failed' + raise SlackSetupError('authorization_failed') from None + finally: + session.pop('credentials', None) + session.pop('authorize_url', None) + + async def challenge(self, bot_uuid, request): + session = next((s for s in self.sessions.values() if s['bot_uuid'] == bot_uuid and s.get('credentials')), None) + if not session: + return None + body = await request.get_data() + timestamp = request.headers.get('X-Slack-Request-Timestamp', '') + if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: + return None + signature = ( + 'v0=' + + hmac.new( + session['credentials']['signing_secret'].encode(), + b'v0:' + timestamp.encode() + b':' + body, + hashlib.sha256, + ).hexdigest() + ) + if not hmac.compare_digest(signature, request.headers.get('X-Slack-Signature', '')): + return None + data = await request.get_json(silent=True) + if not isinstance(data, dict) or data.get('type') != 'url_verification': + return None + context = session['context'] + await self.ap.workspace_service.get_execution_binding( + context.workspace_uuid, + expected_generation=context.placement_generation, + ) + return {'challenge': data.get('challenge', '')} + + +def get_slack_setup(ap): + if not hasattr(ap, 'slack_setup_service'): + ap.slack_setup_service = SlackSetupService(ap) + return ap.slack_setup_service diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index 1a6f73fd9..6a97430e1 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -217,6 +217,30 @@ class LangBotMCPServer: _authorized(Permission.RESOURCE_MANAGE), bot_id, session_id, )) + @mcp.tool(description='Create/configure a Slack app for an existing Workspace bot draft. Returns a setup session; poll it for the installation authorization URL. The user must approve Slack installation. Configuration tokens stay in memory only.') + async def start_slack_setup(bot_uuid: str, access_token: str, refresh_token: str, + webhook_url: str = '', name: str = 'LangBot', + socket_mode: bool = False, redirect_url: str = '') -> str: + from langbot.pkg.api.http.service.slack_setup import get_slack_setup + return _dump(await get_slack_setup(ap).start( + _authorized(Permission.RESOURCE_MANAGE), bot_uuid=bot_uuid, access_token=access_token, + refresh_token=refresh_token, webhook_url=webhook_url, name=name, + socket_mode=socket_mode, redirect_url=redirect_url, + )) + + @mcp.tool(description='Read an owned Slack setup session. Success includes adapter credentials; save them with the existing bot update tool. Socket Mode additionally needs an App-Level Token with connections:write.') + async def get_slack_setup_status(session_id: str) -> str: + from langbot.pkg.api.http.service.slack_setup import get_slack_setup + return _dump(get_slack_setup(ap).status(_authorized(Permission.RESOURCE_MANAGE), session_id)) + + @mcp.tool(description='Discard an owned Slack setup session and its temporary credentials. Does not delete the Slack app.') + async def cancel_slack_setup(session_id: str) -> str: + from langbot.pkg.api.http.service.slack_setup import get_slack_setup + service = get_slack_setup(ap) + service.owned(_authorized(Permission.RESOURCE_MANAGE), session_id) + service.discard(session_id) + return _dump({'cancelled': True}) + @mcp.tool( description='List Workspace executions, including events that did not start a processor. The legacy pipeline_ids filter accepts Agent, Pipeline and event processor IDs. Returns bounded rows and summary metrics.' ) diff --git a/src/langbot/pkg/platform/adapters/slack/adapter.py b/src/langbot/pkg/platform/adapters/slack/adapter.py index c2ee91a8d..e07e9bd0e 100644 --- a/src/langbot/pkg/platform/adapters/slack/adapter.py +++ b/src/langbot/pkg/platform/adapters/slack/adapter.py @@ -41,14 +41,14 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap arbitrary_types_allowed = True def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): - required_keys = ['bot_token', 'signing_secret'] + required_keys = ['bot_token', 'app_token' if config.get('socket_mode', False) else 'signing_secret'] missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: raise Exception(f'Slack Omni adapter missing config: {missing_keys}') bot = SlackClient( bot_token=config['bot_token'], - signing_secret=config['signing_secret'], + signing_secret=config.get('signing_secret', ''), logger=logger, unified_mode=True, ) @@ -144,11 +144,15 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap return await self.bot.handle_unified_webhook(request) async def run_async(self): + if self.config.get('socket_mode', False): + await self.bot.run_socket(self.config['app_token']) + return await self.logger.info('Slack Omni adapter running in unified webhook mode') while True: await asyncio.sleep(1) async def kill(self) -> bool: + await self.bot.close_socket() self._message_cache.clear() self._user_cache.clear() self._group_cache.clear() diff --git a/src/langbot/pkg/platform/adapters/slack/manifest.yaml b/src/langbot/pkg/platform/adapters/slack/manifest.yaml index b5163f55e..4ac0cf12f 100644 --- a/src/langbot/pkg/platform/adapters/slack/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/slack/manifest.yaml @@ -8,9 +8,9 @@ metadata: zh_Hans: Slack zh_Hant: Slack description: - en_US: Slack adapter with event-driven orchestration support, using LangBot's unified webhook endpoint. - zh_Hans: Slack 适配器,支持事件驱动编排,通过 LangBot 统一 Webhook 接收 Slack 事件订阅消息。 - zh_Hant: Slack 適配器,支援事件驅動編排,透過 LangBot 統一 Webhook 接收 Slack 事件訂閱訊息。 + en_US: Slack adapter with event-driven orchestration, supporting Webhook and Socket Mode. + zh_Hans: Slack 适配器,支持事件驱动编排,可使用 Webhook 或无需公网地址的长连接。 + zh_Hant: Slack 適配器,支援事件驅動編排,可使用 Webhook 或無需公開網址的長連線。 icon: slack.png spec: @@ -22,7 +22,26 @@ spec: en: https://langbot.app/docs/en/usage/platforms/slack ja: https://langbot.app/docs/ja/usage/platforms/slack config: + - name: quick_setup + label: {en_US: Quick Slack setup, zh_Hans: Slack 快速配置, zh_Hant: Slack 快速設定, ja_JP: Slack クイック設定} + description: {en_US: Create and configure an app using App Configuration Tokens., zh_Hans: 填入配置令牌,自动创建应用并配置权限,授权后填入凭据。, zh_Hant: 填入設定權杖,自動建立應用程式並設定權限,授權後填入憑證。, ja_JP: 設定トークンでアプリを作成し、承認後に認証情報を入力します。} + type: qr-code-login + login_platform: slack + required: false + - name: socket_mode + label: {en_US: Socket Mode, zh_Hans: 长连接模式, zh_Hant: 長連線模式, ja_JP: Socket Mode} + type: boolean + default: false + required: false + - name: app_token + label: {en_US: App-Level Token, zh_Hans: App-Level Token, zh_Hant: App-Level Token, ja_JP: App-Level Token} + description: {en_US: Generate an xapp- token with connections:write in Basic Information., zh_Hans: 在 Basic Information 中生成带 connections:write 权限的 xapp- 令牌。, zh_Hant: 在 Basic Information 產生具有 connections:write 權限的 xapp- 權杖。, ja_JP: Basic Information で connections:write 権限の xapp- トークンを生成してください。} + type: string + required: false + default: '' + show_if: {field: socket_mode, operator: eq, value: true} - name: webhook_url + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: Webhook Callback URL zh_Hans: Webhook 回调地址 @@ -43,12 +62,13 @@ spec: required: true default: "" - name: signing_secret + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: Signing Secret zh_Hans: 签名密钥 zh_Hant: 簽名密鑰 type: string - required: true + required: false default: "" supported_events: diff --git a/src/langbot/pkg/platform/sources/slack.py b/src/langbot/pkg/platform/sources/slack.py index df18a0765..57a9c0f33 100644 --- a/src/langbot/pkg/platform/sources/slack.py +++ b/src/langbot/pkg/platform/sources/slack.py @@ -102,14 +102,14 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): def __init__(self, config: dict, logger: EventLogger): required_keys = [ 'bot_token', - 'signing_secret', + 'app_token' if config.get('socket_mode', False) else 'signing_secret', ] - missing_keys = [key for key in required_keys if key not in config] + missing_keys = [key for key in required_keys if not config.get(key)] if missing_keys: raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员') bot = SlackClient( - bot_token=config['bot_token'], signing_secret=config['signing_secret'], logger=logger, unified_mode=True + bot_token=config['bot_token'], signing_secret=config.get('signing_secret', ''), logger=logger, unified_mode=True ) super().__init__( @@ -186,6 +186,9 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return await self.bot.handle_unified_webhook(request) async def run_async(self): + if self.config.get('socket_mode', False): + await self.bot.run_socket(self.config['app_token']) + return # 统一 webhook 模式下,不启动独立的 Quart 应用 # 保持运行但不启动独立端口 async def keep_alive(): @@ -195,7 +198,8 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await keep_alive() async def kill(self) -> bool: - return False + await self.bot.close_socket() + return True async def unregister_listener( self, diff --git a/src/langbot/pkg/platform/sources/slack.yaml b/src/langbot/pkg/platform/sources/slack.yaml index 44fd4b337..f6bbd97e2 100644 --- a/src/langbot/pkg/platform/sources/slack.yaml +++ b/src/langbot/pkg/platform/sources/slack.yaml @@ -12,9 +12,9 @@ metadata: es_ES: Slack description: en_US: Slack Adapter - zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式 - zh_Hant: Slack 適配器,需要公網地址以接收 Slack 訊息推送,請查看文件了解使用方式 - ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。 + zh_Hans: Slack 适配器,支持 Webhook 和无需公网地址的长连接,请查看文档了解使用方式 + zh_Hant: Slack 適配器,支援 Webhook 或無需公開網址的 Socket Mode。 + ja_JP: Slack アダプター。Webhook または公開 URL が不要な Socket Mode を利用できます。 th_TH: อะแดปเตอร์ Slack ต้องการที่อยู่สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก Slack โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน vi_VN: Bộ điều hợp Slack, cần địa chỉ công cộng để nhận thông báo tin nhắn từ Slack, vui lòng xem tài liệu để biết cách sử dụng es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso @@ -29,7 +29,26 @@ spec: en: https://langbot.app/docs/en/usage/platforms/slack ja: https://langbot.app/docs/ja/usage/platforms/slack config: + - name: quick_setup + label: {en_US: Quick Slack setup, zh_Hans: Slack 快速配置, zh_Hant: Slack 快速設定, ja_JP: Slack クイック設定} + description: {en_US: Create and configure an app using App Configuration Tokens., zh_Hans: 填入配置令牌,自动创建应用并配置权限,授权后填入凭据。, zh_Hant: 填入設定權杖,自動建立應用程式並設定權限,授權後填入憑證。, ja_JP: 設定トークンでアプリを作成し、承認後に認証情報を入力します。} + type: qr-code-login + login_platform: slack + required: false + - name: socket_mode + label: {en_US: Socket Mode, zh_Hans: 长连接模式, zh_Hant: 長連線模式, ja_JP: Socket Mode} + type: boolean + default: false + required: false + - name: app_token + label: {en_US: App-Level Token, zh_Hans: App-Level Token, zh_Hant: App-Level Token, ja_JP: App-Level Token} + description: {en_US: Generate an xapp- token with connections:write in Basic Information., zh_Hans: 在 Basic Information 中生成带 connections:write 权限的 xapp- 令牌。, zh_Hant: 在 Basic Information 產生具有 connections:write 權限的 xapp- 權杖。, ja_JP: Basic Information で connections:write 権限の xapp- トークンを生成してください。} + type: string + required: false + default: '' + show_if: {field: socket_mode, operator: eq, value: true} - name: webhook_url + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: Webhook Callback URL zh_Hans: Webhook 回调地址 @@ -62,6 +81,7 @@ spec: required: true default: "" - name: signing_secret + show_if: {field: socket_mode, operator: eq, value: false} label: en_US: signing_secret zh_Hans: 密钥 @@ -71,7 +91,7 @@ spec: vi_VN: Khóa ký es_ES: Secreto de firma type: string - required: true + required: false default: "" execution: python: diff --git a/tests/unit_tests/api/test_slack_setup.py b/tests/unit_tests/api/test_slack_setup.py new file mode 100644 index 000000000..2a25effd6 --- /dev/null +++ b/tests/unit_tests/api/test_slack_setup.py @@ -0,0 +1,153 @@ +import asyncio +import hashlib +import hmac +import json +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.api.http.service.slack_setup import SlackSetupService, SlackSetupError, callback_url + + +def context(workspace='one', principal='user'): + return SimpleNamespace(instance_uuid='local', workspace_uuid=workspace, placement_generation=1, principal=principal) + + +def service(): + ap = SimpleNamespace( + bot_service=SimpleNamespace(get_bot=AsyncMock(return_value={'adapter': 'slack-omni'})), + workspace_service=SimpleNamespace(get_execution_binding=AsyncMock()), + task_mgr=SimpleNamespace( + create_user_task=lambda coro, **kwargs: SimpleNamespace(task=asyncio.create_task(coro)) + ), + ) + obj = SlackSetupService(ap) + obj.api = AsyncMock( + side_effect=[ + {'app_id': 'A123', 'credentials': {'client_id': 'id', 'client_secret': 'secret', 'signing_secret': 'sign'}}, + {'ok': True}, + {'app_id': 'A123', 'token_type': 'bot', 'access_token': 'bot-token'}, + ] + ) + return obj + + +async def start(obj, **kwargs): + result = await obj.start( + context(), + bot_uuid='bot', + access_token='access', + refresh_token='refresh', + webhook_url='https://example.com/bots/bot', + **kwargs, + ) + sid = result['session_id'] + await obj.sessions[sid]['task'] + return sid + + +@pytest.mark.asyncio +async def test_webhook_setup_oauth_and_scope(): + obj = service() + sid = await start(obj) + assert obj.status(context(), sid)['status'] == 'waiting' + assert 'state=' + sid in obj.status(context(), sid)['authorize_url'] + for ctx in (context('two'), context(principal='other')): + with pytest.raises(SlackSetupError): + obj.status(ctx, sid) + calls = obj.api.await_args_list + assert 'event_subscriptions' not in json.loads(calls[0].kwargs['manifest'])['settings'] + assert json.loads(calls[1].kwargs['manifest'])['settings']['event_subscriptions']['request_url'].endswith( + '/bots/bot' + ) + await obj.finish(sid, 'code') + assert obj.status(context(), sid)['config']['bot_token'] == 'bot-token' + assert 'credentials' not in obj.sessions[sid] + with pytest.raises(SlackSetupError): + await obj.finish(sid, 'replay') + obj.discard(sid) + assert not obj.sessions + + +@pytest.mark.asyncio +async def test_socket_manifest_needs_no_public_webhook(): + obj = service() + sid = await start( + obj, socket_mode=True, redirect_url='http://localhost:5300/api/v1/platform/adapters/slack/setup/callback' + ) + config = json.loads(obj.api.await_args_list[1].kwargs['manifest']) + assert config['settings']['socket_mode_enabled'] + assert 'request_url' not in config['settings']['event_subscriptions'] + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_expired_token_rotates_once(): + obj = service() + obj.api.side_effect = [ + SlackSetupError('token_expired'), + {'token': 'new-access', 'refresh_token': 'new-refresh'}, + {'app_id': 'A123', 'credentials': {'client_id': 'id', 'client_secret': 'secret', 'signing_secret': 'sign'}}, + {'ok': True}, + ] + sid = await start(obj) + assert obj.api.await_args_list[1].args == ('tooling.tokens.rotate',) + assert obj.api.await_args_list[2].args == ('apps.manifest.create', 'new-access') + assert 'refresh' not in obj.sessions[sid] + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_provisioning_error_is_visible_without_secrets(): + obj = service() + obj.api.side_effect = RuntimeError('sensitive upstream text') + sid = await start(obj) + assert obj.status(context(), sid) == {'status': 'error', 'error': 'setup_failed'} + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_denied_authorization(): + obj = service() + sid = await start(obj) + with pytest.raises(SlackSetupError): + await obj.finish(sid, '', 'access_denied') + assert obj.status(context(), sid)['error'] == 'authorization_denied' + assert 'credentials' not in obj.sessions[sid] + obj.discard(sid) + + +@pytest.mark.asyncio +async def test_setup_webhook_only_accepts_signed_verification(): + obj = service() + sid = await start(obj) + body = json.dumps({'type': 'url_verification', 'challenge': 'test'}).encode() + timestamp = str(int(time.time())) + signature = 'v0=' + hmac.new(b'sign', b'v0:' + timestamp.encode() + b':' + body, hashlib.sha256).hexdigest() + req = SimpleNamespace( + get_data=AsyncMock(return_value=body), + get_json=AsyncMock(return_value=json.loads(body)), + headers={'X-Slack-Request-Timestamp': timestamp, 'X-Slack-Signature': signature}, + ) + assert await obj.challenge('bot', req) == {'challenge': 'test'} + req.headers['X-Slack-Signature'] = 'forged' + assert await obj.challenge('bot', req) is None + obj.discard(sid) + + +@pytest.mark.parametrize( + 'url', + [ + 'http://example.com/bots/bot', + 'https://example.com/bots/other', + 'https://user:pass@example.com/bots/bot', + 'https://localhost/bots/bot', + 'https://127.0.0.1/bots/bot', + 'https://example.com/bots/bot?key=secret', + ], +) +def test_invalid_callback_url(url): + with pytest.raises(SlackSetupError): + callback_url(url, 'bot') diff --git a/tests/unit_tests/platform/test_slack_socket.py b/tests/unit_tests/platform/test_slack_socket.py new file mode 100644 index 000000000..80e13b08b --- /dev/null +++ b/tests/unit_tests/platform/test_slack_socket.py @@ -0,0 +1,63 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock +import asyncio + +import pytest + +from langbot.libs.slack_api.api import SlackClient + + +@pytest.mark.asyncio +async def test_socket_ack_dedup_and_backpressure(): + bot = SlackClient('test', '', SimpleNamespace(warning=AsyncMock(), error=AsyncMock()), unified_mode=True) + client = SimpleNamespace(send_socket_mode_response=AsyncMock()) + req = SimpleNamespace(type='events_api', envelope_id='envelope', payload={'event_id': 'event'}) + await bot._socket_request(client, req) + await bot._socket_request(client, req) + assert client.send_socket_mode_response.await_count == 2 + assert bot.socket_queue.qsize() == 1 + for i in range(99): + bot.socket_queue.put_nowait({'id': i}) + req.payload = {'event_id': 'another'} + await bot._socket_request(client, req) + assert client.send_socket_mode_response.await_count == 2 + assert 'another' not in bot.socket_seen + await bot.close_socket() + assert bot.socket_queue.empty() + + +@pytest.mark.asyncio +async def test_bot_events_do_not_loop(): + bot = SlackClient('test', '', SimpleNamespace(), unified_mode=True) + bot._handle_message = AsyncMock() + await bot.handle_payload({'event': {'bot_id': 'other-bot', 'channel_type': 'im'}}) + bot._handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_socket_lifecycle_closes_sdk_and_worker(monkeypatch): + connected = asyncio.Event() + client = SimpleNamespace( + socket_mode_request_listeners=[], connect=AsyncMock(side_effect=connected.set), close=AsyncMock() + ) + monkeypatch.setattr('slack_sdk.socket_mode.aiohttp.SocketModeClient', lambda **kwargs: client) + bot = SlackClient('test', '', SimpleNamespace(info=AsyncMock()), unified_mode=True) + task = asyncio.create_task(bot.run_socket('app-token')) + await asyncio.wait_for(connected.wait(), 1) + await bot.close_socket() + await asyncio.wait_for(task, 1) + client.close.assert_awaited_once() + assert bot.socket_worker is None + + +@pytest.mark.asyncio +async def test_socket_connect_failure_cleans_resources(monkeypatch): + client = SimpleNamespace( + socket_mode_request_listeners=[], connect=AsyncMock(side_effect=ValueError('invalid_auth')), close=AsyncMock() + ) + monkeypatch.setattr('slack_sdk.socket_mode.aiohttp.SocketModeClient', lambda **kwargs: client) + bot = SlackClient('test', '', SimpleNamespace(info=AsyncMock()), unified_mode=True) + with pytest.raises(ValueError): + await bot.run_socket('app-token') + client.close.assert_awaited_once() + assert bot.socket_worker is None diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 650609a88..dc580fb23 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -155,7 +155,8 @@ function detectConnectionMode( if (!webhookField.show_if) return 'webhook'; return conditionMatches( webhookField.show_if, - values[webhookField.show_if.field], + values[webhookField.show_if.field] ?? + config.find((item) => item.name === webhookField.show_if?.field)?.default, ) ? 'webhook' : 'persistent'; diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 9aaaa9d95..133770a89 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -24,6 +24,7 @@ import { import QrCodeLoginDialog, { QrLoginPlatform, } from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; +import SlackSetupDialog from '@/app/home/components/qrcode-login/SlackSetupDialog'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; @@ -752,12 +753,21 @@ function DynamicFormFieldList({ // State for QR code login dialog const [qrDialogOpen, setQrDialogOpen] = useState(false); const [qrDialogPlatform, setQrDialogPlatform] = - useState('feishu'); + useState('feishu'); return (
{/* QR code login dialog */} - { + for (const [key, value] of Object.entries(credentials)) setFormValue(key, value); + }} + /> : + />} {itemConfigList.map((config, index) => { if (hiddenItemNames?.includes(config.name)) return null; @@ -954,7 +964,7 @@ function DynamicFormFieldList({ onClick={() => { if (!isEditing) { setQrDialogPlatform( - (config.login_platform as QrLoginPlatform) || 'feishu', + (config.login_platform as QrLoginPlatform | 'slack') || 'feishu', ); setQrDialogOpen(true); } @@ -986,7 +996,7 @@ function DynamicFormFieldList({ onClick={(e) => { e.stopPropagation(); setQrDialogPlatform( - (config.login_platform as QrLoginPlatform) || 'feishu', + (config.login_platform as QrLoginPlatform | 'slack') || 'feishu', ); setQrDialogOpen(true); }} diff --git a/web/src/app/home/components/qrcode-login/SlackSetupDialog.tsx b/web/src/app/home/components/qrcode-login/SlackSetupDialog.tsx new file mode 100644 index 000000000..4deacdd83 --- /dev/null +++ b/web/src/app/home/components/qrcode-login/SlackSetupDialog.tsx @@ -0,0 +1,328 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, ExternalLink } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { getBackendBaseUrl } from '@/app/infra/http/backendUrl'; +import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; + +type Config = Record; + +export default function SlackSetupDialog({ + open, + onOpenChange, + botId, + webhookUrl, + socketMode, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + botId?: string; + webhookUrl: string; + socketMode: boolean; + onSuccess: (config: Config) => void; +}) { + const { t } = useTranslation(); + const [access, setAccess] = useState(''); + const [refresh, setRefresh] = useState(''); + const [name, setName] = useState('LangBot'); + const [url, setUrl] = useState(webhookUrl); + const [redirect, setRedirect] = useState(''); + const [appToken, setAppToken] = useState(''); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [authorizeUrl, setAuthorizeUrl] = useState(''); + const [appId, setAppId] = useState(''); + const [config, setConfig] = useState(null); + const generation = useRef(0); + const session = useRef<{ + id: string; + base: string; + headers: Record; + } | null>(null); + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + + const cleanup = () => { + generation.current++; + const current = session.current; + session.current = null; + if (current) + void fetch(`${current.base}/${current.id}`, { + method: 'DELETE', + headers: current.headers, + keepalive: true, + }).catch(() => {}); + }; + + useEffect(() => { + if (open) { + setUrl(webhookUrl); + const backend = new URL( + getBackendBaseUrl() || window.location.origin, + window.location.origin, + ); + if (backend.hostname === '127.0.0.1') backend.hostname = 'localhost'; + setRedirect( + `${backend.href.replace(/\/$/, '')}/api/v1/platform/adapters/slack/setup/callback`, + ); + setStatus('idle'); + setError(''); + setConfig(null); + setAppId(''); + setAuthorizeUrl(''); + } + return () => { + cleanup(); + setAccess(''); + setRefresh(''); + setAppToken(''); + }; + }, [open, webhookUrl]); + + const start = async () => { + cleanup(); + const version = generation.current; + setStatus('creating'); + setError(''); + const workspace = getActiveWorkspaceUuid(); + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('token')}`, + ...(workspace ? { 'X-Workspace-Id': workspace } : {}), + }; + const base = `${getBackendBaseUrl()}/api/v1/platform/adapters/slack/setup`; + try { + const response = await fetch(base, { + method: 'POST', + headers, + body: JSON.stringify({ + bot_uuid: botId, + access_token: access, + refresh_token: refresh, + name, + webhook_url: url, + socket_mode: socketMode, + redirect_url: redirect, + }), + }); + const result = await response.json(); + if (!response.ok || result.code !== 0) + throw new Error(result.msg || `HTTP ${response.status}`); + const current = { id: result.data.session_id as string, base, headers }; + if (generation.current !== version) { + void fetch(`${base}/${current.id}`, { method: 'DELETE', headers }); + return; + } + session.current = current; + setAccess(''); + setRefresh(''); + const deadline = Date.now() + 15 * 60 * 1000; + while (generation.current === version && Date.now() < deadline) { + const poll = await fetch(`${base}/${current.id}`, { + headers, + cache: 'no-store', + }); + const json = await poll.json(); + if (generation.current !== version) return; + if (!poll.ok || json.code !== 0) + throw new Error(json.msg || `HTTP ${poll.status}`); + const data = json.data; + setStatus(data.status); + if (data.app_id) setAppId(data.app_id); + if (data.authorize_url) setAuthorizeUrl(data.authorize_url); + if (data.status === 'error') throw new Error(data.error); + if (data.status === 'success') { + setConfig(data.config); + return; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + if (generation.current === version) throw new Error('session_expired'); + } catch (cause) { + if (generation.current !== version) return; + setError(cause instanceof Error ? cause.message : 'setup_failed'); + setStatus('error'); + } + }; + + const apply = () => { + if (!config) return; + onSuccessRef.current({ + ...config, + ...(socketMode ? { app_token: appToken.trim() } : {}), + }); + onOpenChange(false); + }; + + return ( + + + + {t('slackSetup.title')} + {t('slackSetup.description')} + + {!botId ? ( +

{t('slackSetup.saveFirst')}

+ ) : ( +
+ {status === 'idle' && ( + <> + + {t('slackSetup.generate')} + +
+ + setName(e.target.value)} + /> +
+
+ + setAccess(e.target.value)} + /> +
+
+ + setRefresh(e.target.value)} + /> +
+
+ + + socketMode + ? setRedirect(e.target.value) + : setUrl(e.target.value) + } + /> +

+ {t( + socketMode + ? 'slackSetup.redirectHint' + : 'slackSetup.webhookHint', + )} +

+
+ + + )} + {(status === 'creating' || status === 'authorizing') && ( +

+ + {t(`slackSetup.${status}`)} +

+ )} + {status === 'waiting' && ( +
+

{t('slackSetup.waiting')}

+ +
+ )} + {socketMode && appId && ( +
+

{t('slackSetup.appTokenHint')}

+ + {t('slackSetup.openApp')} + + + setAppToken(e.target.value)} + /> +
+ )} + {status === 'error' && ( +
+

+ {t('slackSetup.failed')} {error} +

+ {appId && ( +

+ {t('slackSetup.appCreated')} {appId} +

+ )} + +
+ )} + {status === 'success' && ( +
+

{t('slackSetup.success')}

+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 9a8c859a3..db4b9ef1a 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1,5 +1,7 @@ +import slackSetup from './slack-setup/en-US'; import pipelineMigration from './pipeline-migration/en-US'; const enUS = { + slackSetup, inflight: { autoCollapse: "Auto-collapse", empty: "No events are being processed", diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 5ece813ee..e83688d70 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1,5 +1,7 @@ +import slackSetup from './slack-setup/ja-JP'; import pipelineMigration from './pipeline-migration/ja-JP'; const jaJP = { + slackSetup, inflight: { autoCollapse: "自動折りたたみ", empty: "処理中のイベントはありません", diff --git a/web/src/i18n/locales/slack-setup/en-US.ts b/web/src/i18n/locales/slack-setup/en-US.ts new file mode 100644 index 000000000..a4a4682c0 --- /dev/null +++ b/web/src/i18n/locales/slack-setup/en-US.ts @@ -0,0 +1,29 @@ +export default { + title: 'Quick Slack setup', + description: + 'Create and configure a Slack app, authorize installation, then fill the adapter credentials.', + saveFirst: 'Create the bot draft first, then open this assistant.', + generate: 'Generate App Configuration Tokens', + name: 'App name', + redirect: 'OAuth return URL', + webhook: 'Webhook URL', + redirectHint: + 'Only your browser needs to reach this URL. HTTPS or http://localhost is supported; no public event webhook is needed.', + webhookHint: 'Use your public HTTPS URL and keep the /bots/… path unchanged.', + create: 'Create and configure', + creating: 'Creating and configuring the app…', + authorizing: 'Completing authorization…', + waiting: + 'The app is configured. Authorize its installation in Slack, then return here.', + authorize: 'Authorize in Slack', + appTokenHint: + 'Socket Mode also needs an App-Level Token. In Basic Information → App-Level Tokens, generate a token with connections:write and paste it below.', + openApp: 'Open app settings', + failed: 'Setup failed:', + appCreated: + 'An app has already been created. Retrying creates another app; you can manage the existing one in Slack:', + retry: 'Start a new setup', + success: + 'Authorization completed. Apply the credentials below, then save the bot configuration.', + apply: 'Fill adapter configuration', +}; diff --git a/web/src/i18n/locales/slack-setup/ja-JP.ts b/web/src/i18n/locales/slack-setup/ja-JP.ts new file mode 100644 index 000000000..3ff8f79d0 --- /dev/null +++ b/web/src/i18n/locales/slack-setup/ja-JP.ts @@ -0,0 +1,28 @@ +export default { + title: 'Slack クイック設定', + description: + 'アプリを自動作成・設定し、インストール承認後に認証情報を入力します。', + saveFirst: '先にボットの下書きを作成してください。', + generate: 'App Configuration Tokens を生成', + name: 'アプリ名', + redirect: 'OAuth 戻り先 URL', + webhook: 'Webhook URL', + redirectHint: + 'ブラウザーからアクセス可能な HTTPS または http://localhost を指定します。公開イベント Webhook は不要です。', + webhookHint: '公開 HTTPS URL を指定し、末尾の /bots/… を維持してください。', + create: '作成して自動設定', + creating: 'アプリを作成・設定中…', + authorizing: '承認を完了中…', + waiting: '設定が完了しました。Slack でインストールを承認して戻ってください。', + authorize: 'Slack で承認', + appTokenHint: + 'Socket Mode には App-Level Token が必要です。Basic Information → App-Level Tokens で connections:write 権限のトークンを生成し、入力してください。', + openApp: 'アプリ設定を開く', + failed: '設定に失敗しました:', + appCreated: + 'アプリは作成済みです。再試行すると別のアプリが作成されます。既存のアプリは Slack で管理できます:', + retry: '新しい設定を開始', + success: + '承認が完了しました。認証情報を入力してボット設定を保存してください。', + apply: 'アダプター設定に入力', +}; diff --git a/web/src/i18n/locales/slack-setup/zh-Hans.ts b/web/src/i18n/locales/slack-setup/zh-Hans.ts new file mode 100644 index 000000000..ec4fe52fc --- /dev/null +++ b/web/src/i18n/locales/slack-setup/zh-Hans.ts @@ -0,0 +1,26 @@ +export default { + title: 'Slack 快速配置', + description: '自动创建和配置应用,授权安装后填入适配器凭据。', + saveFirst: '请先创建机器人草稿,再打开快速配置助手。', + generate: '生成 App Configuration Tokens', + name: '应用名称', + redirect: 'OAuth 授权返回地址', + webhook: 'Webhook 地址', + redirectHint: + '此地址只需浏览器能够访问,支持 HTTPS 或 http://localhost;不需要公网事件回调。', + webhookHint: '填写公网 HTTPS 地址,保留末尾 /bots/… 路径。', + create: '创建并自动配置', + creating: '正在创建和配置应用…', + authorizing: '正在完成授权…', + waiting: '应用配置完成。请在 Slack 确认安装授权,然后返回此处。', + authorize: '前往 Slack 授权', + appTokenHint: + '长连接还需要 App-Level Token。在 Basic Information → App-Level Tokens 中生成带 connections:write 权限的 Token,填入下方。', + openApp: '打开应用设置', + failed: '配置失败:', + appCreated: + '应用已创建。重新开始会创建另一个应用,已有应用可在 Slack 后台管理:', + retry: '重新开始配置', + success: '授权完成。填入凭据后,请保存机器人配置。', + apply: '填入适配器配置', +}; diff --git a/web/src/i18n/locales/slack-setup/zh-Hant.ts b/web/src/i18n/locales/slack-setup/zh-Hant.ts new file mode 100644 index 000000000..8d465e7a1 --- /dev/null +++ b/web/src/i18n/locales/slack-setup/zh-Hant.ts @@ -0,0 +1,26 @@ +export default { + title: 'Slack 快速設定', + description: '自動建立及設定應用程式,授權安裝後填入介接器憑證。', + saveFirst: '請先建立機器人草稿,再開啟設定助手。', + generate: '產生 App Configuration Tokens', + name: '應用程式名稱', + redirect: 'OAuth 授權返回網址', + webhook: 'Webhook 網址', + redirectHint: + '此網址只需瀏覽器能存取,支援 HTTPS 或 http://localhost;無需公開事件回呼。', + webhookHint: '填寫公開 HTTPS 網址,保留結尾 /bots/… 路徑。', + create: '建立並自動設定', + creating: '正在建立及設定應用程式…', + authorizing: '正在完成授權…', + waiting: '設定完成。請在 Slack 確認安裝授權,再返回此處。', + authorize: '前往 Slack 授權', + appTokenHint: + '長連線還需要 App-Level Token。在 Basic Information → App-Level Tokens 產生具有 connections:write 權限的 Token,填入下方。', + openApp: '開啟應用程式設定', + failed: '設定失敗:', + appCreated: + '應用程式已建立。重新開始會建立另一個應用程式,可在 Slack 後台管理現有應用程式:', + retry: '重新開始設定', + success: '授權完成。填入憑證後,請儲存機器人設定。', + apply: '填入介接器設定', +}; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 00018fb4f..5db3af53a 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1,5 +1,7 @@ +import slackSetup from './slack-setup/zh-Hans'; import pipelineMigration from './pipeline-migration/zh-Hans'; const zhHans = { + slackSetup, inflight: { autoCollapse: "自动折叠", empty: "当前没有正在处理的事件", diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index a01659ceb..6122cd42b 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1,5 +1,7 @@ +import slackSetup from './slack-setup/zh-Hant'; import pipelineMigration from './pipeline-migration/zh-Hant'; const zhHant = { + slackSetup, inflight: { autoCollapse: "自動摺疊", empty: "目前沒有正在處理的事件",