rolespace 0.2.3 → 0.2.6

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 +354 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rolespace",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
4
4
  "description": "Official Rolespace bot SDK for Node.js",
5
5
  "main": "rolespace.js",
6
6
  "engines": { "node": ">=18" },
package/rolespace.js CHANGED
@@ -246,6 +246,173 @@ class RolespaceEmbed {
246
246
  }
247
247
  }
248
248
 
249
+ // ═════════════ Interactive components (panels of buttons + selects) ════════
250
+ //
251
+ // A bot attaches a Components panel to a message to render clickable buttons
252
+ // and select menus. When a user interacts, the bot picks the interaction up
253
+ // off `rs.interactions()` and replies with one of the `rs.respond*` helpers.
254
+ //
255
+ // Usage:
256
+ // const panel = new Components()
257
+ // .row(new Button('yes', 'Yes').success(),
258
+ // new Button('no', 'No').secondary())
259
+ // .row(new Select('topic', 'Pick a topic…')
260
+ // .option('Bugs', 'bugs')
261
+ // .option('Ideas', 'ideas'));
262
+ // await rs.sendMessage(serverId, channelId, 'Pick one:', panel);
263
+ //
264
+ // Server-side limits: max 5 rows, max 5 buttons per row, max 1 select per row,
265
+ // max 25 options per select.
266
+
267
+ /** A clickable button. Default style is 'secondary' — chain .primary() /
268
+ * .success() / .danger() / .secondary() to change it, or use Button.link(url, label)
269
+ * for a static link button. */
270
+ class Button {
271
+ constructor(customId, label) {
272
+ this.customId = customId;
273
+ this.label = label;
274
+ this.style = 'secondary';
275
+ this.url = null;
276
+ }
277
+ static link(url, label = 'Open') {
278
+ const b = new Button('', label);
279
+ b.style = 'link';
280
+ b.url = url;
281
+ return b;
282
+ }
283
+ primary() { this.style = 'primary'; return this; }
284
+ secondary() { this.style = 'secondary'; return this; }
285
+ success() { this.style = 'success'; return this; }
286
+ danger() { this.style = 'danger'; return this; }
287
+
288
+ toJSON() {
289
+ return this.style === 'link'
290
+ ? { type: 'button', style: 'link', label: this.label, url: this.url }
291
+ : { type: 'button', style: this.style, label: this.label, customId: this.customId };
292
+ }
293
+ }
294
+
295
+ /** A drop-down select menu. Add options with .option(label, value, description?).
296
+ * Default is single-select; use .range(min, max) to allow multiple selections. */
297
+ class Select {
298
+ constructor(customId, placeholder) {
299
+ this.customId = customId;
300
+ this.placeholder = placeholder || null;
301
+ this.minValues = 1;
302
+ this.maxValues = 1;
303
+ this.options = [];
304
+ }
305
+ option(label, value, description) {
306
+ const o = { label, value };
307
+ if (description != null) o.description = description;
308
+ this.options.push(o);
309
+ return this;
310
+ }
311
+ range(min, max) { this.minValues = min; this.maxValues = max; return this; }
312
+
313
+ toJSON() {
314
+ return {
315
+ type: 'select',
316
+ customId: this.customId,
317
+ placeholder: this.placeholder,
318
+ minValues: this.minValues,
319
+ maxValues: this.maxValues,
320
+ options: this.options.map(o => ({ ...o })),
321
+ };
322
+ }
323
+ }
324
+
325
+ /** The root component panel: an ordered list of rows. Each .row(...components)
326
+ * appends one row holding the given buttons/selects. */
327
+ class Components {
328
+ constructor() {
329
+ this._rows = [];
330
+ }
331
+ row(...components) {
332
+ this._rows.push(components);
333
+ return this;
334
+ }
335
+ /** Serialize to the array shape sendMessage / respond expect. */
336
+ toJSON() {
337
+ return this._rows.map(row => ({
338
+ type: 'row',
339
+ components: row.map(c => (c && typeof c.toJSON === 'function') ? c.toJSON() : c),
340
+ }));
341
+ }
342
+ }
343
+
344
+ // ═════════════════════════════════ Modals ═══════════════════════════════════
345
+ //
346
+ // A small form a bot opens in response to an interaction. The user fills it
347
+ // and submits, which arrives as a `modal_submit` interaction with the filled
348
+ // values in `ix.data.fields`.
349
+ //
350
+ // Usage:
351
+ // const modal = new Modal('bug-form', 'Report a bug')
352
+ // .short('summary', 'Summary')
353
+ // .paragraph('details', 'What happened?').optional();
354
+ // await rs.respondModal(ix.id, modal);
355
+
356
+ /** A modal form — title, customId, and up to 5 inputs. Add inputs with
357
+ * .short() / .paragraph() / .image(). Chain .optional() after an input to make
358
+ * it not required. */
359
+ class Modal {
360
+ constructor(customId, title) {
361
+ this.customId = customId;
362
+ this.title = title;
363
+ this._inputs = [];
364
+ }
365
+ short(customId, label, opts) {
366
+ opts = opts || {};
367
+ this._inputs.push({
368
+ customId, label, style: 'short',
369
+ placeholder: opts.placeholder || null,
370
+ required: true,
371
+ maxLength: opts.maxLength || 1000,
372
+ value: opts.value || null,
373
+ });
374
+ return this;
375
+ }
376
+ paragraph(customId, label, opts) {
377
+ opts = opts || {};
378
+ this._inputs.push({
379
+ customId, label, style: 'paragraph',
380
+ placeholder: opts.placeholder || null,
381
+ required: true,
382
+ maxLength: opts.maxLength || 1000,
383
+ value: opts.value || null,
384
+ });
385
+ return this;
386
+ }
387
+ image(customId, label, opts) {
388
+ opts = opts || {};
389
+ this._inputs.push({
390
+ customId, label, style: 'image',
391
+ required: true,
392
+ multiple: !!opts.multiple,
393
+ currentUrl: opts.currentUrl || null,
394
+ });
395
+ return this;
396
+ }
397
+ /** Mark the LAST added input as optional. */
398
+ optional() {
399
+ if (this._inputs.length) this._inputs[this._inputs.length - 1].required = false;
400
+ return this;
401
+ }
402
+ /** Mark the last added input as required (already the default). */
403
+ required() {
404
+ if (this._inputs.length) this._inputs[this._inputs.length - 1].required = true;
405
+ return this;
406
+ }
407
+ toJSON() {
408
+ return {
409
+ title: this.title,
410
+ customId: this.customId,
411
+ inputs: this._inputs.map(i => ({ ...i })),
412
+ };
413
+ }
414
+ }
415
+
249
416
  // ════════════════════════ Main client ══════════════════════════════════
