rolespace 0.1.0 → 0.2.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/rolespace.js +678 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rolespace",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Official Rolespace bot SDK for Node.js",
5
5
  "main": "rolespace.js",
6
6
  "engines": { "node": ">=18" },
package/rolespace.js CHANGED
@@ -5,25 +5,42 @@
5
5
  * Requires Node 18+ (uses built-in fetch + crypto).
6
6
  *
7
7
  * Quick start:
8
- * const { Rolespace } = require('./rolespace');
8
+ * const { Rolespace, RolespaceEmbed } = require('rolespace');
9
9
  * const rs = Rolespace.fromEnv(); // reads ROLESPACE_BOT_TOKEN
10
10
  * const me = await rs.me();
11
11
  * console.log(`Logged in as ${me.bot.username}`);
12
12
  *
13
+ * const msg = await rs.sendMessage(serverId, channelId, 'Hello!');
14
+ * console.log(msg.id, msg.content);
15
+ *
16
+ * const card = new RolespaceEmbed()
17
+ * .withTitle('Patch 2.4')
18
+ * .withColor('#5f85f7')
19
+ * .addField('Author', '@lynn', true);
20
+ * await rs.sendMessage(serverId, channelId, 'Heads up:', card);
21
+ *
13
22
  * What this SDK gives you that raw fetch doesn't:
23
+ * - Strongly-typed wrapper classes: msg.content instead of msg.content
24
+ * (yes, JS is loose anyway — but you get IntelliSense in TypeScript via JSDoc,
25
+ * plus the wrapper protects from missing fields throwing.)
26
+ * - RolespaceEmbed builder so you don't hand-shape JSON for rich cards
14
27
  * - Token is loaded from env by default (no hardcoded tokens in source)
15
28
  * - 429 rate-limit retries with exponential backoff + Retry-After
16
29
  * - Async iterator over interactions (no manual polling loop)
17
30
  * - Constant-time webhook signature verification (Rolespace.verifyWebhook)
18
31
  * - TLS verification is enforced; can only be disabled with an explicit, scary opt-in
19
32
  * - Authorization header is never logged
33
+ *
34
+ * Escape hatch: every typed wrapper exposes `.raw` — the underlying parsed JSON
35
+ * object — so any field the server adds before this SDK is updated still works:
36
+ * const newField = msg.raw.brandNewField;
20
37
  */
21
38
  'use strict';
22
39
 
23
40
  const crypto = require('crypto');
24
41
 
25
42
  const DEFAULT_BASE = 'https://rolespace.net';
26
- const SDK_VERSION = '0.1.0';
43
+ const SDK_VERSION = '0.2.0';
27
44
 
