rolespace 0.2.3__tar.gz → 0.2.6__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rolespace
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: Official Rolespace bot SDK for Python
5
5
  Author: Rolespace
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rolespace"
3
- version = "0.2.3"
3
+ version = "0.2.6"
4
4
  description = "Official Rolespace bot SDK for Python"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rolespace
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: Official Rolespace bot SDK for Python
5
5
  Author: Rolespace
6
6
  License: MIT License
@@ -429,6 +429,177 @@ class RolespaceEmbed:
429
429
  return out
430
430
 
431
431
 
432
+ # ═════════════ Interactive components (panels of buttons + selects) ════════
433
+ #
434
+ # A bot attaches a Components panel to a message to render clickable buttons
435
+ # and select menus. When a user interacts, the bot picks the interaction up
436
+ # off ``rs.interactions()`` and replies with one of the ``rs.respond_*`` helpers.
437
+ #
438
+ # Usage::
439
+ #
440
+ # panel = (Components()
441
+ # .row(Button("yes", "Yes").success(),
442
+ # Button("no", "No").secondary())
443
+ # .row(Select("topic", "Pick a topic…")
444
+ # .option("Bugs", "bugs")
445
+ # .option("Ideas", "ideas")))
446
+ # rs.send_message(server_id, channel_id, "Pick one:", panel)
447
+ #
448
+ # Server-side limits: max 5 rows, max 5 buttons per row, max 1 select per row,
449
+ # max 25 options per select.
450
+
451
+
452
+ class Button:
453
+ """A clickable button. Default style is ``secondary`` — chain ``.primary()`` /
454
+ ``.success()`` / ``.danger()`` / ``.secondary()`` to change it, or use
455
+ ``Button.link(url, label)`` for a static link button."""
456
+
457
+ def __init__(self, custom_id: str, label: str):
458
+ self.custom_id = custom_id
459
+ self.label = label
460
+ self.style = "secondary"
461
+ self.url: Optional[str] = None
462
+
463
+ @classmethod
464
+ def link(cls, url: str, label: str = "Open") -> "Button":
465
+ b = cls("", label)
466
+ b.style = "link"
467
+ b.url = url
468
+ return b
469
+
470
+ def primary(self) -> "Button": self.style = "primary"; return self
471
+ def secondary(self) -> "Button": self.style = "secondary"; return self
472
+ def success(self) -> "Button": self.style = "success"; return self
473
+ def danger(self) -> "Button": self.style = "danger"; return self
474
+
475
+ def to_dict(self) -> dict:
476
+ if self.style == "link":
477
+ return {"type": "button", "style": "link", "label": self.label, "url": self.url}
478
+ return {"type": "button", "style": self.style, "label": self.label, "customId": self.custom_id}
479
+
480
+
481
+ class Select:
482
+ """A drop-down select menu. Add options with ``.option(label, value, description=None)``.
483
+ Default is single-select; use ``.range(min, max)`` to allow multiple selections."""
484
+
485
+ def __init__(self, custom_id: str, placeholder: Optional[str] = None):
486
+ self.custom_id = custom_id
487
+ self.placeholder = placeholder
488
+ self.min_values = 1
489
+ self.max_values = 1
490
+ self.options: List[dict] = []
491
+
492
+ def option(self, label: str, value: str, description: Optional[str] = None) -> "Select":
493
+ opt: dict = {"label": label, "value": value}
494
+ if description is not None:
495
+ opt["description"] = description
496
+ self.options.append(opt)
497
+ return self
498
+
499
+ def range(self, min_values: int, max_values: int) -> "Select":
500
+ self.min_values = min_values
501
+ self.max_values = max_values
502
+ return self
503
+
504
+ def to_dict(self) -> dict:
505
+ return {
506
+ "type": "select",
507
+ "customId": self.custom_id,
508
+ "placeholder": self.placeholder,
509
+ "minValues": self.min_values,
510
+ "maxValues": self.max_values,
511
+ "options": list(self.options),
512
+ }
513
+
514
+
515
+ class Components:
516
+ """The root component panel: an ordered list of rows. Each ``.row(*components)``
517
+ appends one row holding the given buttons/selects."""
518
+
519
+ def __init__(self):
520
+ self._rows: List[List[Any]] = []
521
+
522
+ def row(self, *components) -> "Components":
523
+ self._rows.append(list(components))
524
+ return self
525
+
526
+ def to_list(self) -> list:
527
+ return [
528
+ {"type": "row", "components": [c.to_dict() if hasattr(c, "to_dict") else c for c in row]}
529
+ for row in self._rows
530
+ ]
531
+
532
+
533
+ # ═════════════════════════════════ Modals ═══════════════════════════════════
534
+ #
535
+ # A small form a bot opens in response to an interaction. The user fills it
536
+ # and submits, which arrives as a ``modal_submit`` interaction with the values
537
+ # in ``ix.data["fields"]``.
538
+ #
539
+ # Usage::
540
+ #
541
+ # modal = (Modal("bug-form", "Report a bug")
542
+ # .short("summary", "Summary")
543
+ # .paragraph("details", "What happened?"))
544
+ # rs.respond_modal(ix.id, modal)
545
+
546
+
547
+ class Modal:
548
+ """A modal form — title, customId, and up to 5 inputs. Add inputs with
549
+ ``.short(...)`` / ``.paragraph(...)`` / ``.image(...)``. Chain ``.optional()``
550
+ after an input to make it not required."""
551
+
552
+ def __init__(self, custom_id: str, title: str):
553
+ self.custom_id = custom_id
554
+ self.title = title
555
+ self._inputs: List[dict] = []
556
+
557
+ def short(self, custom_id: str, label: str, placeholder: Optional[str] = None,
558
+ max_length: int = 1000, value: Optional[str] = None) -> "Modal":
559
+ self._inputs.append({
560
+ "customId": custom_id, "label": label, "style": "short",
561
+ "placeholder": placeholder, "required": True,
562
+ "maxLength": max_length, "value": value,
563
+ })
564
+ return self
565
+
566
+ def paragraph(self, custom_id: str, label: str, placeholder: Optional[str] = None,
567
+ max_length: int = 1000, value: Optional[str] = None) -> "Modal":
568
+ self._inputs.append({
569
+ "customId": custom_id, "label": label, "style": "paragraph",
570
+ "placeholder": placeholder, "required": True,
571
+ "maxLength": max_length, "value": value,
572
+ })
573
+ return self
574
+
575
+ def image(self, custom_id: str, label: str, current_url: Optional[str] = None,
576
+ multiple: bool = False) -> "Modal":
577
+ self._inputs.append({
578
+ "customId": custom_id, "label": label, "style": "image",
579
+ "required": True, "multiple": multiple, "currentUrl": current_url,
580
+ })
581
+ return self
582
+
583
+ def optional(self) -> "Modal":
584
+ """Mark the LAST added input as optional."""
585
+ if self._inputs:
586
+ self._inputs[-1]["required"] = False
587
+ return self
588
+
589
+ def required(self) -> "Modal":
590
+ """Mark the last added input as required (already the default)."""
591
+ if self._inputs:
592
+ self._inputs[-1]["required"] = True
593
+ return self
594
+
595
+ def to_dict(self) -> dict:
596
+ return {
597
+ "title": self.title,
598
+ "customId": self.custom_id,
599
+ "inputs": [dict(i) for i in self._inputs],
600
+ }
601
+
602
+
432
603
  # ════════════════════════ Main client ══════════════════════════════════
