rolespace 0.2.2__tar.gz → 0.2.4__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.2
3
+ Version: 0.2.4
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.2"
3
+ version = "0.2.4"
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.2
3
+ Version: 0.2.4
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,24 +702,85 @@ 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
  )
@@ -821,9 +1053,43 @@ class Rolespace:
821
1053
  time.sleep(idle_delay)
822
1054
 
823
1055
  def respond(self, interaction_id: int, reply: dict) -> Any:
824
- """Respond to an interaction. ``reply`` is ``{"type": "message"|"update"|"modal"|"ack", ...}``."""
1056
+ """Respond to an interaction. ``reply`` is ``{"type": "message"|"update"|"modal"|"ack", ...}``.
1057
+ Most callers want one of the typed ``respond_*`` helpers below instead."""
825
1058
  return self.post(f"/interactions/{interaction_id}/callback", reply)
826
1059
 
1060
+ # ---- Typed interaction response helpers ─────────────────────────────────
1061
+
1062
+ def respond_ack(self, interaction_id: int) -> Any:
1063
+ """Acknowledge an interaction with no visible response."""
1064
+ return self.post(f"/interactions/{interaction_id}/callback", {"type": "ack"})
1065
+
1066
+ def respond_message(self, interaction_id: int, content: Union[str, RolespaceEmbed, "Components", None] = "",
1067
+ *extras, ephemeral: bool = False) -> Any:
1068
+ """Post a new message in response to an interaction. Pass extras (embeds and/or one
1069
+ Components panel) the same way as ``send_message``. ``ephemeral=True`` makes the reply
1070
+ visible only to the user who interacted."""
1071
+ payload = _build_message_payload(content, extras)
1072
+ payload["type"] = "message"
1073
+ payload["ephemeral"] = ephemeral
1074
+ return self.post(f"/interactions/{interaction_id}/callback", payload)
1075
+
1076
+ def respond_update(self, interaction_id: int, content: Optional[str] = None,
1077
+ components: Optional["Components"] = None) -> Any:
1078
+ """Edit the panel that was clicked. Pass ``None`` for either to leave it
1079
+ unchanged; pass an empty ``Components()`` to remove the panel entirely."""
1080
+ payload: dict = {"type": "update"}
1081
+ if content is not None:
1082
+ payload["content"] = content
1083
+ if components is not None:
1084
+ payload["components"] = components.to_list()
1085
+ return self.post(f"/interactions/{interaction_id}/callback", payload)
1086
+
1087
+ def respond_modal(self, interaction_id: int, modal: "Modal") -> Any:
1088
+ """Open a modal form in response to the interaction. Submission arrives as a
1089
+ ``modal_submit`` interaction with the values in ``ix.data["fields"]``."""
1090
+ return self.post(f"/interactions/{interaction_id}/callback",
1091
+ {"type": "modal", "modal": modal.to_dict()})
1092
+
827
1093
  # ---- Listening for new messages in a channel ─────────────────────────
828
1094
  def watch_messages(
829
1095
  self,
@@ -933,18 +1199,70 @@ def _unwrap_list(resp: Any) -> list:
933
1199
  return []
934
1200
 
935
1201
 
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 {})}
1202
+ # Accepted permission names (case-insensitive). Mirrors the C# enum surface.
1203
+ _PERMISSION_KEYS = {
1204
+ "administrator": "administrator",
1205
+ "manageserver": "manageServer",
1206
+ "manageroles": "manageRoles",
1207
+ "managechannels": "manageChannels",
1208
+ "managemessages": "manageMessages",
1209
+ "kickmembers": "kickMembers",
1210
+ "banmembers": "banMembers",
1211
+ "sendmessages": "sendMessages",
1212
+ "viewchannels": "viewChannels",
1213
+ "addreactions": "addReactions",
1214
+ }
1215
+
1216
+
1217
+ def _permission_flag(perms: dict, name: Optional[str]) -> bool:
1218
+ """True if `perms[name]` is truthy, matching keys case-insensitively.
1219
+ Returns False for unknown names."""
1220
+ if not name or not isinstance(name, str):
1221
+ return False
1222
+ key = _PERMISSION_KEYS.get(name.strip().lower())
1223
+ return bool(perms.get(key)) if key else False
1224
+
1225
+
1226
+ def _build_message_payload(content: Any, extras: tuple) -> dict:
1227
+ """Normalize send_message/send_dm arguments into the server's expected payload shape.
1228
+
1229
+ ``extras`` may contain any mix of ``RolespaceEmbed`` instances and one
1230
+ ``Components`` panel — the result will carry both as appropriate keys.
1231
+ """
1232
+ # Promote a leading Embed / Components to the extras list so the rest of the
1233
+ # logic stays uniform.
944
1234
  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.
1235
+ extras = (content,) + tuple(extras)
1236
+ content = ""
1237
+ elif isinstance(content, Components):
1238
+ extras = (content,) + tuple(extras)
1239
+ content = ""
1240
+ elif isinstance(content, dict):
1241
+ # Raw payload — caller knows what they're doing. Don't merge extras into it.
949
1242
  return dict(content)
950
- raise TypeError(f"send_message: unsupported content type {type(content).__name__}")
1243
+ elif content is None:
1244
+ content = ""
1245
+ elif not isinstance(content, str):
1246
+ raise TypeError(f"send_message: unsupported content type {type(content).__name__}")
1247
+
1248
+ embed_dicts: List[dict] = []
1249
+ components_payload = None
1250
+ for x in extras:
1251
+ if isinstance(x, RolespaceEmbed):
1252
+ embed_dicts.append(x.to_dict())
1253
+ elif isinstance(x, Components):
1254
+ if components_payload is not None:
1255
+ raise TypeError("send_message: only one Components panel is allowed per message.")
1256
+ components_payload = x.to_list()
1257
+ elif isinstance(x, dict):
1258
+ # Allow a dict in extras for raw {embed_dict} cases.
1259
+ embed_dicts.append(x)
1260
+ else:
1261
+ raise TypeError(f"send_message: unsupported extra type {type(x).__name__}")
1262
+
1263
+ out: dict = {"content": content}
1264
+ if embed_dicts:
1265
+ out["embeds"] = embed_dicts
1266
+ if components_payload is not None:
1267
+ out["components"] = components_payload
1268
+ return out
File without changes
File without changes
File without changes
File without changes