arcade-slack 0.4.6__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.
arcade_slack/constants.py CHANGED
@@ -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:
@@ -8,23 +8,3 @@ class PaginationTimeoutError(SlackToolkitError):
8
8
  def __init__(self, timeout_seconds: int):
9
9
  self.timeout_seconds = timeout_seconds
10
10
  super().__init__(f"The pagination process timed out after {timeout_seconds} seconds.")
11
-
12
-
13
- class ItemNotFoundError(SlackToolkitError):
14
- """Raised when an item is not found."""
15
-
16
-
17
- class UsernameNotFoundError(SlackToolkitError):
18
- """Raised when a user is not found by the username searched"""
19
-
20
- def __init__(self, usernames_found: list[str], username_not_found: str) -> None:
21
- self.usernames_found = usernames_found
22
- self.username_not_found = username_not_found
23
-
24
-
25
- class ConversationNotFoundError(SlackToolkitError):
26
- """Raised when a conversation is not found"""
27
-
28
-
29
- class DirectMessageConversationNotFoundError(ConversationNotFoundError):
30
- """Raised when a direct message conversation searched is not found"""
@@ -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}
arcade_slack/models.py CHANGED
@@ -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
+ )