1import {
2 ChatInputCommandInteraction,
3 EmbedBuilder,
4 SlashCommandBuilder,
5} from 'discord.js'
6import { Command } from '@/types/bot'
7import axios from 'axios'
8import { getGlobalBlacklist, getUserBlacklist, addToHistory } from '@/utils/db'
9
10const BOORU_APIS = {
11 gelbooru: {
12 url: 'https://gelbooru.com/index.php',
13 params: (tags: string) => ({
14 page: 'dapi',
15 s: 'post',
16 q: 'index',
17 json: 1,
18 tags: tags,
19 limit: 100,
20 api_key: process.env.GELBOORU_API_KEY,
21 user_id: process.env.GELBOORU_USER_ID,
22 }),
23 },
24 r34: {
25 url: 'https://api.rule34.xxx/index.php',
26 params: (tags: string) => ({
27 page: 'dapi',
28 s: 'post',
29 q: 'index',
30 json: 1,
31 tags: tags,
32 limit: 100,
33 }),
34 },
35 danbooru: {
36 url: 'https://danbooru.donmai.us/posts.json',
37 params: (tags: string) => ({
38 tags: tags,
39 random: true,
40 limit: 100,
41 }),
42 },
43 konachan: {
44 url: 'https://konachan.com/post.json',
45 params: (tags: string) => ({
46 tags: tags,
47 limit: 100,
48 }),
49 },
50 yandere: {
51 url: 'https://yande.re/post.json',
52 params: (tags: string) => ({
53 tags: tags,
54 limit: 100,
55 }),
56 },
57 safebooru: {
58 url: 'https://safebooru.org/index.php',
59 params: (tags: string) => ({
60 page: 'dapi',
61 s: 'post',
62 q: 'index',
63 json: 1,
64 tags: tags,
65 limit: 100,
66 }),
67 },
68}
69
70async function getValidPost(posts: any[], maxAttempts = 5) {
71 let attempts = 0
72 while (attempts < maxAttempts) {
73 const post = posts[Math.floor(Math.random() * posts.length)]
74 const fileUrl = post.file_url || post.large_file_url || post.file_url
75 // Check if it's not a video (common video extensions)
76 if (fileUrl && !fileUrl.match(/\.(webm|mp4|mov|avi)$/i)) {
77 return { ...post, file_url: fileUrl }
78 }
79 attempts++
80 }
81 return null
82}
83
84async function fetchFromBooru(site: keyof typeof BOORU_APIS, tags: string) {
85 const api = BOORU_APIS[site]
86 const response = await axios.get(api.url, { params: api.params(tags) })
87
88 switch (site) {
89 case 'danbooru':
90 case 'konachan':
91 case 'yandere':
92 return response.data
93 case 'r34':
94 case 'gelbooru':
95 case 'safebooru':
96 return Array.isArray(response.data) ?
97 response.data
98 : response.data?.post || []
99 default:
100 return []
101 }
102}
103
104export default {
105 data: new SlashCommandBuilder()
106 .setName('booru')
107 .setDescription('Get a random image from various booru sites')
108 .setNSFW(true)
109 .addStringOption((option) =>
110 option
111 .setName('site')
112 .setDescription('The booru site to search')
113 .setRequired(true)
114 .addChoices(
115 { name: 'Gelbooru', value: 'gelbooru' },
116 { name: 'Rule34', value: 'r34' },
117 { name: 'Danbooru', value: 'danbooru' },
118 { name: 'Konachan', value: 'konachan' },
119 { name: 'Yande.re', value: 'yandere' },
120 { name: 'Safebooru', value: 'safebooru' },
121 ),
122 )
123 .addStringOption((option) =>
124 option
125 .setName('tags')
126 .setDescription('Space-separated tags to search for')
127 .setRequired(false),
128 )
129 .addStringOption((option) =>
130 option
131 .setName('blacklist')
132 .setDescription('Space-separated tags to exclude (e.g., "tag1 tag2")')
133 .setRequired(false),
134 ),
135
136 async execute(interaction: ChatInputCommandInteraction) {
137 if (!interaction.channel?.isTextBased()) {
138 await interaction.reply({
139 content: '⚠️ This command can only be used in text-based channels!',
140 ephemeral: true,
141 })
142 return
143 }
144
145 await interaction.deferReply({ ephemeral: true })
146
147 try {
148 const site = interaction.options.getString(
149 'site',
150 true,
151 ) as keyof typeof BOORU_APIS
152 const userTags = interaction.options.getString('tags')?.trim() || ''
153 const commandBlacklist =
154 interaction.options.getString('blacklist')?.trim() || ''
155
156 // Get blacklists from database
157 const [globalBlacklist, userBlacklist] = await Promise.all([
158 getGlobalBlacklist(),
159 getUserBlacklist(interaction.user.id),
160 ])
161
162 // Process command blacklist tags
163 const formattedCommandBlacklist = commandBlacklist
164 .split(' ')
165 .filter((tag) => tag.length > 0)
166 .map((tag) => (tag.startsWith('-') ? tag : `-${tag}`))
167 .join(' ')
168
169 // Combine all tags: user tags + command blacklist + user blacklist + global blacklist
170 const tags = [
171 userTags,
172 formattedCommandBlacklist,
173 ...userBlacklist,
174 ...globalBlacklist,
175 ]
176 .filter(Boolean)
177 .join(' ')
178 .trim()
179
180 const posts = await fetchFromBooru(site, tags)
181
182 if (!posts || (Array.isArray(posts) && posts.length === 0)) {
183 await interaction.editReply('No results found for those tags.')
184 return
185 }
186
187 const validPost = await getValidPost(posts)
188
189 if (!validPost) {
190 await interaction.editReply(
191 'No valid image posts found. Try different tags.',
192 )
193 return
194 }
195
196 // Store in history
197 await addToHistory(
198 interaction.user.id,
199 site,
200 userTags.split(' ').filter(Boolean),
201 validPost.file_url,
202 )
203
204 const embed = new EmbedBuilder()
205 .setColor('#FF69B4')
206 .setImage(validPost.file_url)
207 .setFooter({ text: `Tags: ${validPost.tags?.slice(0, 100)}...` })
208 .setTimestamp()
209
210 await interaction.editReply({ embeds: [embed] })
211 } catch (error) {
212 console.error('Booru command error:', error)
213 await interaction.editReply(
214 'Failed to fetch image. Please try again later.',
215 )
216 }
217 },
218} as Command
219