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,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)
|