Skip to content

Commit

Permalink
feat(anilist): add share button for hidden responses (#245)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyranet authored Nov 2, 2024
1 parent d132d3b commit 54aae5d
Show file tree
Hide file tree
Showing 13 changed files with 266 additions and 145 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"main": "dist/main.js",
"type": "module",
"imports": {
"#lib/anilist": "./dist/lib/utilities/anilist/index.js",
"#lib/*": "./dist/lib/*.js"
},
"scripts": {
Expand Down
6 changes: 4 additions & 2 deletions src/commands/search/anime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { anilistAnimeGet, anilistAnimeSearch } from '#lib/apis/anilist/anilist-utilities';
import { anilistAnimeGet, anilistAnimeSearch, handleAniListResult } from '#lib/anilist';
import { LanguageKeys } from '#lib/i18n/LanguageKeys';
import { AnimeCommand } from '#lib/structures/AnimeCommand';
import { Command, RegisterCommand } from '@skyra/http-framework';
Expand All @@ -17,13 +17,15 @@ const Root = LanguageKeys.Commands.AniList;
)
export class UserCommand extends AnimeCommand<'anime'> {
public override async chatInputRun(interaction: Command.ChatInputInteraction, options: AnimeCommand.Arguments<'anime'>) {
return this.handleResult({
const response = handleAniListResult({
interaction,
result: await anilistAnimeGet(options.anime),
kind: 'anime',
hideDescription: options['hide-description'],
hide: options.hide
});

return interaction.reply(response);
}

protected override autocompleteFetch(options: AnimeCommand.AutocompleteArguments<'anime'>) {
Expand Down
6 changes: 4 additions & 2 deletions src/commands/search/manga.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { anilistMangaGet, anilistMangaSearch } from '#lib/apis/anilist/anilist-utilities';
import { anilistMangaGet, anilistMangaSearch, handleAniListResult } from '#lib/anilist';
import { LanguageKeys } from '#lib/i18n/LanguageKeys';
import { AnimeCommand } from '#lib/structures/AnimeCommand';
import { Command, RegisterCommand } from '@skyra/http-framework';
Expand All @@ -17,13 +17,15 @@ const Root = LanguageKeys.Commands.AniList;
)
export class UserCommand extends AnimeCommand<'manga'> {
public override async chatInputRun(interaction: Command.ChatInputInteraction, options: AnimeCommand.Arguments<'manga'>) {
return this.handleResult({
const response = handleAniListResult({
interaction,
result: await anilistMangaGet(options.manga),
kind: 'manga',
hideDescription: options['hide-description'],
hide: options.hide
});

return interaction.reply(response);
}

protected override autocompleteFetch(options: AnimeCommand.AutocompleteArguments<'manga'>) {
Expand Down
23 changes: 23 additions & 0 deletions src/interaction-handlers/anilist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { anilistAnimeGet, anilistMangaGet, handleAniListResult } from '#lib/anilist';
import { InteractionHandler, type Interactions } from '@skyra/http-framework';

export class UserHandler extends InteractionHandler {
public async run(interaction: Interactions.MessageComponentButton, parameters: Parameters) {
const kind = parameters[0];
const hideDescription = parameters[1];
const id = Number(parameters[2]);

const result = await (kind === 'anime' ? anilistAnimeGet : anilistMangaGet)(id);
const response = handleAniListResult({
interaction,
result,
kind,
hideDescription: hideDescription === '1',
hide: false
});

return interaction.reply(response);
}
}

type Parameters = [kind: 'anime' | 'manga', hideDescription: '0' | '1', id: `${number}`];
3 changes: 3 additions & 0 deletions src/lib/i18n/LanguageKeys/Commands/AniList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ export const CountryChina = T('commands/anilist:countryChina');
export const CountryJapan = T('commands/anilist:countryJapan');
export const CountryKorea = T('commands/anilist:countryKorea');
export const CountryTaiwan = T('commands/anilist:countryTaiwan');

export const ButtonSource = T('commands/anilist:buttonSource');
export const ButtonShare = T('commands/anilist:buttonShare');
140 changes: 6 additions & 134 deletions src/lib/structures/AnimeCommand.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { MediaFormat, type MediaTitle } from '#lib/apis/anilist/anilist-types';
import { parseAniListDescription, type AnilistEntryTypeByKind } from '#lib/apis/anilist/anilist-utilities';
import { BrandingColors } from '#lib/common/constants';
import { getAniListTitle, MediaFormat, type AnilistEntryTypeByKind } from '#lib/anilist';
import { LanguageKeys } from '#lib/i18n/LanguageKeys';
import { durationFormatter } from '#lib/utilities/duration-formatter';
import { minutes } from '#lib/utilities/time-utilities';
import { EmbedBuilder, bold, hideLinkEmbed, hyperlink } from '@discordjs/builders';
import { AniListSearchTitleLanguage } from '@prisma/client';
import type { Result } from '@sapphire/result';
import { cutText, filterNullish, isNullishOrEmpty, isNullishOrZero } from '@sapphire/utilities';
import { Command, type AutocompleteInteractionArguments, type MessageResponseOptions } from '@skyra/http-framework';
import { getSupportedLanguageT, getSupportedUserLanguageT, resolveUserKey, type TFunction, type TypedT } from '@skyra/http-framework-i18n';
import { cutText, isNullishOrEmpty, isNullishOrZero } from '@sapphire/utilities';
import { Command, type AutocompleteInteractionArguments } from '@skyra/http-framework';
import { getSupportedUserLanguageT, type TFunction, type TypedT } from '@skyra/http-framework-i18n';
import type { FetchError } from '@skyra/safe-fetch';
import { MessageFlags, type LocaleString } from 'discord-api-types/v10';
import type { LocaleString } from 'discord-api-types/v10';

const Root = LanguageKeys.Commands.AniList;

Expand Down Expand Up @@ -49,128 +44,13 @@ export abstract class AnimeCommand<Kind extends 'anime' | 'manga'> extends Comma
const kind = t(isNullishOrEmpty(value.format) ? Root.Unknown : FormatKeys[value.format]);
const description = ` — ${year} ${kind}`;

const title = this.getTitle(value.title, t.lng as LocaleString, value.countryOfOrigin, titleLanguage);
const title = getAniListTitle(value.title, t.lng as LocaleString, value.countryOfOrigin, titleLanguage);
return `${cutText(title, 100 - description.length)}${description}`;
}

protected handleResult(options: HandlerOptions<Kind>) {
const { interaction, kind } = options;
const hide = options.hide ?? false;
const hideDescription = options.hideDescription ?? false;

const t = hide ? getSupportedUserLanguageT(interaction) : getSupportedLanguageT(interaction);
const response = options.result.match({
ok: (value) =>
isNullishOrEmpty(value) ? this.createErrorResponse(interaction, kind) : this.createResponse(value, t, hideDescription, hide),
err: () => this.createErrorResponse(interaction, kind)
});
return interaction.reply(response);
}

protected createResponse(value: AnilistEntryTypeByKind<Kind>, t: TFunction, hideDescription: boolean, hide: boolean): MessageResponseOptions {
return { embeds: [this.createEmbed(value, t, hideDescription).toJSON()], flags: hide ? MessageFlags.Ephemeral : undefined };
}

protected createErrorResponse(interaction: Command.ChatInputInteraction, kind: Kind) {
return {
content: resolveUserKey(interaction, kind === 'anime' ? Root.Anime.SearchError : Root.Manga.SearchError),
flags: MessageFlags.Ephemeral
};
}

protected abstract autocompleteFetch(
options: AnimeCommand.AutocompleteArguments<Kind>
): Promise<Result<readonly AnilistEntryTypeByKind<Kind>[], FetchError>>;

private createEmbed(value: AnilistEntryTypeByKind<Kind>, t: TFunction, hideDescription: boolean) {
const anilistTitles = t(Root.EmbedTitles);
const description = [
`**${anilistTitles.romajiName}**: ${value.title.romaji || t(LanguageKeys.Common.None)}`,
`**${anilistTitles.englishName}**: ${value.title.english || t(LanguageKeys.Common.None)}`,
`**${anilistTitles.nativeName}**: ${value.title.native || t(LanguageKeys.Common.None)}`
];

if (value.countryOfOrigin) {
description.push(`${bold(anilistTitles.countryOfOrigin)}: ${this.getCountry(t, value.countryOfOrigin)}`);
}

if ('episodes' in value && value.episodes) {
description.push(`${bold(anilistTitles.episodes)}: ${t(LanguageKeys.Common.FormatNumber, { value: value.episodes })}`);
}

if ('chapters' in value && value.chapters) {
description.push(`${bold(anilistTitles.chapters)}: ${t(LanguageKeys.Common.FormatNumber, { value: value.chapters })}`);
}

if ('volumes' in value && value.volumes) {
description.push(`${bold(anilistTitles.volumes)}: ${t(LanguageKeys.Common.FormatNumber, { value: value.volumes })}`);
}

if ('duration' in value && value.duration) {
description.push(`${bold(anilistTitles.episodeLength)}: ${durationFormatter.format(minutes(value.duration), 1)}`);
}

if (value.externalLinks?.length) {
const externalLinks = value.externalLinks
.map((link) => (link?.url && link.site ? hyperlink(link.site, hideLinkEmbed(link.url)) : undefined))
.filter(filterNullish);

if (externalLinks.length) {
description.push(`${bold(anilistTitles.externalLinks)}: ${t(LanguageKeys.Common.FormatList, { value: externalLinks })}`);
}
}

if (!hideDescription && value.description) {
description.push('', parseAniListDescription(value.description));
}

const locale = t.lng as LocaleString;
return new EmbedBuilder()
.setColor(BrandingColors.Primary)
.setTitle(this.getTitle(value.title, locale, value.countryOfOrigin, AniListSearchTitleLanguage.English))
.setURL(value.siteUrl ?? null)
.setDescription(description.join('\n'))
.setImage(`https://img.anili.st/media/${value.id}`)
.setFooter({ text: '© anilist.co' });
}

private getCountry(t: TFunction, origin: NonNullable<AnilistEntryTypeByKind<Kind>['countryOfOrigin']>) {
switch (origin) {
case 'CN':
return `${t(Root.CountryChina)} 🇨🇳`;
case 'JP':
return `${t(Root.CountryJapan)} 🇯🇵`;
case 'KR':
return `${t(Root.CountryKorea)} 🇰🇷`;
case 'TW':
return `${t(Root.CountryTaiwan)} 🇹🇼`;
default:
this.container.logger.warn(`[ANILIST] Received unknown origin: ${origin}`);
return origin;
}
}

private getTitle(title: MediaTitle, locale: LocaleString, origin: string | null | undefined, titleLanguage: AniListSearchTitleLanguage) {
switch (titleLanguage) {
case AniListSearchTitleLanguage.English:
return title.english ?? title.romaji ?? title.native!;
case AniListSearchTitleLanguage.Romaji:
return title.romaji ?? title.english ?? title.native!;
case AniListSearchTitleLanguage.Native:
return title.native ?? title.romaji ?? title.english!;
default:
return this.shouldUseNative(locale, origin ?? 'JP')
? (title.native ?? title.english ?? title.romaji!)
: (title.english ?? title.romaji ?? title.native!);
}
}

private shouldUseNative(locale: LocaleString, origin: string) {
if (locale === 'ja') return origin === 'JP';
if (locale === 'zh-CN') return origin === 'CN';
if (locale === 'ko') return origin === 'KR';
return false;
}
}

export namespace AnimeCommand {
Expand All @@ -180,14 +60,6 @@ export namespace AnimeCommand {
export type AutocompleteArguments<Kind extends 'anime' | 'manga'> = AutocompleteInteractionArguments<MakeArguments<Kind, string>>;
}

export interface HandlerOptions<Kind extends 'anime' | 'manga'> {
interaction: Command.ChatInputInteraction;
result: Result<AnilistEntryTypeByKind<Kind> | null, FetchError>;
kind: Kind;
hideDescription: boolean | null | undefined;
hide: boolean | null | undefined;
}

type Pretty<Type extends object> = { [K in keyof Type]: Type[K] };
type MakeArguments<Kind extends 'anime' | 'manga', Value extends string | number> = Pretty<
{ [key in Kind]: Value } & { hide?: boolean; 'hide-description'?: boolean }
Expand Down
3 changes: 3 additions & 0 deletions src/lib/utilities/anilist/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from '#lib/utilities/anilist/response';
export * from '#lib/utilities/anilist/types';
export * from '#lib/utilities/anilist/utilities';
Loading

0 comments on commit 54aae5d

Please # to comment.