mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
feat(slack): add guided app setup and Socket Mode support
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import slackSetup from './slack-setup/ja-JP';
|
||||
import pipelineMigration from './pipeline-migration/ja-JP';
|
||||
const jaJP = {
|
||||
slackSetup,
|
||||
inflight: {
|
||||
autoCollapse: "自動折りたたみ",
|
||||
empty: "処理中のイベントはありません",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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: '填入介接器設定',
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import slackSetup from './slack-setup/zh-Hans';
|
||||
import pipelineMigration from './pipeline-migration/zh-Hans';
|
||||
const zhHans = {
|
||||
slackSetup,
|
||||
inflight: {
|
||||
autoCollapse: "自动折叠",
|
||||
empty: "当前没有正在处理的事件",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import slackSetup from './slack-setup/zh-Hant';
|
||||
import pipelineMigration from './pipeline-migration/zh-Hant';
|
||||
const zhHant = {
|
||||
slackSetup,
|
||||
inflight: {
|
||||
autoCollapse: "自動摺疊",
|
||||
empty: "目前沒有正在處理的事件",
|
||||
|
||||
Reference in New Issue
Block a user