From ecaaba9d3c50cf98077ddaa769028a2d5eabbc15 Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Fri, 21 Feb 2025 00:42:21 +0100 Subject: [PATCH] [nextgen/cardstack] add BUTTON_GENERATOR - support dynamic button resolving - update all commands using dynstack to latest api - add supplemental to wolfram --- commands/interaction/slash/search/anime.js | 5 +- commands/message/search/anime.js | 5 +- commands/message/search/news.js | 6 +- commands/message/search/wolfram-alpha.js | 75 ++++- labscore/api/endpoints.js | 1 + labscore/api/index.js | 8 +- labscore/cardstack/constants.js | 34 +- labscore/cardstack/stack.js | 344 ++++++++++++--------- labscore/constants.js | 1 + labscore/utils/hash.js | 53 ++++ 10 files changed, 356 insertions(+), 176 deletions(-) create mode 100644 labscore/utils/hash.js diff --git a/commands/interaction/slash/search/anime.js b/commands/interaction/slash/search/anime.js index 83bd2a4..1d3f18a 100644 --- a/commands/interaction/slash/search/anime.js +++ b/commands/interaction/slash/search/anime.js @@ -2,7 +2,7 @@ const { anime, animeSupplemental} = require('#api'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); const { createDynamicCardStack } = require("#cardstack/index"); -const { ResolveCallbackTypes } = require("#cardstack/constants"); +const { ResolveCallbackTypes, InteractiveComponentTypes} = require("#cardstack/constants"); const { hexToDecimalColor } = require("#utils/color"); const { createEmbed, page } = require('#utils/embed'); @@ -121,6 +121,7 @@ module.exports = { cards: pages, interactive: { episodes_button: { + type: InteractiveComponentTypes.BUTTON, label: "Episodes", inline: false, visible: true, @@ -176,6 +177,7 @@ module.exports = { } }, characters_button: { + type: InteractiveComponentTypes.BUTTON, label: "Characters", inline: false, visible: true, @@ -217,6 +219,7 @@ module.exports = { } }, related_button: { + type: InteractiveComponentTypes.BUTTON, label: "Related", inline: false, visible: true, diff --git a/commands/message/search/anime.js b/commands/message/search/anime.js index 6e3f5f9..4487b48 100644 --- a/commands/message/search/anime.js +++ b/commands/message/search/anime.js @@ -2,7 +2,7 @@ const { anime, animeSupplemental} = require('#api'); const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); const { createDynamicCardStack } = require("#cardstack/index"); -const { ResolveCallbackTypes } = require("#cardstack/constants"); +const { ResolveCallbackTypes, InteractiveComponentTypes} = require("#cardstack/constants"); const { hexToDecimalColor } = require("#utils/color"); const { createEmbed, page } = require('#utils/embed'); @@ -110,6 +110,7 @@ module.exports = { cards: pages, interactive: { episodes_button: { + type: InteractiveComponentTypes.BUTTON, label: "Episodes", inline: false, visible: true, @@ -165,6 +166,7 @@ module.exports = { } }, characters_button: { + type: InteractiveComponentTypes.BUTTON, label: "Characters", inline: false, visible: true, @@ -206,6 +208,7 @@ module.exports = { } }, related_button: { + type: InteractiveComponentTypes.BUTTON, label: "Related", inline: false, visible: true, diff --git a/commands/message/search/news.js b/commands/message/search/news.js index 3caa211..03d75bf 100644 --- a/commands/message/search/news.js +++ b/commands/message/search/news.js @@ -2,11 +2,10 @@ const { googleNews, googleNewsSupplemental} = require('#api'); const { PERMISSION_GROUPS } = require('#constants'); const { createDynamicCardStack } = require("#cardstack/index"); -const { ResolveCallbackTypes } = require("#cardstack/constants"); +const { ResolveCallbackTypes, InteractiveComponentTypes} = require("#cardstack/constants"); const { createEmbed, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); -const { link } = require('#utils/markdown'); const { editOrReply } = require('#utils/message'); const { STATIC_ASSETS, STATIC_ICONS, STATICS} = require("#utils/statics"); @@ -76,6 +75,7 @@ module.exports = { cards: pages, interactive: { full_coverage_button: { + type: InteractiveComponentTypes.BUTTON, label: "Full Coverage", icon: "button_full_coverage", inline: true, @@ -83,7 +83,7 @@ module.exports = { return (pg.getState("full_coverage_key") !== null) }, condition: true, - renderLoadingState: (pg) => { + renderLoadingState: () => { return createEmbed("default", context, { author: { name: "Full Coverage", diff --git a/commands/message/search/wolfram-alpha.js b/commands/message/search/wolfram-alpha.js index 3df0699..4ebc705 100644 --- a/commands/message/search/wolfram-alpha.js +++ b/commands/message/search/wolfram-alpha.js @@ -1,4 +1,4 @@ -const {wolframAlpha} = require("#api"); +const { wolframAlpha, wolframSupplemental} = require("#api"); const { paginator } = require('#client'); const { PERMISSION_GROUPS } = require('#constants'); @@ -6,10 +6,12 @@ const { createEmbed, formatPaginationEmbeds, page } = require('#utils/embed'); const { acknowledge } = require('#utils/interactions'); const { citation, smallIconPill } = require('#utils/markdown'); const { editOrReply } = require('#utils/message') -const { STATICS } = require('#utils/statics') +const { STATICS, STATIC_ASSETS} = require('#utils/statics') +const {createDynamicCardStack} = require("#cardstack/index"); +const {InteractiveComponentTypes, ResolveCallbackTypes} = require("#cardstack/constants"); function createWolframPage(context, pod, query, sources) { - let res = page(createEmbed("default", context, { + let res = createEmbed("default", context, { author: { name: pod.title, url: `https://www.wolframalpha.com/input?i=${encodeURIComponent(query)}` @@ -19,24 +21,28 @@ function createWolframPage(context, pod, query, sources) { iconUrl: STATICS.wolframalpha, text: `Wolfram|Alpha • ${context.application.name}` } - })) - if (pod.icon) res.embeds[0].author.iconUrl = pod.icon - if (pod.value) res.embeds[0].description = pod.value.substr(0, 1000) + }) + if (pod.icon) res.author.iconUrl = pod.icon + if (pod.value) res.description = pod.value.substr(0, 1000) if (pod.value && pod.refs) { for (const r of pod.refs) { - let src = Object.values(sources).filter((s) => s.ref == r)[0] + let src = Object.values(sources).filter((s) => s.ref === r)[0] if (!src) continue; // Only add a direct source if one is available if (src.collections) { - res.embeds[0].description += citation(r, src.url, src.title + ' | ' + src.collections[0].text) + res.description += citation(r, src.url, src.title + ' | ' + src.collections[0].text) continue; } - if (src.url) res.embeds[0].description += citation(r, src.url, src.title) + if (src.url) res.description += citation(r, src.url, src.title) } } - if (pod.image) res.embeds[0].image = { url: pod.image }; - return res; + if (pod.image) res.image = { url: pod.image }; + return page(res, {}, { + supplemental: pod.states, + pod_icon: pod.icon, + pod_title: pod.title + }); } module.exports = { @@ -54,7 +60,6 @@ module.exports = { permissionsClient: [...PERMISSION_GROUPS.baseline], run: async (context, args) => { await acknowledge(context); - if(context.message.messageReference) { let msg = await context.message.channel.fetchMessage(context.message.messageReference.messageId); @@ -67,16 +72,54 @@ module.exports = { let search = await wolframAlpha(context, args.query) search = search.response - if (search.body.status == 1) return editOrReply(context, createEmbed("warning", context, search.body.message)) + if (search.body.status === 1) return editOrReply(context, createEmbed("warning", context, search.body.message)) let pages = [] for (const res of search.body.data) { pages.push(createWolframPage(context, res, args.query, search.body.sources)) } - await paginator.createPaginator({ - context, - pages: formatPaginationEmbeds(pages) + + return await createDynamicCardStack(context, { + cards: pages, + interactive: { + state_buttons: { + type: InteractiveComponentTypes.BUTTON_GENERATOR, + inline: true, + // Resolve Components + resolveComponents: (pg)=>{ + if(!pg.getState("supplemental") || pg.getState("supplemental").length === 0) return []; + return pg.getState("supplemental").map((b)=>{ + return { + label: b.label, + visible: true, + condition: true, + customId: b.supplemental_key, + icon: "button_wolfram_compute", + renderLoadingState: (pg, component) => { + return createEmbed("default", context, { + author: { + name: `${pg.getState("pod_title")} › ${component.label}`, + iconUrl: pg.getState("pod_icon") + }, + image: { + url: STATIC_ASSETS.chat_loading + } + }) + }, + resolvePage: async (pg, component)=>{ + let sup = await wolframSupplemental(context, component.customId); + + return { + type: ResolveCallbackTypes.REPLACE_PARENT_CARD, + card: createWolframPage(context, sup.response.body.pod_supplemental, args.query, sup.response.body.sources) + } + } + } + }) + } + } + } }); } catch (e) { if(e.response.body?.error) return editOrReply(context, createEmbed("warning", context, e.response.body.error.message)) diff --git a/labscore/api/endpoints.js b/labscore/api/endpoints.js index 5b3fce3..bbffa30 100644 --- a/labscore/api/endpoints.js +++ b/labscore/api/endpoints.js @@ -54,6 +54,7 @@ const Api = Object.freeze({ SEARCH_WEATHER: '/search/weather', SEARCH_WIKIHOW: '/search/wikihow', SEARCH_WOLFRAM_ALPHA: '/search/wolfram-alpha', + SEARCH_WOLFRAM_SUPPLEMENTAL: '/search/wolfram-supplemental', SEARCH_YOUTUBE: '/search/youtube', TTS_IMTRANSLATOR: '/tts/imtranslator', diff --git a/labscore/api/index.js b/labscore/api/index.js index 6031c89..18feec6 100644 --- a/labscore/api/index.js +++ b/labscore/api/index.js @@ -126,7 +126,7 @@ module.exports.googleImages = async function(context, query, nsfw){ }) } -module.exports.googleNews = async function(context, query, nsfw){ +module.exports.googleNews = async function(context, query){ return await request(Api.SEARCH_GOOGLE_NEWS, "GET", {}, { q: query }) @@ -232,6 +232,12 @@ module.exports.wolframAlpha = async function(context, query){ }) } +module.exports.wolframSupplemental = async function(context, supplementalKey){ + return await request(Api.SEARCH_WOLFRAM_SUPPLEMENTAL, "GET", {}, { + supplemental_key: supplementalKey + }) +} + module.exports.youtube = async function(context, query, category){ return await request(Api.SEARCH_YOUTUBE, "GET", {}, { q: query, diff --git a/labscore/cardstack/constants.js b/labscore/cardstack/constants.js index 5cd23f3..b003302 100644 --- a/labscore/cardstack/constants.js +++ b/labscore/cardstack/constants.js @@ -22,12 +22,32 @@ module.exports.STACK_CACHE_KEYS = Object.freeze({ * - This callback type will also unselect the button * - `REPLACE_ROOT_STACK` - Replaces the root stack * - This callback type will also unselect the button + * + * @readonly + * @enum {number} */ module.exports.ResolveCallbackTypes = Object.freeze({ - SUBSTACK: 0, - REPLACE_PARENT_CARD: 1, - REPLACE_STACK: 2, - 0: "SUBSTACK", - 1: "REPLACE_PARENT_CARD", - 2: "REPLACE_STACK", -}) \ No newline at end of file + UNKNOWN_CALLBACK_TYPE: 0, + SUBSTACK: 1, + REPLACE_PARENT_CARD: 2, + REPLACE_STACK: 3 +}) + +/** + * @typedef {number} InteractiveComponentTypes + **/ + +/** + * Interactive Component Type + * + * @readonly + * @enum {InteractiveComponentTypes} + */ +module.exports.InteractiveComponentTypes = Object.freeze({ + /** Unknown Component Value */ + UNKNOWN_COMPONENT_TYPE: 0, + /** A singular dynamic button */ + BUTTON: 1, + /** Button generator that can return as many buttons as are necessary. */ + BUTTON_GENERATOR: 2 +}); \ No newline at end of file diff --git a/labscore/cardstack/stack.js b/labscore/cardstack/stack.js index 2419958..5001ffb 100644 --- a/labscore/cardstack/stack.js +++ b/labscore/cardstack/stack.js @@ -1,16 +1,23 @@ const {DISCORD_INVITES} = require("#constants"); -const { createEmbed, page } = require("#utils/embed"); -const { iconAsEmojiObject, icon, link, codeblock } = require("#utils/markdown"); -const { editOrReply } = require("#utils/message"); -const { STATIC_ASSETS } = require("#utils/statics"); +const {createEmbed, page} = require("#utils/embed"); +const {iconAsEmojiObject, icon, link, codeblock} = require("#utils/markdown"); +const {editOrReply} = require("#utils/message"); +const {STATIC_ASSETS} = require("#utils/statics"); -const { Context } = require("detritus-client/lib/command"); -const { MessageComponentTypes, InteractionCallbackTypes } = require("detritus-client/lib/constants"); -const { Message } = require("detritus-client/lib/structures"); -const { ComponentContext, Components, ComponentActionRow} = require("detritus-client/lib/utils"); +const {Context} = require("detritus-client/lib/command"); +const {MessageComponentTypes, InteractionCallbackTypes} = require("detritus-client/lib/constants"); +const {Message} = require("detritus-client/lib/structures"); +const {ComponentContext, Components, ComponentActionRow, ComponentButton} = require("detritus-client/lib/utils"); -const {DEFAULT_BUTTON_ICON_MAPPINGS, STACK_CACHE_KEYS, BuiltInButtonTypes, ResolveCallbackTypes} = require("./constants"); +const { + DEFAULT_BUTTON_ICON_MAPPINGS, + STACK_CACHE_KEYS, + BuiltInButtonTypes, + ResolveCallbackTypes +} = require("./constants"); +const {InteractiveComponentTypes} = require("#cardstack/constants"); +const {Xid} = require("#utils/hash"); /** * Stores all active card stacks @@ -38,17 +45,17 @@ class DynamicCardStack { * @param {boolean} options.pageNumbers Renders Page Numbers in the footer of all embeds in cards. * @param {Function} options.pageNumberGenerator Function that renders a page number. Default style is `Page /` */ - constructor(context, options){ + constructor(context, options) { this.context = context; this.cards = options.cards || []; - this.buttons = options.buttons || ["previous","next"] + this.buttons = options.buttons || ["previous", "next"] this.interactive_components = options.interactive || {}; this.index = options.startingIndex || 0; this.loopPages = options.loop || true; - this.expires = options.expires || 5*60*1000; + this.expires = options.expires || 5 * 60 * 1000; this.pageNumbers = options.pageNumbers || true; - this.pageNumberGenerator = options.pageNumberGenerator || ((pg)=>`Page ${pg.index + 1}/${pg.activeCardStack.length}`); + this.pageNumberGenerator = options.pageNumberGenerator || ((pg) => `Page ${pg.index + 1}/${pg.activeCardStack.length}`); this.rootIndex = this.index; @@ -56,6 +63,8 @@ class DynamicCardStack { this.pageState = []; this.currentSelectedSubcategory = null; + this.currentComponentsBatch = {}; + /* this.lastInteraction = Date.now(); this.spawned = 0; @@ -67,11 +76,11 @@ class DynamicCardStack { /** * Kills the dynamic card stack. */ - async kill(clearComponents){ + async kill(clearComponents) { clearTimeout(this.timeout); this.listener.clear(); - if(clearComponents) await this._edit(this.getCurrentCard(), []) + if (clearComponents) await this._edit(this.getCurrentCard(), []) // Remove reference to free the cardstack for GC activeStacks.delete(this.context.message || this.context.interaction); @@ -81,8 +90,9 @@ class DynamicCardStack { * Get a Stack from an attached reference (message/interaction). * @param {Message} ref Attached message/interaction * @returns {DynamicCardStack} + * @private */ - _getStackByReference(ref){ + _getStackByReference(ref) { return activeStacks.get(ref); } @@ -90,10 +100,10 @@ class DynamicCardStack { * Attaches a cardstack to its internal reference. * @private */ - _createDynamicCardStack(){ + _createDynamicCardStack() { // Kill any previously active cardstacks on this reference // (prevents oddities when editing a regular command) - if(activeStacks.get(this.context.message || this.context.interaction)){ + if (activeStacks.get(this.context.message || this.context.interaction)) { this._getStackByReference(this.context.message || this.context.interaction).kill(); } @@ -106,8 +116,8 @@ class DynamicCardStack { * @returns {number} Timeout * @private */ - _createTimeout(){ - return setTimeout(()=>{ + _createTimeout() { + return setTimeout(() => { /* // This currently isn't doable with our Components listener. if(this.spawned - this.lastInteraction <= this.expires){ @@ -125,11 +135,12 @@ class DynamicCardStack { /** * Creates a new cardstack in the given channel + * @private */ - _spawn(){ - try{ + _spawn() { + try { this._createDynamicCardStack(this.context.client); - + this.activeCardStack = [...this.cards]; this.updatePageState() @@ -138,7 +149,7 @@ class DynamicCardStack { this.listener = new Components({ timeout: this.expires, run: this._handleInteraction.bind(this), - onError: (e)=>{ + onError: (e) => { console.log(e) } }) @@ -151,7 +162,7 @@ class DynamicCardStack { ...this.getCurrentCard(), components: this.listener }); - }catch(e){ + } catch (e) { console.log(e) } } @@ -159,11 +170,11 @@ class DynamicCardStack { /** * Resolves page state for all root stack cards. */ - updatePageState(){ + updatePageState() { let i = 0; this.pageState = []; - for(const ac of this.cards){ - if(ac["_meta"]){ + for (const ac of this.cards) { + if (ac["_meta"]) { this.pageState[i] = Object.assign({}, ac["_meta"]); } i++; @@ -176,33 +187,33 @@ class DynamicCardStack { * @param index Page Index * @returns {*} */ - getCardByIndex(index){ - try{ + getCardByIndex(index) { + try { // TODO: remove this some time after launch let card = structuredClone(this.activeCardStack[index]); // This creates an error card with debug information // in case that our activeCardStack gets corrupted // or lost somehow (bad implementation) - if(!this.activeCardStack[index]) card = page(createEmbed("errordetail", this.context, { + if (!this.activeCardStack[index]) card = page(createEmbed("errordetail", this.context, { error: "Unable to resolve card.", content: `Index: \`${this.index}\`, Stack Size: \`${this.index}\`\n` + (Object.keys(this.getAllStateForPage(this.index)).length >= 1 ? codeblock("json", [JSON.stringify(this.getAllStateForPage(this.index), null, 2)]).substr(0, 5000) : "") })) - if(!card.content) card.content = ""; + if (!card.content) card.content = ""; card.content += `\n-# ${icon("flask_mini")} You are using the new page system • Leave feedback or report bugs in our ${link(DISCORD_INVITES.feedback_cardstack, "Discord Server", "labsCore Support", false)}!` // Render Page Numbers. // Conditions: // - We have more than one card in the active stack // - We have embeds in the stack - if(this.pageNumbers && card.embeds?.length && this.activeCardStack.length >= 2){ - card.embeds = card.embeds.map((e)=>{ - if(!e.footer) e.footer = { text: this.pageNumberGenerator(this) } + if (this.pageNumbers && card.embeds?.length && this.activeCardStack.length >= 2) { + card.embeds = card.embeds.map((e) => { + if (!e.footer) e.footer = {text: this.pageNumberGenerator(this)} else { - if(e.footer.text) e.footer.text += ` • ${this.pageNumberGenerator(this)}`; + if (e.footer.text) e.footer.text += ` • ${this.pageNumberGenerator(this)}`; else e.footer.text = this.pageNumberGenerator(this); } return e; @@ -210,11 +221,11 @@ class DynamicCardStack { } return card; - }catch(e){ + } catch (e) { console.log(e) return page(createEmbed("errordetail", this.context, { error: "Unable to render card:", - content: codeblock("js",[(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')]) + content: codeblock("js", [(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')]) })) } } @@ -223,29 +234,29 @@ class DynamicCardStack { * Advances the index and returns the next card from the stack. * @returns {Message} Card */ - nextCard(){ + nextCard() { this.index = this.index + 1; - if(this.index >= this.activeCardStack.length){ - if(this.loopPages) this.index = 0; + if (this.index >= this.activeCardStack.length) { + if (this.loopPages) this.index = 0; } - if(this.currentSelectedSubcategory == null) this.rootIndex = this.index; - return Object.assign(this.getCardByIndex(this.index), { components: this._renderComponents() }); + if (this.currentSelectedSubcategory == null) this.rootIndex = this.index; + return Object.assign(this.getCardByIndex(this.index), {components: this._renderComponents()}); } /** * Decreases the index and returns the next card from the stack. * @returns {Message} Card */ - previousPage(){ + previousPage() { this.index = this.index - 1; - if(this.index < 0){ - if(this.loopPages) this.index = this.activeCardStack.length - 1; + if (this.index < 0) { + if (this.loopPages) this.index = this.activeCardStack.length - 1; else this.index = 0; } - if(this.currentSelectedSubcategory == null) this.rootIndex = this.index; - return Object.assign(this.getCardByIndex(this.index), { components: this._renderComponents() }); + if (this.currentSelectedSubcategory == null) this.rootIndex = this.index; + return Object.assign(this.getCardByIndex(this.index), {components: this._renderComponents()}); } /** @@ -254,25 +265,25 @@ class DynamicCardStack { * @param {Message} cardContent Card Content * @param {boolean, Array} components Custom Components Array */ - async _edit(cardContent, components = false){ + async _edit(cardContent, components = false) { let message = Object.assign({}, cardContent); - if(!components){ + if (!components) { this.listener.components = this._renderComponents(); message.components = this.listener; } else { message.components = components; } - if(message["_meta"]) delete message["_meta"]; + if (message["_meta"]) delete message["_meta"]; - try{ + try { return editOrReply(this.context, { ...message, reference: true, allowedMentions: {parse: [], repliedUser: false} }) - }catch(e){ + } catch (e) { console.log(e) } } @@ -282,17 +293,17 @@ class DynamicCardStack { * active stack. * @returns {Message} Card */ - getCurrentCard(){ + getCurrentCard() { return this.getCardByIndex(this.index) } /** * Retrieves state from the currently active root card - * @param {String} key + * @param {String} key */ - getState(key){ - if(!this.pageState[this.rootIndex]) return null; - if(!this.pageState[this.rootIndex][key]) return null; + getState(key) { + if (!this.pageState[this.rootIndex]) return null; + if (!this.pageState[this.rootIndex][key]) return null; return this.pageState[this.rootIndex][key]; } @@ -301,7 +312,7 @@ class DynamicCardStack { * Only really intended for debugging purposes. * @returns {Object} */ - getAllState(){ + getAllState() { return this.pageState; } @@ -310,20 +321,66 @@ class DynamicCardStack { * Only really intended for debugging purposes. * @returns {Object} */ - getAllStateForPage(index){ + getAllStateForPage(index) { return this.pageState[index] || {}; } + /** + * Renders an InteractiveComponent as a ComponentButton + * @param id (Parent) Component ID + * @param button InteractiveComponent + * @param disabled Disabled by default + * @returns ComponentButton Button Component + */ + _renderButton(id, button, disabled = false) { + // Validate if the component should be visible on this page. + // If a function is provided we need to execute it. + if (typeof (button.visible) === "boolean" && button.visible === false) return null; + else if (typeof (button.visible) === "function" && !button.visible(this)) return null; + + let component = { + type: MessageComponentTypes.BUTTON, + // id/XID is used for dynamically generated components via BUTTON_GENERATOR + customId: button.customId ? id + "/" + Xid(button.customId) : id, + style: button.style || 2, + disabled: disabled + } + + // Dynamic disabling + if (!disabled && button.condition && typeof (button.condition) == "function") + component.disabled = !button.condition(this); + + // Dynamic label + if (button.label) { + if (typeof (button.label) === "function") component.label = button.label(this); + else component.label = button.label; + } + + if (button.icon) component.emoji = iconAsEmojiObject(button.icon) || undefined + + // Change color if this is the active button. + // TODO: allow overwriting the "active" color + if (this.currentSelectedSubcategory === id) component.style = 1; + + // Add to active components cache + this.currentComponentsBatch[component.customId] = button; + + return new ComponentButton(component); + } /** * Renders components and button states + * @private */ - _renderComponents(disabled = false){ + _renderComponents(disabled = false) { + // Cache of all currently active (interactive) components. + this.currentComponentsBatch = {}; + let nComponents = new ComponentActionRow({}) let nComponentsSecondary = [new ComponentActionRow({})] // First Row always starts with built-in components - for(const b of this.buttons){ + for (const b of this.buttons) { let btn = { type: MessageComponentTypes.BUTTON, customId: b, @@ -335,60 +392,51 @@ class DynamicCardStack { nComponents.addButton(btn) } - for(const b of Object.keys(this.interactive_components)){ + for (const b of Object.keys(this.interactive_components)) { let button = this.interactive_components[b]; - - // Validate if the component should be visible on this page. - // If a function is provided we need to execute it. - if(typeof(button.visible) === "boolean" && button.visible === false) continue; - else if(typeof(button.visible) === "function" && !button.visible(this)) continue; - - let component = { - type: MessageComponentTypes.BUTTON, - customId: b, - style: button.style || 2, - disabled: disabled + let renderedButtons = []; + switch (button.type) { + case InteractiveComponentTypes.BUTTON: + renderedButtons.push(this._renderButton(b, button, disabled)); + break; + case InteractiveComponentTypes.BUTTON_GENERATOR: + // Resolve buttons to be rendered + let _buttons = button.resolveComponents(this); + for (const btn of _buttons) { + renderedButtons.push(this._renderButton(b, btn, disabled)); + } + break; + default: + console.error("Unknown Component Type: " + button.type + ".") } + if (renderedButtons.length) { + // TODO: support slot field to select row + for (const r of renderedButtons) { + // Insert the component at the correct slot. + if (button.inline) { + // Ensure there is enough space for an inline component. + if (nComponents.components.length >= 5) { + // Ensure there is space on secondary rows. + if (nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) + nComponentsSecondary.push(new ComponentActionRow({})) - // Dynamic disabling - if(!disabled && button.condition && typeof(button.condition) == "function") - component.disabled = !button.condition(this); + nComponentsSecondary[nComponentsSecondary.length - 1].addButton(r); + } else { + nComponents.addButton(r); + } + } else { + // Ensure there is space on secondary rows to insert + // the component. + if (nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) + nComponentsSecondary.push(new ComponentActionRow({})) - // Dynamic label - if(button.label){ - if(typeof(button.label) === "function") component.label = button.label(this); - else component.label = button.label; - } - - if(button.icon) component.emoji = iconAsEmojiObject(button.icon) || undefined - - // Change color if this is the active button. - // TODO: allow overwriting the "active" color - if(this.currentSelectedSubcategory === b) component.style = 1; - - // Insert the component at the correct slot. - if(button.inline){ - // Ensure there is enough space for an inline component. - if(nComponents.components.length >= 5){ - // Ensure there is space on secondary rows. - if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) - nComponentsSecondary.push(new ComponentActionRow({})) - - nComponentsSecondary[nComponentsSecondary.length - 1].addButton(component); - } else { - nComponents.addButton(component); + nComponentsSecondary[nComponentsSecondary.length - 1].addButton(r); + } } - } else { - // Ensure there is space on secondary rows to insert - // the component. - if(nComponentsSecondary[nComponentsSecondary.length - 1].components.length >= 5) - nComponentsSecondary.push(new ComponentActionRow({})) - - nComponentsSecondary[nComponentsSecondary.length - 1].addButton(component); } } - if(nComponentsSecondary[0].components.length >= 1) return [nComponents, ...nComponentsSecondary] + if (nComponentsSecondary[0].components.length >= 1) return [nComponents, ...nComponentsSecondary] return [nComponents]; } @@ -409,9 +457,9 @@ class DynamicCardStack { * @param value Cache Data * @private */ - _setCachedValue(index, componentId, key, value){ - if(!this.stackCache[index]) this.stackCache[index] = {}; - if(!this.stackCache[index][componentId]) this.stackCache[index][componentId] = {}; + _setCachedValue(index, componentId, key, value) { + if (!this.stackCache[index]) this.stackCache[index] = {}; + if (!this.stackCache[index][componentId]) this.stackCache[index][componentId] = {}; this.stackCache[index][componentId][key] = value; } @@ -423,29 +471,30 @@ class DynamicCardStack { * @returns {*|null} Cached Data * @private */ - _getCachedValue(index, componentId, key){ - if(this.interactive_components[componentId].disableCache) return null; + _getCachedValue(index, componentId, key) { + if (this.currentComponentsBatch[componentId].disableCache) return null; - if(!this.stackCache[index]) return null; - if(!this.stackCache[index][componentId]) return null; - if(!this.stackCache[index][componentId][key]) return null; + if (!this.stackCache[index]) return null; + if (!this.stackCache[index][componentId]) return null; + if (!this.stackCache[index][componentId][key]) return null; return this.stackCache[index][componentId][key]; } /** * Handles an interaction from the attached components. - * @param {ComponentContext} ctx + * @param {ComponentContext} ctx + * @private */ - async _handleInteraction(ctx){ - if(ctx.user.id !== this.context.user.id) return ctx.respond({ + async _handleInteraction(ctx) { + if (ctx.user.id !== this.context.user.id) return ctx.respond({ type: InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE }) //this.lastInteraction = Date.now(); // Built-in Buttons - if(Object.values(BuiltInButtonTypes).includes(ctx.data.customId)){ - switch(ctx.data.customId){ + if (Object.values(BuiltInButtonTypes).includes(ctx.data.customId)) { + switch (ctx.data.customId) { case "next": return ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, @@ -463,30 +512,31 @@ class DynamicCardStack { } // Interactive Components - if(this.interactive_components[ctx.data.customId]){ + let cid = ctx.data.customId; + + if (this.currentComponentsBatch[cid]) { // If the selected button is already active, disable it // and restore the root stack at its previous index. - if(this.currentSelectedSubcategory === ctx.data.customId){ + if (this.currentSelectedSubcategory === cid) { this.activeCardStack = [...this.cards]; this.index = this.rootIndex; this.currentSelectedSubcategory = null; return await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, - data: Object.assign(this.getCurrentCard(), { components: this._renderComponents(false)}) + data: Object.assign(this.getCurrentCard(), {components: this._renderComponents(false)}) }) - } - else this.currentSelectedSubcategory = ctx.data.customId; + } else this.currentSelectedSubcategory = cid; let resolveTime = Date.now(); - try{ + try { // If we have a cached result, retrieve it - if(this._getCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS) !== null){ - this.activeCardStack = [...this._getCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS)]; + if (this._getCachedValue(this.rootIndex, cid, STACK_CACHE_KEYS.RESULT_CARDS) !== null) { + this.activeCardStack = [...this._getCachedValue(this.rootIndex, cid, STACK_CACHE_KEYS.RESULT_CARDS)]; await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, - data: Object.assign(this.getCurrentCard(), {components:this._renderComponents(false)}) + data: Object.assign(this.getCurrentCard(), {components: this._renderComponents(false)}) }) return; } else { @@ -494,7 +544,7 @@ class DynamicCardStack { // new stack is being fetched/rendered. Instant results should only // ever be used if we rely on local data or can guarantee almost-instant // processing/fetching times. - if(!this.interactive_components[ctx.data.customId].instantResult) { + if (!this.currentComponentsBatch[cid].instantResult) { let processingEmbed = page(createEmbed("default", ctx, { image: { url: STATIC_ASSETS.card_skeleton @@ -506,22 +556,22 @@ class DynamicCardStack { // i.e COPY_PARENT which will copy select fields // from the parent embed or SKELETON_WITH_TITLE. // -> needs iterating on visual language first - if(this.interactive_components[ctx.data.customId].renderLoadingState) - processingEmbed = page(this.interactive_components[ctx.data.customId].renderLoadingState(this)); + if (this.currentComponentsBatch[cid].renderLoadingState) + processingEmbed = page(this.currentComponentsBatch[cid].renderLoadingState(this, this.currentComponentsBatch[cid])); await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, - data: Object.assign(processingEmbed, { components: this._renderComponents(true)}) + data: Object.assign(processingEmbed, {components: this._renderComponents(true)}) }) } // Compute the active cardstack. - let resolvedNewStack = await this.interactive_components[ctx.data.customId].resolvePage(this); + let resolvedNewStack = await this.currentComponentsBatch[cid].resolvePage(this, this.currentComponentsBatch[cid]); - if(!Object.values(ResolveCallbackTypes).includes(resolvedNewStack.type)) + if (!Object.values(ResolveCallbackTypes).includes(resolvedNewStack.type)) throw new Error(`Invalid Stack Resolve Type (${resolvedNewStack.type})`); - switch(resolvedNewStack.type){ + switch (resolvedNewStack.type) { /** * SUBSTACK * @@ -541,7 +591,7 @@ class DynamicCardStack { // We currently only cache SUBSTACK responses, as the other // types probably need revalidating/refetching since the parent // has changed and might carry new data. - if(!this.interactive_components[ctx.data.customId].disableCache){ + if (!this.currentComponentsBatch[ctx.data.customId].disableCache) { this._setCachedValue(this.rootIndex, ctx.data.customId, STACK_CACHE_KEYS.RESULT_CARDS, [...this.activeCardStack]); } break; @@ -579,15 +629,15 @@ class DynamicCardStack { } } - } catch(e){ + } catch (e) { // Display an error if we're NOT // in the root stack (that would break // things badly). - if(this.currentSelectedSubcategory != null) + if (this.currentSelectedSubcategory != null) this.activeCardStack = [ page(createEmbed("errordetail", ctx, { error: "Card stack rendering failed.", - content: codeblock("js",[(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')]) + content: codeblock("js", [(e ? e.stack || e.message : e).replaceAll(process.cwd(), '')]) })) ] console.log("resolve failed:") @@ -595,10 +645,10 @@ class DynamicCardStack { } // Update the card stack with a card from the new stack. - if(this.interactive_components[ctx.data.customId].instantResult){ + if (this.currentComponentsBatch[ctx.data.customId].instantResult) { await ctx.respond({ type: InteractionCallbackTypes.UPDATE_MESSAGE, - data: Object.assign(this.getCurrentCard(), { components: this._renderComponents()}) + data: Object.assign(this.getCurrentCard(), {components: this._renderComponents()}) }) } else { // This timeout exists 1. for cosmetic reasons so people can @@ -608,12 +658,12 @@ class DynamicCardStack { // If we've already waited at least 2 seconds during processing // it *should* be safe to just edit the message now. - if((Date.now() - resolveTime) < 2000){ - setTimeout(()=>{ - return this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()})) + if ((Date.now() - resolveTime) < 2000) { + setTimeout(() => { + return this._edit(Object.assign(this.getCurrentCard(), {components: this._renderComponents()})) }, 1500) } else { - await this._edit(Object.assign(this.getCurrentCard(), {components:this._renderComponents()})) + await this._edit(Object.assign(this.getCurrentCard(), {components: this._renderComponents()})) } } diff --git a/labscore/constants.js b/labscore/constants.js index 25dcd08..251fcb2 100644 --- a/labscore/constants.js +++ b/labscore/constants.js @@ -101,6 +101,7 @@ module.exports.ICONS_NEXTGEN = Object.freeze({ "open_in_new_alt": "<:nextgen_ico_open_in_new_alt:1336075859181965322>", "button_full_coverage": "<:ico_full_coverage:1341535912017793045>", + "button_wolfram_compute": "<:ico_wolfram_compute:1342276477269577758>", /* Brands */ "brand": "<:nextgen_ico_brand:1336064940670193780>", diff --git a/labscore/utils/hash.js b/labscore/utils/hash.js new file mode 100644 index 0000000..6d49d77 --- /dev/null +++ b/labscore/utils/hash.js @@ -0,0 +1,53 @@ +// Adapted from https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/Xid.java +const START_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const CHARS = START_CHARS + "0123456789"; +const START_RADIX = START_CHARS.length; +const RADIX = CHARS.length; + +/** + * A simple utility for shortening identifiers in a stable way. Generates + * short substitution strings deterministically, using a compact + * (1 to 6 characters in length) repesentation of a 32-bit hash of the key. + * The string is suitable to be used as a JavaScript or CSS identifier. + * Collisions are possible but unlikely, depending on the underlying hash algorithm used. + * + * This substitution scheme uses case-sensitive names for maximum + * compression. Digits are also allowed in all but the first character of a + * class name. There are a few characters allowed by the CSS grammar that we + * choose not to use (e.g. the underscore and hyphen), to keep names simple. + * + * Xid should maintain as minimal dependencies as possible to ease its + * integration with other tools, such as server side HTML generators. + * + * (https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/Xid.java#L18-L32) + * + * @param input Input + * @returns {string} Xid + */ +module.exports.Xid = (input) => { + if(typeof(input)==="string"){ + var h = 0, i, c; + if (input.length === 0) return h; + for (i = 0; i < input.length; i++) { + c = input.charCodeAt(i); + h = ((h << 5) - h) + c; + h |= 0; + } + if(h<=0) h = h*-1; + input = h; + } + + const buf = new Array(6); + let len = 0; + + let l = input - Math.floor(Number.MIN_SAFE_INTEGER); + buf[len++] = START_CHARS.charAt(Math.floor(l % START_RADIX)); + input = Math.floor(l / START_RADIX); + + while (input > 0) { + buf[len++] = CHARS.charAt(input % RADIX); + input = Math.floor(input / RADIX); + } + + return buf.slice(0, len).reverse().join(""); +} \ No newline at end of file