433
604
 
434
605
 
@@ -531,28 +702,109 @@ class Rolespace:
531
702
  """All roles in the server, highest position first."""
532
703
  return [RolespaceRole(r) for r in _unwrap_list(self.get(f"/servers/{id}/roles"))]
533
704
 
705
+ # ---- Role / permission convenience helpers ──────────────────────────
706
+
707
+ def member_roles(self, server_id: int, user_id: int) -> List[RolespaceRole]:
708
+ """Resolve a member's role ids into the full RolespaceRole objects
709
+ (name, color, permissions)."""
710
+ member = self.server_member(server_id, user_id)
711
+ roles = self.server_roles(server_id)
712
+ ids = set(member.role_ids)
713
+ return [r for r in roles if r.id in ids]
714
+
715
+ def has_role(self, server_id: int, user_id: int, role: Union[int, str]) -> bool:
716
+ """True if the member has the given role. Pass an int id, or a name string
717
+ (case-insensitive). Returns False if no role with that name exists.
718
+
719
+ Example::
720
+
721
+ if rs.has_role(server_id, msg.author.id, "Moderator"):
722
+ rs.send_message(server_id, channel_id, "yes, boss")
723
+ """
724
+ member = self.server_member(server_id, user_id)
725
+ if isinstance(role, int):
726
+ return role in member.role_ids
727
+ if not role or not isinstance(role, str):
728
+ return False
729
+ wanted = role.strip().lower()
730
+ roles = self.server_roles(server_id)
731
+ match = next((r for r in roles if r.name.lower() == wanted), None)
732
+ return match is not None and match.id in member.role_ids
733
+
734
+ def has_permission(self, server_id: int, user_id: int, permission: str) -> bool:
735
+ """True if the member is allowed to perform the named action. The server owner
736
+ and anyone with the ``administrator`` flag always pass.
737
+
738
+ Accepted names (case-insensitive): ``administrator``, ``manageServer``,
739
+ ``manageRoles``, ``manageChannels``, ``manageMessages``, ``kickMembers``,
740
+ ``banMembers``, ``sendMessages``, ``viewChannels``, ``addReactions``.
741
+
742
+ Example::
743
+
744
+ if rs.has_permission(server_id, msg.author.id, "banMembers"):
745
+ rs.ban_member(server_id, target_id, "spam")
746
+ """
747
+ member = self.server_member(server_id, user_id)
748
+ if member.is_owner:
749
+ return True
750
+ roles = self.server_roles(server_id)
751
+ ids = set(member.role_ids)
752
+ for r in (r for r in roles if r.id in ids):
753
+ perms = r.permissions or {}
754
+ if perms.get("administrator"):
755
+ return True
756
+ if _permission_flag(perms, permission):
757
+ return True
758
+ return False
759
+
534
760
  def send_message(
535
761
  self,
536
762
  server_id: int,
537
763
  channel_id: int,
538
- content: Union[str, RolespaceEmbed, dict, None] = None,
539
- *embeds: RolespaceEmbed,
764
+ content: Union[str, RolespaceEmbed, "Components", dict, None] = None,
765
+ *extras,
540
766
  ) -> RolespaceMessage:
