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.
- package/package.json +1 -1
- package/rolespace.js +354 -5
package/package.json
CHANGED
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], [
|
|
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 —
|
|
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:
|
|
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
|
|
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
|
-
|
|
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,
|