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.
- {rolespace-0.2.2/rolespace.egg-info → rolespace-0.2.4}/PKG-INFO +1 -1
- {rolespace-0.2.2 → rolespace-0.2.4}/pyproject.toml +1 -1
- {rolespace-0.2.2 → rolespace-0.2.4/rolespace.egg-info}/PKG-INFO +1 -1
- {rolespace-0.2.2 → rolespace-0.2.4}/rolespace.py +336 -18
- {rolespace-0.2.2 → rolespace-0.2.4}/LICENSE +0 -0
- {rolespace-0.2.2 → rolespace-0.2.4}/MANIFEST.in +0 -0
- {rolespace-0.2.2 → rolespace-0.2.4}/README.md +0 -0
- {rolespace-0.2.2 → rolespace-0.2.4}/rolespace.egg-info/SOURCES.txt +0 -0
- {rolespace-0.2.2 → rolespace-0.2.4}/rolespace.egg-info/dependency_links.txt +0 -0
- {rolespace-0.2.2 → rolespace-0.2.4}/rolespace.egg-info/requires.txt +0 -0
- {rolespace-0.2.2 → rolespace-0.2.4}/rolespace.egg-info/top_level.txt +0 -0
- {rolespace-0.2.2 → rolespace-0.2.4}/setup.cfg +0 -0
|
@@ -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
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|