From 8fa5df6c772dddb81821057473cc9c7931f83d67 Mon Sep 17 00:00:00 2001 From: Hyu Date: Sun, 11 Oct 2026 00:17:27 +0800 Subject: [PATCH] feat(slack): support standard events and interactive callbacks --- src/langbot/libs/slack_api/api.py | 96 ++++++--- src/langbot/libs/slack_api/slackevent.py | 3 +- .../pkg/api/http/service/slack_setup.py | 26 ++- .../pkg/platform/adapters/slack/adapter.py | 47 +++- .../pkg/platform/adapters/slack/api_impl.py | 4 +- .../adapters/slack/event_converter.py | 121 ++++++++++- .../pkg/platform/adapters/slack/manifest.yaml | 8 + .../platform/adapters/slack/platform_api.py | 3 +- .../platform/test_slack_eba_adapter.py | 7 +- .../unit_tests/platform/test_slack_events.py | 200 ++++++++++++++++++ .../qrcode-login/SlackSetupDialog.tsx | 3 + web/src/i18n/locales/slack-setup/en-US.ts | 2 + web/src/i18n/locales/slack-setup/ja-JP.ts | 2 + web/src/i18n/locales/slack-setup/zh-Hans.ts | 2 + web/src/i18n/locales/slack-setup/zh-Hant.ts | 2 + 15 files changed, 475 insertions(+), 51 deletions(-) create mode 100644 tests/unit_tests/platform/test_slack_events.py diff --git a/src/langbot/libs/slack_api/api.py b/src/langbot/libs/slack_api/api.py index 9774290be..d96422a80 100644 --- a/src/langbot/libs/slack_api/api.py +++ b/src/langbot/libs/slack_api/api.py @@ -1,6 +1,8 @@ import asyncio import json import traceback +from urllib.parse import parse_qs +from slack_sdk.signature import SignatureVerifier from quart import Quart, jsonify, request from slack_sdk.web.async_client import AsyncWebClient from .slackevent import SlackEvent @@ -64,13 +66,20 @@ class SlackClient: body = await req.get_data() if len(body) > _MAX_CALLBACK_BODY_BYTES: raise ValueError('Slack callback body exceeds the size limit') - data = await asyncio.to_thread(json.loads, body) + if not SignatureVerifier(self.signing_secret).is_valid_request(body, dict(req.headers)): + return jsonify({'error': 'Invalid Slack signature'}), 401 + if req.mimetype == 'application/x-www-form-urlencoded': + data = json.loads(parse_qs(body.decode()).get('payload', ['{}'])[0]) + else: + data = json.loads(body) if 'type' in data: if data['type'] == 'url_verification': return data['challenge'] - await self.handle_payload(data) - return jsonify({'status': 'ok'}) + self._start_worker() + if not self._enqueue(data, data.get('event_id')): + return jsonify({'error': 'Slack event queue is full'}), 503 + return '', 200 except Exception as e: await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') @@ -79,29 +88,64 @@ class SlackClient: async def handle_payload(self, data): """Dispatch the same event representation for HTTP and Socket Mode.""" raw = data.get('event', {}) + for authorization in data.get('authorizations', []): + if not self.bot_user_id and authorization.get('is_bot') and authorization.get('user_id'): + self.bot_user_id = authorization['user_id'] + break + if data.get('type') in {'block_actions', 'view_submission', 'view_closed', 'shortcut', 'message_action'}: + await self._handle_message(SlackEvent(data), 'event') + return if raw.get('bot_id') or raw.get('subtype') == 'bot_message': return - if raw.get('channel_type') == 'im': + kind, subtype = raw.get('type'), raw.get('subtype') + if kind == 'message' and subtype not in (None, 'file_share', 'thread_broadcast'): + await self._handle_message(SlackEvent(data), 'event') + elif raw.get('channel_type') == 'im' and kind == 'message': await self._handle_message(SlackEvent.from_payload(data)) - elif raw.get('type') == 'app_mention': + elif kind == 'app_mention': raw['channel_type'] = 'channel' await self._handle_message(SlackEvent.from_payload(data)) + elif kind != 'message': + await self._handle_message(SlackEvent(data), 'event') + + def _enqueue(self, payload, key=None): + if key and key in self.socket_seen: + return True + try: + self.socket_queue.put_nowait(payload) + except asyncio.QueueFull: + return False + if key: + self.socket_seen[key] = True + while len(self.socket_seen) > 4096: + self.socket_seen.popitem(last=False) + return True + + def _start_worker(self): + if self.socket_worker is not None and not self.socket_worker.done(): + return + + async def worker(): + while True: + payload = await self.socket_queue.get() + try: + await self.handle_payload(payload) + except Exception: + await self.logger.error('Slack event processing failed') + finally: + self.socket_queue.task_done() + + self.socket_worker = asyncio.create_task(worker()) async def _socket_request(self, client, req): from slack_sdk.socket_mode.response import SocketModeResponse - if req.type == 'events_api': + if req.type in {'events_api', 'interactive'}: 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) + if not self._enqueue(req.payload, key): + # Leave unacknowledged so Slack can retry; do not grow tasks without bounds. + await self.logger.warning('Slack Socket Mode event queue is full') + return await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) async def run_socket(self, app_token): @@ -111,17 +155,7 @@ class SlackClient: 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()) + self._start_worker() try: await self.socket_client.connect() await self.logger.info('Slack Socket Mode connected') @@ -143,11 +177,11 @@ class SlackClient: self.socket_queue.get_nowait() self.socket_queue.task_done() - async def _handle_message(self, event: SlackEvent): + async def _handle_message(self, event: SlackEvent, kind=None): """ 处理消息事件。 """ - msg_type = event.type + msg_type = kind or event.type if msg_type in self._message_handlers: for handler in self._message_handlers[msg_type]: await handler(event) @@ -167,7 +201,7 @@ class SlackClient: try: response = await self.client.chat_postMessage(channel=channel_id, text=text) if self.bot_user_id is None and response.get('ok'): - self.bot_user_id = response.get('message', {}).get('bot_id') + self.bot_user_id = response.get('message', {}).get('user') return response.data if hasattr(response, 'data') else response except Exception as e: await self.logger.error(f'Error in send_message: {e}') @@ -177,7 +211,7 @@ class SlackClient: try: response = await self.client.chat_postMessage(channel='@' + user_id, text=text) if self.bot_user_id is None and response.get('ok'): - self.bot_user_id = response.get('message', {}).get('bot_id') + self.bot_user_id = response.get('message', {}).get('user') return response.data if hasattr(response, 'data') else response except Exception as e: diff --git a/src/langbot/libs/slack_api/slackevent.py b/src/langbot/libs/slack_api/slackevent.py index 4f79a1d59..0834d212b 100644 --- a/src/langbot/libs/slack_api/slackevent.py +++ b/src/langbot/libs/slack_api/slackevent.py @@ -65,7 +65,8 @@ class SlackEvent(dict): @property def message_id(self) -> str: - return self.get('event_id', '') + raw = self.get('event', {}) + return raw.get('deleted_ts') or raw.get('message', {}).get('ts') or raw.get('ts', '') @property def pic_url(self) -> str: diff --git a/src/langbot/pkg/api/http/service/slack_setup.py b/src/langbot/pkg/api/http/service/slack_setup.py index b5d7b1156..cc6938920 100644 --- a/src/langbot/pkg/api/http/service/slack_setup.py +++ b/src/langbot/pkg/api/http/service/slack_setup.py @@ -47,7 +47,30 @@ def manifest(name: str, webhook_url: str, redirect_url: str, *, events: bool, so if events: settings['event_subscriptions'] = { **({} if socket_mode else {'request_url': webhook_url}), - 'bot_events': ['app_mention', 'message.im'], + 'bot_events': [ + 'app_mention', + 'message.im', + 'message.channels', + 'message.groups', + 'message.mpim', + 'reaction_added', + 'reaction_removed', + 'member_joined_channel', + 'member_left_channel', + 'channel_rename', + 'group_rename', + 'channel_archive', + 'channel_unarchive', + 'group_archive', + 'group_unarchive', + 'app_home_opened', + 'app_uninstalled', + 'tokens_revoked', + ], + } + settings['interactivity'] = { + 'is_enabled': True, + **({} if socket_mode else {'request_url': webhook_url}), } return { 'display_information': {'name': name}, @@ -60,6 +83,7 @@ def manifest(name: str, webhook_url: str, redirect_url: str, *, events: bool, so 'scopes': { 'bot': [ 'app_mentions:read', + 'reactions:read', 'chat:write', 'channels:history', 'channels:read', diff --git a/src/langbot/pkg/platform/adapters/slack/adapter.py b/src/langbot/pkg/platform/adapters/slack/adapter.py index 37d3ec35f..81b1c94ca 100644 --- a/src/langbot/pkg/platform/adapters/slack/adapter.py +++ b/src/langbot/pkg/platform/adapters/slack/adapter.py @@ -33,7 +33,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap typing.Type[platform_events.Event], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] = {} - _message_cache: dict[str, platform_events.MessageReceivedEvent] = {} + _message_cache: dict[tuple[str, str], platform_events.MessageReceivedEvent] = {} _user_cache: dict[str, platform_entities.User] = {} _group_cache: dict[str, platform_entities.UserGroup] = {} _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] = {} @@ -74,6 +74,14 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap def get_supported_events(self) -> list[str]: return [ 'message.received', + 'message.edited', + 'message.deleted', + 'message.reaction', + 'group.member_joined', + 'group.member_left', + 'group.info_updated', + 'bot.invited_to_group', + 'bot.removed_from_group', 'platform.specific', ] @@ -111,8 +119,15 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap raise ValueError('Slack reply_message requires a SlackEvent source object') target_type = 'channel' if source.type == 'channel' else 'person' target_id = source.channel_id if source.type == 'channel' else source.user_id - raw = await send_chain(self.bot, target_type, target_id, message) - return platform_events.MessageResult(message_id=source.message_id, raw=raw) + thread_ts = source.get('event', {}).get('thread_ts') or (source.message_id if quote_origin else None) + if thread_ts: + target_type, target_id = 'channel', source.channel_id + raw = await send_chain(self.bot, target_type, target_id, message, thread_ts) + responses = raw.get('responses', []) + sent = responses[-1] if responses else {} + return platform_events.MessageResult( + message_id=sent.get('ts') or sent.get('message', {}).get('ts', ''), raw=raw + ) async def call_platform_api(self, action: str, params: dict = {}) -> dict: handler = PLATFORM_API_MAP.get(action) @@ -144,6 +159,9 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap return await self.bot.handle_unified_webhook(request) async def run_async(self): + identity = await self.bot.client.auth_test() + self.bot_account_id = identity.get('user_id', '') + self.bot.bot_user_id = self.bot_account_id if self.config.get('socket_mode', False): await self.bot.run_socket(self.config['app_token']) return @@ -163,11 +181,30 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap return False def _register_native_handlers(self): - for msg_type in ('im', 'channel'): + for msg_type in ('im', 'channel', 'event'): self.bot.on_message(msg_type)(self._handle_native_event) async def _handle_native_event(self, event: SlackEvent): try: + self.event_converter.bot_user_id = getattr(self.bot, 'bot_user_id', None) or self.bot_account_id + raw = event.get('event', {}) + channel = raw.get('channel') or raw.get('item', {}).get('channel', '') + if ( + isinstance(channel, str) + and channel.startswith('D') + and channel not in self.event_converter.private_chats + and ( + raw.get('subtype') in {'message_changed', 'message_deleted'} + or raw.get('type') in {'reaction_added', 'reaction_removed'} + ) + ): + try: + conversation = await self.bot.client.conversations_info(channel=channel) + self.event_converter.remember_private_chat(channel, conversation.get('channel', {}).get('user', '')) + except Exception: + await self.logger.warning('Slack DM participant lookup failed; using channel ID for this event') + if event.get('event', {}).get('type') in {'app_uninstalled', 'tokens_revoked'}: + await self.logger.warning(f'Slack application lifecycle event: {event["event"]["type"]}') if platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners: legacy_event = await self.event_converter.target2legacy(event) if legacy_event and type(legacy_event) in self.listeners: @@ -190,7 +227,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap def _cache_event(self, event: platform_events.Event): if not isinstance(event, platform_events.MessageReceivedEvent): return - self._message_cache[str(event.message_id)] = event + self._message_cache[(str(event.chat_id), str(event.message_id))] = event self._user_cache[str(event.sender.id)] = event.sender if event.group: self._group_cache[str(event.group.id)] = event.group diff --git a/src/langbot/pkg/platform/adapters/slack/api_impl.py b/src/langbot/pkg/platform/adapters/slack/api_impl.py index 500738b15..13fe35096 100644 --- a/src/langbot/pkg/platform/adapters/slack/api_impl.py +++ b/src/langbot/pkg/platform/adapters/slack/api_impl.py @@ -9,7 +9,7 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes class SlackAPIMixin: - _message_cache: dict[str, platform_events.MessageReceivedEvent] + _message_cache: dict[tuple[str, str], platform_events.MessageReceivedEvent] _user_cache: dict[str, platform_entities.User] _group_cache: dict[str, platform_entities.UserGroup] _member_cache: dict[tuple[str, str], platform_entities.UserGroupMember] @@ -20,7 +20,7 @@ class SlackAPIMixin: chat_id: typing.Union[int, str], message_id: typing.Union[int, str], ) -> platform_events.MessageReceivedEvent: - event = self._message_cache.get(str(message_id)) + event = self._message_cache.get((str(chat_id), str(message_id))) if event is None: raise NotSupportedError('get_message:message_not_cached') return event diff --git a/src/langbot/pkg/platform/adapters/slack/event_converter.py b/src/langbot/pkg/platform/adapters/slack/event_converter.py index 6ebd48d91..2dae3491e 100644 --- a/src/langbot/pkg/platform/adapters/slack/event_converter.py +++ b/src/langbot/pkg/platform/adapters/slack/event_converter.py @@ -12,8 +12,16 @@ from langbot_plugin.api.entities.builtin.platform import events as platform_even class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): - def __init__(self, bot_token: str = ''): + def __init__(self, bot_token: str = '', bot_user_id: str = ''): self.bot_token = bot_token + self.bot_user_id = bot_user_id + self.private_chats: dict[str, str] = {} + + def remember_private_chat(self, channel_id: str, user_id: str): + if channel_id and user_id: + self.private_chats[channel_id] = user_id + while len(self.private_chats) > 4096: + self.private_chats.pop(next(iter(self.private_chats))) @staticmethod async def yiri2target(event: platform_events.Event) -> typing.Any: @@ -54,9 +62,93 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): ) async def target2yiri(self, event: SlackEvent) -> platform_events.Event: - if event.type in {'im', 'channel'}: + raw = event.get('event', {}) + kind = raw.get('type', '') + subtype = raw.get('subtype', '') + common = dict( + adapter_name=ADAPTER_NAME, + timestamp=_timestamp_value(event), + source_platform_object=SlackEvent(_public_payload(dict(event))), + ) + channel = raw.get('channel', '') or raw.get('item', {}).get('channel', '') + channel_info = channel if isinstance(channel, dict) else {'id': channel} + channel_id = channel_info.get('id', '') + group = platform_entities.UserGroup(id=channel_id, name=channel_info.get('name', channel_id)) + + def user(uid): + return platform_entities.User(id=uid, nickname=uid) if uid else None + + private = raw.get('channel_type') == 'im' or str(channel_id).startswith('D') + chat = dict( + chat_type=platform_entities.ChatType.PRIVATE if private else platform_entities.ChatType.GROUP, + chat_id=self.private_chats.get(channel_id, channel_id) if private else channel_id, + group=None if private else group, + ) + if kind == 'message' and subtype == 'message_changed': + message = raw.get('message', {}) + updated = SlackEvent({**event, 'event': {**raw, **message}}) + return platform_events.MessageEditedEvent( + **common, + **chat, + message_id=message.get('ts', ''), + editor=user(message.get('edited', {}).get('user') or message.get('user')) or user('unknown'), + new_content=await SlackMessageConverter.target2yiri(updated, self.bot_token), + ) + if kind == 'message' and subtype == 'message_deleted': + return platform_events.MessageDeletedEvent(**common, **chat, message_id=raw.get('deleted_ts', '')) + if kind in {'reaction_added', 'reaction_removed'} and raw.get('item', {}).get('type') == 'message': + return platform_events.MessageReactionEvent( + **common, + **chat, + message_id=raw['item'].get('ts', ''), + user=user(raw.get('user')) or user('unknown'), + reaction=raw.get('reaction', ''), + is_add=kind == 'reaction_added', + ) + if kind in {'member_joined_channel', 'member_left_channel'}: + member = user(raw.get('user')) or user('unknown') + inviter = user(raw.get('inviter')) + if self.bot_user_id and raw.get('user') == self.bot_user_id: + if kind == 'member_joined_channel' and inviter: + return platform_events.BotInvitedToGroupEvent(**common, group=group, inviter=inviter) + if kind == 'member_left_channel': + return platform_events.BotRemovedFromGroupEvent(**common, group=group) + if kind == 'member_joined_channel': + return platform_events.MemberJoinedEvent( + **common, + group=group, + member=member, + inviter=inviter, + join_type='invite' if inviter else 'direct', + ) + return platform_events.MemberLeftEvent(**common, group=group, member=member) + fields = { + 'channel_rename': 'name', + 'group_rename': 'name', + 'channel_archive': 'archived', + 'channel_unarchive': 'archived', + 'group_archive': 'archived', + 'group_unarchive': 'archived', + 'channel_topic': 'topic', + 'group_topic': 'topic', + 'channel_purpose': 'purpose', + 'group_purpose': 'purpose', + } + change = subtype if kind == 'message' else kind + if change in fields: + return platform_events.GroupInfoUpdatedEvent( + **common, + group=group, + operator=user(raw.get('user')), + changed_fields=[fields[change]], + ) + if ( + kind in {'message', 'app_mention'} + and subtype in {'', 'file_share', 'thread_broadcast'} + and event.type in {'im', 'channel'} + ): return await self.message_to_eba(event) - return self.platform_specific(event, f'slack.{event.type or "unknown"}') + return self.platform_specific(event, f'slack.{subtype or kind or event.get("type") or event.type or "unknown"}') async def message_to_eba(self, event: SlackEvent) -> platform_events.MessageReceivedEvent: sender_id = event.user_id or '' @@ -67,6 +159,8 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): chat_type = platform_entities.ChatType.PRIVATE chat_id = sender_id group = None + if event.type == 'im': + self.remember_private_chat(event.channel_id, sender_id) if event.type == 'channel': chat_type = platform_entities.ChatType.GROUP chat_id = event.channel_id or '' @@ -82,7 +176,7 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): chat_id=chat_id or '', group=group, timestamp=_timestamp_value(event), - source_platform_object=event, + source_platform_object=SlackEvent(_public_payload(dict(event))), ) @staticmethod @@ -91,15 +185,28 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): type='platform.specific', adapter_name=ADAPTER_NAME, action=action, - data=dict(event), + data=_public_payload(dict(event)), timestamp=_timestamp_value(event), - source_platform_object=event, + source_platform_object=SlackEvent(_public_payload(dict(event))), ) def _timestamp_value(event: SlackEvent) -> float: - raw_ts = event.get('event', {}).get('ts') or event.get('event', {}).get('event_ts') + raw_ts = event.get('event', {}).get('event_ts') or event.get('event', {}).get('ts') or event.get('event_time') try: return float(raw_ts) except (TypeError, ValueError): return time.time() + + +def _public_payload(value): + """Keep callback credentials out of event data and monitoring records.""" + if isinstance(value, dict): + return { + key: _public_payload(item) + for key, item in value.items() + if key not in {'token', 'response_url', 'response_urls'} + } + if isinstance(value, list): + return [_public_payload(item) for item in value] + return value diff --git a/src/langbot/pkg/platform/adapters/slack/manifest.yaml b/src/langbot/pkg/platform/adapters/slack/manifest.yaml index 4ac0cf12f..7f59b510d 100644 --- a/src/langbot/pkg/platform/adapters/slack/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/slack/manifest.yaml @@ -73,6 +73,14 @@ spec: supported_events: - message.received + - message.edited + - message.deleted + - message.reaction + - group.member_joined + - group.member_left + - group.info_updated + - bot.invited_to_group + - bot.removed_from_group - platform.specific supported_apis: diff --git a/src/langbot/pkg/platform/adapters/slack/platform_api.py b/src/langbot/pkg/platform/adapters/slack/platform_api.py index e1a80bf04..1e6d37d6c 100644 --- a/src/langbot/pkg/platform/adapters/slack/platform_api.py +++ b/src/langbot/pkg/platform/adapters/slack/platform_api.py @@ -5,7 +5,8 @@ import typing async def get_mode(adapter, params: dict) -> dict: return { - 'webhook': True, + 'webhook': not adapter.config.get('socket_mode', False), + 'socket_mode': adapter.config.get('socket_mode', False), 'bot_account_id': adapter.bot_account_id, } diff --git a/tests/unit_tests/platform/test_slack_eba_adapter.py b/tests/unit_tests/platform/test_slack_eba_adapter.py index 0b8f7deac..8c6e7decd 100644 --- a/tests/unit_tests/platform/test_slack_eba_adapter.py +++ b/tests/unit_tests/platform/test_slack_eba_adapter.py @@ -190,7 +190,7 @@ async def test_slack_event_converter_maps_private_group_and_platform_specific(): assert isinstance(group_event.message_chain[1], platform_message.At) assert isinstance(platform_event, platform_events.PlatformSpecificEvent) - assert platform_event.action == 'slack.file_share' + assert platform_event.action == 'slack.message' @pytest.mark.asyncio @@ -212,7 +212,7 @@ async def test_slack_adapter_dispatches_eba_and_legacy_and_caches_group_event(): assert len(eba_calls) == 1 assert len(legacy_calls) == 1 received = eba_calls[0] - assert await adapter.get_message('group', 'C-1', 'evt-1') == received + assert await adapter.get_message('group', 'C-1', '1710003600.000100') == received assert (await adapter.get_group_info('C-1')).id == 'C-1' assert (await adapter.get_group_member_info('C-1', 'U-1')).user.id == 'U-1' @@ -225,7 +225,7 @@ async def test_slack_send_reply_platform_api_and_unsupported(): reply_result = await adapter.reply_message( source_event, platform_message.MessageChain([platform_message.Plain(text='reply')]) ) - assert reply_result.message_id == 'evt-1' + assert reply_result.message_id == '1.2' assert ('person', 'U-1', 'reply') in adapter.bot.sent await adapter.send_message( @@ -235,6 +235,7 @@ async def test_slack_send_reply_platform_api_and_unsupported(): assert await adapter.call_platform_api('get_mode', {}) == { 'webhook': True, + 'socket_mode': False, 'bot_account_id': 'B-1', } assert await adapter.call_platform_api('auth_test', {}) == {'ok': True, 'user_id': 'B-1'} diff --git a/tests/unit_tests/platform/test_slack_events.py b/tests/unit_tests/platform/test_slack_events.py new file mode 100644 index 000000000..733bdf8ab --- /dev/null +++ b/tests/unit_tests/platform/test_slack_events.py @@ -0,0 +1,200 @@ +import asyncio +import json +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock +from urllib.parse import urlencode + +import pytest +from slack_sdk.signature import SignatureVerifier + +from langbot.libs.slack_api.api import SlackClient +from langbot.libs.slack_api.slackevent import SlackEvent +from langbot.pkg.platform.adapters.slack.event_converter import SlackEventConverter +from langbot.pkg.api.http.service.slack_setup import manifest +from langbot.libs.slack_api.media import send_chain +from langbot_plugin.api.entities.builtin.platform import message as pm + + +def client(): + return SlackClient('test', 'secret', SimpleNamespace(error=AsyncMock(), warning=AsyncMock()), True) + + +async def test_thread_text_and_file_use_the_same_channel_and_thread(): + bot = client() + bot.client = SimpleNamespace( + chat_postMessage=AsyncMock(return_value={'ok': True, 'ts': '2.1'}), + files_upload_v2=AsyncMock(return_value={'ok': True, 'files': [{'id': 'F1'}]}), + ) + await send_chain( + bot, + 'channel', + 'D1', + pm.MessageChain( + [ + pm.Plain(text='reply'), + pm.File(name='test.txt', base64='dGVzdA=='), + ] + ), + thread_ts='1.1', + ) + bot.client.chat_postMessage.assert_awaited_once_with(channel='D1', text='reply', thread_ts='1.1') + assert bot.client.files_upload_v2.call_args.kwargs['thread_ts'] == '1.1' + assert bot.client.files_upload_v2.call_args.kwargs['channel'] == 'D1' + + +async def test_http_backpressure_does_not_mark_event_seen(): + bot = client() + for index in range(100): + assert bot._enqueue({'index': index}, str(index)) + assert not bot._enqueue({'event_id': 'overflow'}, 'overflow') + assert 'overflow' not in bot.socket_seen + await bot.close_socket() + + +@pytest.mark.parametrize( + ('raw', 'expected'), + [ + ( + {'type': 'message', 'subtype': 'message_changed', 'message': {'ts': '1.1', 'text': 'new', 'user': 'U1'}}, + 'message.edited', + ), + ({'type': 'message', 'subtype': 'message_deleted', 'deleted_ts': '1.1'}, 'message.deleted'), + ( + {'type': 'reaction_added', 'user': 'U1', 'reaction': 'thumbsup', 'item': {'type': 'message', 'ts': '1.1'}}, + 'message.reaction', + ), + ( + { + 'type': 'reaction_removed', + 'user': 'U1', + 'reaction': 'thumbsup', + 'item': {'type': 'message', 'ts': '1.1'}, + }, + 'message.reaction', + ), + ({'type': 'member_joined_channel', 'user': 'U1'}, 'group.member_joined'), + ({'type': 'member_left_channel', 'user': 'U1'}, 'group.member_left'), + ({'type': 'member_joined_channel', 'user': 'UBOT', 'inviter': 'U1'}, 'bot.invited_to_group'), + ({'type': 'member_left_channel', 'user': 'UBOT'}, 'bot.removed_from_group'), + ({'type': 'channel_rename', 'channel': {'id': 'C1', 'name': 'new'}}, 'group.info_updated'), + ({'type': 'group_archive'}, 'group.info_updated'), + ({'type': 'message', 'subtype': 'channel_topic', 'topic': 'new'}, 'group.info_updated'), + ({'type': 'app_home_opened', 'user': 'U1', 'tab': 'home'}, 'platform.specific'), + ({'type': 'app_uninstalled'}, 'platform.specific'), + ({'type': 'tokens_revoked', 'tokens': {'bot': ['UBOT']}}, 'platform.specific'), + ], +) +async def test_standard_events(raw, expected): + event = await SlackEventConverter(bot_user_id='UBOT').target2yiri( + SlackEvent({'event_id': 'delivery', 'event': {'channel': 'C1', 'channel_type': 'channel', **raw}}) + ) + assert event.type == expected + if expected.startswith('message.'): + assert event.message_id == '1.1' + assert event.chat_id == 'C1' + if expected == 'message.reaction': + assert event.is_add == (raw['type'] == 'reaction_added') + if expected == 'message.deleted': + assert event.operator is None + if expected == 'message.edited': + assert 'new' in str(event.new_content) + if raw['type'] == 'channel_rename': + assert event.group.name == 'new' + assert event.changed_fields == ['name'] + + +async def test_channel_messages_are_not_duplicate_mentions_or_new_messages_on_edit(): + bot = client() + bot._handle_message = AsyncMock() + await bot.handle_payload({'event': {'type': 'message', 'channel_type': 'channel', 'text': '<@BOT> hello'}}) + bot._handle_message.assert_not_awaited() + await bot.handle_payload({'event': {'type': 'app_mention', 'channel': 'C1', 'text': '<@BOT> hello'}}) + assert bot._handle_message.await_count == 1 + await bot.handle_payload({'event': {'type': 'message', 'channel_type': 'im', 'subtype': 'message_changed'}}) + assert bot._handle_message.call_args.args[1] == 'event' + + +@pytest.mark.parametrize('kind', ['block_actions', 'view_submission', 'view_closed', 'shortcut', 'message_action']) +async def test_interactions_dispatched_and_sanitized(kind): + bot = client() + handler = AsyncMock() + bot.on_message('event')(handler) + payload = {'type': kind, 'token': 'secret', 'response_url': 'secret-url', 'user': {'id': 'U1'}} + await bot.handle_payload(payload) + event = await SlackEventConverter().target2yiri(handler.call_args.args[0]) + assert event.action == f'slack.{kind}' + assert 'token' not in event.data and 'response_url' not in event.data + + +async def test_http_signature_fast_ack_form_and_retry_dedup(): + bot = client() + gate = asyncio.Event() + + async def process(_): + await gate.wait() + + bot.handle_payload = AsyncMock(side_effect=process) + + def request(body, mime): + stamp = str(int(time.time())) + return SimpleNamespace( + get_data=AsyncMock(return_value=body), + mimetype=mime, + headers={ + 'X-Slack-Request-Timestamp': stamp, + 'X-Slack-Signature': SignatureVerifier('secret').generate_signature(timestamp=stamp, body=body), + }, + ) + + body = urlencode({'payload': json.dumps({'type': 'block_actions'})}).encode() + try: + async with bot.app.app_context(): + assert await bot._handle_callback_internal(request(body, 'application/x-www-form-urlencoded')) == ('', 200) + invalid = request(body, 'application/x-www-form-urlencoded') + invalid.headers['X-Slack-Signature'] = 'invalid' + assert (await bot._handle_callback_internal(invalid))[1] == 401 + event_body = json.dumps({'event_id': 'E1', 'event': {'type': 'app_home_opened'}}).encode() + await bot._handle_callback_internal(request(event_body, 'application/json')) + await bot._handle_callback_internal(request(event_body, 'application/json')) + assert bot.socket_queue.qsize() == 2 + finally: + gate.set() + await bot.close_socket() + + +async def test_socket_interactivity_ack_and_delivery(): + bot = client() + socket = SimpleNamespace(send_socket_mode_response=AsyncMock()) + payload = {'type': 'view_submission'} + request = SimpleNamespace(type='interactive', envelope_id='E1', payload=payload) + await bot._socket_request(socket, request) + await bot._socket_request(socket, request) + assert bot.socket_queue.qsize() == 1 + socket.send_socket_mode_response.assert_awaited() + assert await bot.socket_queue.get() == payload + + +@pytest.mark.parametrize('socket', [True, False]) +def test_setup_complete_manifest(socket): + result = manifest( + 'LangBot', 'https://example.com/bots/1', 'https://example.com/callback', events=True, socket_mode=socket + ) + settings = result['settings'] + assert { + 'message.channels', + 'message.groups', + 'message.mpim', + 'reaction_added', + 'reaction_removed', + 'member_joined_channel', + 'member_left_channel', + 'app_home_opened', + 'app_uninstalled', + 'tokens_revoked', + } <= set(settings['event_subscriptions']['bot_events']) + assert 'reactions:read' in result['oauth_config']['scopes']['bot'] + assert settings['interactivity']['is_enabled'] + assert ('request_url' in settings['interactivity']) is not socket + assert ('request_url' in settings['event_subscriptions']) is not socket + assert 'interactivity' not in manifest('LangBot', '', '', events=False, socket_mode=socket)['settings'] diff --git a/web/src/app/home/components/qrcode-login/SlackSetupDialog.tsx b/web/src/app/home/components/qrcode-login/SlackSetupDialog.tsx index 4deacdd83..3bbf97a69 100644 --- a/web/src/app/home/components/qrcode-login/SlackSetupDialog.tsx +++ b/web/src/app/home/components/qrcode-login/SlackSetupDialog.tsx @@ -177,6 +177,9 @@ export default function SlackSetupDialog({
{status === 'idle' && ( <> +

+ {t('slackSetup.permissionsHint')} +