541
767
  """Send a message.
542
768
 
769
+ Extras can be ``RolespaceEmbed`` instances (up to 10) and/or a single
770
+ ``Components`` panel — pass them in any order alongside the text.
771
+
543
772
  Examples::
544
773
 
545
774
  rs.send_message(server_id, channel_id, "Hello!")
546
775
  rs.send_message(server_id, channel_id, "Heads up:", embed)
547
776
  rs.send_message(server_id, channel_id, "Two updates:", e1, e2)
548
- rs.send_message(server_id, channel_id, embed) # embed only
777
+ rs.send_message(server_id, channel_id, "Pick one:", panel) # Components
778
+ rs.send_message(server_id, channel_id, "Heads up:", embed, panel) # both
779
+ rs.send_message(server_id, channel_id, embed) # embed only
780
+ rs.send_message(server_id, channel_id, panel) # panel only
549
781
  rs.send_message(server_id, channel_id, {"content": "raw payload"})
550
782
  """
551
- payload = _build_message_payload(content, embeds)
783
+ payload = _build_message_payload(content, extras)
552
784
  return RolespaceMessage(
553
785
  self.post(f"/servers/{server_id}/channels/{channel_id}/messages", payload)
554
786
  )
555
787
 
788
+ def send_ephemeral(self, server_id: int, channel_id: int, recipient_user_id: int,
789
+ content: Union[str, RolespaceEmbed, "Components", None] = "",
790
+ *extras) -> dict:
791
+ """Send an ephemeral message to a single user in a channel — they see it,
792
+ nobody else does, and it's gone on reload. Accepts the same extras as
793
+ ``send_message`` (embeds and/or one Components panel).
794
+
795
+ Same scopes/permissions as a normal send, plus the recipient must be able
796
+ to view the channel.
797
+
798
+ Example::
799
+
800
+ # Reply ephemerally to whoever just typed "!secret":
801
+ rs.send_ephemeral(server_id, channel_id, msg.author.id,
802
+ "Here's your private info 👀")
803
+ """
804
+ payload = _build_message_payload(content, extras)
805
+ payload["recipientAccountId"] = recipient_user_id
806
+ return self.post(f"/servers/{server_id}/channels/{channel_id}/ephemeral", payload)
807
+
556
808
  def list_messages(self, server_id: int, channel_id: int, limit: int = 50,
557
809
  before: Optional[str] = None) -> List[RolespaceMessage]:
