feat(slack): support standard events and interactive callbacks

This commit is contained in:
Hyu
2026-10-11 00:17:27 +08:00
parent 7d4e13509b
commit 8fa5df6c77
15 changed files with 475 additions and 51 deletions
+65 -31
View File
@@ -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:
+2 -1
View File
@@ -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:
@@ -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',
@@ -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
@@ -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
@@ -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
@@ -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:
@@ -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,
}
@@ -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'}
@@ -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']
@@ -177,6 +177,9 @@ export default function SlackSetupDialog({
<div className="space-y-4">
{status === 'idle' && (
<>
<p className="text-sm text-muted-foreground">
{t('slackSetup.permissionsHint')}
</p>
<a
className="text-sm text-primary underline"
href="https://api.slack.com/apps"
@@ -1,4 +1,6 @@
export default {
permissionsHint:
'Requests message, channel, user, file and reaction permissions. Subscribes to edits/deletions, reactions, membership, channel updates, App Home and app lifecycle events; enables button/form callbacks. Channel replies still require an @mention. Existing apps need updated subscriptions and reinstallation after adding scopes.',
title: 'Quick Slack setup',
description:
'Create and configure a Slack app, authorize installation, then fill the adapter credentials.',
@@ -1,4 +1,6 @@
export default {
permissionsHint:
'メッセージ、チャンネル、ユーザー、ファイル、リアクションの権限を要求します。編集・削除、リアクション、メンバー、チャンネル更新、App Home、アプリのライフサイクルを購読し、ボタン・フォームのコールバックを有効にします。チャンネルの通常返信には @メンションが必要です。既存アプリは購読を追加し、権限追加後に再インストールしてください。',
title: 'Slack クイック設定',
description:
'アプリを自動作成・設定し、インストール承認後に認証情報を入力します。',
@@ -1,4 +1,6 @@
export default {
permissionsHint:
'申请消息、频道、用户、文件和表情反应权限,订阅消息编辑/删除、表情反应、成员变化、频道更新、应用首页和生命周期事件,并启用按钮/表单回调。频道普通回复仍需 @ 机器人。已有应用需补齐订阅,新增权限后重新安装授权。',
title: 'Slack 快速配置',
description: '自动创建和配置应用,授权安装后填入适配器凭据。',
saveFirst: '请先创建机器人草稿,再打开快速配置助手。',
@@ -1,4 +1,6 @@
export default {
permissionsHint:
'申請訊息、頻道、使用者、檔案和表情反應權限,訂閱訊息編輯/刪除、表情反應、成員變化、頻道更新、應用首頁和生命週期事件,並啟用按鈕/表單回呼。頻道一般回覆仍需 @ 機器人。既有應用需補齊訂閱,新增權限後重新安裝授權。',
title: 'Slack 快速設定',
description: '自動建立及設定應用程式,授權安裝後填入介接器憑證。',
saveFirst: '請先建立機器人草稿,再開啟設定助手。',