Files
LangBot/tests/unit_tests/platform/test_slack_events.py
T

201 lines
8.2 KiB
Python

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']