arcade-slack 0.1.6__py3-none-any.whl → 0.4.6__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 +13 -0
- arcade_slack/critics.py +34 -0
- arcade_slack/custom_types.py +26 -0
- arcade_slack/exceptions.py +30 -0
- arcade_slack/models.py +206 -0
- arcade_slack/tools/chat.py +868 -56
- arcade_slack/tools/users.py +87 -0
- arcade_slack/utils.py +447 -0
- arcade_slack-0.4.6.dist-info/METADATA +23 -0
- arcade_slack-0.4.6.dist-info/RECORD +14 -0
- {arcade_slack-0.1.6.dist-info → arcade_slack-0.4.6.dist-info}/WHEEL +1 -1
- arcade_slack-0.4.6.dist-info/licenses/LICENSE +21 -0
- arcade_slack-0.1.6.dist-info/METADATA +0 -14
- arcade_slack-0.1.6.dist-info/RECORD +0 -6
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import Annotated, Any, cast
|
|
2
|
+
|
|
3
|
+
from arcade_tdk import ToolContext, tool
|
|
4
|
+
from arcade_tdk.auth import Slack
|
|
5
|
+
from arcade_tdk.errors import RetryableToolError
|
|
6
|
+
from slack_sdk.errors import SlackApiError
|
|
7
|
+
from slack_sdk.web.async_client import AsyncWebClient
|
|
8
|
+
|
|
9
|
+
from arcade_slack.constants import MAX_PAGINATION_TIMEOUT_SECONDS
|
|
10
|
+
from arcade_slack.models import SlackPaginationNextCursor, SlackUser
|
|
11
|
+
from arcade_slack.utils import (
|
|
12
|
+
async_paginate,
|
|
13
|
+
extract_basic_user_info,
|
|
14
|
+
is_user_a_bot,
|
|
15
|
+
is_user_deleted,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@tool(
|
|
20
|
+
requires_auth=Slack(
|
|
21
|
+
scopes=["users:read", "users:read.email"],
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
async def get_user_info_by_id(
|
|
25
|
+
context: ToolContext,
|
|
26
|
+
user_id: Annotated[str, "The ID of the user to get"],
|
|
27
|
+
) -> Annotated[dict[str, Any], "The user's information"]:
|
|
28
|
+
"""Get the information of a user in Slack."""
|
|
29
|
+
|
|
30
|
+
token = (
|
|
31
|
+
context.authorization.token if context.authorization and context.authorization.token else ""
|
|
32
|
+
)
|
|
33
|
+
slackClient = AsyncWebClient(token=token)
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
response = await slackClient.users_info(user=user_id)
|
|
37
|
+
except SlackApiError as e:
|
|
38
|
+
if e.response.get("error") == "user_not_found":
|
|
39
|
+
users = await list_users(context)
|
|
40
|
+
available_users = ", ".join(f"{user['id']} ({user['name']})" for user in users["users"])
|
|
41
|
+
|
|
42
|
+
raise RetryableToolError(
|
|
43
|
+
"User not found",
|
|
44
|
+
developer_message=f"User with ID '{user_id}' not found.",
|
|
45
|
+
additional_prompt_content=f"Available users: {available_users}",
|
|
46
|
+
retry_after_ms=500,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
user_dict_raw: dict[str, Any] = response.get("user", {}) or {}
|
|
50
|
+
user_dict = cast(SlackUser, user_dict_raw)
|
|
51
|
+
user = SlackUser(**user_dict)
|
|
52
|
+
return dict(**extract_basic_user_info(user))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@tool(
|
|
56
|
+
requires_auth=Slack(
|
|
57
|
+
scopes=["users:read", "users:read.email"],
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
async def list_users(
|
|
61
|
+
context: ToolContext,
|
|
62
|
+
exclude_bots: Annotated[bool | None, "Whether to exclude bots from the results"] = True,
|
|
63
|
+
limit: Annotated[int | None, "The maximum number of users to return."] = None,
|
|
64
|
+
next_cursor: Annotated[str | None, "The next cursor token to use for pagination."] = None,
|
|
65
|
+
) -> Annotated[dict, "The users' info"]:
|
|
66
|
+
"""List all users in the authenticated user's Slack team."""
|
|
67
|
+
|
|
68
|
+
token = (
|
|
69
|
+
context.authorization.token if context.authorization and context.authorization.token else ""
|
|
70
|
+
)
|
|
71
|
+
slackClient = AsyncWebClient(token=token)
|
|
72
|
+
|
|
73
|
+
users, next_cursor = await async_paginate(
|
|
74
|
+
func=slackClient.users_list,
|
|
75
|
+
response_key="members",
|
|
76
|
+
limit=limit,
|
|
77
|
+
next_cursor=cast(SlackPaginationNextCursor, next_cursor),
|
|
78
|
+
max_pagination_timeout_seconds=MAX_PAGINATION_TIMEOUT_SECONDS,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
users = [
|
|
82
|
+
extract_basic_user_info(user)
|
|
83
|
+
for user in users
|
|
84
|
+
if not is_user_deleted(user) and (not exclude_bots or not is_user_a_bot(user))
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
return {"users": users, "next_cursor": next_cursor}
|
arcade_slack/utils.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from arcade_tdk import ToolContext
|
|
7
|
+
from arcade_tdk.errors import RetryableToolError
|
|
8
|
+
|
|
9
|
+
from arcade_slack.constants import MAX_PAGINATION_SIZE_LIMIT, MAX_PAGINATION_TIMEOUT_SECONDS
|
|
10
|
+
from arcade_slack.custom_types import SlackPaginationNextCursor
|
|
11
|
+
from arcade_slack.exceptions import (
|
|
12
|
+
PaginationTimeoutError,
|
|
13
|
+
UsernameNotFoundError,
|
|
14
|
+
)
|
|
15
|
+
from arcade_slack.models import (
|
|
16
|
+
BasicUserInfo,
|
|
17
|
+
ConversationMetadata,
|
|
18
|
+
ConversationType,
|
|
19
|
+
ConversationTypeSlackName,
|
|
20
|
+
Message,
|
|
21
|
+
SlackConversation,
|
|
22
|
+
SlackConversationPurpose,
|
|
23
|
+
SlackMessage,
|
|
24
|
+
SlackUser,
|
|
25
|
+
SlackUserList,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_users(user_list_response: SlackUserList) -> str:
|
|
30
|
+
"""Format a list of Slack users into a CSV string.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
userListResponse: The response from the Slack API's users_list method.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
A CSV string with two columns: the users' name and real name, each user in a new line.
|
|
37
|
+
The first line is the header with column names 'name' and 'real_name'.
|
|
38
|
+
"""
|
|
39
|
+
csv_string = "name,real_name\n"
|
|
40
|
+
for user in user_list_response["members"]:
|
|
41
|
+
if not user.get("deleted", False):
|
|
42
|
+
name = user.get("name", "")
|
|
43
|
+
profile = user.get("profile", {})
|
|
44
|
+
real_name = "" if not profile else profile.get("real_name", "")
|
|
45
|
+
csv_string += f"{name},{real_name}\n"
|
|
46
|
+
return csv_string.strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_conversations_as_csv(conversations: dict) -> str:
|
|
50
|
+
"""Format a list of Slack conversations into a CSV string.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
conversations: The response from the Slack API's conversations_list method.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A CSV string with the conversations' names.
|
|
57
|
+
"""
|
|
58
|
+
csv_string = "All active Slack conversations:\n\nname\n"
|
|
59
|
+
for conversation in conversations["channels"]:
|
|
60
|
+
if not conversation.get("is_archived", False):
|
|
61
|
+
name = conversation.get("name", "")
|
|
62
|
+
csv_string += f"{name}\n"
|
|
63
|
+
return csv_string.strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def remove_none_values(params: dict) -> dict:
|
|
67
|
+
"""Remove key/value pairs from a dictionary where the value is None.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
params: The dictionary to remove None values from.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A dictionary with None values removed.
|
|
74
|
+
"""
|
|
75
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_slack_conversation_type_as_str(channel: SlackConversation) -> str:
|
|
79
|
+
"""Get the type of conversation from a Slack channel's dictionary.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
channel: The Slack channel's dictionary.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The type of conversation string in Slack naming standard.
|
|
86
|
+
"""
|
|
87
|
+
if channel.get("is_channel"):
|
|
88
|
+
return ConversationTypeSlackName.PUBLIC_CHANNEL.value
|
|
89
|
+
if channel.get("is_group"):
|
|
90
|
+
return ConversationTypeSlackName.PRIVATE_CHANNEL.value
|
|
91
|
+
if channel.get("is_im"):
|
|
92
|
+
return ConversationTypeSlackName.IM.value
|
|
93
|
+
if channel.get("is_mpim"):
|
|
94
|
+
return ConversationTypeSlackName.MPIM.value
|
|
95
|
+
raise ValueError(f"Invalid conversation type in channel {channel.get('name')}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_user_by_username(username: str, users_list: list[dict]) -> SlackUser:
|
|
99
|
+
usernames_found = []
|
|
100
|
+
for user in users_list:
|
|
101
|
+
if isinstance(user.get("name"), str):
|
|
102
|
+
usernames_found.append(user["name"])
|
|
103
|
+
username_found = user.get("name") or ""
|
|
104
|
+
if username.lower() == username_found.lower():
|
|
105
|
+
return SlackUser(**user)
|
|
106
|
+
|
|
107
|
+
raise UsernameNotFoundError(usernames_found=usernames_found, username_not_found=username)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def convert_conversation_type_to_slack_name(
|
|
111
|
+
conversation_type: ConversationType,
|
|
112
|
+
) -> ConversationTypeSlackName:
|
|
113
|
+
"""Convert a conversation type to another using Slack naming standard.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
conversation_type: The conversation type enum value.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The corresponding conversation type enum value using Slack naming standard.
|
|
120
|
+
"""
|
|
121
|
+
mapping = {
|
|
122
|
+
ConversationType.PUBLIC_CHANNEL: ConversationTypeSlackName.PUBLIC_CHANNEL,
|
|
123
|
+
ConversationType.PRIVATE_CHANNEL: ConversationTypeSlackName.PRIVATE_CHANNEL,
|
|
124
|
+
ConversationType.MULTI_PERSON_DIRECT_MESSAGE: ConversationTypeSlackName.MPIM,
|
|
125
|
+
ConversationType.DIRECT_MESSAGE: ConversationTypeSlackName.IM,
|
|
126
|
+
}
|
|
127
|
+
return mapping[conversation_type]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def extract_conversation_metadata(conversation: SlackConversation) -> ConversationMetadata:
|
|
131
|
+
"""Extract conversation metadata from a Slack conversation object.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
conversation: The Slack conversation dictionary.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
A dictionary with the conversation metadata.
|
|
138
|
+
"""
|
|
139
|
+
conversation_type = get_slack_conversation_type_as_str(conversation)
|
|
140
|
+
|
|
141
|
+
purpose: SlackConversationPurpose | None = conversation.get("purpose")
|
|
142
|
+
purpose_value = "" if not purpose else purpose.get("value", "")
|
|
143
|
+
|
|
144
|
+
metadata = ConversationMetadata(
|
|
145
|
+
id=conversation.get("id"),
|
|
146
|
+
name=conversation.get("name"),
|
|
147
|
+
conversation_type=conversation_type,
|
|
148
|
+
is_private=conversation.get("is_private", True),
|
|
149
|
+
is_archived=conversation.get("is_archived", False),
|
|
150
|
+
is_member=conversation.get("is_member", True),
|
|
151
|
+
purpose=purpose_value,
|
|
152
|
+
num_members=conversation.get("num_members", 0),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if conversation_type == ConversationTypeSlackName.IM.value:
|
|
156
|
+
metadata["num_members"] = 2
|
|
157
|
+
metadata["user"] = conversation.get("user")
|
|
158
|
+
metadata["is_user_deleted"] = conversation.get("is_user_deleted")
|
|
159
|
+
elif conversation_type == ConversationTypeSlackName.MPIM.value:
|
|
160
|
+
conversation_name = conversation.get("name", "")
|
|
161
|
+
if conversation_name:
|
|
162
|
+
metadata["num_members"] = len(conversation_name.split("--"))
|
|
163
|
+
else:
|
|
164
|
+
metadata["num_members"] = None
|
|
165
|
+
|
|
166
|
+
return metadata
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def extract_basic_user_info(user_info: SlackUser) -> BasicUserInfo:
|
|
170
|
+
"""Extract a user's basic info from a Slack user dictionary.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
user_info: The Slack user dictionary.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
A dictionary with the user's basic info.
|
|
177
|
+
|
|
178
|
+
See https://api.slack.com/types/user for the structure of the user object.
|
|
179
|
+
"""
|
|
180
|
+
profile = user_info.get("profile", {})
|
|
181
|
+
display_name = None if not profile else profile.get("display_name")
|
|
182
|
+
email = None if not profile else profile.get("email")
|
|
183
|
+
return BasicUserInfo(
|
|
184
|
+
id=user_info.get("id"),
|
|
185
|
+
name=user_info.get("name"),
|
|
186
|
+
is_bot=user_info.get("is_bot"),
|
|
187
|
+
email=email,
|
|
188
|
+
display_name=display_name,
|
|
189
|
+
real_name=user_info.get("real_name"),
|
|
190
|
+
timezone=user_info.get("tz"),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def associate_members_of_multiple_conversations(
|
|
195
|
+
get_members_in_conversation_func: Callable,
|
|
196
|
+
conversations: list[dict],
|
|
197
|
+
context: ToolContext,
|
|
198
|
+
) -> list[dict]:
|
|
199
|
+
"""Associate members to each conversation, returning the updated list."""
|
|
200
|
+
return await asyncio.gather(*[ # type: ignore[no-any-return]
|
|
201
|
+
associate_members_of_conversation(get_members_in_conversation_func, context, conv)
|
|
202
|
+
for conv in conversations
|
|
203
|
+
])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def associate_members_of_conversation(
|
|
207
|
+
get_members_in_conversation_func: Callable,
|
|
208
|
+
context: ToolContext,
|
|
209
|
+
conversation: dict,
|
|
210
|
+
) -> dict:
|
|
211
|
+
response = await get_members_in_conversation_func(context, conversation["id"])
|
|
212
|
+
conversation["members"] = response["members"]
|
|
213
|
+
return conversation
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def retrieve_conversations_by_user_ids(
|
|
217
|
+
list_conversations_func: Callable,
|
|
218
|
+
get_members_in_conversation_func: Callable,
|
|
219
|
+
context: ToolContext,
|
|
220
|
+
conversation_types: list[ConversationType],
|
|
221
|
+
user_ids: list[str],
|
|
222
|
+
exact_match: bool = False,
|
|
223
|
+
limit: int | None = None,
|
|
224
|
+
next_cursor: str | None = None,
|
|
225
|
+
) -> list[dict]:
|
|
226
|
+
"""
|
|
227
|
+
Retrieve conversations filtered by the given user IDs. Includes pagination support
|
|
228
|
+
and optionally limits the number of returned conversations.
|
|
229
|
+
"""
|
|
230
|
+
conversations_found: list[dict] = []
|
|
231
|
+
|
|
232
|
+
response = await list_conversations_func(
|
|
233
|
+
context=context,
|
|
234
|
+
conversation_types=conversation_types,
|
|
235
|
+
next_cursor=next_cursor,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Associate members to each conversation
|
|
239
|
+
conversations_with_members = await associate_members_of_multiple_conversations(
|
|
240
|
+
get_members_in_conversation_func, response["conversations"], context
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
conversations_found.extend(
|
|
244
|
+
filter_conversations_by_user_ids(conversations_with_members, user_ids, exact_match)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return conversations_found[:limit]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def filter_conversations_by_user_ids(
|
|
251
|
+
conversations: list[dict],
|
|
252
|
+
user_ids: list[str],
|
|
253
|
+
exact_match: bool = False,
|
|
254
|
+
) -> list[dict]:
|
|
255
|
+
"""
|
|
256
|
+
Filter conversations by the members' user IDs.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
conversations: The list of conversations to filter.
|
|
260
|
+
user_ids: The user IDs to filter conversations for.
|
|
261
|
+
exact_match: Whether to match the exact number of members in the conversations.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The list of conversations found.
|
|
265
|
+
"""
|
|
266
|
+
matches = []
|
|
267
|
+
for conversation in conversations:
|
|
268
|
+
member_ids = [member["id"] for member in conversation["members"]]
|
|
269
|
+
if exact_match:
|
|
270
|
+
same_length = len(user_ids) == len(member_ids)
|
|
271
|
+
has_all_members = all(user_id in member_ids for user_id in user_ids)
|
|
272
|
+
if same_length and has_all_members:
|
|
273
|
+
matches.append(conversation)
|
|
274
|
+
else:
|
|
275
|
+
if all(user_id in member_ids for user_id in user_ids):
|
|
276
|
+
matches.append(conversation)
|
|
277
|
+
|
|
278
|
+
return matches
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def is_user_a_bot(user: SlackUser) -> bool:
|
|
282
|
+
"""Check if a Slack user represents a bot.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
user: The Slack user dictionary.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
True if the user is a bot, False otherwise.
|
|
289
|
+
|
|
290
|
+
Bots are users with the "is_bot" flag set to true.
|
|
291
|
+
USLACKBOT is the user object for the Slack bot itself and is a special case.
|
|
292
|
+
|
|
293
|
+
See https://api.slack.com/types/user for the structure of the user object.
|
|
294
|
+
"""
|
|
295
|
+
return user.get("is_bot") or user.get("id") == "USLACKBOT"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def is_user_deleted(user: SlackUser) -> bool:
|
|
299
|
+
"""Check if a Slack user represents a deleted user.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
user: The Slack user dictionary.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True if the user is deleted, False otherwise.
|
|
306
|
+
|
|
307
|
+
See https://api.slack.com/types/user for the structure of the user object.
|
|
308
|
+
"""
|
|
309
|
+
is_deleted = user.get("deleted")
|
|
310
|
+
|
|
311
|
+
return is_deleted if isinstance(is_deleted, bool) else False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def async_paginate(
|
|
315
|
+
func: Callable,
|
|
316
|
+
response_key: str | None = None,
|
|
317
|
+
limit: int | None = None,
|
|
318
|
+
next_cursor: SlackPaginationNextCursor | None = None,
|
|
319
|
+
max_pagination_timeout_seconds: int = MAX_PAGINATION_TIMEOUT_SECONDS,
|
|
320
|
+
*args: Any,
|
|
321
|
+
**kwargs: Any,
|
|
322
|
+
) -> tuple[list, SlackPaginationNextCursor | None]:
|
|
323
|
+
"""Paginate a Slack AsyncWebClient's method results.
|
|
324
|
+
|
|
325
|
+
The purpose is to abstract the pagination work and make it easier for the LLM to retrieve the
|
|
326
|
+
amount of items requested by the user, regardless of limits imposed by the Slack API. We still
|
|
327
|
+
return the next cursor, if needed to paginate further.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
func: The Slack AsyncWebClient's method to paginate.
|
|
331
|
+
response_key: The key in the response dictionary to extract the items from (optional). If
|
|
332
|
+
not provided, the entire response dictionary is used.
|
|
333
|
+
limit: The maximum number of items to retrieve (defaults to Slack's suggested limit).
|
|
334
|
+
next_cursor: The cursor to use for pagination (optional).
|
|
335
|
+
*args: Positional arguments to pass to the Slack method.
|
|
336
|
+
**kwargs: Keyword arguments to pass to the Slack method.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
A tuple containing the list of items and the next cursor, if needed to paginate further.
|
|
340
|
+
"""
|
|
341
|
+
results: list[Any] = []
|
|
342
|
+
|
|
343
|
+
async def paginate_loop() -> list[Any]:
|
|
344
|
+
nonlocal results, next_cursor
|
|
345
|
+
should_continue = True
|
|
346
|
+
|
|
347
|
+
"""
|
|
348
|
+
The slack_limit variable makes the Slack API return no more than the appropriate
|
|
349
|
+
amount of items. The loop extends results with the items returned and continues
|
|
350
|
+
iterating if it hasn't reached the limit, and Slack indicates there're more
|
|
351
|
+
items to retrieve.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
while should_continue:
|
|
355
|
+
iteration_limit = limit - len(results) if limit else MAX_PAGINATION_SIZE_LIMIT
|
|
356
|
+
slack_limit = min(iteration_limit, MAX_PAGINATION_SIZE_LIMIT)
|
|
357
|
+
iteration_kwargs = {**kwargs, "limit": slack_limit, "cursor": next_cursor}
|
|
358
|
+
response = await func(*args, **iteration_kwargs)
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
results.extend(dict(response.data) if not response_key else response[response_key])
|
|
362
|
+
except KeyError:
|
|
363
|
+
raise ValueError(f"Response key {response_key} not found in Slack response")
|
|
364
|
+
|
|
365
|
+
next_cursor = response.get("response_metadata", {}).get("next_cursor")
|
|
366
|
+
|
|
367
|
+
if (limit and len(results) >= limit) or not next_cursor:
|
|
368
|
+
should_continue = False
|
|
369
|
+
|
|
370
|
+
return results
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
results = await asyncio.wait_for(paginate_loop(), timeout=max_pagination_timeout_seconds)
|
|
374
|
+
# asyncio.TimeoutError for Python <= 3.10, TimeoutError for Python >= 3.11
|
|
375
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
376
|
+
raise PaginationTimeoutError(max_pagination_timeout_seconds)
|
|
377
|
+
else:
|
|
378
|
+
return results, next_cursor
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def enrich_message_datetime(message: SlackMessage) -> Message:
|
|
382
|
+
"""Enrich message metadata with formatted datetime.
|
|
383
|
+
|
|
384
|
+
It helps LLMs when they need to display the date/time in human-readable format. Slack
|
|
385
|
+
will only return a unix-formatted timestamp (it's not actually UTC Unix timestamp, but
|
|
386
|
+
the Unix timestamp in the user's timezone - I know, odd, but it is what it is).
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
message: The Slack message dictionary.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
The enriched message dictionary.
|
|
393
|
+
"""
|
|
394
|
+
message = Message(**message)
|
|
395
|
+
ts = message.get("ts")
|
|
396
|
+
if isinstance(ts, str):
|
|
397
|
+
try:
|
|
398
|
+
timestamp = float(ts)
|
|
399
|
+
message["datetime_timestamp"] = datetime.fromtimestamp(timestamp).strftime(
|
|
400
|
+
"%Y-%m-%d %H:%M:%S"
|
|
401
|
+
)
|
|
402
|
+
except ValueError:
|
|
403
|
+
pass
|
|
404
|
+
return message
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def convert_datetime_to_unix_timestamp(datetime_str: str) -> int:
|
|
408
|
+
"""Convert a datetime string to a unix timestamp.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
datetime_str: The datetime string ('YYYY-MM-DD HH:MM:SS') to convert to a unix timestamp.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
The unix timestamp integer.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
dt = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
|
|
418
|
+
return int(dt.timestamp())
|
|
419
|
+
except ValueError:
|
|
420
|
+
raise RetryableToolError(
|
|
421
|
+
"Invalid datetime format",
|
|
422
|
+
developer_message=f"The datetime '{datetime_str}' is invalid. "
|
|
423
|
+
"Please provide a datetime string in the format 'YYYY-MM-DD HH:MM:SS'.",
|
|
424
|
+
retry_after_ms=500,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def convert_relative_datetime_to_unix_timestamp(
|
|
429
|
+
relative_datetime: str,
|
|
430
|
+
current_unix_timestamp: int | None = None,
|
|
431
|
+
) -> int:
|
|
432
|
+
"""Convert a relative datetime string in the format 'DD:HH:MM' to unix timestamp.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
relative_datetime: The relative datetime string ('DD:HH:MM') to convert to a unix timestamp.
|
|
436
|
+
current_unix_timestamp: The current unix timestamp (optional). If not provided, the
|
|
437
|
+
current unix timestamp from datetime.now is used.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
The unix timestamp integer.
|
|
441
|
+
"""
|
|
442
|
+
if not current_unix_timestamp:
|
|
443
|
+
current_unix_timestamp = int(datetime.now(timezone.utc).timestamp())
|
|
444
|
+
|
|
445
|
+
days, hours, minutes = map(int, relative_datetime.split(":"))
|
|
446
|
+
seconds = days * 86400 + hours * 3600 + minutes * 60
|
|
447
|
+
return int(current_unix_timestamp - seconds)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arcade_slack
|
|
3
|
+
Version: 0.4.6
|
|
4
|
+
Summary: Arcade.dev LLM tools for Slack
|
|
5
|
+
Author-email: Arcade <dev@arcade.dev>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: aiodns<2.0.0,>=1.0
|
|
9
|
+
Requires-Dist: aiohttp<4.0.0,>=3.7.3
|
|
10
|
+
Requires-Dist: arcade-tdk<3.0.0,>=2.0.0
|
|
11
|
+
Requires-Dist: slack-sdk<4.0.0,>=3.31.0
|
|
12
|
+
Requires-Dist: typing; python_version < '3.7'
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: arcade-ai[evals]<3.0.0,>=2.0.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: arcade-serve<3.0.0,>=2.0.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: mypy<1.6.0,>=1.5.1; extra == 'dev'
|
|
17
|
+
Requires-Dist: pre-commit<3.5.0,>=3.4.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio<0.25.0,>=0.24.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov<4.1.0,>=4.0.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-mock<3.12.0,>=3.11.1; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest<8.4.0,>=8.3.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff<0.8.0,>=0.7.4; extra == 'dev'
|
|
23
|
+
Requires-Dist: tox<4.12.0,>=4.11.1; extra == 'dev'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
arcade_slack/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
arcade_slack/constants.py,sha256=txtPR4G0eYWXu1ABMtNXjH084CwHB6FsrQ1Z7MI43X0,363
|
|
3
|
+
arcade_slack/critics.py,sha256=Sx0nOmvym4LFNX1gZ34ndHkAKAB--X1zRPePcbiVzDo,1209
|
|
4
|
+
arcade_slack/custom_types.py,sha256=_dWh7QuCrBsHj3U-e1-P5gMYFaip_a44eYnpccKG7Yo,896
|
|
5
|
+
arcade_slack/exceptions.py,sha256=4vBwQN_sBRTb0WsbM-ohHrdpUENV70Bu2zzP35L6j0A,1031
|
|
6
|
+
arcade_slack/models.py,sha256=hEQZCaz7f9lnzbIAgXWZkt2lebTTUK7R1YvpBjeCbSI,6274
|
|
7
|
+
arcade_slack/utils.py,sha256=W6-SSe9SDaJfRMCj3b70SyYHjrxstLdRG9SNwj6XRjE,15679
|
|
8
|
+
arcade_slack/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
arcade_slack/tools/chat.py,sha256=nIBqRnAw64azVbB_ewpcsVyP9mk2_2_FMQk_YQFjnnk,35023
|
|
10
|
+
arcade_slack/tools/users.py,sha256=tdexdc-nBBEvSaYKgnvrjWsQrtwJnBjcJdaicNhSoPU,2984
|
|
11
|
+
arcade_slack-0.4.6.dist-info/METADATA,sha256=_NFliyW3f0X_wTQq3y5GZqXfLUDJHlfTAuxmExvh6Ww,953
|
|
12
|
+
arcade_slack-0.4.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
+
arcade_slack-0.4.6.dist-info/licenses/LICENSE,sha256=f4Q0XUZJ2MqZBO1XsqqHhuZfSs2ar1cZEJ45150zERo,1067
|
|
14
|
+
arcade_slack-0.4.6.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Arcade AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: arcade_slack
|
|
3
|
-
Version: 0.1.6
|
|
4
|
-
Summary: Slack tools for LLMs
|
|
5
|
-
Author: Arcade AI
|
|
6
|
-
Author-email: dev@arcade-ai.com
|
|
7
|
-
Requires-Python: >=3.10,<4.0
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
-
Requires-Dist: arcade-ai (==0.1.6)
|
|
14
|
-
Requires-Dist: slack-sdk (>=3.31.0,<4.0.0)
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
arcade_slack/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
arcade_slack/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
arcade_slack/tools/chat.py,sha256=PtIP11A7FOOJnwwk0TGHFzRLBrKiaZlxARQra6q5Z2k,4403
|
|
4
|
-
arcade_slack-0.1.6.dist-info/METADATA,sha256=h4H0D2cqd-cbkmDoqrx389foxHPcc0kjsIrunMeaMys,495
|
|
5
|
-
arcade_slack-0.1.6.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
6
|
-
arcade_slack-0.1.6.dist-info/RECORD,,
|