rolespace 0.2.3 → 0.2.4

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 +333 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rolespace",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
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
  *
@@ -677,6 +910,64 @@ class Rolespace {
677
910
  return this.post(`/interactions/${interactionId}/callback`, reply);
678
911
  }
679
912
 
913
+ // ---- Typed interaction response helpers ─────────────────────────────────
914
+
915
+ /** Acknowledge an interaction with no visible response. */
916
+ respondAck(interactionId) {
917
+ return this.post(`/interactions/${interactionId}/callback`, { type: 'ack' });
918
+ }
919
+
920
+ /**
921
+ * Post a new message in response to an interaction. Pass extras (embeds and/or
922
+ * one Components panel) the same way as sendMessage. Set `ephemeral` to make
923
+ * the reply visible only to the user who interacted (not stored, gone on reload).
924
+ *
925
+ * await rs.respondMessage(ix.id, 'Done!');
926
+ * await rs.respondMessage(ix.id, 'Done!', { ephemeral: true });
927
+ * await rs.respondMessage(ix.id, 'Done!', embed);
928
+ * await rs.respondMessage(ix.id, 'Done!', panel, { ephemeral: true });
929
+ */
930
+ respondMessage(interactionId, content, ...rest) {
931
+ // Pull off the optional trailing options bag — { ephemeral }
932
+ let ephemeral = false;
933
+ if (rest.length > 0) {
934
+ const last = rest[rest.length - 1];
935
+ if (last && typeof last === 'object'
936
+ && !(last instanceof RolespaceEmbed)
937
+ && !(last instanceof Components)
938
+ && Object.prototype.hasOwnProperty.call(last, 'ephemeral')) {
939
+ ephemeral = !!last.ephemeral;
940
+ rest = rest.slice(0, -1);
941
+ }
942
+ }
943
+ const payload = _buildMessagePayload([content, ...rest]);
944
+ payload.type = 'message';
945
+ payload.ephemeral = ephemeral;
946
+ return this.post(`/interactions/${interactionId}/callback`, payload);
947
+ }
948
+
949
+ /**
950
+ * Edit the panel that was clicked. Pass `null` (or omit) for either to leave
951
+ * it unchanged; pass an empty `new Components()` to remove the panel entirely.
952
+ *
953
+ * await rs.respondUpdate(ix.id, 'Thanks for voting!');
954
+ * await rs.respondUpdate(ix.id, null, newPanel);
955
+ * await rs.respondUpdate(ix.id, 'Done', new Components()); // strip panel
956
+ */
957
+ respondUpdate(interactionId, content = null, components = null) {
958
+ const payload = { type: 'update' };
959
+ if (content != null) payload.content = content;
960
+ if (components != null) payload.components = components.toJSON();
961
+ return this.post(`/interactions/${interactionId}/callback`, payload);
962
+ }
963
+
964
+ /** Open a modal form in response to the interaction. Submission arrives as a
965
+ * `modal_submit` interaction with the filled values in `ix.data.fields`. */
966
+ respondModal(interactionId, modal) {
967
+ return this.post(`/interactions/${interactionId}/callback`,
968
+ { type: 'modal', modal: modal.toJSON() });
969
+ }
970
+
680
971
  // ---- Listening for new messages in a channel ───────────────────────────
681
972
  /**
682
973
  * Async iterator that yields new RolespaceMessage objects as they appear in a channel.
@@ -777,36 +1068,73 @@ function _unwrapList(resp) {
777
1068
  return [];
778
1069
  }
779
1070
 
1071
+ // Accepted permission names (case-insensitive). Mirrors the C#/Python surface.
1072
+ const _PERMISSION_KEYS = {
1073
+ administrator: 'administrator',
1074
+ manageserver: 'manageServer',
1075
+ manageroles: 'manageRoles',
1076
+ managechannels: 'manageChannels',
1077
+ managemessages: 'manageMessages',
1078
+ kickmembers: 'kickMembers',
1079
+ banmembers: 'banMembers',
1080
+ sendmessages: 'sendMessages',
1081
+ viewchannels: 'viewChannels',
1082
+ addreactions: 'addReactions',
1083
+ };
1084
+
1085
+ function _permissionFlag(perms, name) {
1086
+ if (typeof name !== 'string' || !name.trim()) return false;
1087
+ const key = _PERMISSION_KEYS[name.trim().toLowerCase()];
1088
+ return key ? !!perms[key] : false;
1089
+ }
1090
+
780
1091
  /**
781
1092
  * Build a message-shaped payload from the variadic tail of sendMessage / sendDM.
782
- * Accepts: ["text"], ["text", embed, ...], [embed], [embed, embed], [{content, embeds, ...}].
1093
+ * Accepts: ["text"], ["text", embed, ...], [embed], [embed, embed], [panel],
1094
+ * ["text", panel], ["text", embed, panel], [{content, embeds, ...}].
783
1095
  */
784
1096
  function _buildMessagePayload(rest) {
785
1097
  if (rest.length === 0) return { content: '' };
786
1098
 
787
- // Single object payload — anything not a string and not a RolespaceEmbed.
1099
+ // Single non-builder object payload — caller hand-shaped the JSON.
788
1100
  if (rest.length === 1) {
789
1101
  const arg = rest[0];
790
1102
  if (typeof arg === 'string') return { content: arg };
791
1103
  if (arg instanceof RolespaceEmbed) return { content: '', embeds: [arg.toJSON()] };
1104
+ if (arg instanceof Components) return { content: '', components: arg.toJSON() };
792
1105
  if (arg && typeof arg === 'object') return arg; // raw payload object
793
1106
  }
794
1107
 
795
- // Mixed: string text + N embeds, or N embeds with no text (caller passed embeds directly).
1108
+ // Mixed: text + any combination of embeds + one Components panel.
796
1109
  let content = '';
797
1110
  const embeds = [];
1111
+ let componentsPayload = null;
798
1112
  for (const arg of rest) {
799
1113
  if (typeof arg === 'string') content = arg;
800
1114
  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
1115
+ else if (arg instanceof Components) {
1116
+ if (componentsPayload !== null) {
1117
+ throw new Error('sendMessage: only one Components panel is allowed per message.');
1118
+ }
1119
+ componentsPayload = arg.toJSON();
1120
+ }
1121
+ // anything else: silently ignored to keep behavior predictable
802
1122
  }
803
- return embeds.length > 0 ? { content, embeds } : { content };
1123
+ const out = { content };
1124
+ if (embeds.length > 0) out.embeds = embeds;
1125
+ if (componentsPayload !== null) out.components = componentsPayload;
1126
+ return out;
804
1127
  }
805
1128
 
806
1129
  module.exports = {
807
1130
  Rolespace,
808
1131
  RolespaceError,
809
1132
  RolespaceEmbed,
1133
+ // Component / modal builders.
1134
+ Components,
1135
+ Button,
1136
+ Select,
1137
+ Modal,
810
1138
  // Response wrappers exported so callers can `instanceof`-check or extend.
811
1139
  RolespaceObject,
812
1140
  RolespaceMe,