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.
arcade_slack/utils.py CHANGED
@@ -1,23 +1,28 @@
1
1
  import asyncio
2
- from collections.abc import Callable
2
+ import json
3
+ import re
4
+ from collections.abc import Callable, Sequence
3
5
  from datetime import datetime, timezone
4
- from typing import Any
6
+ from typing import Any, cast
5
7
 
6
8
  from arcade_tdk import ToolContext
7
9
  from arcade_tdk.errors import RetryableToolError
8
10
 
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,
11
+ from arcade_slack.constants import (
12
+ MAX_CONCURRENT_REQUESTS,
13
+ MAX_PAGINATION_SIZE_LIMIT,
14
+ MAX_PAGINATION_TIMEOUT_SECONDS,
14
15
  )
16
+ from arcade_slack.custom_types import SlackPaginationNextCursor
17
+ from arcade_slack.exceptions import PaginationTimeoutError
15
18
  from arcade_slack.models import (
19
+ AbstractConcurrencySafeCoroutineCaller,
16
20
  BasicUserInfo,
17
21
  ConversationMetadata,
18
22
  ConversationType,
19
23
  ConversationTypeSlackName,
20
24
  Message,
25
+ PaginationSentinel,
21
26
  SlackConversation,
22
27
  SlackConversationPurpose,
23
28
  SlackMessage,
@@ -75,7 +80,7 @@ def remove_none_values(params: dict) -> dict:
75
80
  return {k: v for k, v in params.items() if v is not None}
76
81
 
77
82
 
78
- def get_slack_conversation_type_as_str(channel: SlackConversation) -> str:
83
+ def get_slack_conversation_type_as_str(channel: SlackConversation) -> str | None:
79
84
  """Get the type of conversation from a Slack channel's dictionary.
80
85
 
81
86
  Args:
@@ -92,19 +97,7 @@ def get_slack_conversation_type_as_str(channel: SlackConversation) -> str:
92
97
  return ConversationTypeSlackName.IM.value
93
98
  if channel.get("is_mpim"):
94
99
  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)
100
+ raise ValueError(f"Invalid conversation type in channel: {json.dumps(channel)}")
108
101
 
109
102
 
110
103
  def convert_conversation_type_to_slack_name(
@@ -191,62 +184,6 @@ def extract_basic_user_info(user_info: SlackUser) -> BasicUserInfo:
191
184
  )
192
185
 
193
186
 
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
187
  def filter_conversations_by_user_ids(
251
188
  conversations: list[dict],
252
189
  user_ids: list[str],
@@ -317,6 +254,7 @@ async def async_paginate(
317
254
  limit: int | None = None,
318
255
  next_cursor: SlackPaginationNextCursor | None = None,
319
256
  max_pagination_timeout_seconds: int = MAX_PAGINATION_TIMEOUT_SECONDS,
257
+ sentinel: PaginationSentinel | None = None,
320
258
  *args: Any,
321
259
  **kwargs: Any,
322
260
  ) -> tuple[list, SlackPaginationNextCursor | None]:
@@ -332,6 +270,10 @@ async def async_paginate(
332
270
  not provided, the entire response dictionary is used.
333
271
  limit: The maximum number of items to retrieve (defaults to Slack's suggested limit).
334
272
  next_cursor: The cursor to use for pagination (optional).
273
+ max_pagination_timeout_seconds: The maximum timeout for the pagination loop (defaults to
274
+ MAX_PAGINATION_TIMEOUT_SECONDS).
275
+ sentinel: Control whether the pagination should continue after each iteration (optional).
276
+ If provided, the pagination will stop when the sentinel function returns True.
335
277
  *args: Positional arguments to pass to the Slack method.
336
278
  **kwargs: Keyword arguments to pass to the Slack method.
337
279
 
@@ -358,13 +300,18 @@ async def async_paginate(
358
300
  response = await func(*args, **iteration_kwargs)
359
301
 
360
302
  try:
361
- results.extend(dict(response.data) if not response_key else response[response_key])
303
+ result = dict(response.data) if not response_key else response[response_key]
304
+ results.extend(result)
362
305
  except KeyError:
363
306
  raise ValueError(f"Response key {response_key} not found in Slack response")
364
307
 
365
308
  next_cursor = response.get("response_metadata", {}).get("next_cursor")
366
309
 
367
- if (limit and len(results) >= limit) or not next_cursor:
310
+ if (
311
+ (sentinel and sentinel(last_result=result))
312
+ or (limit and len(results) >= limit)
313
+ or not next_cursor
314
+ ):
368
315
  should_continue = False
369
316
 
370
317
  return results
@@ -445,3 +392,215 @@ def convert_relative_datetime_to_unix_timestamp(
445
392
  days, hours, minutes = map(int, relative_datetime.split(":"))
446
393
  seconds = days * 86400 + hours * 3600 + minutes * 60
447
394
  return int(current_unix_timestamp - seconds)
395
+
396
+
397
+ def short_user_info(user: dict) -> dict[str, str | None]:
398
+ data = {"id": user.get("id")}
399
+ if user.get("name"):
400
+ data["name"] = user["name"]
401
+ if isinstance(user.get("profile"), dict) and user["profile"].get("email"):
402
+ data["email"] = user["profile"]["email"]
403
+ elif user.get("email"):
404
+ data["email"] = user["email"]
405
+ return data
406
+
407
+
408
+ def short_human_users_info(users: list[dict]) -> list[dict[str, str | None]]:
409
+ return [short_user_info(user) for user in users if not user.get("is_bot")]
410
+
411
+
412
+ def is_valid_email(email: str) -> bool:
413
+ """Validate an email address using regex.
414
+
415
+ Args:
416
+ email: The email address to validate.
417
+
418
+ Returns:
419
+ True if the email is valid, False otherwise.
420
+ """
421
+ email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
422
+ return bool(re.match(email_pattern, email))
423
+
424
+
425
+ async def build_multiple_users_retrieval_response(
426
+ context: ToolContext,
427
+ users_responses: list[dict[str, Any]],
428
+ ) -> list[dict[str, Any]]:
429
+ """Builds response list for the get_multiple_users_by_usernames_or_emails function."""
430
+ await raise_for_users_not_found(context, users_responses)
431
+
432
+ users = []
433
+
434
+ for users_response in users_responses:
435
+ users.extend(users_response["users"])
436
+
437
+ return cast(list[dict[str, Any]], users)
438
+
439
+
440
+ async def raise_for_users_not_found(
441
+ context: ToolContext, users_responses: list[dict[str, Any]]
442
+ ) -> None:
443
+ """Raise an error if any user was not found in the responses."""
444
+ users_not_found, available_users = collect_users_not_found_in_responses(users_responses)
445
+
446
+ if users_not_found:
447
+ not_found_message = ", ".join(users_not_found)
448
+ s = "" if len(users_not_found) == 1 else "s"
449
+ message = f"User{s} not found: {not_found_message}"
450
+ available_users_prompt = await get_available_users_prompt(context, available_users)
451
+
452
+ raise RetryableToolError(
453
+ message=message,
454
+ developer_message=message,
455
+ additional_prompt_content=available_users_prompt,
456
+ retry_after_ms=500,
457
+ )
458
+
459
+
460
+ def collect_users_not_found_in_responses(
461
+ responses: list[dict[str, Any]],
462
+ ) -> tuple[list[str], list[dict[str, Any]]]:
463
+ users_not_found = []
464
+ available_users = []
465
+
466
+ for response in responses:
467
+ if response.get("not_found"):
468
+ users_not_found.extend(response["not_found"])
469
+ if response.get("available_users"):
470
+ available_users = response["available_users"]
471
+
472
+ return users_not_found, available_users
473
+
474
+
475
+ async def get_available_users_prompt(
476
+ context: ToolContext,
477
+ available_users: list[dict] | None = None,
478
+ limit: int = 100,
479
+ ) -> str:
480
+ try:
481
+ from arcade_slack.tools.users import list_users # Avoid circular import
482
+
483
+ if isinstance(available_users, list) and available_users:
484
+ available_users = [
485
+ user for user in available_users if not is_user_a_bot(SlackUser(**user))
486
+ ]
487
+ available_users_str = json.dumps(short_human_users_info(available_users))
488
+ next_cursor = None
489
+ potentially_more_users = True
490
+ else:
491
+ users = await list_users(context, limit=limit, exclude_bots=True)
492
+ next_cursor = users["next_cursor"]
493
+ available_users_str = json.dumps(short_human_users_info(users["users"]))
494
+ potentially_more_users = bool(next_cursor)
495
+
496
+ if not potentially_more_users:
497
+ return f"The users available are: {available_users_str}"
498
+ else:
499
+ msg = (
500
+ f"Some of the available users are: {available_users_str}. Potentially more users "
501
+ f"can be retrieved by calling the 'Slack.{list_users.__tool_name__}' tool"
502
+ )
503
+ if next_cursor:
504
+ msg += f" using the next cursor: '{next_cursor}' to continue pagination."
505
+ return msg
506
+ except Exception as e:
507
+ return (
508
+ "The tool tried to retrieve a list of available users, but failed with error: "
509
+ f"{type(e).__name__}: {e!s}. Use the 'Slack.{list_users.__tool_name__}' tool "
510
+ "to get a list of users."
511
+ )
512
+
513
+
514
+ async def gather_with_concurrency_limit(
515
+ coroutine_callers: Sequence[AbstractConcurrencySafeCoroutineCaller],
516
+ semaphore: asyncio.Semaphore | None = None,
517
+ max_concurrent_requests: int = MAX_CONCURRENT_REQUESTS,
518
+ ) -> list[Any]:
519
+ if not semaphore:
520
+ semaphore = asyncio.Semaphore(max_concurrent_requests)
521
+
522
+ return await asyncio.gather(*[caller(semaphore) for caller in coroutine_callers]) # type: ignore[no-any-return]
523
+
524
+
525
+ def cast_user_dict(user: dict[str, Any]) -> dict[str, Any]:
526
+ slack_user = SlackUser(**cast(dict, user))
527
+ return dict(**extract_basic_user_info(slack_user))
528
+
529
+
530
+ async def populate_users_in_messages(auth_token: str, messages: list[dict]) -> list[dict]:
531
+ if not messages:
532
+ return messages
533
+
534
+ users = await get_users_from_messages(auth_token, messages)
535
+ users_by_id = {user["id"]: {"id": user["id"], "name": user["name"]} for user in users}
536
+
537
+ for message in messages:
538
+ if message.get("type") != "message":
539
+ continue
540
+
541
+ # Message author
542
+ message["user"] = users_by_id.get(
543
+ message.get("user"), {"id": message["user"], "name": None}
544
+ )
545
+
546
+ # User mentions in the message text
547
+ text_mentions = re.findall(r"<@([A-Z0-9]+)>", message.get("text", ""))
548
+ for user_id in text_mentions:
549
+ if user_id in users_by_id:
550
+ user = users_by_id.get(user_id, {"id": user_id, "name": None})
551
+ name = user.get("name")
552
+ message["text"] = message["text"].replace(
553
+ f"<@{user_id}>", f"<@{name} (id:{user_id})>" if name else f"<@{user_id}>"
554
+ )
555
+
556
+ # User mentions in reactions
557
+ reactions = message.get("reactions")
558
+ if isinstance(reactions, list):
559
+ for reaction in reactions:
560
+ reaction_users = []
561
+ for user_id in reaction.get("users", []):
562
+ reaction_users.append(users_by_id.get(user_id, {"id": user_id, "name": None}))
563
+ reaction["users"] = reaction_users
564
+
565
+ return messages
566
+
567
+
568
+ async def get_users_from_messages(auth_token: str, messages: list[dict]) -> list[dict[str, Any]]:
569
+ if not messages:
570
+ return []
571
+
572
+ from arcade_slack.user_retrieval import get_users_by_id # Avoid circular import
573
+
574
+ user_ids = get_user_ids_from_messages(messages)
575
+ response = await get_users_by_id(auth_token, user_ids)
576
+ print("\n\n\nresponse:", response, "\n\n\n")
577
+ return response["users"]
578
+
579
+
580
+ def get_user_ids_from_messages(messages: list[dict]) -> list[str]:
581
+ if not messages:
582
+ return []
583
+
584
+ user_ids = []
585
+
586
+ for message in messages:
587
+ if message.get("type") != "message":
588
+ continue
589
+
590
+ # Message author
591
+ user = message.get("user")
592
+ if isinstance(user, str) and user:
593
+ user_ids.append(user)
594
+
595
+ # User mentions in the message text
596
+ text = message.get("text")
597
+ if isinstance(text, str) and text:
598
+ user_ids.extend(re.findall(r"<@([A-Z0-9]+)>", text))
599
+
600
+ # User mentions in reactions
601
+ reactions = message.get("reactions")
602
+ if isinstance(reactions, list):
603
+ for reaction in reactions:
604
+ user_ids.extend(reaction.get("users", []))
605
+
606
+ return user_ids
@@ -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
@@ -0,0 +1,17 @@
1
+ arcade_slack/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ arcade_slack/constants.py,sha256=ANAZOmNSqrbZHDqEvQfau7_91if9EeKV-G5D0VBB9vo,524
3
+ arcade_slack/conversation_retrieval.py,sha256=jsd64-IDpgTpuq-4Hbg1317VatxKnIy8edWhHB6bN7E,2338
4
+ arcade_slack/critics.py,sha256=Sx0nOmvym4LFNX1gZ34ndHkAKAB--X1zRPePcbiVzDo,1209
5
+ arcade_slack/custom_types.py,sha256=-23JnfXjMQ-H8CyRL3CEv9opPMySox1KINH5rrPIOt0,928
6
+ arcade_slack/exceptions.py,sha256=YQ4CTa1LbR-G7sjnQM8LB7LruxZDqPzvo-cptFYW7E8,385
7
+ arcade_slack/message_retrieval.py,sha256=XdzWLJdKtc5DL7H_qQBei6qJ-rz2uGs-Q0HfYqx7EG4,2496
8
+ arcade_slack/models.py,sha256=-zlkAxaDNEcIkQqePYTZXAqcRX4gO5jocJIixeWpK7A,12213
9
+ arcade_slack/user_retrieval.py,sha256=cdfzBbm2fsVNCztt2XbIV2UKuCdHaotfFBdo10EpBcE,6774
10
+ arcade_slack/utils.py,sha256=nKfvpeVmLeIun0y9poPG27jLBji2WMH4h1oWgemPIQ8,21438
11
+ arcade_slack/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ arcade_slack/tools/chat.py,sha256=KWDalEiOhay4Z71EB97dVllm3Uo7sXxjXFfmcpvuWYE,38125
13
+ arcade_slack/tools/users.py,sha256=Kw3MWNOwuUY17X5HGf3JMAH_mfloLRdRzXSBC7Zo7ug,4233
14
+ arcade_slack-0.5.1.dist-info/METADATA,sha256=t__oFJOZyq4HWgW6xFZhKzBmGVc8mm55_QSSCgrUPF4,953
15
+ arcade_slack-0.5.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ arcade_slack-0.5.1.dist-info/licenses/LICENSE,sha256=f4Q0XUZJ2MqZBO1XsqqHhuZfSs2ar1cZEJ45150zERo,1067
17
+ arcade_slack-0.5.1.dist-info/RECORD,,
@@ -1,14 +0,0 @@
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,,