rolespace 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: rolespace
3
+ Version: 0.1.0
4
+ Summary: Official Rolespace bot SDK for Python
5
+ Author: Rolespace
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Rolespace
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/rolespace/rolespace-sdks/tree/main/python
29
+ Project-URL: Documentation, https://rolespace.net/Developer/Docs
30
+ Project-URL: Source, https://github.com/rolespace/rolespace-sdks
31
+ Project-URL: Issues, https://github.com/rolespace/rolespace-sdks/issues
32
+ Keywords: rolespace,bot,sdk,chat,api,webhook
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python :: 3
38
+ Classifier: Programming Language :: Python :: 3 :: Only
39
+ Classifier: Programming Language :: Python :: 3.8
40
+ Classifier: Programming Language :: Python :: 3.9
41
+ Classifier: Programming Language :: Python :: 3.10
42
+ Classifier: Programming Language :: Python :: 3.11
43
+ Classifier: Programming Language :: Python :: 3.12
44
+ Classifier: Topic :: Communications :: Chat
45
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
46
+ Classifier: Typing :: Typed
47
+ Requires-Python: >=3.8
48
+ Description-Content-Type: text/markdown
49
+ License-File: LICENSE
50
+ Requires-Dist: requests>=2.28
51
+ Dynamic: license-file
52
+
53
+ # Rolespace Python SDK
54
+
55
+ Minimal client for the Rolespace bot API. Requires Python 3.8+ and `requests`.
56
+
57
+ ## Install
58
+
59
+ Drop `rolespace.py` into your project, or:
60
+
61
+ ```bash
62
+ pip install rolespace
63
+ ```
64
+
65
+ ## Quick start
66
+
67
+ ```python
68
+ from rolespace import Rolespace
69
+
70
+ # Reads ROLESPACE_BOT_TOKEN from your environment.
71
+ rs = Rolespace.from_env()
72
+
73
+ me = rs.me()
74
+ print(f"Logged in as {me['bot']['username']} (#{me['bot']['id']})")
75
+
76
+ # Send a message:
77
+ rs.send_message(server_id, channel_id, "Hello from my bot!")
78
+
79
+ # Listen for interactions (button clicks, modal submits, etc.):
80
+ for ix in rs.interactions():
81
+ if ix["customId"] == "book":
82
+ rs.respond(ix["id"], {"type": "message", "content": "Booked!", "ephemeral": True})
83
+ ```
84
+
85
+ ## Webhook signature verification
86
+
87
+ ```python
88
+ from flask import Flask, request, abort
89
+ from rolespace import Rolespace
90
+ import os, json
91
+
92
+ app = Flask(__name__)
93
+ SECRET = os.environ["WEBHOOK_SECRET"]
94
+
95
+ @app.post("/webhook")
96
+ def webhook():
97
+ raw = request.get_data() # raw bytes — NOT request.json
98
+ sig = request.headers.get("X-Rolespace-Signature", "")
99
+ if not Rolespace.verify_webhook(raw, sig, SECRET):
100
+ abort(401)
101
+ event = json.loads(raw)
102
+ # ...handle event...
103
+ return ("", 204)
104
+ ```
105
+
106
+ **Important:** use `request.get_data()` — `request.json` re-serializes the body
107
+ and the signature check will fail.
108
+
109
+ ## What this SDK does for you
110
+
111
+ - Loads the token from `ROLESPACE_BOT_TOKEN` so you don't hardcode it
112
+ - Retries 429s with exponential backoff (honors `Retry-After`)
113
+ - Hides the bot token from `repr(client)`
114
+ - Verifies webhook signatures with `hmac.compare_digest` (constant-time)
115
+ - Generator over `/interactions` — no manual polling loop or cursor bookkeeping
116
+
117
+ ## API
118
+
119
+ | Method | What it does |
120
+ |---|---|
121
+ | `rs.me()` | Bot account + owner + scopes |
122
+ | `rs.servers()` | All servers the bot is in |
123
+ | `rs.server(id)` | One server with its channels |
124
+ | `rs.server_channels(id)` / `server_members(id)` | Lists |
125
+ | `rs.send_message(server_id, channel_id, "text" or {...})` | Post a message |
126
+ | `rs.send_dm(recipient_id, "text" or {...})` | Send a DM |
127
+ | `for ix in rs.interactions():` | Stream of interactions |
128
+ | `rs.respond(id, reply)` | Reply to an interaction |
129
+ | `rs.get/post/patch/put/delete(path, json=None)` | Raw HTTP for endpoints not covered above |
130
+ | `Rolespace.verify_webhook(raw_body, sig_header, secret)` | Static; constant-time check |
@@ -0,0 +1,6 @@
1
+ rolespace.py,sha256=EF8o37W4_uGX6EUWnwccdq3pUXnbuW4LVDigD0Jx-Cs,7571
2
+ rolespace-0.1.0.dist-info/licenses/LICENSE,sha256=hwiCe-dWhjcnRE8_I1_UMDEmyUBK-usU0B5tzesUnG0,1066
3
+ rolespace-0.1.0.dist-info/METADATA,sha256=EPxSaouBnU6tplWdTroqBlmlFah6r0TiCPgFymdd2x8,5037
4
+ rolespace-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ rolespace-0.1.0.dist-info/top_level.txt,sha256=9YAf_iodn944GKw6LTsfVi2N6mOsdBGUJbK79OcVURo,10
6
+ rolespace-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rolespace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ rolespace
rolespace.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ Rolespace Python SDK
3
+ ====================
4
+
5
+ A tiny client for the Rolespace bot API. Only dependency: ``requests``.
6
+
7
+ Quick start::
8
+
9
+ from rolespace import Rolespace
10
+ rs = Rolespace.from_env() # reads ROLESPACE_BOT_TOKEN
11
+ me = rs.me()
12
+ print(f"Logged in as {me['bot']['username']}")
13
+
14
+ What this SDK gives you that raw ``requests`` doesn't:
15
+
16
+ * Token is loaded from env by default (no hardcoded tokens in source)
17
+ * 429 rate-limit retries with exponential backoff + ``Retry-After``
18
+ * Generator over interactions (no manual polling loop)
19
+ * Constant-time webhook signature verification (``Rolespace.verify_webhook``)
20
+ * TLS verification is enforced; can only be disabled with an explicit, scary opt-in
21
+ * Authorization header is never logged (``repr`` redacts the token)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import hmac
27
+ import os
28
+ import time
29
+ from typing import Any, Iterator, Optional, Union
30
+
31
+ import requests
32
+
33
+
34
+ DEFAULT_BASE = "https://rolespace.net"
35
+ SDK_VERSION = "0.1.0"
36
+
37
+
38
+ class RolespaceError(Exception):
39
+ """Raised on any non-2xx response from the API."""
40
+
41
+ def __init__(self, message: str, status: int, body: str) -> None:
42
+ super().__init__(message)
43
+ self.status = status
44
+ self.body = body
45
+
46
+
47
+ class Rolespace:
48
+ """Synchronous client for the Rolespace bot API."""
49
+
50
+ def __init__(
51
+ self,
52
+ token: str,
53
+ base_url: str = DEFAULT_BASE,
54
+ max_retries: int = 5,
55
+ dangerously_disable_tls: bool = False,
56
+ ) -> None:
57
+ if not token or not token.startswith("rsp_"):
58
+ raise ValueError('Rolespace: token is required and must start with "rsp_"')
59
+ self._token = token
60
+ self._base_url = base_url.rstrip("/")
61
+ self._max_retries = max_retries
62
+ self._verify_tls = not dangerously_disable_tls
63
+ # One session per client so connections are pooled.
64
+ self._sess = requests.Session()
65
+ self._sess.headers.update({
66
+ "Authorization": f"Bearer {token}",
67
+ "Accept": "application/json",
68
+ "User-Agent": f"rolespace-python/{SDK_VERSION}",
69
+ })
70
+
71
+ # Build a client from env vars.
72
+ @classmethod
73
+ def from_env(cls) -> "Rolespace":
74
+ token = os.environ.get("ROLESPACE_BOT_TOKEN")
75
+ if not token:
76
+ raise RuntimeError(
77
+ "Rolespace.from_env: set ROLESPACE_BOT_TOKEN in your environment"
78
+ )
79
+ return cls(token=token, base_url=os.environ.get("ROLESPACE_API_BASE", DEFAULT_BASE))
80
+
81
+ # Hide the token from logs / debuggers / pickle.
82
+ def __repr__(self) -> str:
83
+ return f"Rolespace(base_url={self._base_url!r}, token='[redacted]')"
84
+
85
+ # ---- HTTP primitives ----
86
+ def request(self, method: str, path: str, json: Any = None) -> Any:
87
+ """Make a raw request. Most callers should use get/post/patch/delete."""
88
+ url = path if path.startswith("http") else (
89
+ self._base_url + (path if path.startswith("/") else "/api/v1/" + path)
90
+ )
91
+ attempt = 0
92
+ while True:
93
+ res = self._sess.request(method, url, json=json, verify=self._verify_tls, timeout=30)
94
+ # 429 → wait Retry-After (or exponential backoff) and try again.
95
+ if res.status_code == 429 and attempt < self._max_retries:
96
+ try:
97
+ ra = float(res.headers.get("Retry-After", "0"))
98
+ except ValueError:
99
+ ra = 0.0
100
+ wait = ra if ra > 0 else min(30.0, 0.5 * (2 ** attempt))
101
+ time.sleep(wait)
102
+ attempt += 1
103
+ continue
104
+ if not res.ok:
105
+ raise RolespaceError(
106
+ f"Rolespace API {res.status_code} on {method} {path}: {res.text[:300]}",
107
+ res.status_code, res.text,
108
+ )
109
+ if res.status_code == 204 or not res.content:
110
+ return None
111
+ ctype = res.headers.get("Content-Type", "")
112
+ return res.json() if "application/json" in ctype else res.text
113
+
114
+ def get(self, path: str) -> Any: return self.request("GET", path)
115
+ def post(self, path: str, json: Any = None) -> Any: return self.request("POST", path, json if json is not None else {})
116
+ def patch(self, path: str, json: Any = None) -> Any: return self.request("PATCH", path, json if json is not None else {})
117
+ def put(self, path: str, json: Any = None) -> Any: return self.request("PUT", path, json if json is not None else {})
118
+ def delete(self, path: str) -> Any: return self.request("DELETE", path)
119
+
120
+ # ---- Typed convenience helpers ----
121
+ def me(self) -> Any: return self.get("/me")
122
+ def servers(self) -> Any: return self.get("/servers")
123
+ def server(self, id: int) -> Any: return self.get(f"/servers/{id}")
124
+ def server_channels(self, id: int) -> Any: return self.get(f"/servers/{id}/channels")
125
+ def server_members(self, id: int) -> Any: return self.get(f"/servers/{id}/members")
126
+
127
+ def send_message(self, server_id: int, channel_id: int, payload: Union[str, dict]) -> Any:
128
+ body = {"content": payload} if isinstance(payload, str) else payload
129
+ return self.post(f"/servers/{server_id}/channels/{channel_id}/messages", body)
130
+
131
+ def send_dm(self, recipient_id: int, payload: Union[str, dict]) -> Any:
132
+ body = {"content": payload} if isinstance(payload, str) else dict(payload)
133
+ body["recipientId"] = recipient_id
134
+ return self.post("/dm", body)
135
+
136
+ # ---- Interaction polling ----
137
+ def interactions(self, idle_delay: float = 1.0) -> Iterator[dict]:
138
+ """
139
+ Yield interactions forever. Resolves the polling loop, backoff, and
140
+ cursor management for you::
141
+
142
+ for ix in rs.interactions():
143
+ rs.respond(ix["id"], {"type": "message", "content": "hi", "ephemeral": True})
144
+
145
+ Break out of the loop normally to stop polling.
146
+ """
147
+ after = 0
148
+ while True:
149
+ page = self.get(f"/interactions?after={after}")
150
+ data = (page or {}).get("data") or []
151
+ for ix in data:
152
+ yield ix
153
+ last_id = (page or {}).get("lastId")
154
+ if isinstance(last_id, int):
155
+ after = last_id
156
+ if not data:
157
+ time.sleep(idle_delay)
158
+
159
+ def respond(self, interaction_id: int, reply: dict) -> Any:
160
+ """Respond to an interaction. ``reply`` is ``{"type": "message"|"update"|"modal"|"ack", ...}``."""
161
+ return self.post(f"/interactions/{interaction_id}/callback", reply)
162
+
163
+ # ---- Webhook signature verification ----
164
+ @staticmethod
165
+ def verify_webhook(raw_body: Union[bytes, str], signature_header: str, secret: str) -> bool:
166
+ """
167
+ Verify an ``X-Rolespace-Signature`` header against a raw request body.
168
+
169
+ IMPORTANT: pass the RAW body bytes, NOT the parsed JSON. If your
170
+ framework parsed the JSON first the byte order changed and the
171
+ signature will never match. With Flask, use ``request.get_data()``
172
+ (not ``request.json``).
173
+ """
174
+ if not raw_body or not signature_header or not secret:
175
+ return False
176
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
177
+ secret_bytes = secret.encode() if isinstance(secret, str) else secret
178
+ expected = "sha256=" + hmac.new(secret_bytes, body, hashlib.sha256).hexdigest()
179
+ return hmac.compare_digest(expected, signature_header)