558
810
  """Recent messages, oldest → newest. Pass ``before`` (a message id) to page backwards."""
@@ -821,9 +1073,43 @@ class Rolespace:
821
1073
  time.sleep(idle_delay)
822
1074
 
823
1075
  def respond(self, interaction_id: int, reply: dict) -> Any:
824
- """Respond to an interaction. ``reply`` is ``{"type": "message"|"update"|"modal"|"ack", ...}``."""
1076
+ """Respond to an interaction. ``reply`` is ``{"type": "message"|"update"|"modal"|"ack", ...}``.
1077
+ Most callers want one of the typed ``respond_*`` helpers below instead."""
825
1078
  return self.post(f"/interactions/{interaction_id}/callback", reply)
826
1079
 
1080
+ # ---- Typed interaction response helpers ─────────────────────────────────
1081
+
1082
+ def respond_ack(self, interaction_id: int) -> Any:
1083
+ """Acknowledge an interaction with no visible response."""
1084
+ return self.post(f"/interactions/{interaction_id}/callback", {"type": "ack"})
1085
+
1086
+ def respond_message(self, interaction_id: int, content: Union[str, RolespaceEmbed, "Components", None] = "",
1087
+ *extras, ephemeral: bool = False) -> Any:
1088
+ """Post a new message in response to an interaction. Pass extras (embeds and/or one
1089
+ Components panel) the same way as ``send_message``. ``ephemeral=True`` makes the reply
1090
+ visible only to the user who interacted."""
1091
+ payload = _build_message_payload(content, extras)
1092
+ payload["type"] = "message"
1093
+ payload["ephemeral"] = ephemeral
1094
+ return self.post(f"/interactions/{interaction_id}/callback", payload)
1095
+
1096
+ def respond_update(self, interaction_id: int, content: Optional[str] = None,
1097
+ components: Optional["Components"] = None) -> Any:
1098
+ """Edit the panel that was clicked. Pass ``None`` for either to leave it
1099
+ unchanged; pass an empty ``Components()`` to remove the panel entirely."""
1100
+ payload: dict = {"type": "update"}
1101
+ if content is not None:
1102
+ payload["content"] = content
1103
+ if components is not None:
1104
+ payload["components"] = components.to_list()
1105
+ return self.post(f"/interactions/{interaction_id}/callback", payload)
1106
+
1107
+ def respond_modal(self, interaction_id: int, modal: "Modal") -> Any:
1108
+ """Open a modal form in response to the interaction. Submission arrives as a
1109
+ ``modal_submit`` interaction with the values in ``ix.data["fields"]``."""
1110
+ return self.post(f"/interactions/{interaction_id}/callback",
1111
+ {"type": "modal", "modal": modal.to_dict()})
1112
+
827
1113
  # ---- Listening for new messages in a channel ─────────────────────────
