arcade-slack 2.0.0__py3-none-any.whl → 2.1.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/critics.py CHANGED
@@ -4,7 +4,7 @@ from arcade_evals import BinaryCritic
4
4
 
5
5
 
6
6
  class RelativeTimeBinaryCritic(BinaryCritic):
7
- def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]:
7
+ def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]:
8
8
  """
9
9
  Evaluates whether the expected and actual relative time strings are equivalent after
10
10
  casting.
@@ -21,6 +21,7 @@ from arcade_slack.user_retrieval import (
21
21
  from arcade_slack.utils import (
22
22
  async_paginate,
23
23
  extract_conversation_metadata,
24
+ invite_users_to_conversation,
24
25
  populate_users_in_messages,
25
26
  raise_for_users_not_found,
26
27
  )
@@ -418,3 +419,98 @@ async def list_conversations(
418
419
  ],
419
420
  "next_cursor": next_cursor,
420
421
  }
422
+
423
+
424
+ @tool(
425
+ requires_auth=Slack(
426
+ scopes=[
427
+ "channels:read",
428
+ "groups:read",
429
+ "channels:write",
430
+ "groups:write",
431
+ "users:read",
432
+ "users:read.email",
433
+ ],
434
+ )
435
+ )
436
+ async def invite_users_to_channel(
437
+ context: ToolContext,
438
+ channel_id: Annotated[
439
+ str | None,
440
+ "The ID of the Slack channel or MPIM (multi-person direct message) to invite users to. "
441
+ "Provide exactly one of channel_id OR channel_name.",
442
+ ] = None,
443
+ channel_name: Annotated[
444
+ str | None,
445
+ "The name of the channel to invite users to. Prefer providing a channel_id when "
446
+ "available for better performance. Note: MPIMs don't have names, so use channel_id "
447
+ "for MPIMs.",
448
+ ] = None,
449
+ user_ids: Annotated[
450
+ list[str] | None,
451
+ "The Slack user IDs of the people to invite. Up to 100 users may be listed. "
452
+ "Provide at least one of user_ids, usernames, or emails.",
453
+ ] = None,
454
+ usernames: Annotated[
455
+ list[str] | None,
456
+ "The Slack usernames of the people to invite. Prefer providing user_ids "
457
+ "and/or emails when available for better performance.",
458
+ ] = None,
459
+ emails: Annotated[
460
+ list[str] | None,
461
+ "The email addresses of the people to invite.",
462
+ ] = None,
463
+ ) -> Annotated[dict, "The response from inviting users to the conversation"]:
464
+ """Invite users to a Slack channel or MPIM (multi-person direct message).
465
+
466
+ This tool invites specified users to join a Slack conversation. It works with:
467
+ - Public channels
468
+ - Private channels
469
+ - MPIMs (multi-person direct messages / group DMs)
470
+
471
+ You can specify users by their user IDs, usernames, or email addresses.
472
+
473
+ Provide exactly one of channel_id or channel_name, and at least one of user_ids, usernames,
474
+ or emails.
475
+
476
+ The tool will resolve usernames and emails to user IDs before inviting them.
477
+ Up to 100 users may be invited at once.
478
+ """
479
+ # XOR validation: exactly one of channel_id or channel_name must be provided
480
+ if not ((channel_id is None) ^ (channel_name is None)):
481
+ raise ToolExecutionError("Provide exactly one of channel_id OR channel_name.")
482
+
483
+ if not any([user_ids, usernames, emails]):
484
+ raise ToolExecutionError(
485
+ "Provide at least one of user_ids, usernames, or emails to invite."
486
+ )
487
+
488
+ auth_token = context.get_auth_token_or_empty()
489
+
490
+ # Resolve channel name to ID if needed
491
+ resolved_channel_id = channel_id
492
+ if not resolved_channel_id:
493
+ channel = await get_channel_by_name(auth_token, cast(str, channel_name))
494
+ resolved_channel_id = channel["id"]
495
+
496
+ # Use the shared helper to invite users
497
+ invite_result = await invite_users_to_conversation(
498
+ context=context,
499
+ conversation_id=cast(str, resolved_channel_id),
500
+ user_ids=user_ids,
501
+ usernames=usernames,
502
+ emails=emails,
503
+ )
504
+
505
+ invited_channel = invite_result.get("channel", {})
506
+ channel_display = invited_channel.get("name", resolved_channel_id)
507
+ message = f"Successfully invited {invite_result['invited_count']} user(s) to {channel_display}"
508
+
509
+ return {
510
+ "success": True,
511
+ "channel_id": resolved_channel_id,
512
+ "channel_name": invited_channel.get("name"),
513
+ "invited_user_ids": invite_result["invited_user_ids"],
514
+ "invited_count": invite_result["invited_count"],
515
+ "message": message,
516
+ }
arcade_slack/utils.py CHANGED
@@ -7,7 +7,8 @@ from datetime import datetime, timezone
7
7
  from typing import Any, cast
8
8
 
9
9
  from arcade_tdk import ToolContext
10
- from arcade_tdk.errors import RetryableToolError
10
+ from arcade_tdk.errors import RetryableToolError, ToolExecutionError
11
+ from slack_sdk.web.async_client import AsyncWebClient
11
12
 
12
13
  from arcade_slack.constants import (
13
14
  MAX_CONCURRENT_REQUESTS,
@@ -569,7 +570,7 @@ async def populate_users_in_messages(auth_token: str, messages: list[dict]) -> l
569
570
  reaction["users"] = reaction_users
570
571
  # If any data is missing, just leave the message as it is
571
572
  except Exception as exc:
572
- logger.exception(exc) # noqa: TRY401
573
+ logger.exception(exc)
573
574
 
574
575
  return messages
575
576
 
@@ -613,3 +614,114 @@ def get_user_ids_from_messages(messages: list[dict]) -> list[str]:
613
614
  user_ids.extend(reaction.get("users", []))
614
615
 
615
616
  return user_ids
617
+
618
+
619
+ def format_invite_errors(errors: Any) -> str:
620
+ """Format error messages from Slack invite API partial errors."""
621
+ if isinstance(errors, list):
622
+ formatted_errors = []
623
+ for error in errors:
624
+ if isinstance(error, dict):
625
+ error_code = error.get("error") or error.get("code") or error.get("message")
626
+ user_id = error.get("user") or error.get("user_id")
627
+ if error_code and user_id:
628
+ formatted_errors.append(f"{error_code} ({user_id})")
629
+ elif error_code:
630
+ formatted_errors.append(str(error_code))
631
+ elif user_id:
632
+ formatted_errors.append(f"unknown_error ({user_id})")
633
+ else:
634
+ formatted_errors.append(str(error))
635
+ else:
636
+ formatted_errors.append(str(error))
637
+ if formatted_errors:
638
+ return ", ".join(formatted_errors)
639
+
640
+ return str(errors)
641
+
642
+
643
+ def raise_for_invite_errors(errors: Any, conversation_id: str | None) -> None:
644
+ """Raise ToolExecutionError for partial invite errors from a 200 response.
645
+
646
+ Slack's conversations.invite API can return ok: true with an errors array
647
+ for partial failures. This function handles those cases.
648
+ """
649
+ if not errors:
650
+ return
651
+
652
+ error_message = format_invite_errors(errors)
653
+ raise ToolExecutionError(f"Failed to invite some users to conversation: {error_message}")
654
+
655
+
656
+ async def invite_users_to_conversation(
657
+ context: ToolContext,
658
+ conversation_id: str,
659
+ user_ids: list[str] | None = None,
660
+ usernames: list[str] | None = None,
661
+ emails: list[str] | None = None,
662
+ ) -> dict[str, Any]:
663
+ """Internal helper to invite users to a conversation (channel or MPIM).
664
+
665
+ This function handles the core logic of resolving users and inviting them.
666
+ It does not wrap errors in try/except - SlackApiError exceptions will bubble
667
+ up to be handled by the SlackErrorAdapter.
668
+
669
+ Args:
670
+ context: The tool context
671
+ conversation_id: The ID of the conversation to invite users to
672
+ user_ids: Optional list of Slack user IDs
673
+ usernames: Optional list of Slack usernames
674
+ emails: Optional list of email addresses
675
+
676
+ Returns:
677
+ A dict with invited_user_ids and invited_count
678
+
679
+ Raises:
680
+ ToolExecutionError: If no valid users found or if partial errors occur
681
+ """
682
+ from arcade_slack.user_retrieval import get_users_by_id_username_or_email
683
+
684
+ # Resolve usernames and emails to user IDs
685
+ resolved_user_ids = user_ids.copy() if user_ids else []
686
+
687
+ if usernames or emails:
688
+ users = await get_users_by_id_username_or_email(
689
+ context=context,
690
+ usernames=usernames,
691
+ emails=emails,
692
+ )
693
+ resolved_user_ids.extend([user["id"] for user in users])
694
+
695
+ if not resolved_user_ids:
696
+ raise ToolExecutionError("No valid users found to invite to the conversation.")
697
+
698
+ # Remove duplicates
699
+ resolved_user_ids = list(set(resolved_user_ids))
700
+
701
+ # Limit to 100 users per Slack API requirements
702
+ if len(resolved_user_ids) > 100:
703
+ user_count = len(resolved_user_ids)
704
+ raise ToolExecutionError(
705
+ f"Cannot invite more than 100 users at once. You provided {user_count} users."
706
+ )
707
+
708
+ slack_client = AsyncWebClient(token=context.get_auth_token_or_empty())
709
+
710
+ # Slack expects a comma-separated string of user IDs
711
+ users_str = ",".join(resolved_user_ids)
712
+ response = await slack_client.conversations_invite(
713
+ channel=conversation_id,
714
+ users=users_str,
715
+ )
716
+
717
+ response_data = cast(dict[str, Any], response.data)
718
+
719
+ # Handle partial errors (200 response with errors array)
720
+ invite_errors = response_data.get("errors")
721
+ raise_for_invite_errors(invite_errors, conversation_id)
722
+
723
+ return {
724
+ "invited_user_ids": resolved_user_ids,
725
+ "invited_count": len(resolved_user_ids),
726
+ "channel": response_data.get("channel", {}),
727
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcade_slack
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Arcade.dev LLM tools for Slack
5
5
  Author-email: Arcade <dev@arcade.dev>
6
6
  License: Proprietary - Arcade Software License Agreement v1.0
@@ -1,19 +1,19 @@
1
1
  arcade_slack/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  arcade_slack/constants.py,sha256=ANAZOmNSqrbZHDqEvQfau7_91if9EeKV-G5D0VBB9vo,524
3
3
  arcade_slack/conversation_retrieval.py,sha256=VKc0Cc0k4UZ0OON_1l-HxWRzk_nSCzgoP5lgGyUxLFs,1902
4
- arcade_slack/critics.py,sha256=Sx0nOmvym4LFNX1gZ34ndHkAKAB--X1zRPePcbiVzDo,1209
4
+ arcade_slack/critics.py,sha256=ZP3jxptwA-8hubUBdhO4cxHrtf_DUAcYt0-TVCXF2AQ,1200
5
5
  arcade_slack/custom_types.py,sha256=-23JnfXjMQ-H8CyRL3CEv9opPMySox1KINH5rrPIOt0,928
6
6
  arcade_slack/exceptions.py,sha256=YQ4CTa1LbR-G7sjnQM8LB7LruxZDqPzvo-cptFYW7E8,385
7
7
  arcade_slack/message_retrieval.py,sha256=ZSspuVfM0yeMMwMFL_M7DeoPnLnUgX_N4_eNPGc4oKk,2488
8
8
  arcade_slack/models.py,sha256=R0dze0YMQW_v_GqarA2F3ZMR3ueN_ant9K855N9uqlE,11958
9
9
  arcade_slack/user_retrieval.py,sha256=27PzjG4C_5B17-6hh6BjX7tCNOdAKnJ-tZ8sif6IKnY,6452
10
- arcade_slack/utils.py,sha256=z6G7GCDXn_7_TshQRx-rnhgmQG8eCQSND5It9_uvhmE,21776
10
+ arcade_slack/utils.py,sha256=WQXxUMB75GpYZkqhhXBc3NRZOYFlmGFrSM_aT2EtvUo,25840
11
11
  arcade_slack/who_am_i_util.py,sha256=Nf6EdRiURva5jMhVx1lFOoKQ-2SuMwjXYbPCmtj6Zcw,3670
12
12
  arcade_slack/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- arcade_slack/tools/chat.py,sha256=VA1JF5qSSOVb0_eIo4Dk3WoiA-bVP2BaQdqs-2XGeiQ,15171
13
+ arcade_slack/tools/chat.py,sha256=g7H9aJs8VZod-rMHSaSiAwBUKPY96jvib8DFlmvTTPE,18681
14
14
  arcade_slack/tools/system_context.py,sha256=l2gZh_WhrIVL2LzttGAz5yiv0Cl6q_6AFyBBUBskz3E,961
15
15
  arcade_slack/tools/users.py,sha256=SZAevnpyI9Q-dSFGZCTOL8zwkLRR0iKh0HE3hk54eUY,3642
16
- arcade_slack-2.0.0.dist-info/METADATA,sha256=ISha1W3J5JhJ_u5ZVQyaVPOPKZUgZ-qPwxkRH8y4dD8,1016
17
- arcade_slack-2.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
- arcade_slack-2.0.0.dist-info/licenses/LICENSE,sha256=ixeE7aL9b2B-_ZYHTY1vQcJB4NufKeo-LWwKNObGDN0,1960
19
- arcade_slack-2.0.0.dist-info/RECORD,,
16
+ arcade_slack-2.1.0.dist-info/METADATA,sha256=lzqZsfeggbNG1lF-CJdX-T7BHXK0-jN2OZXSZFpMi54,1016
17
+ arcade_slack-2.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
18
+ arcade_slack-2.1.0.dist-info/licenses/LICENSE,sha256=ixeE7aL9b2B-_ZYHTY1vQcJB4NufKeo-LWwKNObGDN0,1960
19
+ arcade_slack-2.1.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any