From 0bc0186b72290a10fbd4d70f61513d9f6e2d663b Mon Sep 17 00:00:00 2001 From: bignutty <3515180-bignutty@users.noreply.gitlab.com> Date: Mon, 12 May 2025 00:46:06 +0200 Subject: [PATCH] add manga command --- commands/interaction/slash/search/manga.js | 213 +++++++++++++++++++++ commands/message/search/manga.js | 200 +++++++++++++++++++ labscore/api/endpoints.js | 1 + labscore/api/index.js | 7 + 4 files changed, 421 insertions(+) create mode 100644 commands/interaction/slash/search/manga.js create mode 100644 commands/message/search/manga.js diff --git a/commands/interaction/slash/search/manga.js b/commands/interaction/slash/search/manga.js new file mode 100644 index 0000000..212c71e --- /dev/null +++ b/commands/interaction/slash/search/manga.js @@ -0,0 +1,213 @@ +const { anime, animeSupplemental, manga} = require('#api'); +const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); + +const { createDynamicCardStack } = require("#cardstack/index"); +const { ResolveCallbackTypes, InteractiveComponentTypes} = require("#cardstack/constants"); + +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed, page } = require('#utils/embed'); +const { acknowledge } = require('#utils/interactions'); +const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); +const { editOrReply } = require('#utils/message'); +const { InteractionContextTypes, ApplicationIntegrationTypes, ApplicationCommandOptionTypes } = require('detritus-client/lib/constants'); +const { STATIC_ASSETS } = require("#utils/statics"); + +function renderMangaResultsPage(context, res, includeSupplementalData = true){ + let result = createEmbed("default", context, { + author: { + name: res.title, + url: res.url + }, + description: ``, + fields: [] + }) + + // Add Metadata to Title + if(res.dates){ + if(res.dates.start){ + if(res.dates.end && new Date(res.dates.start).getFullYear() !== new Date(res.dates.end).getFullYear()) result.author.name += ` (${new Date(res.dates.start).getFullYear()} - ${new Date(res.dates.end).getFullYear()})` + else result.author.name += ` (${new Date(res.dates.start).getFullYear()})` + } + } + + // Render Description + if(res.subtitle) result.description += `-# ${res.subtitle}\n\n`; + if(res.type !== "ANIME") result.description += pill(OMNI_ANIME_FORMAT_TYPES[res.type]) + " " + else { + if(res.subtype) result.description += pill(OMNI_ANIME_FORMAT_TYPES[res.subtype]) + " " + else result.description += pill(OMNI_ANIME_FORMAT_TYPES[res.type]) + " " + } + if(res.genres?.length) result.description += res.genres.splice(0,3).map((r)=>smallPill(r)).join(" ") + "\n"; + if(res.tags?.length) result.description += "-# " + res.tags.map((t)=>smallPill(t)).join(" ") + "\n\n"; + if(res.description) result.description += stringwrapPreserveWords(res.description, 600); + if(res.attribution?.description) result.description += `\n\n-# Source • ${res.attribution.description}` + + // Render Images + if(res.cover) result.thumbnail = { url: res.cover }; + if(res.image) result.image = { url: res.image }; + + // Render Color + if(res.color) result.color = hexToDecimalColor(res.color); + + if(res.chapters) { + result.fields.push({ + name: "Chapters", + value: `${res.chapters}`, + inline: true + }) + } + + if(res.volumes) { + result.fields.push({ + name: "Volumes", + value: `${res.volumes}`, + inline: true + }) + } + + if(res.links){ + result.fields.push({ + name: "Links", + value: res.links.map((l)=>`${link(l.url, l.label)}`).join("\n"), + inline: true + }) + } + + return page(result, {}, includeSupplementalData ? { + // Supplemental keys are provided by the backend, + // allow for fetching extra data related to results. + characters_key: res.supplemental.characters, + related_key: res.supplemental.related, + name: res.title, + color: hexToDecimalColor(res.color || COLORS_HEX.embed), + cover: res.cover + } : {}); +} + +module.exports = { + name: 'manga', + description: 'Search for Manga.', + contexts: [ + InteractionContextTypes.GUILD, + InteractionContextTypes.PRIVATE_CHANNEL, + InteractionContextTypes.BOT_DM + ], + integrationTypes: [ + ApplicationIntegrationTypes.USER_INSTALL + ], + options: [ + { + name: 'query', + description: 'Search query.', + type: ApplicationCommandOptionTypes.TEXT, + required: true + }, + { + name: 'incognito', + description: 'Makes the response only visible to you.', + type: ApplicationCommandOptionTypes.BOOLEAN, + required: false, + default: false + } + ], + run: async (context, args) => { + await acknowledge(context, args.incognito, [...PERMISSION_GROUPS.baseline_slash]); + + if(!args.query) return editOrReply(context, createEmbed("warning", context, `Missing Parameter (query).`)) + try{ + let search = await manga(context, args.query, context.channel.nsfw) + search = search.response + + if(search.body.status === 2) return editOrReply(context, createEmbed("error", context, search.body.message)) + + let pages = [] + for(const res of search.body.results){ + pages.push(renderMangaResultsPage(context, res)) + } + + if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`)) + + createDynamicCardStack(context, { + cards: pages, + interactive: { + characters_button: { + type: InteractiveComponentTypes.BUTTON, + label: "Characters", + inline: false, + visible: true, + condition: (page) => { + return (page.getState("characters_key") !== null) + }, + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} › **Characters**`, + image: { + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") + }) + }, + resolvePage: async (pg) => { + let characters = await animeSupplemental(context, pg.getState("characters_key")); + + let cards = characters.response.body.characters.map((c) => { + let card = createEmbed("default", context, { + color: pg.getState("color"), + description: `-# ${pg.getState("name")} › **Characters**\n## ${link(c.url, c.name.full)}`, + fields: [] + }) + + if (c.description) card.description += `\n\n\n${stringwrapPreserveWords(c.description, 600)}`; + if (c.image) card.image = {url: c.image}; + if (pg.getState("cover")) card.thumbnail = {url: pg.getState("cover")}; + + if (c.age) card.fields.push({name: "Age", value: c.age, inline: true}) + + return page(card) + }) + + return { + type: ResolveCallbackTypes.SUBSTACK, + cards: cards + }; + } + }, + related_button: { + type: InteractiveComponentTypes.BUTTON, + label: "Related", + inline: false, + visible: true, + condition: (page) => { + return (page.getState("related_key") !== null) + }, + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} › **Related Content**`, + image: { + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") + }) + }, + resolvePage: async (pg) => { + let episodes = await animeSupplemental(context, pg.getState("related_key")); + + let cards = episodes.response.body.relations.map((e) => renderMangaResultsPage(context, e, false)) + + return { + type: ResolveCallbackTypes.SUBSTACK, + cards: cards + }; + } + } + } + }); + }catch(e){ + if(e.response?.body?.status === 1) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + if(e.response?.body?.status === 2) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + + console.log(e) + return editOrReply(context, createEmbed("error", context, `Unable to perform anime search.`)) + } + }, +}; \ No newline at end of file diff --git a/commands/message/search/manga.js b/commands/message/search/manga.js new file mode 100644 index 0000000..c46a1b5 --- /dev/null +++ b/commands/message/search/manga.js @@ -0,0 +1,200 @@ +const { animeSupplemental, manga} = require('#api'); +const { PERMISSION_GROUPS, OMNI_ANIME_FORMAT_TYPES, COLORS_HEX} = require('#constants'); + +const { createDynamicCardStack } = require("#cardstack/index"); +const { ResolveCallbackTypes, InteractiveComponentTypes} = require("#cardstack/constants"); + +const { hexToDecimalColor } = require("#utils/color"); +const { createEmbed, page } = require('#utils/embed'); +const { acknowledge } = require('#utils/interactions'); +const { smallPill, link, pill, stringwrapPreserveWords, timestamp, TIMESTAMP_FLAGS} = require('#utils/markdown'); +const { editOrReply } = require('#utils/message'); +const { STATIC_ASSETS } = require("#utils/statics"); + +function renderMangaResultsPage(context, res, includeSupplementalData = true){ + let result = createEmbed("default", context, { + author: { + name: res.title, + url: res.url + }, + description: ``, + fields: [] + }) + + // Add Metadata to Title + if(res.dates){ + if(res.dates.start){ + if(res.dates.end && new Date(res.dates.start).getFullYear() !== new Date(res.dates.end).getFullYear()) result.author.name += ` (${new Date(res.dates.start).getFullYear()} - ${new Date(res.dates.end).getFullYear()})` + else result.author.name += ` (${new Date(res.dates.start).getFullYear()})` + } + } + + // Render Description + if(res.subtitle) result.description += `-# ${res.subtitle}\n\n`; + if(res.type !== "ANIME") result.description += pill(OMNI_ANIME_FORMAT_TYPES[res.type]) + " " + else { + if(res.subtype) result.description += pill(OMNI_ANIME_FORMAT_TYPES[res.subtype]) + " " + else result.description += pill(OMNI_ANIME_FORMAT_TYPES[res.type]) + " " + } + if(res.genres?.length) result.description += res.genres.splice(0,3).map((r)=>smallPill(r)).join(" ") + "\n"; + if(res.tags?.length) result.description += "-# " + res.tags.map((t)=>smallPill(t)).join(" ") + "\n\n"; + if(res.description) result.description += stringwrapPreserveWords(res.description, 600); + if(res.attribution?.description) result.description += `\n\n-# Source • ${res.attribution.description}` + + // Render Images + if(res.cover) result.thumbnail = { url: res.cover }; + if(res.image) result.image = { url: res.image }; + + // Render Color + if(res.color) result.color = hexToDecimalColor(res.color); + + if(res.chapters) { + result.fields.push({ + name: "Chapters", + value: `${res.chapters}`, + inline: true + }) + } + + if(res.volumes) { + result.fields.push({ + name: "Volumes", + value: `${res.volumes}`, + inline: true + }) + } + + if(res.links){ + result.fields.push({ + name: "Links", + value: res.links.map((l)=>`${link(l.url, l.label)}`).join("\n"), + inline: true + }) + } + + return page(result, {}, includeSupplementalData ? { + // Supplemental keys are provided by the backend, + // allow for fetching extra data related to results. + characters_key: res.supplemental.characters, + related_key: res.supplemental.related, + name: res.title, + color: hexToDecimalColor(res.color || COLORS_HEX.embed), + cover: res.cover + } : {}); +} + +module.exports = { + name: 'manga', + label: 'query', + aliases: ['man'], + metadata: { + description: 'Returns search results for Manga.', + description_short: 'Search Manga', + examples: [ + 'man phantom busters', + 'ani umibe no étranger' + ], + category: 'search', + usage: 'manga ', + slashCommand: "manga" + }, + permissionsClient: [...PERMISSION_GROUPS.baseline], + run: async (context, args) => { + await acknowledge(context); + + if(!args.query) return editOrReply(context, createEmbed("warning", context, `Missing Parameter (query).`)) + try{ + let search = await manga(context, args.query, context.channel.nsfw) + search = search.response + + if(search.body.status === 2) return editOrReply(context, createEmbed("error", context, search.body.message)) + + let pages = [] + for(const res of search.body.results){ + pages.push(renderMangaResultsPage(context, res)) + } + + if(!pages.length) return editOrReply(context, createEmbed("warning", context, `No results found.`)) + + createDynamicCardStack(context, { + cards: pages, + interactive: { + characters_button: { + type: InteractiveComponentTypes.BUTTON, + label: "Characters", + visible: true, + condition: (page) => { + return (page.getState("characters_key") !== null) + }, + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} › **Characters**`, + image: { + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") + }) + }, + resolvePage: async (pg) => { + let characters = await animeSupplemental(context, pg.getState("characters_key")); + + let cards = characters.response.body.characters.map((c) => { + let card = createEmbed("default", context, { + color: pg.getState("color"), + description: `-# ${pg.getState("name")} › **Characters**\n## ${link(c.url, c.name.full)}`, + fields: [] + }) + + if (c.description) card.description += `\n\n\n${stringwrapPreserveWords(c.description, 600)}`; + if (c.image) card.image = {url: c.image}; + if (pg.getState("cover")) card.thumbnail = {url: pg.getState("cover")}; + + if (c.age) card.fields.push({name: "Age", value: c.age, inline: true}) + + return page(card) + }) + + return { + type: ResolveCallbackTypes.SUBSTACK, + cards: cards + }; + } + }, + related_button: { + type: InteractiveComponentTypes.BUTTON, + label: "Related", + visible: true, + condition: (page) => { + return (page.getState("related_key") !== null) + }, + renderLoadingState: (pg) => { + return createEmbed("default", context, { + description: `-# ${pg.getState("name")} › **Related Content**`, + image: { + url: STATIC_ASSETS.card_skeleton + }, + color: pg.getState("color") + }) + }, + resolvePage: async (pg) => { + let episodes = await animeSupplemental(context, pg.getState("related_key")); + + let cards = episodes.response.body.relations.map((e) => renderMangaResultsPage(context, e, false)) + + return { + type: ResolveCallbackTypes.SUBSTACK, + cards: cards + }; + } + } + } + }); + }catch(e){ + if(e.response?.body?.status === 1) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + if(e.response?.body?.status === 2) return editOrReply(context, createEmbed("warning", context, e.response?.body?.message)) + + console.log(e) + return editOrReply(context, createEmbed("error", context, `Unable to perform manga search.`)) + } + } +}; \ No newline at end of file diff --git a/labscore/api/endpoints.js b/labscore/api/endpoints.js index 75c2df7..9327d47 100644 --- a/labscore/api/endpoints.js +++ b/labscore/api/endpoints.js @@ -33,6 +33,7 @@ const Api = Object.freeze({ OMNI_ANIME: '/omni/anime', OMNI_ANIME_SUPPLEMENTAL: '/omni/anime-supplemental', + OMNI_MANGA: '/omni/manga', OMNI_MOVIE: '/omni/movie', PHOTOFUNIA_RETRO_WAVE: '/photofunia/retro-wave', diff --git a/labscore/api/index.js b/labscore/api/index.js index 0c01e08..da8f646 100644 --- a/labscore/api/index.js +++ b/labscore/api/index.js @@ -451,6 +451,13 @@ module.exports.animeSupplemental = async function(context, supplementalKey){ }) } +module.exports.manga = async function(context, query, includeAdultContent){ + return await request(Api.OMNI_MANGA, "GET", {}, { + q: query, + include_adult: includeAdultContent + }) +} + module.exports.movie = async function(context, query, includeAdultContent){ return await request(Api.OMNI_MOVIE, "GET", {}, { q: query,