arcade-slack 0.4.6__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/PKG-INFO +1 -1
  2. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/arcade_slack/constants.py +7 -2
  3. arcade_slack-0.5.1/arcade_slack/conversation_retrieval.py +74 -0
  4. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/arcade_slack/custom_types.py +4 -4
  5. arcade_slack-0.5.1/arcade_slack/exceptions.py +10 -0
  6. arcade_slack-0.5.1/arcade_slack/message_retrieval.py +76 -0
  7. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/arcade_slack/models.py +165 -1
  8. arcade_slack-0.5.1/arcade_slack/tools/chat.py +1079 -0
  9. arcade_slack-0.5.1/arcade_slack/tools/users.py +99 -0
  10. arcade_slack-0.5.1/arcade_slack/user_retrieval.py +214 -0
  11. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/arcade_slack/utils.py +238 -79
  12. arcade_slack-0.5.1/conftest.py +163 -0
  13. arcade_slack-0.5.1/evals/chat/eval_get_metadata.py +206 -0
  14. arcade_slack-0.5.1/evals/chat/eval_get_users_in_conversation.py +81 -0
  15. arcade_slack-0.5.1/evals/chat/eval_list_conversations.py +175 -0
  16. arcade_slack-0.5.1/evals/chat/messages/eval_get_channel_messages.py +622 -0
  17. arcade_slack-0.5.1/evals/chat/messages/eval_get_dm_messages.py +191 -0
  18. arcade_slack-0.5.1/evals/chat/messages/eval_get_mpim_messages.py +165 -0
  19. arcade_slack-0.5.1/evals/chat/messages/eval_send_messages.py +279 -0
  20. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/evals/eval_users.py +96 -5
  21. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/pyproject.toml +1 -1
  22. arcade_slack-0.5.1/tests/test_chat.py +1168 -0
  23. arcade_slack-0.5.1/tests/test_models.py +107 -0
  24. arcade_slack-0.5.1/tests/test_user_retrieval.py +313 -0
  25. arcade_slack-0.5.1/tests/test_users.py +374 -0
  26. arcade_slack-0.5.1/tests/test_utils.py +689 -0
  27. arcade_slack-0.4.6/arcade_slack/exceptions.py +0 -30
  28. arcade_slack-0.4.6/arcade_slack/tools/chat.py +0 -942
  29. arcade_slack-0.4.6/arcade_slack/tools/users.py +0 -87
  30. arcade_slack-0.4.6/conftest.py +0 -20
  31. arcade_slack-0.4.6/evals/eval_chat.py +0 -1168
  32. arcade_slack-0.4.6/tests/test_chat.py +0 -992
  33. arcade_slack-0.4.6/tests/test_users.py +0 -92
  34. arcade_slack-0.4.6/tests/test_utils.py +0 -684
  35. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/.gitignore +0 -0
  36. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/.pre-commit-config.yaml +0 -0
  37. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/.ruff.toml +0 -0
  38. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/LICENSE +0 -0
  39. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/Makefile +0 -0
  40. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/arcade_slack/__init__.py +0 -0
  41. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/arcade_slack/critics.py +0 -0
  42. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/arcade_slack/tools/__init__.py +0 -0
  43. {arcade_slack-0.4.6 → arcade_slack-0.5.1}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcade_slack
3
- Version: 0.4.6
3
+ Version: 0.5.1
4
4
  Summary: Arcade.dev LLM tools for Slack
5
5
  Author-email: Arcade <dev@arcade.dev>
6
6
  License-File: LICENSE
@@ -1,13 +1,18 @@
1
1
  import os
2
2
 
3
- from arcade_slack.custom_types import PositiveInt
3
+ from arcade_slack.custom_types import PositiveNonZeroInt
4
4
 
5
5
  MAX_PAGINATION_SIZE_LIMIT = 200
6
6
 
