rolespace 0.2.1__tar.gz → 0.2.2__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.1/rolespace.egg-info → rolespace-0.2.2}/PKG-INFO +1 -1
- {rolespace-0.2.1 → rolespace-0.2.2}/pyproject.toml +1 -1
- {rolespace-0.2.1 → rolespace-0.2.2/rolespace.egg-info}/PKG-INFO +1 -1
- {rolespace-0.2.1 → rolespace-0.2.2}/rolespace.py +315 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/LICENSE +0 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/MANIFEST.in +0 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/README.md +0 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/rolespace.egg-info/SOURCES.txt +0 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/rolespace.egg-info/dependency_links.txt +0 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/rolespace.egg-info/requires.txt +0 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/rolespace.egg-info/top_level.txt +0 -0
- {rolespace-0.2.1 → rolespace-0.2.2}/setup.cfg +0 -0
|
@@ -43,11 +43,17 @@ import hashlib
|
|
|
43
43
|
import hmac
|
|
44
44
|
import os
|
|
45
45
|
import time
|
|
46
|
+
from datetime import datetime, timezone
|
|
46
47
|
from typing import Any, Iterator, List, Optional, Union
|
|
47
48
|
|
|
48
49
|
import requests
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
def _utcnow_iso() -> str:
|
|
53
|
+
"""ISO-8601 UTC timestamp used as the initial cursor for watch_messages()."""
|
|
54
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
55
|
+
|
|
56
|
+
|
|
51
57
|
DEFAULT_BASE = "https://rolespace.net"
|
|
52
58
|
SDK_VERSION = "0.2.0"
|
|
53
59
|
|
|
@@ -547,10 +553,239 @@ class Rolespace:
|
|
|
547
553
|
self.post(f"/servers/{server_id}/channels/{channel_id}/messages", payload)
|
|
548
554
|
)
|
|
549
555
|
|
|
556
|
+
def list_messages(self, server_id: int, channel_id: int, limit: int = 50,
|
|
557
|
+
before: Optional[str] = None) -> List[RolespaceMessage]:
|
|
558
|
+
"""Recent messages, oldest → newest. Pass ``before`` (a message id) to page backwards."""
|
|
559
|
+
url = f"/servers/{server_id}/channels/{channel_id}/messages?limit={limit}"
|
|
560
|
+
if before:
|
|
561
|
+
from urllib.parse import quote
|
|
562
|
+
url += f"&before={quote(before)}"
|
|
563
|
+
return [RolespaceMessage(m) for m in _unwrap_list(self.get(url))]
|
|
564
|
+
|
|
550
565
|
def get_message(self, server_id: int, channel_id: int, message_id: str) -> RolespaceMessage:
|
|
551
566
|
"""Fetch a single message by id."""
|
|
552
567
|
return RolespaceMessage(self.get(f"/servers/{server_id}/channels/{channel_id}/messages/{message_id}"))
|
|
553
568
|
|
|
569
|
+
def edit_message(self, server_id: int, channel_id: int, message_id: str, new_content: str) -> RolespaceMessage:
|
|
570
|
+
"""Edit the bot's own message. Returns the updated message."""
|
|
571
|
+
return RolespaceMessage(self.patch(
|
|
572
|
+
f"/servers/{server_id}/channels/{channel_id}/messages/{message_id}",
|
|
573
|
+
json={"content": new_content}))
|
|
574
|
+
|
|
575
|
+
def delete_message(self, server_id: int, channel_id: int, message_id: str) -> Any:
|
|
576
|
+
"""Delete a message (own message OR any with ManageMessages)."""
|
|
577
|
+
return self.delete(f"/servers/{server_id}/channels/{channel_id}/messages/{message_id}")
|
|
578
|
+
|
|
579
|
+
def pin_message(self, server_id: int, channel_id: int, message_id: str) -> Any:
|
|
580
|
+
"""Pin a message. Requires ManageMessages."""
|
|
581
|
+
return self.put(f"/servers/{server_id}/channels/{channel_id}/messages/{message_id}/pin")
|
|
582
|
+
|
|
583
|
+
def unpin_message(self, server_id: int, channel_id: int, message_id: str) -> Any:
|
|
584
|
+
"""Unpin a message. Requires ManageMessages."""
|
|
585
|
+
return self.delete(f"/servers/{server_id}/channels/{channel_id}/messages/{message_id}/pin")
|
|
586
|
+
|
|
587
|
+
def add_reaction(self, server_id: int, channel_id: int, message_id: str, emoji: str) -> Any:
|
|
588
|
+
"""React to a message. Emoji is URL-encoded automatically."""
|
|
589
|
+
from urllib.parse import quote
|
|
590
|
+
return self.put(f"/servers/{server_id}/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji)}")
|
|
591
|
+
|
|
592
|
+
def remove_reaction(self, server_id: int, channel_id: int, message_id: str, emoji: str) -> Any:
|
|
593
|
+
"""Remove the bot's own reaction."""
|
|
594
|
+
from urllib.parse import quote
|
|
595
|
+
return self.delete(f"/servers/{server_id}/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji)}")
|
|
596
|
+
|
|
597
|
+
# ---- Channel + category management ─────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
def create_channel(self, server_id: int, name: str, type: str = "text",
|
|
600
|
+
category_id: Optional[int] = None, topic: Optional[str] = None,
|
|
601
|
+
is_private: bool = False) -> RolespaceChannel:
|
|
602
|
+
"""Create a channel. ``type`` is one of: text, voice, announcement, forum, rules."""
|
|
603
|
+
return RolespaceChannel(self.post(f"/servers/{server_id}/channels", json={
|
|
604
|
+
"name": name, "type": type, "categoryId": category_id,
|
|
605
|
+
"topic": topic, "isPrivate": is_private,
|
|
606
|
+
}))
|
|
607
|
+
|
|
608
|
+
def update_channel(self, server_id: int, channel_id: int,
|
|
609
|
+
name: Optional[str] = None, topic: Optional[str] = None) -> Any:
|
|
610
|
+
"""Rename and/or change a channel's topic. Pass ``None`` for fields to leave alone."""
|
|
611
|
+
return self.patch(f"/servers/{server_id}/channels/{channel_id}",
|
|
612
|
+
json={"name": name, "topic": topic})
|
|
613
|
+
|
|
614
|
+
def delete_channel(self, server_id: int, channel_id: int) -> Any:
|
|
615
|
+
"""Delete a channel and its contents. Requires ManageChannels."""
|
|
616
|
+
return self.delete(f"/servers/{server_id}/channels/{channel_id}")
|
|
617
|
+
|
|
618
|
+
def create_category(self, server_id: int, name: str) -> Any:
|
|
619
|
+
"""Create a category. Requires ManageChannels."""
|
|
620
|
+
return self.post(f"/servers/{server_id}/categories", json={"name": name})
|
|
621
|
+
|
|
622
|
+
def update_category(self, server_id: int, category_id: int, name: str) -> Any:
|
|
623
|
+
"""Rename a category."""
|
|
624
|
+
return self.patch(f"/servers/{server_id}/categories/{category_id}", json={"name": name})
|
|
625
|
+
|
|
626
|
+
def delete_category(self, server_id: int, category_id: int, delete_channels: bool = False) -> Any:
|
|
627
|
+
"""Delete a category. With ``delete_channels=True``, also deletes every channel inside."""
|
|
628
|
+
suffix = "true" if delete_channels else "false"
|
|
629
|
+
return self.delete(f"/servers/{server_id}/categories/{category_id}?deleteChannels={suffix}")
|
|
630
|
+
|
|
631
|
+
# ---- Member moderation ────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
def kick_member(self, server_id: int, user_id: int, reason: Optional[str] = None) -> Any:
|
|
634
|
+
"""Kick a member. Requires KickMembers."""
|
|
635
|
+
url = f"/servers/{server_id}/members/{user_id}"
|
|
636
|
+
if reason:
|
|
637
|
+
from urllib.parse import quote
|
|
638
|
+
url += f"?reason={quote(reason)}"
|
|
639
|
+
return self.delete(url)
|
|
640
|
+
|
|
641
|
+
def ban_member(self, server_id: int, user_id: int, reason: Optional[str] = None) -> Any:
|
|
642
|
+
"""Ban a member. Requires BanMembers."""
|
|
643
|
+
return self.post(f"/servers/{server_id}/members/{user_id}/ban", json={"reason": reason})
|
|
644
|
+
|
|
645
|
+
def unban_member(self, server_id: int, user_id: int) -> Any:
|
|
646
|
+
"""Lift a ban."""
|
|
647
|
+
return self.delete(f"/servers/{server_id}/members/{user_id}/ban")
|
|
648
|
+
|
|
649
|
+
def set_nickname(self, server_id: int, user_id: int, nickname: Optional[str]) -> Any:
|
|
650
|
+
"""Set or clear a member's server nickname. ``None``/empty clears."""
|
|
651
|
+
return self.patch(f"/servers/{server_id}/members/{user_id}/nickname",
|
|
652
|
+
json={"nickname": nickname})
|
|
653
|
+
|
|
654
|
+
def assign_role(self, server_id: int, user_id: int, role_id: int) -> Any:
|
|
655
|
+
"""Assign a role to a member. Requires ManageRoles."""
|
|
656
|
+
return self.put(f"/servers/{server_id}/members/{user_id}/roles/{role_id}")
|
|
657
|
+
|
|
658
|
+
def remove_role(self, server_id: int, user_id: int, role_id: int) -> Any:
|
|
659
|
+
"""Remove a role from a member."""
|
|
660
|
+
return self.delete(f"/servers/{server_id}/members/{user_id}/roles/{role_id}")
|
|
661
|
+
|
|
662
|
+
# ---- Forum threads ────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
def list_threads(self, server_id: int, channel_id: int) -> list:
|
|
665
|
+
"""List threads in a forum channel."""
|
|
666
|
+
return _unwrap_list(self.get(f"/servers/{server_id}/channels/{channel_id}/threads"))
|
|
667
|
+
|
|
668
|
+
def get_thread(self, server_id: int, channel_id: int, thread_id: int) -> dict:
|
|
669
|
+
"""Fetch a thread + its posts."""
|
|
670
|
+
return self.get(f"/servers/{server_id}/channels/{channel_id}/threads/{thread_id}")
|
|
671
|
+
|
|
672
|
+
def create_thread(self, server_id: int, channel_id: int, title: str, content: str,
|
|
673
|
+
tags: Optional[List[str]] = None) -> dict:
|
|
674
|
+
"""Start a new thread in a forum channel."""
|
|
675
|
+
return self.post(f"/servers/{server_id}/channels/{channel_id}/threads",
|
|
676
|
+
json={"title": title, "content": content, "tags": list(tags) if tags else None})
|
|
677
|
+
|
|
678
|
+
def reply_to_thread(self, server_id: int, channel_id: int, thread_id: int, content: str,
|
|
679
|
+
reply_to_post_id: Optional[int] = None) -> dict:
|
|
680
|
+
"""Reply in a thread."""
|
|
681
|
+
return self.post(
|
|
682
|
+
f"/servers/{server_id}/channels/{channel_id}/threads/{thread_id}/posts",
|
|
683
|
+
json={"content": content, "replyToPostId": reply_to_post_id})
|
|
684
|
+
|
|
685
|
+
# ---- Streams (read) ───────────────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
def my_stream(self) -> dict:
|
|
688
|
+
"""The bot's own channel status: live, viewers, title, game, HLS URL."""
|
|
689
|
+
return self.get("/streams/me")
|
|
690
|
+
|
|
691
|
+
def live_streams(self) -> list:
|
|
692
|
+
"""The current live directory (public)."""
|
|
693
|
+
return _unwrap_list(self.get("/streams/live"))
|
|
694
|
+
|
|
695
|
+
def stream_for(self, account_id: int) -> dict:
|
|
696
|
+
"""Public live status for any account."""
|
|
697
|
+
return self.get(f"/streams/{account_id}")
|
|
698
|
+
|
|
699
|
+
def stream_moderators(self) -> list:
|
|
700
|
+
"""The bot's stream chat moderators. Owner-only."""
|
|
701
|
+
return _unwrap_list(self.get("/streams/me/moderators"))
|
|
702
|
+
|
|
703
|
+
def stream_bans(self) -> list:
|
|
704
|
+
"""The bot's stream chat bans + timeouts. Owner-only."""
|
|
705
|
+
return _unwrap_list(self.get("/streams/me/bans"))
|
|
706
|
+
|
|
707
|
+
# ---- Stream moderation ────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
def add_stream_moderator(self, account_id: int) -> Any:
|
|
710
|
+
"""Promote a chat moderator on the bot's channel."""
|
|
711
|
+
return self.post("/streams/me/moderators", json={"accountId": account_id})
|
|
712
|
+
|
|
713
|
+
def remove_stream_moderator(self, account_id: int) -> Any:
|
|
714
|
+
"""Demote a chat moderator."""
|
|
715
|
+
return self.delete(f"/streams/me/moderators/{account_id}")
|
|
716
|
+
|
|
717
|
+
def ban_stream_chatter(self, account_id: int, reason: Optional[str] = None) -> Any:
|
|
718
|
+
"""Permanently ban a chatter."""
|
|
719
|
+
return self.post("/streams/me/bans", json={"accountId": account_id, "reason": reason})
|
|
720
|
+
|
|
721
|
+
def timeout_stream_chatter(self, account_id: int, duration_seconds: int,
|
|
722
|
+
reason: Optional[str] = None) -> Any:
|
|
723
|
+
"""Temporarily ban a chatter for ``duration_seconds``."""
|
|
724
|
+
return self.post("/streams/me/timeouts",
|
|
725
|
+
json={"accountId": account_id, "durationSeconds": duration_seconds, "reason": reason})
|
|
726
|
+
|
|
727
|
+
def lift_stream_ban(self, account_id: int) -> Any:
|
|
728
|
+
"""Lift a ban or timeout."""
|
|
729
|
+
return self.delete(f"/streams/me/bans/{account_id}")
|
|
730
|
+
|
|
731
|
+
def update_stream_chat_settings(self, allow_urls: bool, subscribers_only: bool) -> Any:
|
|
732
|
+
"""Set chat mode (subscribers only, URL allow)."""
|
|
733
|
+
return self.patch("/streams/me/chat-settings",
|
|
734
|
+
json={"allowUrls": allow_urls, "subscribersOnly": subscribers_only})
|
|
735
|
+
|
|
736
|
+
# ---- Webhooks (outgoing — event delivery) ─────────────────────────────
|
|
737
|
+
|
|
738
|
+
def list_outgoing_webhooks(self) -> list:
|
|
739
|
+
"""List the bot's outgoing (event-delivery) webhooks."""
|
|
740
|
+
return _unwrap_list(self.get("/webhooks/outgoing"))
|
|
741
|
+
|
|
742
|
+
def create_outgoing_webhook(self, target_type: str, target_id: int, url: str,
|
|
743
|
+
events: List[str]) -> dict:
|
|
744
|
+
"""Register an outgoing webhook. The response includes a one-time ``secret`` — store it.
|
|
745
|
+
|
|
746
|
+
``target_type`` is ``"server"`` or ``"stream"``. ``target_id`` is the server id (for
|
|
747
|
+
"server") or the bot's own account id (for "stream"). ``events`` is a list of names
|
|
748
|
+
like ``["message.created"]`` or ``["stream.online", "stream.offline"]``.
|
|
749
|
+
"""
|
|
750
|
+
return self.post("/webhooks/outgoing", json={
|
|
751
|
+
"targetType": target_type, "targetId": target_id, "url": url, "events": list(events),
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
def delete_outgoing_webhook(self, webhook_id: int) -> Any:
|
|
755
|
+
"""Delete an outgoing webhook."""
|
|
756
|
+
return self.delete(f"/webhooks/outgoing/{webhook_id}")
|
|
757
|
+
|
|
758
|
+
# ---- Webhooks (incoming — post-to-channel URL) ────────────────────────
|
|
759
|
+
|
|
760
|
+
def list_incoming_webhooks(self) -> list:
|
|
761
|
+
"""List the bot's incoming webhooks."""
|
|
762
|
+
return _unwrap_list(self.get("/webhooks/incoming"))
|
|
763
|
+
|
|
764
|
+
def create_incoming_webhook(self, server_id: int, channel_id: int,
|
|
765
|
+
name: Optional[str] = None) -> dict:
|
|
766
|
+
"""Create an incoming webhook bound to a channel. Response includes the one-time POST URL with its token."""
|
|
767
|
+
return self.post("/webhooks/incoming", json={
|
|
768
|
+
"serverId": server_id, "channelId": channel_id, "name": name,
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
def delete_incoming_webhook(self, webhook_id: int) -> Any:
|
|
772
|
+
"""Delete an incoming webhook."""
|
|
773
|
+
return self.delete(f"/webhooks/incoming/{webhook_id}")
|
|
774
|
+
|
|
775
|
+
@staticmethod
|
|
776
|
+
def post_incoming_webhook(webhook_url: str, payload: dict) -> bool:
|
|
777
|
+
"""Post to an incoming webhook URL — anonymous (no bot token; the URL token is auth).
|
|
778
|
+
|
|
779
|
+
Static so you can call it without instantiating a client::
|
|
780
|
+
|
|
781
|
+
Rolespace.post_incoming_webhook(url, {"content": "Deploy done!"})
|
|
782
|
+
"""
|
|
783
|
+
try:
|
|
784
|
+
r = requests.post(webhook_url, json=payload, timeout=15)
|
|
785
|
+
return r.ok
|
|
786
|
+
except requests.RequestException:
|
|
787
|
+
return False
|
|
788
|
+
|
|
554
789
|
def send_dm(
|
|
555
790
|
self,
|
|
556
791
|
recipient_id: int,
|
|
@@ -589,6 +824,86 @@ class Rolespace:
|
|
|
589
824
|
"""Respond to an interaction. ``reply`` is ``{"type": "message"|"update"|"modal"|"ack", ...}``."""
|
|
590
825
|
return self.post(f"/interactions/{interaction_id}/callback", reply)
|
|
591
826
|
|
|
827
|
+
# ---- Listening for new messages in a channel ─────────────────────────
|
|
828
|
+
def watch_messages(
|
|
829
|
+
self,
|
|
830
|
+
server_id: int,
|
|
831
|
+
channel_id: int,
|
|
832
|
+
idle_delay: float = 2.0,
|
|
833
|
+
batch_size: int = 50,
|
|
834
|
+
since: Optional[str] = None,
|
|
835
|
+
include_own: bool = False,
|
|
836
|
+
own_account_id: Optional[int] = None,
|
|
837
|
+
on_error: Optional[Any] = None,
|
|
838
|
+
) -> Iterator[RolespaceMessage]:
|
|
839
|
+
"""Yield new ``RolespaceMessage`` objects from a channel as they appear.
|
|
840
|
+
|
|
841
|
+
Wraps the polling loop, cursor bookkeeping, and graceful error backoff::
|
|
842
|
+
|
|
843
|
+
me = rs.me()
|
|
844
|
+
for msg in rs.watch_messages(server_id, channel_id, own_account_id=me.bot.id):
|
|
845
|
+
if msg.content.startswith("!ping"):
|
|
846
|
+
rs.send_message(server_id, channel_id, "pong")
|
|
847
|
+
|
|
848
|
+
For high-volume / production bots, prefer outgoing webhooks (push) over polling.
|
|
849
|
+
Polling is fine for low-traffic channels, dev/testing, or environments where you
|
|
850
|
+
can't expose a public HTTP receiver.
|
|
851
|
+
|
|
852
|
+
Parameters
|
|
853
|
+
----------
|
|
854
|
+
server_id, channel_id : int
|
|
855
|
+
idle_delay : float
|
|
856
|
+
Seconds between polls. Defaults to 2.
|
|
857
|
+
batch_size : int
|
|
858
|
+
Max messages per poll. Defaults to 50.
|
|
859
|
+
since : str
|
|
860
|
+
ISO-8601 timestamp; only yield messages newer than this. Defaults to "now"
|
|
861
|
+
so existing channel history is skipped.
|
|
862
|
+
include_own : bool
|
|
863
|
+
When False (default), messages posted by THIS bot are filtered out. Avoids
|
|
864
|
+
common reply-loop bugs.
|
|
865
|
+
own_account_id : int
|
|
866
|
+
Required when include_own is False. Usually ``rs.me().bot.id``.
|
|
867
|
+
on_error : callable(exc)
|
|
868
|
+
Called on each polling failure. Defaults to swallowing silently.
|
|
869
|
+
"""
|
|
870
|
+
# Bootstrap the cursor: skip everything that's already in the channel.
|
|
871
|
+
if since is not None:
|
|
872
|
+
last_seen_ts = since
|
|
873
|
+
else:
|
|
874
|
+
try:
|
|
875
|
+
seed = self.get(f"/servers/{server_id}/channels/{channel_id}/messages?limit=1")
|
|
876
|
+
seed_data = (seed or {}).get("data") or []
|
|
877
|
+
last_seen_ts = seed_data[-1]["timestamp"] if seed_data else _utcnow_iso()
|
|
878
|
+
except Exception as ex:
|
|
879
|
+
if on_error:
|
|
880
|
+
on_error(ex)
|
|
881
|
+
last_seen_ts = _utcnow_iso()
|
|
882
|
+
|
|
883
|
+
while True:
|
|
884
|
+
try:
|
|
885
|
+
page = self.get(f"/servers/{server_id}/channels/{channel_id}/messages?limit={batch_size}")
|
|
886
|
+
msgs = (page or {}).get("data") or []
|
|
887
|
+
# API returns oldest → newest; iterate in order.
|
|
888
|
+
for m in msgs:
|
|
889
|
+
ts = m.get("timestamp")
|
|
890
|
+
if not ts or ts <= last_seen_ts:
|
|
891
|
+
continue
|
|
892
|
+
if (not include_own
|
|
893
|
+
and own_account_id is not None
|
|
894
|
+
and (m.get("author") or {}).get("id") == own_account_id):
|
|
895
|
+
last_seen_ts = ts
|
|
896
|
+
continue
|
|
897
|
+
yield RolespaceMessage(m)
|
|
898
|
+
last_seen_ts = ts
|
|
899
|
+
except Exception as ex:
|
|
900
|
+
if on_error:
|
|
901
|
+
on_error(ex)
|
|
902
|
+
# Back off harder on transient failures so we don't hammer a flaky API.
|
|
903
|
+
time.sleep(min(30.0, idle_delay * 4))
|
|
904
|
+
continue
|
|
905
|
+
time.sleep(idle_delay)
|
|
906
|
+
|
|
592
907
|
# ---- Webhook signature verification ──────────────────────────────────
|
|
593
908
|
|
|
594
909
|
@staticmethod
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|