250
417
 
251
418
  class Rolespace {
@@ -358,6 +525,72 @@ class Rolespace {
358
525
  return _unwrapList(resp).map(r => new RolespaceRole(r));
359
526
  }
360
527
 
528
+ // ---- Role / permission convenience helpers ──────────────────────────
529
+
530
+ /**
531
+ * Resolve a member's role ids into the full RolespaceRole objects
532
+ * (name, color, permissions). One serverMember + one serverRoles call.
533
+ * @returns {Promise<RolespaceRole[]>}
534
+ */
535
+ async memberRoles(serverId, userId) {
536
+ const [member, roles] = await Promise.all([
537
+ this.serverMember(serverId, userId),
538
+ this.serverRoles(serverId),
539
+ ]);
540
+ const ids = new Set(member.roleIds.map(String));
541
+ return roles.filter(r => ids.has(String(r.id)));
542
+ }
543
+
544
+ /**
545
+ * True if the member has the given role. Pass a numeric id, or a name string
546
+ * (case-insensitive). Returns false if no role with that name exists.
547
+ *
548
+ * if (await rs.hasRole(serverId, msg.author.id, 'Moderator')) { ... }
549
+ * if (await rs.hasRole(serverId, msg.author.id, modRoleId)) { ... }
550
+ *
551
+ * @returns {Promise<boolean>}
552
+ */
553
+ async hasRole(serverId, userId, role) {
554
+ const member = await this.serverMember(serverId, userId);
555
+ if (typeof role === 'number' || typeof role === 'bigint') {
556
+ const wanted = String(role);
557
+ return member.roleIds.some(id => String(id) === wanted);
558
+ }
559
+ if (typeof role !== 'string' || !role.trim()) return false;
560
+ const wanted = role.trim().toLowerCase();
561
+ const roles = await this.serverRoles(serverId);
562
+ const match = roles.find(r => (r.name || '').toLowerCase() === wanted);
563
+ if (!match) return false;
564
+ const wantedId = String(match.id);
565
+ return member.roleIds.some(id => String(id) === wantedId);
566
+ }
567
+
568
+ /**
569
+ * True if the member is allowed to perform the named action. The server owner
570
+ * and anyone with the `administrator` flag always pass.
571
+ *
572
+ * Accepted names (case-insensitive): administrator, manageServer, manageRoles,
573
+ * manageChannels, manageMessages, kickMembers, banMembers, sendMessages,
574
+ * viewChannels, addReactions.
575
+ *
576
+ * if (await rs.hasPermission(serverId, msg.author.id, 'banMembers')) { ... }
577
+ *
578
+ * @returns {Promise<boolean>}
579
+ */
580
+ async hasPermission(serverId, userId, permission) {
581
+ const member = await this.serverMember(serverId, userId);
582
+ if (member.isOwner) return true;
583
+ const roles = await this.serverRoles(serverId);
584
+ const ids = new Set(member.roleIds.map(String));
585
+ for (const r of roles) {
586
+ if (!ids.has(String(r.id))) continue;
587
+ const perms = r.permissions || {};
588
+ if (perms.administrator) return true;
589
+ if (_permissionFlag(perms, permission)) return true;
590
+ }
591
+ return false;
592
+ }
593
+
361
594
  /**
362
595
  * Send a message.
363
596
  *
@@ -375,6 +608,27 @@ class Rolespace {
375
608
  return new RolespaceMessage(await this.post(`/servers/${serverId}/channels/${channelId}/messages`, payload));
376
609
  }
377
610
 
611
+ /**
612
+ * Send an ephemeral message to a single user in a channel — they see it,
613
+ * nobody else does, and it's gone on reload. Accepts the same extras as
614
+ * sendMessage (embeds and/or one Components panel).
615
+ *
616
+ * Same scopes/permissions as a normal send, plus the recipient must be
617
+ * able to view the channel.
618
+ *
619
+ * // Reply ephemerally to whoever just typed "!secret":
620
+ * await rs.sendEphemeral(serverId, channelId, msg.author.id,
621
+ * "Here's your private info 👀");
622
+ *
623
+ * // With an embed + button panel:
624
+ * await rs.sendEphemeral(serverId, channelId, userId, "Take your pick:", embed, panel);
625
+ */
626
+ async sendEphemeral(serverId, channelId, recipientUserId, ...rest) {
627
+ const payload = _buildMessagePayload(rest);
628
+ payload.recipientAccountId = recipientUserId;
629
+ return this.post(`/servers/${serverId}/channels/${channelId}/ephemeral`, payload);
630
+ }
631
+
378
632
  /** Fetch a single message by id. */
379
633
  async getMessage(serverId, channelId, messageId) {
380
634
  return new RolespaceMessage(await this.get(`/servers/${serverId}/channels/${channelId}/messages/${messageId}`));
@@ -677,6 +931,64 @@ class Rolespace {
677
931
  return this.post(`/interactions/${interactionId}/callback`, reply);
678
932
  }
679
933
 
934
+ // ---- Typed interaction response helpers ─────────────────────────────────
935
+
936
+ /** Acknowledge an interaction with no visible response. */
937
+ respondAck(interactionId) {
938
+ return this.post(`/interactions/${interactionId}/callback`, { type: 'ack' });
939
+ }
940
+
941
+ /**
942
+ * Post a new message in response to an interaction. Pass extras (embeds and/or
943
+ * one Components panel) the same way as sendMessage. Set `ephemeral` to make
944
+ * the reply visible only to the user who interacted (not stored, gone on reload).
945
+ *
946
+ * await rs.respondMessage(ix.id, 'Done!');
947
+ * await rs.respondMessage(ix.id, 'Done!', { ephemeral: true });
948
+ * await rs.respondMessage(ix.id, 'Done!', embed);
949
+ * await rs.respondMessage(ix.id, 'Done!', panel, { ephemeral: true });
950
+ */
951
+ respondMessage(interactionId, content, ...rest) {
952
+ // Pull off the optional trailing options bag — { ephemeral }
953
+ let ephemeral = false;
954
+ if (rest.length > 0) {
955
+ const last = rest[rest.length - 1];
956
+ if (last && typeof last === 'object'
957
+ && !(last instanceof RolespaceEmbed)
958
+ && !(last instanceof Components)
959
+ && Object.prototype.hasOwnProperty.call(last, 'ephemeral')) {
960
+ ephemeral = !!last.ephemeral;
961
+ rest = rest.slice(0, -1);
962
+ }
963
+ }
964
+ const payload = _buildMessagePayload([content, ...rest]);
965
+ payload.type = 'message';
966
+ payload.ephemeral = ephemeral;
967
+ return this.post(`/interactions/${interactionId}/callback`, payload);
968
+ }
969
+
970
+ /**
971
+ * Edit the panel that was clicked. Pass `null` (or omit) for either to leave
972
+ * it unchanged; pass an empty `new Components()` to remove the panel entirely.
973
+ *
974
+ * await rs.respondUpdate(ix.id, 'Thanks for voting!');
975
+ * await rs.respondUpdate(ix.id, null, newPanel);
976
+ * await rs.respondUpdate(ix.id, 'Done', new Components()); // strip panel
977
+ */
978
+ respondUpdate(interactionId, content = null, components = null) {
979
+ const payload = { type: 'update' };
980
+ if (content != null) payload.content = content;
981
+ if (components != null) payload.components = components.toJSON();
982
+ return this.post(`/interactions/${interactionId}/callback`, payload);
983
+ }
984
+
985
+ /** Open a modal form in response to the interaction. Submission arrives as a
986
+ * `modal_submit` interaction with the filled values in `ix.data.fields`. */
987
+ respondModal(interactionId, modal) {
988
+ return this.post(`/interactions/${interactionId}/callback`,
989
+ { type: 'modal', modal: modal.toJSON() });
990
+ }
991
+
680
992
  // ---- Listening for new messages in a channel ───────────────────────────
681
993
  /**
682
994
  * Async iterator that yields new RolespaceMessage objects as they appear in a channel.
@@ -777,36 +1089,73 @@ function _unwrapList(resp) {
777
1089
  return [];
778
1090
  }
779
1091
 
1092
+ // Accepted permission names (case-insensitive). Mirrors the C#/Python surface.
1093
+ const _PERMISSION_KEYS = {
1094
+ administrator: 'administrator',
1095
+ manageserver: 'manageServer',
1096
+ manageroles: 'manageRoles',
1097
+ managechannels: 'manageChannels',
1098
+ managemessages: 'manageMessages',
1099
+ kickmembers: 'kickMembers',
1100
+ banmembers: 'banMembers',
1101
+ sendmessages: 'sendMessages',
1102
+ viewchannels: 'viewChannels',
1103
+ addreactions: 'addReactions',
1104
+ };
1105
+
1106
+ function _permissionFlag(perms, name) {
1107
+ if (typeof name !== 'string' || !name.trim()) return false;
1108
+ const key = _PERMISSION_KEYS[name.trim().toLowerCase()];
1109
+ return key ? !!perms[key] : false;
1110
+ }
1111
+
780
1112
  /**
781
1113
  * Build a message-shaped payload from the variadic tail of sendMessage / sendDM.
782
- * Accepts: ["text"], ["text", embed, ...], [embed], [embed, embed], [{content, embeds, ...}].
1114
+ * Accepts: ["text"], ["text", embed, ...], [embed], [embed, embed], [panel],
1115
+ * ["text", panel], ["text", embed, panel], [{content, embeds, ...}].
783
1116
  */
784
1117
  function _buildMessagePayload(rest) {
785
1118
  if (rest.length === 0) return { content: '' };
786
1119
 
787
- // Single object payload — anything not a string and not a RolespaceEmbed.
1120
+ // Single non-builder object payload — caller hand-shaped the JSON.
788
1121
  if (rest.length === 1) {
789
1122
  const arg = rest[0];
790
1123
  if (typeof arg === 'string') return { content: arg };
791
1124
  if (arg instanceof RolespaceEmbed) return { content: '', embeds: [arg.toJSON()] };
1125
+ if (arg instanceof Components) return { content: '', components: arg.toJSON() };
792
1126
  if (arg && typeof arg === 'object') return arg; // raw payload object
793
1127
  }
794
1128
 
795
- // Mixed: string text + N embeds, or N embeds with no text (caller passed embeds directly).
1129
+ // Mixed: text + any combination of embeds + one Components panel.
796
1130
  let content = '';
797
1131
  const embeds = [];
1132
+ let componentsPayload = null;
798
1133
  for (const arg of rest) {
799
1134
  if (typeof arg === 'string') content = arg;
800
1135
  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
1136
+ else if (arg instanceof Components) {
1137
+ if (componentsPayload !== null) {
1138
+ throw new Error('sendMessage: only one Components panel is allowed per message.');
1139
+ }
1140
+ componentsPayload = arg.toJSON();
1141
+ }
1142
+ // anything else: silently ignored to keep behavior predictable
802
1143
  }
803
- return embeds.length > 0 ? { content, embeds } : { content };
1144
+ const out = { content };
1145
+ if (embeds.length > 0) out.embeds = embeds;
1146
+ if (componentsPayload !== null) out.components = componentsPayload;
1147
+ return out;
804
1148
  }
805
1149
 
806
1150
  module.exports = {
807
1151
  Rolespace,
808
1152
  RolespaceError,
809
1153
  RolespaceEmbed,
1154
+ // Component / modal builders.
1155
+ Components,
1156
+ Button,
1157
+ Select,
1158
+ Modal,
810
1159
  // Response wrappers exported so callers can `instanceof`-check or extend.
811
1160
  RolespaceObject,
812
1161
  RolespaceMe,