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
5047interface 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
6464export 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+ }
0 commit comments