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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rolespace
3
- Version: 0.2.1
3
+ Version: 0.2.2
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.1"
3
+ version = "0.2.2"
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.1
3
+ Version: 0.2.2
4
4
  Summary: Official Rolespace bot SDK for Python
5
5
  Author: Rolespace
6
6
  License: MIT License
@@ -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