Skip to content

Commit ac297ed

Browse files
committed
feat: new api for anime
1 parent d601afb commit ac297ed

File tree

3 files changed

+91
-76
lines changed

3 files changed

+91
-76
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"scripts": {
88
"build": "tsc && cp src/webhook.png src/ship.png src/ship2.png src/deathbattle.png src/deathbattle2.png src/deathbattle3.png dist/",
99
"start": "node dist/index.js",
10-
"dev": "tsx src/index.ts",
10+
"dev": "tsx --require dotenv/config src/index.ts",
1111
"watch": "tsc -w",
1212
"format": "prettier . --write"
1313
},

src/commands/anime.ts

Lines changed: 89 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
AttachmentBuilder,
66
} from "discord.js";
77

8-
export const data = new SlashCommandBuilder()
8+
const builder = new SlashCommandBuilder()
99
.setName("anime")
1010
.setDescription("Summon a random anime image from the archives")
1111
.addStringOption((option) =>
@@ -23,43 +23,43 @@ export const data = new SlashCommandBuilder()
2323
"Exclude specific tags (comma-separated, e.g: boy, short_hair)",
2424
)
2525
.setRequired(false),
26-
)
27-
.addIntegerOption((option) =>
28-
option
29-
.setName("min_size")
30-
.setDescription("Minimum image size (width/height)")
31-
.setMinValue(100)
32-
.setMaxValue(4000)
33-
.setRequired(false),
34-
)
35-
.addIntegerOption((option) =>
36-
option
37-
.setName("max_size")
38-
.setDescription("Maximum image size (width/height)")
39-
.setMinValue(500)
40-
.setMaxValue(8000)
41-
.setRequired(false),
42-
)
43-
.addBooleanOption((option) =>
26+
);
27+
28+
const RATING_MODE_ENABLED = process.env.ANIME_RATING_MODE === "true";
29+
30+
if (RATING_MODE_ENABLED) {
31+
builder.addStringOption((option) =>
4432
option
45-
.setName("high_quality")
46-
.setDescription("Use high quality (non-compressed) images")
33+
.setName("rating")
34+
.setDescription("Content rating filter")
35+
.addChoices(
36+
{ name: "Safe", value: "safe" },
37+
{ name: "Suggestive", value: "suggestive" },
38+
{ name: "Borderline", value: "borderline" },
39+
{ name: "Explicit", value: "explicit" },
40+
)
4741
.setRequired(false),
4842
);
43+
}
44+
45+
export const data = builder;
4946

5047
interface AnimeImageResponse {
51-
file_url: string;
52-
md5: string;
48+
id: number;
49+
url: string;
50+
width?: number;
51+
height?: number;
52+
artist_id?: number;
53+
artist_name?: string | null;
5354
tags: string[];
54-
width: number;
55-
height: number;
56-
source?: string;
57-
author?: string;
58-
has_children: boolean;
59-
_id: number;
55+
source_url?: string | null;
56+
rating: "safe" | "suggestive" | "borderline" | "explicit";
57+
color_dominant?: number[];
58+
color_palette?: number[][];
6059
}
6160

62-
const API_BASE_URL = "https://pic.re";
61+
const API_BASE_URL = "https://api.nekosapi.com/v4";
62+
const FETCH_TIMEOUT = 15000; // 15 seconds
6363