828
1114
  def watch_messages(
829
1115
  self,
@@ -933,18 +1219,70 @@ def _unwrap_list(resp: Any) -> list:
933
1219
  return []
934
1220
 
935
1221
 
936
- def _build_message_payload(content: Any, embeds: tuple) -> dict:
937
- """Normalize send_message/send_dm arguments into the server's expected payload shape."""
938
- embed_dicts = [e.to_dict() if isinstance(e, RolespaceEmbed) else e for e in embeds]
939
-
940
- if content is None:
941
- return {"content": "", **({"embeds": embed_dicts} if embed_dicts else {})}
942
- if isinstance(content, str):
943
- return {"content": content, **({"embeds": embed_dicts} if embed_dicts else {})}
1222
+ # Accepted permission names (case-insensitive). Mirrors the C# enum surface.
1223
+ _PERMISSION_KEYS = {
1224
+ "administrator": "administrator",
1225
+ "manageserver": "manageServer",
1226
+ "manageroles": "manageRoles",
1227
+ "managechannels": "manageChannels",
1228
+ "managemessages": "manageMessages",
1229
+ "kickmembers": "kickMembers",
1230
+ "banmembers": "banMembers",
1231
+ "sendmessages": "sendMessages",
1232
+ "viewchannels": "viewChannels",
1233
+ "addreactions": "addReactions",
1234
+ }
1235
+
1236
+
1237
+ def _permission_flag(perms: dict, name: Optional[str]) -> bool:
1238
+ """True if `perms[name]` is truthy, matching keys case-insensitively.
1239
+ Returns False for unknown names."""
1240
+ if not name or not isinstance(name, str):
1241
+ return False
1242
+ key = _PERMISSION_KEYS.get(name.strip().lower())
1243
+ return bool(perms.get(key)) if key else False
1244
+
1245
+
1246
+ def _build_message_payload(content: Any, extras: tuple) -> dict:
1247
+ """Normalize send_message/send_dm arguments into the server's expected payload shape.
1248
+
1249
+ ``extras`` may contain any mix of ``RolespaceEmbed`` instances and one
1250
+ ``Components`` panel — the result will carry both as appropriate keys.
1251
+ """
1252
+ # Promote a leading Embed / Components to the extras list so the rest of the
1253
+ # logic stays uniform.
944
1254
  if isinstance(content, RolespaceEmbed):
945
- # Treat the leading embed as just another embed; no text.
946
- return {"content": "", "embeds": [content.to_dict(), *embed_dicts]}
947
- if isinstance(content, dict):
948
- # Raw payload caller knows what they're doing. Don't merge embeds into it.
1255
+ extras = (content,) + tuple(extras)
1256
+ content = ""
1257
+ elif isinstance(content, Components):
1258
+ extras = (content,) + tuple(extras)
1259
+ content = ""
1260
+ elif isinstance(content, dict):
1261
+ # Raw payload — caller knows what they're doing. Don't merge extras into it.
949
1262
  return dict(content)
950
- raise TypeError(f"send_message: unsupported content type {type(content).__name__}")
1263
+ elif content is None:
1264
+ content = ""
1265
+ elif not isinstance(content, str):
1266
+ raise TypeError(f"send_message: unsupported content type {type(content).__name__}")
1267
+
1268
+ embed_dicts: List[dict] = []
1269
+ components_payload = None
1270
+ for x in extras:
1271
+ if isinstance(x, RolespaceEmbed):
1272
+ embed_dicts.append(x.to_dict())
1273
+ elif isinstance(x, Components):
1274
+ if components_payload is not None:
1275
+ raise TypeError("send_message: only one Components panel is allowed per message.")
1276
+ components_payload = x.to_list()
1277
+ elif isinstance(x, dict):
1278
+ # Allow a dict in extras for raw {embed_dict} cases.
1279
+ embed_dicts.append(x)
1280
+ else:
1281
+ raise TypeError(f"send_message: unsupported extra type {type(x).__name__}")
1282
+
1283
+ out: dict = {"content": content}
1284
+ if embed_dicts:
1285
+ out["embeds"] = embed_dicts
1286
+ if components_payload is not None:
1287
+ out["components"] = components_payload
1288
+ return out
File without changes
File without changes
File without changes
File without changes