arcade-slack 0.4.6__py3-none-any.whl → 0.5.1__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.
@@ -2,12 +2,13 @@ from typing import Annotated, Any, cast
2
2
 
3
3
  from arcade_tdk import ToolContext, tool
4
4
  from arcade_tdk.auth import Slack
5
- from arcade_tdk.errors import RetryableToolError
6
- from slack_sdk.errors import SlackApiError
7
5
  from slack_sdk.web.async_client import AsyncWebClient
8
6
 
9
7
  from arcade_slack.constants import MAX_PAGINATION_TIMEOUT_SECONDS
10
- from arcade_slack.models import SlackPaginationNextCursor, SlackUser
8
+ from arcade_slack.models import (
9
+ SlackPaginationNextCursor,
10
+ )
11
+ from arcade_slack.user_retrieval import get_users_by_id_username_or_email
11
12
  from arcade_slack.utils import (
12
13
  async_paginate,
13
14
  extract_basic_user_info,
@@ -16,62 +17,58 @@ from arcade_slack.utils import (
16
17
  )
17
18
 
18
19
 
19
- @tool(
20
- requires_auth=Slack(
21
- scopes=["users:read", "users:read.email"],
22
- )
23
- )
24
- async def get_user_info_by_id(
20
+ @tool(requires_auth=Slack(scopes=["users:read", "users:read.email"]))
21
+ async def get_users_info(
25
22
  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)
23
+ user_ids: Annotated[list[str] | None, "The IDs of the users to get"] = None,
24
+ usernames: Annotated[
25
+ list[str] | None,
26
+ "The usernames of the users to get. Prefer retrieving by user_ids and/or emails, "
27
+ "when available, since the performance is better.",
28
+ ] = None,
29
+ emails: Annotated[list[str] | None, "The emails of the users to get"] = None,
30
+ ) -> Annotated[dict[str, Any], "The users' information"]:
31
+ """Get the information of one or more users in Slack by ID, username, and/or email.
34
32
 
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"])
33
+ Provide any combination of user_ids, usernames, and/or emails. If you need to retrieve
34
+ data about multiple users, DO NOT CALL THE TOOL MULTIPLE TIMES. Instead, call it once
35
+ with all the user_ids, usernames, and/or emails. IF YOU CALL THIS TOOL MULTIPLE TIMES
36
+ UNNECESSARILY, YOU WILL RELEASE MORE CO2 IN THE ATMOSPHERE AND CONTRIBUTE TO GLOBAL WARMING.
41
37
 
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
- )
38
+ If you need to get metadata or messages of a conversation, use the
39
+ `Slack.GetConversationMetadata` or `Slack.GetMessages` tool instead. These
40
+ tools accept user_ids, usernames, and/or emails. Do not retrieve users' info first,
41
+ as it is inefficient, releases more CO2 in the atmosphere, and contributes to climate change.
42
+ """
43
+ users = await get_users_by_id_username_or_email(context, user_ids, usernames, emails)
44
+ return {"users": users}
48
45
 
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
46
 
54
-
55
- @tool(
56
- requires_auth=Slack(
57
- scopes=["users:read", "users:read.email"],
58
- )
59
- )
47
+ @tool(requires_auth=Slack(scopes=["users:read", "users:read.email"]))
60
48
  async def list_users(
61
49
  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,
50
+ exclude_bots: Annotated[
51
+ bool | None, "Whether to exclude bots from the results. Defaults to True."
52
+ ] = True,
53
+ limit: Annotated[
54
+ int,
55
+ # The user object is relatively small, so we allow a higher limit than the default of 200.
56
+ "The maximum number of users to return. Defaults to 200. Maximum is 500.",
57
+ ] = 200,
64
58
  next_cursor: Annotated[str | None, "The next cursor token to use for pagination."] = None,
65
59
  ) -> Annotated[dict, "The users' info"]:
66
- """List all users in the authenticated user's Slack team."""
60
+ """List all users in the authenticated user's Slack team.
67
61
 
68
- token = (
69
- context.authorization.token if context.authorization and context.authorization.token else ""
70
- )
71
- slackClient = AsyncWebClient(token=token)
62
+ If you need to get metadata or messages of a conversation, use the
63
+ `Slack.GetConversationMetadata` tool or `Slack.GetMessages` tool instead. These
64
+ tools accept a user_id, username, and/or email. Do not use this tool to first retrieve user(s),
65
+ as it is inefficient and releases more CO2 in the atmosphere, contributing to climate change.
66
+ """
67
+ limit = max(1, min(limit, 500))
68
+ slack_client = AsyncWebClient(token=context.get_auth_token_or_empty())
72
69
 
73
70
  users, next_cursor = await async_paginate(
74
- func=slackClient.users_list,
71
+ func=slack_client.users_list,
75
72
  response_key="members",
76
73
  limit=limit,
77
74
  next_cursor=cast(SlackPaginationNextCursor, next_cursor),
@@ -85,3 +82,18 @@ async def list_users(
85
82
  ]
86
83
 
87
84
  return {"users": users, "next_cursor": next_cursor}
85
+
86
+
87
+ # NOTE: This tool is kept here for backwards compatibility.
88
+ # Use the `Slack.GetUsersInfo` tool instead.
89
+ @tool(requires_auth=Slack(scopes=["users:read", "users:read.email"]))
90
+ async def get_user_info_by_id(
91
+ context: ToolContext,
92
+ user_id: Annotated[str, "The ID of the user to get"],
93
+ ) -> Annotated[dict[str, Any], "The user's information"]:
94
+ """Get the information of a user in Slack.
95
+
96
+ This tool is deprecated. Use the `Slack.GetUsersInfo` tool instead.
97
+ """
98
+ users = await get_users_info(context, user_ids=[user_id])
99
+ return cast(dict[str, Any], users["users"][0])
@@ -0,0 +1,214 @@
1
+ import asyncio
2
+ from typing import Any, cast
3
+
4
+ from arcade_tdk import ToolContext
5
+ from arcade_tdk.errors import ToolExecutionError
6
+ from slack_sdk.errors import SlackApiError
7
+ from slack_sdk.web.async_client import AsyncWebClient
8
+
9
+ from arcade_slack.constants import MAX_CONCURRENT_REQUESTS, MAX_PAGINATION_TIMEOUT_SECONDS
10
+ from arcade_slack.models import (
11
+ FindMultipleUsersByIdSentinel,
12
+ FindMultipleUsersByUsernameSentinel,
13
+ GetUserByEmailCaller,
14
+ )
15
+ from arcade_slack.utils import (
16
+ async_paginate,
17
+ build_multiple_users_retrieval_response,
18
+ cast_user_dict,
19
+ gather_with_concurrency_limit,
20
+ is_user_a_bot,
21
+ is_valid_email,
22
+ short_user_info,
23
+ )
24
+
25
+
26
+ async def get_users_by_id_username_or_email(
27
+ context: ToolContext,
28
+ user_ids: str | list[str] | None = None,
29
+ usernames: str | list[str] | None = None,
30
+ emails: str | list[str] | None = None,
31
+ semaphore: asyncio.Semaphore | None = None,
32
+ ) -> list[dict[str, Any]]:
33
+ """Get the metadata of a user by their ID, username, or email.
34
+
35
+ Provide any combination of user_ids, usernames, and/or emails. Always prefer providing user_ids
36
+ and/or emails, when available, since the performance is better.
37
+ """
38
+ if isinstance(user_ids, str):
39
+ user_ids = [user_ids]
40
+ if isinstance(usernames, str):
41
+ usernames = [usernames]
42
+ if isinstance(emails, str):
43
+ emails = [emails]
44
+
45
+ if not any([user_ids, usernames, emails]):
46
+ raise ToolExecutionError("At least one of user_ids, usernames, or emails must be provided")
47
+
48
+ if not semaphore:
49
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
50
+
51
+ user_retrieval_calls = []
52
+
53
+ auth_token = context.get_auth_token_or_empty()
54
+
55
+ if user_ids:
56
+ user_retrieval_calls.append(get_users_by_id(auth_token, user_ids, semaphore))
57
+
58
+ if usernames:
59
+ user_retrieval_calls.append(get_users_by_username(auth_token, usernames, semaphore))
60
+
61
+ if emails:
62
+ user_retrieval_calls.append(get_users_by_email(auth_token, emails, semaphore))
63
+
64
+ responses = await asyncio.gather(*user_retrieval_calls)
65
+
66
+ return await build_multiple_users_retrieval_response(context, responses)
67
+
68
+
69
+ async def get_users_by_id(
70
+ auth_token: str,
71
+ user_ids: list[str],
72
+ semaphore: asyncio.Semaphore | None = None,
73
+ ) -> dict[str, list]:
74
+ user_ids = list(set(user_ids))
75
+
76
+ if len(user_ids) == 1:
77
+ user = await get_single_user_by_id(auth_token, user_ids[0])
78
+ if not user:
79
+ return {"users": [], "not_found": user_ids}
80
+ else:
81
+ return {"users": [user], "not_found": []}
82
+
83
+ if not semaphore:
84
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
85
+
86
+ async with semaphore:
87
+ slack_client = AsyncWebClient(token=auth_token)
88
+ response, _ = await async_paginate(
89
+ func=slack_client.users_list,
90
+ response_key="members",
91
+ sentinel=FindMultipleUsersByIdSentinel(user_ids=user_ids),
92
+ )
93
+
94
+ user_ids_pending = set(user_ids)
95
+ users = []
96
+
97
+ for user in response:
98
+ user_dict = cast(dict, user)
99
+ if user_dict["id"] in user_ids_pending:
100
+ users.append(cast_user_dict(user_dict))
101
+ user_ids_pending.remove(user_dict["id"])
102
+
103
+ return {"users": users, "not_found": list(user_ids_pending)}
104
+
105
+
106
+ async def get_single_user_by_id(auth_token: str, user_id: str) -> dict[str, Any] | None:
107
+ slack_client = AsyncWebClient(token=auth_token)
108
+ try:
109
+ response = await slack_client.users_info(user=user_id)
110
+ if not response.get("ok"):
111
+ return None
112
+ return cast_user_dict(response["user"])
113
+ except SlackApiError as e:
114
+ if "not_found" in e.response.get("error", ""):
115
+ return None
116
+ else:
117
+ message = f"There was an error getting the user with ID {user_id}."
118
+ slack_error_message = e.response.get("error", "Unknown Slack API error")
119
+ raise ToolExecutionError(
120
+ message=message,
121
+ developer_message=f"{message}: {slack_error_message}",
122
+ ) from e
123
+
124
+
125
+ async def get_users_by_username(
126
+ auth_token: str,
127
+ usernames: list[str],
128
+ semaphore: asyncio.Semaphore | None = None,
129
+ ) -> dict[str, list[dict]]:
130
+ usernames = list(set(usernames))
131
+
132
+ if not semaphore:
133
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
134
+
135
+ slack_client = AsyncWebClient(token=auth_token)
136
+
137
+ async with semaphore:
138
+ users, _ = await async_paginate(
139
+ func=slack_client.users_list,
140
+ response_key="members",
141
+ max_pagination_timeout_seconds=MAX_PAGINATION_TIMEOUT_SECONDS,
142
+ sentinel=FindMultipleUsersByUsernameSentinel(usernames=usernames),
143
+ )
144
+
145
+ users_found = []
146
+ usernames_lower = {username.casefold() for username in usernames}
147
+ usernames_pending = set(usernames)
148
+ available_users = []
149
+
150
+ for user in users:
151
+ if is_user_a_bot(user):
152
+ continue
153
+
154
+ available_users.append(short_user_info(user))
155
+
156
+ if not isinstance(user.get("name"), str):
157
+ continue
158
+
159
+ username_lower = user["name"].casefold()
160
+
161
+ if username_lower in usernames_lower:
162
+ users_found.append(cast_user_dict(user))
163
+ # Username/handle is unique in Slack, we can ignore it after finding a match
164
+ for pending_username in usernames_pending:
165
+ if pending_username.casefold() == username_lower:
166
+ usernames_pending.remove(pending_username)
167
+ break
168
+
169
+ response: dict[str, Any] = {"users": users_found}
170
+
171
+ if usernames_pending:
172
+ response["not_found"] = list(usernames_pending)
173
+ response["available_users"] = available_users
174
+
175
+ return response
176
+
177
+
178
+ async def get_users_by_email(
179
+ auth_token: str,
180
+ emails: list[str],
181
+ semaphore: asyncio.Semaphore | None = None,
182
+ ) -> dict[str, list[dict]]:
183
+ emails = list(set(emails))
184
+
185
+ for email in emails:
186
+ if not is_valid_email(email):
187
+ raise ToolExecutionError(f"Invalid email address: {email}")
188
+
189
+ if not semaphore:
190
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
191
+
192
+ slack_client = AsyncWebClient(token=auth_token)
193
+ callers = [GetUserByEmailCaller(slack_client.users_lookupByEmail, email) for email in emails]
194
+
195
+ results = await gather_with_concurrency_limit(
196
+ coroutine_callers=callers,
197
+ semaphore=semaphore,
198
+ )
199
+
200
+ users = []
201
+ emails_not_found = []
202
+
203
+ for result in results:
204
+ if result["user"]:
205
+ users.append(cast_user_dict(result["user"]))
206
+ else:
207
+ emails_not_found.append(result["email"])
208
+
209
+ response: dict[str, Any] = {"users": users}
210
+
211
+ if emails_not_found:
212
+ response["not_found"] = emails_not_found
213
+
214
+ return response