7
- MAX_PAGINATION_TIMEOUT_SECONDS = PositiveInt(
7
+ MAX_PAGINATION_TIMEOUT_SECONDS = PositiveNonZeroInt(
8
8
  os.environ.get(
9
9
  "MAX_PAGINATION_TIMEOUT_SECONDS",
10
10
  os.environ.get("MAX_SLACK_PAGINATION_TIMEOUT_SECONDS", 30),
11
11
  ),
12
12
  name="MAX_PAGINATION_TIMEOUT_SECONDS or MAX_SLACK_PAGINATION_TIMEOUT_SECONDS",
13
13
  )
14
+
15
+ MAX_CONCURRENT_REQUESTS = PositiveNonZeroInt(
16
+ os.environ.get("SLACK_MAX_CONCURRENT_REQUESTS", 3),
17
+ name="SLACK_MAX_CONCURRENT_REQUESTS",
18
+ )
@@ -0,0 +1,74 @@
1
+ import json
2
+ from typing import cast
3
+
4
+ from arcade_tdk.errors import RetryableToolError, ToolExecutionError
5
+ from slack_sdk.errors import SlackApiError
6
+ from slack_sdk.web.async_client import AsyncWebClient
7
+
8
+ from arcade_slack.models import (
9
+ ConversationType,
10
+ FindChannelByNameSentinel,
11
+ )
12
+ from arcade_slack.utils import (
13
+ async_paginate,
14
+ extract_conversation_metadata,
15
+ )
16
+
17
+
18
+ async def get_conversation_by_id(
19
+ auth_token: str,
20
+ conversation_id: str,
21
+ ) -> dict:
22
+ """Get metadata of a conversation in Slack by the conversation_id."""
23
+ try:
24
+ slack_client = AsyncWebClient(token=auth_token)
25
+ response = await slack_client.conversations_info(
26
+ channel=conversation_id,
27
+ include_locale=True,
28
+ include_num_members=True,
29
+ )
30
+ return dict(**extract_conversation_metadata(response["channel"]))
31
+
32
+ except SlackApiError as e:
33
+ slack_error = cast(str, e.response.get("error", ""))
34
+ if "not_found" in slack_error.lower():
35
+ message = f"Conversation with ID '{conversation_id}' not found."
36
+ raise ToolExecutionError(message=message, developer_message=message)
37
+ raise
38
+
39
+
40
+ async def get_channel_by_name(
41
+ auth_token: str,
42
+ channel_name: str,
43
+ ) -> dict:
44
+ channel_name_casefolded = channel_name.lstrip("#").casefold()
45
+
46
+ slack_client = AsyncWebClient(token=auth_token)
47
+
48
+ results, _ = await async_paginate(
49
+ func=slack_client.conversations_list,
50
+ response_key="channels",
51
+ types=",".join([
52
+ ConversationType.PUBLIC_CHANNEL.value,
53
+ ConversationType.PRIVATE_CHANNEL.value,
54
+ ]),
55
+ exclude_archived=True,
56
+ sentinel=FindChannelByNameSentinel(channel_name_casefolded),
57
+ )
58
+
59
+ available_channels = []
60
+
61
+ for channel in results:
62
+ if channel["name"].casefold() == channel_name_casefolded:
63
+ return dict(**extract_conversation_metadata(channel))
64
+ else:
65
+ available_channels.append({"id": channel["id"], "name": channel["name"]})
66
+
67
+ error_message = f"Channel with name '{channel_name}' not found."
68
+
69
+ raise RetryableToolError(
70
+ message=error_message,
71
+ developer_message=error_message,
72
+ additional_prompt_content=f"Available channels: {json.dumps(available_channels)}",
73
+ retry_after_ms=500,
74
+ )
@@ -1,11 +1,11 @@
1
1
  from typing import NewType
2
2
 
3
3
 
4
- class PositiveInt(int):
5
- def __new__(cls, value: str | int, name: str = "value") -> "PositiveInt":
4
+ class PositiveNonZeroInt(int):
5
+ def __new__(cls, value: str | int, name: str = "value") -> "PositiveNonZeroInt":
6
6
  def validate(val: int) -> int:
7
- if val <= 0:
8
- raise ValueError(f"{name} must be positive, got {val}")
7
+ if val < 1:
8
+ raise ValueError(f"{name} must be a positive non-zero integer, got {val}")
9
9
  return val
10
10
 
11
11
  try:
@@ -0,0 +1,10 @@
1
+ class SlackToolkitError(Exception):
2
+ """Base class for all Slack toolkit errors."""
3
+
4
+
5
+ class PaginationTimeoutError(SlackToolkitError):
6
+ """Raised when a timeout occurs during pagination."""
7
+
8
+ def __init__(self, timeout_seconds: int):
9
+ self.timeout_seconds = timeout_seconds
10
+ super().__init__(f"The pagination process timed out after {timeout_seconds} seconds.")
@@ -0,0 +1,76 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ from arcade_tdk.errors import ToolExecutionError
5
+ from slack_sdk.web.async_client import AsyncWebClient
6
+
7
+ from arcade_slack.utils import (
8
+ async_paginate,
9
+ convert_datetime_to_unix_timestamp,
10
+ convert_relative_datetime_to_unix_timestamp,
11
+ enrich_message_datetime,
12
+ )
13
+
14
+
15
+ async def retrieve_messages_in_conversation(
16
+ conversation_id: str,
17
+ auth_token: str | None = None,
18
+ oldest_relative: str | None = None,
19
+ latest_relative: str | None = None,
20
+ oldest_datetime: str | None = None,
21
+ latest_datetime: str | None = None,
22
+ limit: int | None = None,
23
+ next_cursor: str | None = None,
24
+ ) -> dict:
25
+ error_message = None
26
+ if oldest_datetime and oldest_relative:
27
+ error_message = "Cannot specify both 'oldest_datetime' and 'oldest_relative'."
28
+
29
+ if latest_datetime and latest_relative:
30
+ error_message = "Cannot specify both 'latest_datetime' and 'latest_relative'."
31
+
32
+ if error_message:
33
+ raise ToolExecutionError(error_message, developer_message=error_message)
34
+
35
+ current_unix_timestamp = int(datetime.now(timezone.utc).timestamp())
36
+
37
+ if latest_relative:
38
+ latest_timestamp = convert_relative_datetime_to_unix_timestamp(
39
+ latest_relative, current_unix_timestamp
40
+ )
41
+ elif latest_datetime:
42
+ latest_timestamp = convert_datetime_to_unix_timestamp(latest_datetime)
43
+ else:
44
+ latest_timestamp = None
45
+
46
+ if oldest_relative:
47
+ oldest_timestamp = convert_relative_datetime_to_unix_timestamp(
48
+ oldest_relative, current_unix_timestamp
49
+ )
50
+ elif oldest_datetime:
51
+ oldest_timestamp = convert_datetime_to_unix_timestamp(oldest_datetime)
52
+ else:
53
+ oldest_timestamp = None
54
+
55
+ datetime_args: dict[str, Any] = {}
56
+ if oldest_timestamp:
57
+ datetime_args["oldest"] = oldest_timestamp
58
+ if latest_timestamp:
59
+ datetime_args["latest"] = latest_timestamp
60
+
61
+ slackClient = AsyncWebClient(token=auth_token)
62
+
63
+ response, next_cursor = await async_paginate(
64
+ slackClient.conversations_history,
65
+ "messages",
66
+ limit=limit,
67
+ next_cursor=next_cursor,
68
+ channel=conversation_id,
69
+ include_all_metadata=True,
70
+ inclusive=True, # Include messages at the start and end of the time range
71
+ **datetime_args,
72
+ )
73
+
74
+ messages = [enrich_message_datetime(message) for message in response]
75
+
76
+ return {"messages": messages, "next_cursor": next_cursor}
@@ -1,5 +1,12 @@
1
+ import asyncio
2
+ from abc import ABC, abstractmethod
3
+ from collections.abc import Awaitable, Callable
4
+ from contextlib import suppress
1
5
  from enum import Enum
2
- from typing import Literal, TypedDict
6
+ from typing import Any, Literal, TypedDict
7
+
8
+ from arcade_tdk.errors import ToolExecutionError
9
+ from slack_sdk.errors import SlackApiError
3
10
 
4
11
  from arcade_slack.custom_types import (
5
12
  SlackOffsetSecondsFromUTC,
@@ -24,6 +31,16 @@ class ConversationType(str, Enum):
24
31
  MULTI_PERSON_DIRECT_MESSAGE = "multi_person_direct_message"
25
32
  DIRECT_MESSAGE = "direct_message"
26
33
 
34
+ def to_slack_name_str(self) -> str:
35
+ mapping = {
36
+ ConversationType.PUBLIC_CHANNEL: ConversationTypeSlackName.PUBLIC_CHANNEL.value,
37
+ ConversationType.PRIVATE_CHANNEL: ConversationTypeSlackName.PRIVATE_CHANNEL.value,
38
+ ConversationType.MULTI_PERSON_DIRECT_MESSAGE: ConversationTypeSlackName.MPIM.value,
39
+ ConversationType.DIRECT_MESSAGE: ConversationTypeSlackName.IM.value,
40
+ }
41
+
42
+ return mapping[self]
43
+
27
44
 
28
45
  """
29
46
  About Slack dictionaries: Slack does not guarantee the presence of all fields for a given
@@ -204,3 +221,150 @@ class SlackConversationsToolResponse(TypedDict, total=True):
204
221
 
205
222
  conversations: list[ConversationMetadata]
206
223
  next_cursor: SlackPaginationNextCursor | None
224
+
225
+
226
+ class PaginationSentinel(ABC):
227
+ """Base class for pagination sentinel classes."""
228
+
229
+ def __init__(self, **kwargs: Any) -> None:
230
+ self.kwargs = kwargs
231
+
232
+ @abstractmethod
233
+ def __call__(self, last_result: Any) -> bool:
234
+ """Determine if the pagination should stop."""
235
+ raise NotImplementedError
236
+
237
+
238
+ class FindUserByUsernameSentinel(PaginationSentinel):
239
+ """Sentinel class for finding a user by username."""
240
+
241
+ def __call__(self, last_result: Any) -> bool:
242
+ for user in last_result:
243
+ if not isinstance(user.get("name"), str):
244
+ continue
245
+ if user.get("name").casefold() == self.kwargs["username"].casefold():
246
+ return True
247
+ return False
248
+
249
+
250
+ class FindMultipleUsersByUsernameSentinel(PaginationSentinel):
251
+ """Sentinel class for finding multiple users by username."""
252
+
253
+ def __init__(self, usernames: list[str]) -> None:
254
+ if not usernames:
255
+ raise ValueError("usernames must be a non-empty list of strings")
256
+ super().__init__(usernames=usernames)
257
+ self.usernames_pending = {username.casefold() for username in usernames}
258
+
259
+ def _flag_username_found(self, username: str) -> None:
260
+ with suppress(KeyError):
261
+ self.usernames_pending.remove(username.casefold())
262
+
263
+ def _all_usernames_found(self) -> bool:
264
+ return not self.usernames_pending
265
+
266
+ def __call__(self, last_result: Any) -> bool:
267
+ if not self.usernames_pending:
268
+ return True
269
+ for user in last_result:
270
+ username = user.get("name")
271
+ if not isinstance(username, str):
272
+ continue
273
+ if username.casefold() in self.usernames_pending:
274
+ self._flag_username_found(username)
275
+ if self._all_usernames_found():
276
+ return True
277
+ return False
278
+
279
+
280
+ class FindMultipleUsersByIdSentinel(PaginationSentinel):
281
+ """Sentinel class for finding multiple users by ID."""
282
+
283
+ def __init__(self, user_ids: list[str]) -> None:
284
+ if not user_ids:
285
+ raise ValueError("user_ids must be a non-empty list of strings")
286
+ super().__init__(user_ids=user_ids)
287
+ self.user_ids_pending = set(user_ids)
288
+
289
+ def _flag_user_id_found(self, user_id: str) -> None:
290
+ with suppress(KeyError):
291
+ self.user_ids_pending.remove(user_id.casefold())
292
+
293
+ def _all_user_ids_found(self) -> bool:
294
+ return not self.user_ids_pending
295
+
296
+ def __call__(self, last_result: Any) -> bool:
297
+ if not self.user_ids_pending:
298
+ return True
299
+ for user in last_result:
300
+ user_id = user.get("id")
301
+ if user_id in self.user_ids_pending:
302
+ self._flag_user_id_found(user_id)
303
+ if self._all_user_ids_found():
304
+ return True
305
+ return False
306
+
307
+
308
+ class FindChannelByNameSentinel(PaginationSentinel):
309
+ """Sentinel class for finding a channel by name."""
310
+
311
+ def __init__(self, channel_name: str) -> None:
312
+ super().__init__(channel_name=channel_name)
313
+ self.channel_name_casefold = channel_name.casefold()
314
+
315
+ def __call__(self, last_result: Any) -> bool:
316
+ for channel in last_result:
317
+ channel_name = channel.get("name")
318
+ if not isinstance(channel_name, str):
319
+ continue
320
+ if channel_name.casefold() == self.channel_name_casefold:
321
+ return True
322
+ return False
323
+
324
+
325
+ class AbstractConcurrencySafeCoroutineCaller(ABC):
326
+ """Abstract base class for concurrency-safe coroutine callers."""
327
+
328
+ def __init__(self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> None:
329
+ self.func = func
330
+ self.args = args
331
+ self.kwargs = kwargs
332
+
333
+ @abstractmethod
334
+ async def __call__(self, semaphore: asyncio.Semaphore) -> Any:
335
+ """Call a coroutine with a semaphore."""
336
+ raise NotImplementedError
337
+
338
+
339
+ class ConcurrencySafeCoroutineCaller(AbstractConcurrencySafeCoroutineCaller):
340
+ """Calls a coroutine with an asyncio semaphore."""
341
+
342
+ async def __call__(self, semaphore: asyncio.Semaphore) -> Any:
343
+ async with semaphore:
344
+ return await self.func(*self.args, **self.kwargs)
345
+
346
+
347
+ class GetUserByEmailCaller(AbstractConcurrencySafeCoroutineCaller):
348
+ """Call Slack's lookupByEmail method with an asyncio semaphore while handling API errors."""
349
+
350
+ def __init__(
351
+ self,
352
+ func: Callable[..., Awaitable[Any]],
353
+ email: str,
354
+ ) -> None:
355
+ super().__init__(func)
356
+ self.email = email
357
+
358
+ async def __call__(self, semaphore: asyncio.Semaphore) -> dict[str, Any]:
359
+ async with semaphore:
360
+ try:
361
+ user = await self.func(email=self.email)
362
+ return {"user": user["user"], "email": self.email}
363
+ except SlackApiError as e:
364
+ if e.response.get("error") in ["user_not_found", "users_not_found"]:
365
+ return {"user": None, "email": self.email}
366
+ else:
367
+ raise ToolExecutionError(
368
+ message="Error getting user by email",
369
+ developer_message=f"Error getting user by email: {e.response.get('error')}",
370
+ )