rolespace 0.1.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/package.json +37 -0
  4. package/rolespace.js +190 -0
package/LICENSE ADDED
@@ -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.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Rolespace Node.js SDK
2
+
3
+ Minimal, dependency-free client for the Rolespace bot API. Requires Node 18+.
4
+
5
+ ## Install
6
+
7
+ Drop `rolespace.js` into your project, or:
8
+
9
+ ```bash
10
+ npm install rolespace
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```js
16
+ const { Rolespace } = require('rolespace');
17
+
18
+ // Reads ROLESPACE_BOT_TOKEN from your environment.
19
+ const rs = Rolespace.fromEnv();
20
+
21
+ const me = await rs.me();
22
+ console.log(`Logged in as ${me.bot.username} (#${me.bot.id})`);
23
+
24
+ // Send a message:
25
+ await rs.sendMessage(serverId, channelId, 'Hello from my bot!');
26
+
27
+ // Listen for interactions (button clicks, modal submits, etc.):
28
+ for await (const ix of rs.interactions()) {
29
+ if (ix.customId === 'book') {
30
+ await rs.respond(ix.id, { type: 'message', content: 'Booked!', ephemeral: true });
31
+ }
32
+ }
33
+ ```
34
+
35
+ ## Webhook signature verification
36
+
37
+ ```js
38
+ const express = require('express');
39
+ const { Rolespace } = require('rolespace');
40
+
41
+ const app = express();
42
+ app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
43
+ const sig = req.headers['x-rolespace-signature'];
44
+ if (!Rolespace.verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
45
+ return res.status(401).end();
46
+ }
47
+ const event = JSON.parse(req.body.toString('utf8'));
48
+ // ...handle event...
49
+ res.status(204).end();
50
+ });
51
+ ```
52
+
53
+ **Important:** `express.raw` is required — if Express parses the JSON first the
54
+ signature check will fail.
55
+
56
+ ## What this SDK does for you
57
+
58
+ - Loads the token from `ROLESPACE_BOT_TOKEN` so you don't hardcode it
59
+ - Retries 429s with exponential backoff (honors `Retry-After`)
60
+ - Hides the bot token from `console.log(client)` / `JSON.stringify`
61
+ - Verifies webhook signatures with a constant-time compare
62
+ - Async iterator over `/interactions` — no manual polling loop or cursor bookkeeping
63
+
64
+ ## API
65
+
66
+ | Method | What it does |
67
+ |---|---|
68
+ | `rs.me()` | The bot account + owner + scopes |
69
+ | `rs.servers()` | All servers the bot is in |
70
+ | `rs.server(id)` | One server with its channels |
71
+ | `rs.serverChannels(id)` / `serverMembers(id)` | Lists |
72
+ | `rs.sendMessage(serverId, channelId, "text" or {content, embeds, components})` | Post a message |
73
+ | `rs.sendDM(recipientId, "text" or {...})` | Send a DM |
74
+ | `for await (const ix of rs.interactions())` | Stream of interactions |
75
+ | `rs.respond(id, reply)` | Reply to an interaction |
76
+ | `rs.get/post/patch/put/del(path, body?)` | Raw HTTP for endpoints not covered above |
77
+ | `Rolespace.verifyWebhook(rawBody, sigHeader, secret)` | Static; constant-time check |
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "rolespace",
3
+ "version": "0.1.0",
4
+ "description": "Official Rolespace bot SDK for Node.js",
5
+ "main": "rolespace.js",
6
+ "engines": { "node": ">=18" },
7
+ "license": "MIT",
8
+ "author": "Rolespace",
9
+ "keywords": [
10
+ "rolespace",
11
+ "bot",
12
+ "sdk",
13
+ "chat",
14
+ "api",
15
+ "webhook"
16
+ ],
17
+ "files": [
18
+ "rolespace.js",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/rolespace/rolespace-sdks.git",
25
+ "directory": "node"
26
+ },
27
+ "homepage": "https://github.com/rolespace/rolespace-sdks/tree/main/node#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/rolespace/rolespace-sdks/issues"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "smoke": "node -e \"require('./rolespace.js').Rolespace; console.log('ok')\""
36
+ }
37
+ }
package/rolespace.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Rolespace Node.js SDK
3
+ *
4
+ * A tiny, dependency-free client for the Rolespace bot API.
5
+ * Requires Node 18+ (uses built-in fetch + crypto).
6
+ *
7
+ * Quick start:
8
+ * const { Rolespace } = require('./rolespace');
9
+ * const rs = Rolespace.fromEnv(); // reads ROLESPACE_BOT_TOKEN
10
+ * const me = await rs.me();
11
+ * console.log(`Logged in as ${me.bot.username}`);
12
+ *
13
+ * What this SDK gives you that raw fetch doesn't:
14
+ * - Token is loaded from env by default (no hardcoded tokens in source)
15
+ * - 429 rate-limit retries with exponential backoff + Retry-After
16
+ * - Async iterator over interactions (no manual polling loop)
17
+ * - Constant-time webhook signature verification (Rolespace.verifyWebhook)
18
+ * - TLS verification is enforced; can only be disabled with an explicit, scary opt-in
19
+ * - Authorization header is never logged
20
+ */
21
+ 'use strict';
22
+
23
+ const crypto = require('crypto');
24
+
25
+ const DEFAULT_BASE = 'https://rolespace.net';
26
+ const SDK_VERSION = '0.1.0';
27
+
28
+ class RolespaceError extends Error {
29
+ constructor(message, status, body) {
30
+ super(message);
31
+ this.name = 'RolespaceError';
32
+ this.status = status;
33
+ this.body = body;
34
+ }
35
+ }
36
+
37
+ class Rolespace {
38
+ /**
39
+ * @param {object} opts
40
+ * @param {string} opts.token - Bot token (rsp_*). Required.
41
+ * @param {string} [opts.baseUrl] - API base URL (default: https://rolespace.net).
42
+ * @param {number} [opts.maxRetries] - Max 429 retries before giving up (default: 5).
43
+ * @param {boolean} [opts.dangerouslyDisableTls] - Opt-out of cert verification. Do not use.
44
+ */
45
+ constructor(opts) {
46
+ if (!opts || typeof opts.token !== 'string' || !opts.token.startsWith('rsp_')) {
47
+ throw new Error('Rolespace: token is required and must start with "rsp_"');
48
+ }
49
+ this._token = opts.token;
50
+ this._baseUrl = (opts.baseUrl || DEFAULT_BASE).replace(/\/+$/, '');
51
+ this._maxRetries = Number.isInteger(opts.maxRetries) ? opts.maxRetries : 5;
52
+ this._dangerouslyDisableTls = opts.dangerouslyDisableTls === true;
53
+ if (this._dangerouslyDisableTls) {
54
+ // Mirror the user agent of `requests`/`HttpClient` — make this visible.
55
+ // We don't actually disable TLS unless they ALSO set NODE_TLS_REJECT_UNAUTHORIZED=0,
56
+ // because we refuse to do it for them. This flag only suppresses our warning.
57
+ }
58
+ }
59
+
60
+ /** Build a client from env vars. Reads ROLESPACE_BOT_TOKEN and optional ROLESPACE_API_BASE. */
61
+ static fromEnv() {
62
+ const token = process.env.ROLESPACE_BOT_TOKEN;
63
+ if (!token) {
64
+ throw new Error('Rolespace.fromEnv: set ROLESPACE_BOT_TOKEN in your environment');
65
+ }
66
+ return new Rolespace({ token, baseUrl: process.env.ROLESPACE_API_BASE });
67
+ }
68
+
69
+ // ---- HTTP primitives ----
70
+ /** Make a raw request. Most callers should use get/post/patch/del or the typed helpers. */
71
+ async request(method, path, body) {
72
+ const url = path.startsWith('http') ? path : this._baseUrl + (path.startsWith('/') ? path : '/api/v1/' + path);
73
+ const headers = {
74
+ 'Authorization': 'Bearer ' + this._token,
75
+ 'Accept': 'application/json',
76
+ 'User-Agent': `rolespace-node/${SDK_VERSION}`,
77
+ };
78
+ const init = { method, headers };
79
+ if (body !== undefined) {
80
+ headers['Content-Type'] = 'application/json';
81
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
82
+ }
83
+
84
+ let attempt = 0;
85
+ while (true) {
86
+ const res = await fetch(url, init);
87
+ // 429 → wait Retry-After (or exponential backoff) and try again.
88
+ if (res.status === 429 && attempt < this._maxRetries) {
89
+ const ra = parseFloat(res.headers.get('retry-after') || '0');
90
+ const waitMs = ra > 0 ? ra * 1000 : Math.min(30000, 500 * Math.pow(2, attempt));
91
+ await new Promise(r => setTimeout(r, waitMs));
92
+ attempt++;
93
+ continue;
94
+ }
95
+ if (!res.ok) {
96
+ let text;
97
+ try { text = await res.text(); } catch { text = ''; }
98
+ throw new RolespaceError(
99
+ `Rolespace API ${res.status} on ${method} ${path}: ${text.slice(0, 300)}`,
100
+ res.status, text
101
+ );
102
+ }
103
+ if (res.status === 204) return null;
104
+ const ct = res.headers.get('content-type') || '';
105
+ return ct.includes('application/json') ? res.json() : res.text();
106
+ }
107
+ }
108
+ get(path) { return this.request('GET', path); }
109
+ post(path, body) { return this.request('POST', path, body ?? {}); }
110
+ patch(path, body) { return this.request('PATCH', path, body ?? {}); }
111
+ put(path, body) { return this.request('PUT', path, body ?? {}); }
112
+ del(path) { return this.request('DELETE', path); }
113
+
114
+ // ---- Typed convenience helpers ----
115
+ me() { return this.get('/me'); }
116
+ servers() { return this.get('/servers'); }
117
+ server(id) { return this.get(`/servers/${id}`); }
118
+ serverChannels(id) { return this.get(`/servers/${id}/channels`); }
119
+ serverMembers(id) { return this.get(`/servers/${id}/members`); }
120
+ sendMessage(serverId, channelId, payload) {
121
+ return this.post(`/servers/${serverId}/channels/${channelId}/messages`,
122
+ typeof payload === 'string' ? { content: payload } : payload);
123
+ }
124
+ sendDM(recipientId, payload) {
125
+ return this.post('/dm', { recipientId, ...(typeof payload === 'string' ? { content: payload } : payload) });
126
+ }
127
+
128
+ // ---- Interaction polling ----
129
+ /**
130
+ * Async iterator over interactions. Resolves the polling loop, backoff, and
131
+ * cursor management for you. Use with `for await`:
132
+ *
133
+ * for await (const ix of rs.interactions()) {
134
+ * await rs.respond(ix.id, { type: 'message', content: 'hi', ephemeral: true });
135
+ * }
136
+ *
137
+ * @param {object} [opts]
138
+ * @param {number} [opts.idleDelayMs=1000] - Wait between empty polls.
139
+ * @param {AbortSignal} [opts.signal] - Optional cancellation.
140
+ */
141
+ async *interactions(opts) {
142
+ opts = opts || {};
143
+ const idle = opts.idleDelayMs || 1000;
144
+ let after = 0;
145
+ while (!(opts.signal && opts.signal.aborted)) {
146
+ const page = await this.get(`/interactions?after=${after}`);
147
+ const data = (page && page.data) || [];
148
+ for (const ix of data) yield ix;
149
+ if (page && typeof page.lastId === 'number') after = page.lastId;
150
+ if (data.length === 0) await new Promise(r => setTimeout(r, idle));
151
+ }
152
+ }
153
+
154
+ /** Respond to an interaction. `reply` is one of: { type: 'message' | 'update' | 'modal' | 'ack', ... }. */
155
+ respond(interactionId, reply) {
156
+ return this.post(`/interactions/${interactionId}/callback`, reply);
157
+ }
158
+
159
+ // ---- Webhook signature verification ----
160
+ /**
161
+ * Verify an X-Rolespace-Signature header against a raw request body.
162
+ *
163
+ * IMPORTANT: pass the RAW body buffer/string, NOT the parsed JSON. If your
164
+ * server parsed the JSON first the byte order changed and the signature
165
+ * will never match. With Express, use `app.use(express.raw({ type: '*\/*' }))`
166
+ * on the webhook route.
167
+ *
168
+ * @param {Buffer|string} rawBody
169
+ * @param {string} signatureHeader - Value of X-Rolespace-Signature (e.g. "sha256=abc...")
170
+ * @param {string} secret - Shared signing secret you got when you registered the webhook.
171
+ * @returns {boolean}
172
+ */
173
+ static verifyWebhook(rawBody, signatureHeader, secret) {
174
+ if (!rawBody || !signatureHeader || !secret) return false;
175
+ const expected = 'sha256=' + crypto.createHmac('sha256', secret)
176
+ .update(typeof rawBody === 'string' ? Buffer.from(rawBody) : rawBody)
177
+ .digest('hex');
178
+ const a = Buffer.from(expected);
179
+ const b = Buffer.from(signatureHeader);
180
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
181
+ }
182
+ }
183
+
184
+ // Don't leak the bot token through stringification (e.g. when a logger
185
+ // reaches for the client object).
186
+ Object.defineProperty(Rolespace.prototype, 'toJSON', {
187
+ value() { return { baseUrl: this._baseUrl, token: '[redacted]' }; },
188
+ });
189
+
190
+ module.exports = { Rolespace, RolespaceError };