6464
export async function execute(
6565
interaction: ChatInputCommandInteraction,
@@ -69,18 +69,22 @@ export async function execute(
6969
try {
7070
const includeParam = interaction.options.getString("include");
7171
const excludeParam = interaction.options.getString("exclude");
72-
const minSize = interaction.options.getInteger("min_size");
73-
const maxSize = interaction.options.getInteger("max_size");
74-
const highQuality = interaction.options.getBoolean("high_quality") ?? false;
72+
let rating = interaction.options.getString("rating");
73+
74+
if (process.env.ANIME_RATING_MODE !== "true") {
75+
rating = "safe";
76+
}
77+
7578
const params = new URLSearchParams();
79+
params.append("limit", "1");
7680

7781
if (includeParam) {
7882
const includeTags = includeParam
7983
.split(",")
8084
.map((tag) => tag.trim().toLowerCase())
8185
.filter((tag) => tag.length > 0);
8286
if (includeTags.length > 0) {
83-
params.append("in", includeTags.join(","));
87+
params.append("tags", includeTags.join(","));
8488
}
8589
}
8690

@@ -90,81 +94,81 @@ export async function execute(
9094
.map((tag) => tag.trim().toLowerCase())
9195
.filter((tag) => tag.length > 0);
9296
if (excludeTags.length > 0) {
93-
params.append("nin", excludeTags.join(","));
97+
params.append("without_tags", excludeTags.join(","));
9498
}
9599
}
96100

97-
if (minSize) {
98-
params.append("min_size", minSize.toString());
99-
}
100-
101-
if (maxSize) {
102-
params.append("max_size", maxSize.toString());
101+
if (rating) {
102+
params.append("rating", rating);
103103
}
104104

105-
if (!highQuality) {
106-
params.append("compress", "true");
107-
}
108-
109-
const metadataUrl = `${API_BASE_URL}/image.json${params.toString() ? "?" + params.toString() : ""}`;
105+
const metadataUrl = `${API_BASE_URL}/images/random?${params.toString()}`;
110106
console.log(`[ANIME] Fetching metadata from: ${metadataUrl}`);
111107

112-
const response = await fetch(metadataUrl);
108+
const metadataResponse = await fetchT(metadataUrl, FETCH_TIMEOUT);
113109

114-
if (!response.ok) {
115-
throw new Error(`API responded with status: ${response.status}`);
110+
if (!metadataResponse.ok) {
111+
throw new Error(`Metadata fetch failed with status: ${metadataResponse.status}`);
116112
}
117113

118-
const imageData = (await response.json()) as AnimeImageResponse;
114+
const data = (await metadataResponse.json()) as AnimeImageResponse[];
119115

120-
let imageUrl = imageData.file_url;
121-
if (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://")) {
122-
imageUrl = `https://${imageUrl}`;
116+
if (!data || data.length === 0) {
117+
throw new Error("No images found");
123118
}
124119

120+
const imageData = data[0];
121+
const imageUrl = imageData.url;
122+
125123
console.log(`[ANIME] Fetching image from: ${imageUrl}`);
126-
const imageResponse = await fetch(imageUrl);
124+
const imageResponse = await fetchT(imageUrl, FETCH_TIMEOUT);
127125

128126
if (!imageResponse.ok) {
129-
throw new Error(
130-
`Image fetch failed with status: ${imageResponse.status}`,
131-
);
127+
throw new Error(`Image fetch failed with status: ${imageResponse.status}`);
132128
}
133129

134130
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
135-
const urlParts = imageUrl.split(".");
136-
const extension = urlParts[urlParts.length - 1].split("?")[0] || "jpg";
131+
const contentType = imageResponse.headers.get("content-type") || "image/webp";
132+
const extension = contentType.split("/")[1] || "webp";
137133
const attachment = new AttachmentBuilder(imageBuffer, {
138-
name: `anime_${imageData._id}.${extension}`,
134+
name: `anime_${imageData.id}.${extension}`,
139135
});
140136

141137
const embed = new EmbedBuilder()
142138
.setColor("#ff90c8")
143139
.setTitle("🎨 ARTWORK SUMMONED")
144140
.setDescription("**An image has been retrieved from the archives!**")
145-
.setImage(`attachment://anime_${imageData._id}.${extension}`)
141+
.setImage(`attachment://anime_${imageData.id}.${extension}`)
146142
.addFields({
147143
name: "📊 Details",
148-
value: `**Dimensions:** ${imageData.width} × ${imageData.height}px`,
144+
value: `**ID:** ${imageData.id}`,
149145
inline: true,
150146
})
151147
.setFooter({
152-
text: "Sacred archives of pic.re",
148+
text: "Sacred archives of Nekos",
153149
})
154150
.setTimestamp();
155151

156-
if (imageData.author) {
152+
if (RATING_MODE_ENABLED) {
153+
embed.addFields({
154+
name: "🔒 Rating",
155+
value: imageData.rating,
156+
inline: true,
157+
});
158+
}
159+
160+
if (imageData.artist_name) {
157161
embed.addFields({
158162
name: "👤 Artist",
159-
value: imageData.author,
163+
value: imageData.artist_name,
160164
inline: true,
161165
});
162166
}
163167

164-
if (imageData.source) {
168+
if (imageData.source_url) {
165169
embed.addFields({
166170
name: "🔗 Source",
167-
value: `[Original Artwork](${imageData.source})`,
171+
value: `[Original Artwork](${imageData.source_url})`,
168172
inline: true,
169173
});
170174
}
@@ -193,19 +197,16 @@ export async function execute(
193197

194198
let errorMessage = "**THE ARCHIVES HAVE FAILED TO RESPOND!**";
195199

196-
// i may make these ephemerals in the future,
197-
// but for now i want to know errors in pubs too
198-
// in case the moment someone run into this issue
199200
if (error instanceof Error) {
200-
if (error.message.includes("404")) {
201+
if (error.message.includes("No images found")) {
201202
errorMessage =
202203
"**NO IMAGES FOUND MATCHING YOUR CRITERIA!** Try different tags or remove some filters.";
204+
} else if (error.message.includes("timeout")) {
205+
errorMessage =
206+
"**THE ARCHIVES ARE TAKING TOO LONG TO RESPOND!** The image server is slow. Please try again.";
203207
} else if (error.message.includes("403")) {
204208
errorMessage =
205209
"**ACCESS TO THE ARCHIVES IS FORBIDDEN!** The API may be temporarily unavailable.";
206-
} else if (error.message.includes("timeout")) {
207-
errorMessage =
208-
"**THE ARCHIVES ARE TAKING TOO LONG TO RESPOND!** Please try again.";
209210
}
210211
}
211212

@@ -214,3 +215,17 @@ export async function execute(
214215
});
215216
}
216217
}
218+
219+
async function fetchT(
220+
url: string,
221+
timeout: number,
222+
): Promise<Response> {
223+
const controller = new AbortController();
224+
const timeoutId = setTimeout(() => controller.abort(), timeout);
225+
226+
try {
227+
return await fetch(url, { signal: controller.signal });
228+
} finally {
229+
clearTimeout(timeoutId);
230+
}
231+
}

src/utils/rolePermissions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { GuildMember } from "discord.js";
22
import { FANDOM_ROLE_MAP, TDS_WIKI_STAFF } from "./roleConstants.js";
33

4-
const COMMANDS_CHANNEL_ID = "1366497229161894018";
4+
const COMMANDS_CHANNEL_ID = process.env.CMDS_CHANNEL_ID;
55

66
export enum PermissionLevel {
77
BASIC = "basic",

0 commit comments

Comments
 (0)