feat(slack): add guided app setup and Socket Mode support

This commit is contained in:
Hyu
2026-10-10 23:37:30 +08:00
parent 5c8275e564
commit 4a700ac0f3
23 changed files with 1233 additions and 38 deletions
+13
View File
@@ -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/<bot_uuid>`.
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 |
+76 -18
View File
@@ -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):
"""
处理消息事件。
@@ -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/<session_id>', 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()})
@@ -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.
@@ -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
+24
View File
@@ -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.'
)
@@ -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()
@@ -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:
+8 -4
View File
@@ -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,
+24 -4
View File
@@ -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:
+153
View File
@@ -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:[email protected]/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')
@@ -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
@@ -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';
@@ -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<QrLoginPlatform>('feishu');
useState<QrLoginPlatform | 'slack'>('feishu');
return (
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
{/* QR code login dialog */}
<QrCodeLoginDialog
{qrDialogPlatform === 'slack' ? <SlackSetupDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
botId={systemContext?.bot_uuid as string | undefined}
webhookUrl={(systemContext?.webhook_url as string) || ''}
socketMode={!!form.getValues('socket_mode')}
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) setFormValue(key, value);
}}
/> : <QrCodeLoginDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
platform={qrDialogPlatform}
@@ -768,7 +778,7 @@ function DynamicFormFieldList({
}
}
}}
/>
/>}
{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);
}}
@@ -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<string, string | boolean>;
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<Config | null>(null);
const generation = useRef(0);
const session = useRef<{
id: string;
base: string;
headers: Record<string, string>;
} | 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<string, string> = {
'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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('slackSetup.title')}</DialogTitle>
<DialogDescription>{t('slackSetup.description')}</DialogDescription>
</DialogHeader>
{!botId ? (
<p>{t('slackSetup.saveFirst')}</p>
) : (
<div className="space-y-4">
{status === 'idle' && (
<>
<a
className="text-sm text-primary underline"
href="https://api.slack.com/apps"
target="_blank"
rel="noreferrer"
>
{t('slackSetup.generate')}
</a>
<div className="space-y-2">
<Label htmlFor="slack-name">{t('slackSetup.name')}</Label>
<Input
id="slack-name"
value={name}
maxLength={35}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slack-access">Access Token</Label>
<Input
id="slack-access"
type="password"
autoComplete="off"
value={access}
onChange={(e) => setAccess(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slack-refresh">Refresh Token</Label>
<Input
id="slack-refresh"
type="password"
autoComplete="off"
value={refresh}
onChange={(e) => setRefresh(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slack-url">
{t(
socketMode ? 'slackSetup.redirect' : 'slackSetup.webhook',
)}
</Label>
<Input
id="slack-url"
value={socketMode ? redirect : url}
onChange={(e) =>
socketMode
? setRedirect(e.target.value)
: setUrl(e.target.value)
}
/>
<p className="text-xs text-muted-foreground">
{t(
socketMode
? 'slackSetup.redirectHint'
: 'slackSetup.webhookHint',
)}
</p>
</div>
<Button
type="button"
onClick={() => void start()}
disabled={!access.trim() || !refresh.trim() || !name.trim()}
>
{t('slackSetup.create')}
</Button>
</>
)}
{(status === 'creating' || status === 'authorizing') && (
<p className="flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{t(`slackSetup.${status}`)}
</p>
)}
{status === 'waiting' && (
<div className="space-y-3">
<p className="text-sm">{t('slackSetup.waiting')}</p>
<Button asChild>
<a href={authorizeUrl} target="_blank" rel="noreferrer">
{t('slackSetup.authorize')}
<ExternalLink className="ml-2 size-4" />
</a>
</Button>
</div>
)}
{socketMode && appId && (
<div className="space-y-2 rounded-lg border p-3">
<p className="text-sm">{t('slackSetup.appTokenHint')}</p>
<a
className="text-sm text-primary underline"
href={`https://api.slack.com/apps/${appId}/general`}
target="_blank"
rel="noreferrer"
>
{t('slackSetup.openApp')}
</a>
<Label htmlFor="slack-app-token" className="block">
App-Level Token
</Label>
<Input
id="slack-app-token"
type="password"
autoComplete="off"
placeholder="xapp-…"
value={appToken}
onChange={(e) => setAppToken(e.target.value)}
/>
</div>
)}
{status === 'error' && (
<div className="space-y-2">
<p role="alert" className="text-sm text-destructive">
{t('slackSetup.failed')} {error}
</p>
{appId && (
<p className="text-xs text-muted-foreground">
{t('slackSetup.appCreated')} {appId}
</p>
)}
<Button
type="button"
onClick={() => {
cleanup();
setStatus('idle');
}}
>
{t('slackSetup.retry')}
</Button>
</div>
)}
{status === 'success' && (
<div className="space-y-3">
<p className="text-sm">{t('slackSetup.success')}</p>
<Button
type="button"
onClick={apply}
disabled={socketMode && !appToken.trim().startsWith('xapp-')}
>
{t('slackSetup.apply')}
</Button>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
+2
View File
@@ -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",
+2
View File
@@ -1,5 +1,7 @@
import slackSetup from './slack-setup/ja-JP';
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
slackSetup,
inflight: {
autoCollapse: "自動折りたたみ",
empty: "処理中のイベントはありません",
+29
View File
@@ -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',
};
+28
View File
@@ -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: 'アダプター設定に入力',
};
@@ -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: '填入适配器配置',
};
@@ -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: '填入介接器設定',
};
+2
View File
@@ -1,5 +1,7 @@
import slackSetup from './slack-setup/zh-Hans';
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
slackSetup,
inflight: {
autoCollapse: "自动折叠",
empty: "当前没有正在处理的事件",
+2
View File
@@ -1,5 +1,7 @@
import slackSetup from './slack-setup/zh-Hant';
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
slackSetup,
inflight: {
autoCollapse: "自動摺疊",
empty: "目前沒有正在處理的事件",