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.
- package/package.json +1 -1
- package/rolespace.js +333 -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
|
*
|
|
@@ -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], [
|
|
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 —
|
|
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:
|
|
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
|
|
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
|
-
|
|
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,
|