28
45
  class RolespaceError extends Error {
29
46
  constructor(message, status, body) {
@@ -34,13 +51,209 @@ class RolespaceError extends Error {
34
51
  }
35
52
  }
36
53
 
54
+ // ═══════════════════ Response wrapper base class ═══════════════════════
55
+ // All typed response wrappers extend this. Subclasses just declare getters
56
+ // over `this._raw` for the fields they care about. Missing fields return
57
+ // undefined (or sensible defaults from coerce helpers) instead of throwing.
58
+
59
+ class RolespaceObject {
60
+ constructor(raw) {
61
+ // Store the underlying parsed JSON so callers can always reach unmodelled fields.
62
+ Object.defineProperty(this, '_raw', { value: raw || {}, enumerable: false });
63
+ }
64
+ /** The underlying parsed JSON. Use for fields not yet modelled. */
65
+ get raw() { return this._raw; }
66
+
67
+ /** Sensible toJSON so JSON.stringify(wrapper) gives back the original shape. */
68
+ toJSON() { return this._raw; }
69
+ }
70
+
71
+ // ═══════════════════════════ /me ═══════════════════════════════════════
72
+
73
+ class RolespaceMe extends RolespaceObject {
74
+ /** The bot account — author of anything the bot does. */
75
+ get bot() { return new RolespaceUser(this._raw.bot || {}); }
76
+ /** The human owner, if visible. */
77
+ get owner() { return this._raw.owner ? new RolespaceUser(this._raw.owner) : null; }
78
+ /** Granted OAuth-style scopes. */
79
+ get scopes() { return (this._raw.application && this._raw.application.scopes) || []; }
80
+ /** Numeric id of the application registration. */
81
+ get applicationId() { return (this._raw.application && this._raw.application.id) || 0; }
82
+ }
83
+
84
+ // ═══════════════════════ Users / members ═══════════════════════════════
85
+
86
+ class RolespaceUser extends RolespaceObject {
87
+ /** Numeric account id. Same id everywhere in the API. */
88
+ get id() { return this._raw.id ?? this._raw.userId ?? 0; }
89
+ get username() { return this._raw.username || ''; }
90
+ get displayName() { return this._raw.displayName || ''; }
91
+ get nickname() { return this._raw.nickname || null; }
92
+ get avatarUrl() { return this._raw.avatar || this._raw.avatarUrl || null; }
93
+ }
94
+
95
+ class RolespaceMember extends RolespaceUser {
96
+ /** True if this member owns the server. */
97
+ get isOwner() { return !!this._raw.isOwner; }
98
+ /** Role ids assigned to this member in this server. */
99
+ get roleIds() { return this._raw.roleIds || []; }
100
+ }
101
+
102
+ // ═══════════════════════════ Servers ═══════════════════════════════════
103
+
104
+ class RolespaceServer extends RolespaceObject {
105
+ get id() { return this._raw.id || 0; }
106
+ get name() { return this._raw.name || ''; }
107
+ get description() { return this._raw.description || null; }
108
+ get iconUrl() { return this._raw.iconUrl || null; }
109
+ get bannerUrl() { return this._raw.bannerUrl || null; }
110
+ get isPublic() { return !!this._raw.isPublic; }
111
+ get ownerId() { return this._raw.ownerId || 0; }
112
+ get memberCount() { return this._raw.memberCount || 0; }
113
+ get createdAt() { return this._raw.createdAt || null; }
114
+ get categories() { return (this._raw.categories || []).map(c => new RolespaceCategory(c)); }
115
+ /** Flatten the category tree into a single channel list. */
116
+ allChannels() {
117
+ const out = [];
118
+ for (const cat of this.categories) for (const ch of cat.channels) out.push(ch);
119
+ return out;
120
+ }
121
+ }
122
+
123
+ class RolespaceCategory extends RolespaceObject {
124
+ get id() { return this._raw.id || 0; }
125
+ get name() { return this._raw.name || ''; }
126
+ get position() { return this._raw.position || 0; }
127
+ get channels() { return (this._raw.channels || []).map(c => new RolespaceChannel(c)); }
128
+ }
129
+
130
+ class RolespaceChannel extends RolespaceObject {
131
+ get id() { return this._raw.id || 0; }
132
+ get serverId() { return this._raw.serverId || 0; }
133
+ get categoryId() { return this._raw.categoryId || null; }
134
+ get categoryName() { return this._raw.categoryName || null; }
135
+ get name() { return this._raw.name || ''; }
136
+ get topic() { return this._raw.topic || null; }
137
+ /** Lowercase string: text, voice, announcement, forum, rules. */
138
+ get type() { return this._raw.type || ''; }
139
+ get position() { return this._raw.position || 0; }
140
+ get isNsfw() { return !!this._raw.isNsfw; }
141
+ get isPrivate() { return !!this._raw.isPrivate; }
142
+ }
143
+
144
+ class RolespaceRole extends RolespaceObject {
145
+ get id() { return this._raw.id || 0; }
146
+ get name() { return this._raw.name || ''; }
147
+ get color() { return this._raw.color || null; }
148
+ get position() { return this._raw.position || 0; }
149
+ get isEveryone() { return !!this._raw.isEveryone; }
150
+ get isDefault() { return !!this._raw.isDefault; }
151
+ get permissions() { return this._raw.permissions || {}; }
152
+ }
153
+
154
+ // ═══════════════════════════ Messages ══════════════════════════════════
155
+
156
+ class RolespaceMessage extends RolespaceObject {
157
+ /** String id (GUID). Message ids are strings, NOT numeric. */
158
+ get id() { return this._raw.id || ''; }
159
+ get channelId() { return this._raw.channelId || 0; }
160
+ get serverId() { return this._raw.serverId || 0; }
161
+ get content() { return this._raw.content || ''; }
162
+ get timestamp() { return this._raw.timestamp || null; }
163
+ get editedAt() { return this._raw.editedAt || null; }
164
+ get isPinned() { return !!this._raw.isPinned; }
165
+ get replyToMessageId() { return this._raw.replyToMessageId || null; }
166
+ get author() { return new RolespaceUser(this._raw.author || {}); }
167
+ get reactions() { return this._raw.reactions || []; }
168
+ get attachments() { return this._raw.attachments || []; }
169
+ }
170
+
171
+ // ═══════════════════════ Interactions ══════════════════════════════════
172
+
173
+ class RolespaceInteraction extends RolespaceObject {
174
+ /** Numeric interaction id. Pass to client.respond(...). */
175
+ get id() { return this._raw.id || 0; }
176
+ /** One of 'button', 'select', 'modal_submit'. */
177
+ get type() { return this._raw.type || ''; }
178
+ /** The customId the bot set on the component. Use this to dispatch. */
179
+ get customId() { return this._raw.customId || ''; }
180
+ get serverId() { return this._raw.serverId || 0; }
181
+ get channelId() { return this._raw.channelId || 0; }
182
+ /** String id of the source message. */
183
+ get messageId() { return this._raw.messageId || null; }
184
+ /** Who clicked / submitted. */
185
+ get user() { return new RolespaceUser(this._raw.user || {}); }
186
+ /** Extra payload — shape varies by type. */
187
+ get data() { return this._raw.data || null; }
188
+ get createdAt() { return this._raw.createdAt || null; }
189
+ }
190
+
191
+ // ════════════════════════ Embeds (rich cards) ══════════════════════════
192
+ //
193
+ // Outbound builder. Two styles work — pick whichever reads better:
194
+ //
195
+ // // Plain-object style:
196
+ // const card = new RolespaceEmbed({ title: 'Hi', color: '#5f85f7' });
197
+ // card.addField('Author', '@lynn', true);
198
+ //
199
+ // // Fluent builder style:
200
+ // const card = new RolespaceEmbed()
201
+ // .withTitle('Hi')
202
+ // .withColor('#5f85f7')
203
+ // .addField('Author', '@lynn', true);
204
+ //
205
+ // Pass to sendMessage as the last (or only) argument.
206
+
207
+ class RolespaceEmbed {
208
+ constructor(initial) {
209
+ // Copy whatever the caller passed in; everything optional.
210
+ Object.assign(this, initial || {});
211
+ }
212
+
213
+ withTitle(title) { this.title = title; return this; }
214
+ withUrl(url) { this.url = url; return this; }
215
+ withDescription(desc) { this.description = desc; return this; }
216
+ /** Hex string like "#5f85f7". */
217
+ withColor(hex) { this.color = hex; return this; }
218
+ /** flag: optional "nsfw" | "triggering" | "spoiler" — render blurred behind a click-to-reveal cover. */
219
+ withImage(url, flag) { this.image = url; if (flag) this.imageFlag = flag; return this; }
220
+ withThumbnail(url) { this.thumbnail = url; return this; }
221
+ withAuthor(name, iconUrl) {
222
+ this.author = iconUrl ? { name, iconUrl } : { name };
223
+ return this;
224
+ }
225
+ withFooter(text) { this.footer = text; return this; }
226
+
227
+ /** Append an inline name/value field. Up to 25 per embed. */
228
+ addField(name, value, inline = false) {
229
+ (this.fields ||= []).push({ name, value, inline });
230
+ return this;
231
+ }
232
+
233
+ /** Append a gallery image. Up to 24 per embed. */
234
+ addGalleryImage(url, flag) {
235
+ (this.gallery ||= []).push(flag ? { url, flag } : url);
236
+ return this;
237
+ }
238
+
239
+ /** Used by JSON.stringify — strips internal stuff. (None here, but kept for future.) */
240
+ toJSON() {
241
+ const out = {};
242
+ for (const k of Object.keys(this)) {
243
+ if (this[k] !== undefined && this[k] !== null) out[k] = this[k];
244
+ }
245
+ return out;
246
+ }
247
+ }
248
+
249
+ // ════════════════════════ Main client ══════════════════════════════════
250
+
37
251
  class Rolespace {
38
252
  /**
39
253
  * @param {object} opts
40
254
  * @param {string} opts.token - Bot token (rsp_*). Required.
41
255
  * @param {string} [opts.baseUrl] - API base URL (default: https://rolespace.net).
42
256
  * @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
257
  */
45
258
  constructor(opts) {
46
259
  if (!opts || typeof opts.token !== 'string' || !opts.token.startsWith('rsp_')) {
@@ -49,25 +262,16 @@ class Rolespace {
49
262
  this._token = opts.token;
50
263
  this._baseUrl = (opts.baseUrl || DEFAULT_BASE).replace(/\/+$/, '');
51
264
  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
265
  }
59
266
 
60
267
  /** Build a client from env vars. Reads ROLESPACE_BOT_TOKEN and optional ROLESPACE_API_BASE. */
61
268
  static fromEnv() {
62
269
  const token = process.env.ROLESPACE_BOT_TOKEN;
63
- if (!token) {
64
- throw new Error('Rolespace.fromEnv: set ROLESPACE_BOT_TOKEN in your environment');
65
- }
270
+ if (!token) throw new Error('Rolespace.fromEnv: set ROLESPACE_BOT_TOKEN in your environment');
66
271
  return new Rolespace({ token, baseUrl: process.env.ROLESPACE_API_BASE });
67
272
  }
68
273
 
69
- // ---- HTTP primitives ----
70
- /** Make a raw request. Most callers should use get/post/patch/del or the typed helpers. */
274
+ // ---- HTTP primitives ─────────────────────────────────────────────────
71
275
  async request(method, path, body) {
72
276
  const url = path.startsWith('http') ? path : this._baseUrl + (path.startsWith('/') ? path : '/api/v1/' + path);
73
277
  const headers = {
@@ -84,7 +288,6 @@ class Rolespace {
84
288
  let attempt = 0;
85
289
  while (true) {
86
290
  const res = await fetch(url, init);
87
- // 429 → wait Retry-After (or exponential backoff) and try again.
88
291
  if (res.status === 429 && attempt < this._maxRetries) {
89
292
  const ra = parseFloat(res.headers.get('retry-after') || '0');
90
293
  const waitMs = ra > 0 ? ra * 1000 : Math.min(30000, 500 * Math.pow(2, attempt));
@@ -111,32 +314,350 @@ class Rolespace {
111
314
  put(path, body) { return this.request('PUT', path, body ?? {}); }
112
315
  del(path) { return this.request('DELETE', path); }
113
316
 
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);
317
+ // ---- Typed convenience helpers ───────────────────────────────────────
318
+ // Each wraps the raw response in a typed class so callers get autocompleted
319
+ // accessors (msg.content) instead of raw dict access (msg.content but also
320
+ // msg.weirdMisspelling that silently returns undefined).
321
+
322
+ /** The authenticated application + bot identity + owner + scopes. */
323
+ async me() {
324
+ return new RolespaceMe(await this.get('/me'));
325
+ }
326
+
327
+ /** All servers the bot has been added to (summary objects). */
328
+ async servers() {
329
+ const resp = await this.get('/servers');
330
+ return _unwrapList(resp).map(s => new RolespaceServer(s));
331
+ }
332
+
333
+ /** One server with its categories and visible channels. */
334
+ async server(id) {
335
+ return new RolespaceServer(await this.get(`/servers/${id}`));
336
+ }
337
+
338
+ /** Flat list of channels the bot can view in the server. */
339
+ async serverChannels(id) {
340
+ const resp = await this.get(`/servers/${id}/channels`);
341
+ return _unwrapList(resp).map(c => new RolespaceChannel(c));
342
+ }
343
+
344
+ /** All members of the server. */
345
+ async serverMembers(id) {
346
+ const resp = await this.get(`/servers/${id}/members`);
347
+ return _unwrapList(resp).map(m => new RolespaceMember(m));
123
348
  }
124
- sendDM(recipientId, payload) {
125
- return this.post('/dm', { recipientId, ...(typeof payload === 'string' ? { content: payload } : payload) });
349
+
350
+ /** One member of the server. */
351
+ async serverMember(serverId, userId) {
352
+ return new RolespaceMember(await this.get(`/servers/${serverId}/members/${userId}`));
353
+ }
354
+
355
+ /** All roles in the server, highest position first. */
356
+ async serverRoles(id) {
357
+ const resp = await this.get(`/servers/${id}/roles`);
358
+ return _unwrapList(resp).map(r => new RolespaceRole(r));
126
359
  }
127
360
 
128
- // ---- Interaction polling ----
129
361
  /**
130
- * Async iterator over interactions. Resolves the polling loop, backoff, and
131
- * cursor management for you. Use with `for await`:
362
+ * Send a message.
363
+ *
364
+ * Signatures:
365
+ * sendMessage(serverId, channelId, "plain text")
366
+ * sendMessage(serverId, channelId, "text", embed) // RolespaceEmbed
367
+ * sendMessage(serverId, channelId, "text", embed1, embed2) // multiple embeds
368
+ * sendMessage(serverId, channelId, embed) // embed only, no text
369
+ * sendMessage(serverId, channelId, { content, embeds, components, replyToMessageId })
370
+ *
371
+ * Returns a RolespaceMessage.
372
+ */
373
+ async sendMessage(serverId, channelId, ...rest) {
374
+ const payload = _buildMessagePayload(rest);
375
+ return new RolespaceMessage(await this.post(`/servers/${serverId}/channels/${channelId}/messages`, payload));
376
+ }
377
+
378
+ /** Fetch a single message by id. */
379
+ async getMessage(serverId, channelId, messageId) {
380
+ return new RolespaceMessage(await this.get(`/servers/${serverId}/channels/${channelId}/messages/${messageId}`));
381
+ }
382
+
383
+ /** Recent messages, oldest → newest. Pass `before` (a message id) to page backwards. */
384
+ async listMessages(serverId, channelId, opts) {
385
+ opts = opts || {};
386
+ const limit = opts.limit || 50;
387
+ let url = `/servers/${serverId}/channels/${channelId}/messages?limit=${limit}`;
388
+ if (opts.before) url += `&before=${encodeURIComponent(opts.before)}`;
389
+ const resp = await this.get(url);
390
+ return _unwrapList(resp).map(m => new RolespaceMessage(m));
391
+ }
392
+
393
+ /** Edit the bot's own message. Returns the updated message. */
394
+ async editMessage(serverId, channelId, messageId, newContent) {
395
+ return new RolespaceMessage(await this.patch(
396
+ `/servers/${serverId}/channels/${channelId}/messages/${messageId}`,
397
+ { content: newContent }));
398
+ }
399
+
400
+ /** Delete a message (own message OR any with ManageMessages). */
401
+ deleteMessage(serverId, channelId, messageId) {
402
+ return this.del(`/servers/${serverId}/channels/${channelId}/messages/${messageId}`);
403
+ }
404
+
405
+ /** Pin a message. Requires ManageMessages. */
406
+ pinMessage(serverId, channelId, messageId) {
407
+ return this.put(`/servers/${serverId}/channels/${channelId}/messages/${messageId}/pin`);
408
+ }
409
+
410
+ /** Unpin a message. Requires ManageMessages. */
411
+ unpinMessage(serverId, channelId, messageId) {
412
+ return this.del(`/servers/${serverId}/channels/${channelId}/messages/${messageId}/pin`);
413
+ }
414
+
415
+ /** React to a message. Emoji is URL-encoded automatically. */
416
+ addReaction(serverId, channelId, messageId, emoji) {
417
+ return this.put(`/servers/${serverId}/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`);
418
+ }
419
+
420
+ /** Remove the bot's own reaction. */
421
+ removeReaction(serverId, channelId, messageId, emoji) {
422
+ return this.del(`/servers/${serverId}/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`);
423
+ }
424
+
425
+ // ---- Channel + category management ─────────────────────────────────────
426
+
427
+ /**
428
+ * Create a channel.
429
+ * @param {object} opts
430
+ * @param {string} opts.type text | voice | announcement | forum | rules (default "text")
431
+ * @param {number} [opts.categoryId]
432
+ * @param {string} [opts.topic]
433
+ * @param {boolean} [opts.isPrivate]
434
+ */
435
+ async createChannel(serverId, name, opts) {
436
+ opts = opts || {};
437
+ return new RolespaceChannel(await this.post(`/servers/${serverId}/channels`, {
438
+ name,
439
+ type: opts.type || 'text',
440
+ categoryId: opts.categoryId,
441
+ topic: opts.topic,
442
+ isPrivate: !!opts.isPrivate,
443
+ }));
444
+ }
445
+
446
+ /** Rename and/or change a channel's topic. Pass null/undefined for fields to leave alone. */
447
+ updateChannel(serverId, channelId, opts) {
448
+ opts = opts || {};
449
+ return this.patch(`/servers/${serverId}/channels/${channelId}`, {
450
+ name: opts.name, topic: opts.topic,
451
+ });
452
+ }
453
+
454
+ /** Delete a channel and its contents. Requires ManageChannels. */
455
+ deleteChannel(serverId, channelId) {
456
+ return this.del(`/servers/${serverId}/channels/${channelId}`);
457
+ }
458
+
459
+ /** Create a category. Requires ManageChannels. */
460
+ createCategory(serverId, name) {
461
+ return this.post(`/servers/${serverId}/categories`, { name });
462
+ }
463
+
464
+ /** Rename a category. */
465
+ updateCategory(serverId, categoryId, name) {
466
+ return this.patch(`/servers/${serverId}/categories/${categoryId}`, { name });
467
+ }
468
+
469
+ /** Delete a category. With deleteChannels=true, also deletes every channel inside. */
470
+ deleteCategory(serverId, categoryId, deleteChannels) {
471
+ return this.del(`/servers/${serverId}/categories/${categoryId}?deleteChannels=${deleteChannels ? 'true' : 'false'}`);
472
+ }
473
+
474
+ // ---- Member moderation ────────────────────────────────────────────────
475
+
476
+ /** Kick a member. Requires KickMembers. */
477
+ kickMember(serverId, userId, reason) {
478
+ let url = `/servers/${serverId}/members/${userId}`;
479
+ if (reason) url += `?reason=${encodeURIComponent(reason)}`;
480
+ return this.del(url);
481
+ }
482
+
483
+ /** Ban a member. Requires BanMembers. */
484
+ banMember(serverId, userId, reason) {
485
+ return this.post(`/servers/${serverId}/members/${userId}/ban`, { reason: reason || null });
486
+ }
487
+
488
+ /** Lift a ban. */
489
+ unbanMember(serverId, userId) {
490
+ return this.del(`/servers/${serverId}/members/${userId}/ban`);
491
+ }
492
+
493
+ /** Set or clear a member's server nickname. Pass null/empty to clear. */
494
+ setNickname(serverId, userId, nickname) {
495
+ return this.patch(`/servers/${serverId}/members/${userId}/nickname`, { nickname: nickname || null });
496
+ }
497
+
498
+ /** Assign a role to a member. Requires ManageRoles. */
499
+ assignRole(serverId, userId, roleId) {
500
+ return this.put(`/servers/${serverId}/members/${userId}/roles/${roleId}`);
501
+ }
502
+
503
+ /** Remove a role from a member. */
504
+ removeRole(serverId, userId, roleId) {
505
+ return this.del(`/servers/${serverId}/members/${userId}/roles/${roleId}`);
506
+ }
507
+
508
+ // ---- Forum threads ────────────────────────────────────────────────────
509
+
510
+ /** List threads in a forum channel. */
511
+ async listThreads(serverId, channelId) {
512
+ const resp = await this.get(`/servers/${serverId}/channels/${channelId}/threads`);
513
+ return _unwrapList(resp);
514
+ }
515
+
516
+ /** Fetch a thread + its posts. */
517
+ getThread(serverId, channelId, threadId) {
518
+ return this.get(`/servers/${serverId}/channels/${channelId}/threads/${threadId}`);
519
+ }
520
+
521
+ /** Start a new thread in a forum channel. */
522
+ createThread(serverId, channelId, title, content, tags) {
523
+ return this.post(`/servers/${serverId}/channels/${channelId}/threads`,
524
+ { title, content, tags: tags ? Array.from(tags) : null });
525
+ }
526
+
527
+ /** Reply in a thread. */
528
+ replyToThread(serverId, channelId, threadId, content, replyToPostId) {
529
+ return this.post(`/servers/${serverId}/channels/${channelId}/threads/${threadId}/posts`,
530
+ { content, replyToPostId: replyToPostId || null });
531
+ }
532
+
533
+ // ---- Streams (read) ───────────────────────────────────────────────────
534
+
535
+ /** The bot's own channel status: live, viewers, title, game, HLS URL. */
536
+ myStream() { return this.get('/streams/me'); }
537
+
538
+ /** The current live directory (public). */
539
+ async liveStreams() { return _unwrapList(await this.get('/streams/live')); }
540
+
541
+ /** Public live status for any account. */
542
+ streamFor(accountId) { return this.get(`/streams/${accountId}`); }
543
+
544
+ /** The bot's stream chat moderators. Owner-only. */
545
+ async streamModerators() { return _unwrapList(await this.get('/streams/me/moderators')); }
546
+
547
+ /** The bot's stream chat bans + timeouts. Owner-only. */
548
+ async streamBans() { return _unwrapList(await this.get('/streams/me/bans')); }
549
+
550
+ // ---- Stream moderation ────────────────────────────────────────────────
551
+
552
+ /** Promote a chat moderator on the bot's channel. */
553
+ addStreamModerator(accountId) {
554
+ return this.post('/streams/me/moderators', { accountId });
555
+ }
556
+
557
+ /** Demote a chat moderator. */
558
+ removeStreamModerator(accountId) {
559
+ return this.del(`/streams/me/moderators/${accountId}`);
560
+ }
561
+
562
+ /** Permanently ban a chatter. */
563
+ banStreamChatter(accountId, reason) {
564
+ return this.post('/streams/me/bans', { accountId, reason: reason || null });
565
+ }
566
+
567
+ /** Temporarily ban a chatter for `durationSeconds`. */
568
+ timeoutStreamChatter(accountId, durationSeconds, reason) {
569
+ return this.post('/streams/me/timeouts', { accountId, durationSeconds, reason: reason || null });
570
+ }
571
+
572
+ /** Lift a ban or timeout. */
573
+ liftStreamBan(accountId) {
574
+ return this.del(`/streams/me/bans/${accountId}`);
575
+ }
576
+
577
+ /** Set chat mode (subscribers only, URL allow). */
578
+ updateStreamChatSettings(allowUrls, subscribersOnly) {
579
+ return this.patch('/streams/me/chat-settings', { allowUrls, subscribersOnly });
580
+ }
581
+
582
+ // ---- Webhooks (outgoing — event delivery) ─────────────────────────────
583
+
584
+ /** List the bot's outgoing (event-delivery) webhooks. */
585
+ async listOutgoingWebhooks() { return _unwrapList(await this.get('/webhooks/outgoing')); }
586
+
587
+ /**
588
+ * Register an outgoing webhook. Response includes a one-time `secret` — store it.
589
+ * @param {string} targetType "server" or "stream"
590
+ * @param {number} targetId server id, or bot's own account id for stream target
591
+ * @param {string} url public HTTPS endpoint receiving POSTs
592
+ * @param {string[]} events e.g. ["message.created"] or ["stream.online", "stream.offline"]
593
+ */
594
+ createOutgoingWebhook(targetType, targetId, url, events) {
595
+ return this.post('/webhooks/outgoing', { targetType, targetId, url, events: Array.from(events) });
596
+ }
597
+
598
+ /** Delete an outgoing webhook. */
599
+ deleteOutgoingWebhook(webhookId) {
600
+ return this.del(`/webhooks/outgoing/${webhookId}`);
601
+ }
602
+
603
+ // ---- Webhooks (incoming — post-to-channel URL) ────────────────────────
604
+
605
+ /** List the bot's incoming webhooks. */
606
+ async listIncomingWebhooks() { return _unwrapList(await this.get('/webhooks/incoming')); }
607
+
608
+ /** Create an incoming webhook bound to a channel. Response includes the one-time POST URL with its token. */
609
+ createIncomingWebhook(serverId, channelId, name) {
610
+ return this.post('/webhooks/incoming', { serverId, channelId, name: name || null });
611
+ }
612
+
613
+ /** Delete an incoming webhook. */
614
+ deleteIncomingWebhook(webhookId) {
615
+ return this.del(`/webhooks/incoming/${webhookId}`);
616
+ }
617
+
618
+ /**
619
+ * Post to an incoming webhook URL — anonymous (no bot token; the URL token is auth).
620
+ * Static so you can call it without instantiating a Rolespace client:
621
+ *
622
+ * await Rolespace.postIncomingWebhook(url, { content: 'Deploy done!' });
623
+ */
624
+ static async postIncomingWebhook(webhookUrl, payload) {
625
+ try {
626
+ const r = await fetch(webhookUrl, {
627
+ method: 'POST',
628
+ headers: { 'Content-Type': 'application/json' },
629
+ body: JSON.stringify(payload),
630
+ });
631
+ return r.ok;
632
+ } catch { return false; }
633
+ }
634
+
635
+ /**
636
+ * Send a direct message.
637
+ * sendDM(recipientId, "text")
638
+ * sendDM(recipientId, "text", embed[, embed...])
639
+ * sendDM(recipientId, embed)
640
+ * sendDM(recipientId, { content, embeds, ... })
641
+ */
642
+ async sendDM(recipientId, ...rest) {
643
+ const payload = _buildMessagePayload(rest);
644
+ payload.recipientId = recipientId;
645
+ return new RolespaceMessage(await this.post('/dm', payload));
646
+ }
647
+
648
+ // ---- Interaction polling ─────────────────────────────────────────────
649
+ /**
650
+ * Async iterator over interactions. Yields RolespaceInteraction instances.
132
651
  *
133
652
  * for await (const ix of rs.interactions()) {
134
- * await rs.respond(ix.id, { type: 'message', content: 'hi', ephemeral: true });
653
+ * if (ix.customId === 'book') {
654
+ * await rs.respond(ix.id, { type: 'message', content: `Hi ${ix.user.displayName}!` });
655
+ * }
135
656
  * }
136
657
  *
137
658
  * @param {object} [opts]
138
- * @param {number} [opts.idleDelayMs=1000] - Wait between empty polls.
139
- * @param {AbortSignal} [opts.signal] - Optional cancellation.
659
+ * @param {number} [opts.idleDelayMs=1000]
660
+ * @param {AbortSignal} [opts.signal]
140
661
  */
141
662
  async *interactions(opts) {
142
663
  opts = opts || {};
@@ -145,31 +666,92 @@ class Rolespace {
145
666
  while (!(opts.signal && opts.signal.aborted)) {
146
667
  const page = await this.get(`/interactions?after=${after}`);
147
668
  const data = (page && page.data) || [];
148
- for (const ix of data) yield ix;
669
+ for (const ix of data) yield new RolespaceInteraction(ix);
149
670
  if (page && typeof page.lastId === 'number') after = page.lastId;
150
671
  if (data.length === 0) await new Promise(r => setTimeout(r, idle));
151
672
  }
152
673
  }
153
674
 
154
- /** Respond to an interaction. `reply` is one of: { type: 'message' | 'update' | 'modal' | 'ack', ... }. */
675
+ /** Respond to an interaction. `reply` is { type: 'message'|'update'|'modal'|'ack', ... }. */
155
676
  respond(interactionId, reply) {
156
677
  return this.post(`/interactions/${interactionId}/callback`, reply);
157
678
  }
158
679
 
159
- // ---- Webhook signature verification ----
680
+ // ---- Listening for new messages in a channel ───────────────────────────
160
681
  /**
161
- * Verify an X-Rolespace-Signature header against a raw request body.
682
+ * Async iterator that yields new RolespaceMessage objects as they appear in a channel.
683
+ * Wraps the polling loop, cursor bookkeeping, and graceful error backoff so you can write:
162
684
  *
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.
685
+ * for await (const msg of rs.watchMessages(serverId, channelId, { ownAccountId: me.bot.id })) {
686
+ * if (msg.content.startsWith('!ping')) {
687
+ * await rs.sendMessage(serverId, channelId, 'pong');
688
+ * }
689
+ * }
167
690
  *
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}
691
+ * For high-volume / production bots, prefer outgoing webhooks (push) over polling.
692
+ * Polling is fine for low-traffic channels, dev/testing, or environments where you
693
+ * can't expose a public HTTP receiver.
694
+ *
695
+ * @param {number} serverId
696
+ * @param {number} channelId
697
+ * @param {object} [opts]
698
+ * @param {number} [opts.idleDelayMs=2000] Wait between polls.
699
+ * @param {number} [opts.batchSize=50] Max messages per poll.
700
+ * @param {string|Date} [opts.since] Don't yield messages older than this timestamp.
701
+ * Defaults to "right now" so existing history is skipped.
702
+ * @param {boolean} [opts.includeOwn=false] Yield messages posted by THIS bot. Default skips them
703
+ * to avoid common feedback loops.
704
+ * @param {number|null} [opts.ownAccountId] Required if includeOwn=false — pass `(await rs.me()).bot.id`.
705
+ * @param {AbortSignal} [opts.signal] Cancellation.
706
+ * @param {function} [opts.onError] Called with an Error on each polling failure.
172
707
  */
708
+ async *watchMessages(serverId, channelId, opts) {
709
+ opts = opts || {};
710
+ const idle = opts.idleDelayMs || 2000;
711
+ const batchSize = opts.batchSize || 50;
712
+ let lastSeenTs;
713
+ if (opts.since) {
714
+ lastSeenTs = (opts.since instanceof Date) ? opts.since.toISOString() : String(opts.since);
715
+ } else {
716
+ // Bootstrap with the most-recent message timestamp (or "now") so we DON'T replay history.
717
+ try {
718
+ const seed = await this.get(`/servers/${serverId}/channels/${channelId}/messages?limit=1`);
719
+ const seedData = (seed && seed.data) || [];
720
+ lastSeenTs = seedData.length > 0
721
+ ? seedData[seedData.length - 1].timestamp
722
+ : new Date().toISOString();
723
+ } catch (err) {
724
+ if (opts.onError) opts.onError(err);
725
+ lastSeenTs = new Date().toISOString();
726
+ }
727
+ }
728
+
729
+ while (!(opts.signal && opts.signal.aborted)) {
730
+ try {
731
+ const page = await this.get(`/servers/${serverId}/channels/${channelId}/messages?limit=${batchSize}`);
732
+ const msgs = (page && page.data) || [];
733
+ // API returns oldest → newest within the page; iterate in order so we yield in order too.
734
+ for (const m of msgs) {
735
+ if (!m || !m.timestamp || m.timestamp <= lastSeenTs) continue;
736
+ if (!opts.includeOwn && opts.ownAccountId
737
+ && m.author && Number(m.author.id) === Number(opts.ownAccountId)) {
738
+ lastSeenTs = m.timestamp;
739
+ continue;
740
+ }
741
+ yield new RolespaceMessage(m);
742
+ lastSeenTs = m.timestamp;
743
+ }
744
+ } catch (err) {
745
+ if (opts.onError) opts.onError(err);
746
+ // Back off harder on transient failures so we don't hammer a flaky API.
747
+ await new Promise(r => setTimeout(r, Math.min(30000, idle * 4)));
748
+ continue;
749
+ }
750
+ await new Promise(r => setTimeout(r, idle));
751
+ }
752
+ }
753
+
754
+ // ---- Webhook signature verification ──────────────────────────────────
173
755
  static verifyWebhook(rawBody, signatureHeader, secret) {
174
756
  if (!rawBody || !signatureHeader || !secret) return false;
175
757
  const expected = 'sha256=' + crypto.createHmac('sha256', secret)
@@ -181,10 +763,59 @@ class Rolespace {
181
763
  }
182
764
  }
183
765
 
184
- // Don't leak the bot token through stringification (e.g. when a logger
185
- // reaches for the client object).
766
+ // Don't leak the bot token through stringification.
186
767
  Object.defineProperty(Rolespace.prototype, 'toJSON', {
187
768
  value() { return { baseUrl: this._baseUrl, token: '[redacted]' }; },
188
769
  });
189
770
 
190
- module.exports = { Rolespace, RolespaceError };
771
+ // ═════════════════════════ helpers ═════════════════════════════════════
772
+
773
+ /** Unwrap { data: [...] } responses; pass through bare arrays. */
774
+ function _unwrapList(resp) {
775
+ if (Array.isArray(resp)) return resp;
776
+ if (resp && Array.isArray(resp.data)) return resp.data;
777
+ return [];
778
+ }
779
+
780
+ /**
781
+ * Build a message-shaped payload from the variadic tail of sendMessage / sendDM.
782
+ * Accepts: ["text"], ["text", embed, ...], [embed], [embed, embed], [{content, embeds, ...}].
783
+ */
784
+ function _buildMessagePayload(rest) {
785
+ if (rest.length === 0) return { content: '' };
786
+
787
+ // Single object payload — anything not a string and not a RolespaceEmbed.
788
+ if (rest.length === 1) {
789
+ const arg = rest[0];
790
+ if (typeof arg === 'string') return { content: arg };
791
+ if (arg instanceof RolespaceEmbed) return { content: '', embeds: [arg.toJSON()] };
792
+ if (arg && typeof arg === 'object') return arg; // raw payload object
793
+ }
794
+
795
+ // Mixed: string text + N embeds, or N embeds with no text (caller passed embeds directly).
796
+ let content = '';
797
+ const embeds = [];
798
+ for (const arg of rest) {
799
+ if (typeof arg === 'string') content = arg;
800
+ else if (arg instanceof RolespaceEmbed) embeds.push(arg.toJSON());
801
+ else if (arg && typeof arg === 'object') Object.assign({}, arg); // ignore stray objects to keep behavior predictable
802
+ }
803
+ return embeds.length > 0 ? { content, embeds } : { content };
804
+ }
805
+
806
+ module.exports = {
807
+ Rolespace,
808
+ RolespaceError,
809
+ RolespaceEmbed,
810
+ // Response wrappers exported so callers can `instanceof`-check or extend.
811
+ RolespaceObject,
812
+ RolespaceMe,
813
+ RolespaceUser,
814
+ RolespaceMember,
815
+ RolespaceServer,
816
+ RolespaceCategory,
817
+ RolespaceChannel,
818
+ RolespaceRole,
819
+ RolespaceMessage,
820
+ RolespaceInteraction,
821
+ };