booru.ts

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