Aethel Bot OSS repository! aethel.xyz
bot fun ai discord discord-bot aethel
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: New landing page, blocked NSFW for social command, and unallowed words check for AI's response.

+446 -193
+3 -1
Dockerfile
··· 4 4 ARG VITE_BOT_API_URL 5 5 ARG VITE_STATUS_API_KEY 6 6 ARG VITE_FRONTEND_URL 7 + ARG STATUS_API_KEY 7 8 8 9 ENV SOURCE_COMMIT=${SOURCE_COMMIT} 9 10 ENV NODE_ENV=production 10 11 ENV VITE_BOT_API_URL=${VITE_BOT_API_URL} 11 12 ENV VITE_STATUS_API_KEY=${VITE_STATUS_API_KEY} 12 13 ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL} 14 + ENV STATUS_API_KEY=${STATUS_API_KEY} 13 15 14 16 WORKDIR /app 15 17 ··· 54 56 ENV SOURCE_COMMIT=${SOURCE_COMMIT} 55 57 ENV NODE_ENV=production 56 58 ENV VITE_BOT_API_URL=${VITE_BOT_API_URL} 57 - ENV STATUS_API_KEY=${VITE_STATUS_API_KEY} 59 + ENV STATUS_API_KEY=${STATUS_API_KEY} 58 60 ENV VITE_STATUS_API_KEY=${STATUS_API_KEY} 59 61 ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL} 60 62
+21 -1
src/commands/utilities/ai.ts
··· 439 439 const prompt = interaction.options.getString('prompt')!; 440 440 commandLogger.logFromInteraction( 441 441 interaction, 442 - `prompt: "${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}"`, 442 + `AI command executed - prompt content hidden for privacy`, 443 443 ); 444 444 445 445 const invokerId = getInvokerId(interaction); ··· 479 479 const aiResponse = await makeAIRequest(config, conversation); 480 480 if (!aiResponse) return; 481 481 482 + const { getUnallowedWordCategory } = await import('@/utils/validation'); 483 + const category = getUnallowedWordCategory(aiResponse.content); 484 + if (category) { 485 + logger.warn(`AI response contained unallowed words in category: ${category}`); 486 + await interaction.editReply( 487 + 'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.', 488 + ); 489 + return; 490 + } 491 + 482 492 const updatedConversation = [ 483 493 ...conversation.filter((msg) => msg.role !== 'system'), 484 494 { role: 'assistant', content: aiResponse.content }, ··· 515 525 } 516 526 517 527 fullResponse += aiResponse.content; 528 + 529 + const { getUnallowedWordCategory } = await import('@/utils/validation'); 530 + const category = getUnallowedWordCategory(fullResponse); 531 + if (category) { 532 + logger.warn(`AI response contained unallowed words in category: ${category}`); 533 + await interaction.editReply( 534 + 'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.', 535 + ); 536 + return; 537 + } 518 538 519 539 const urlProcessedResponse = processUrls(fullResponse); 520 540 const chunks = splitResponseIntoChunks(urlProcessedResponse);
+15 -1
src/events/messageCreate.ts
··· 57 57 logger.info(isDM ? 'Processing DM message...' : 'Processing mention in server...'); 58 58 59 59 try { 60 - logger.debug(`${isDM ? 'DM' : 'Message'} received (${message.content.length} characters)`); 60 + logger.debug( 61 + `${isDM ? 'DM' : 'Message'} received (${message.content.length} characters) - content hidden for privacy`, 62 + ); 61 63 62 64 const conversationKey = getConversationKey(message); 63 65 const conversation = conversations.get(conversationKey) || []; ··· 257 259 258 260 aiResponse.content = processUrls(aiResponse.content); 259 261 aiResponse.content = aiResponse.content.replace(/@(everyone|here)/gi, '@\u200b$1'); 262 + 263 + const { getUnallowedWordCategory } = await import('@/utils/validation'); 264 + const category = getUnallowedWordCategory(aiResponse.content); 265 + if (category) { 266 + logger.warn(`AI response contained unallowed words in category: ${category}`); 267 + await message.reply({ 268 + content: 269 + 'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.', 270 + allowedMentions: { parse: ['users'] as const }, 271 + }); 272 + return; 273 + } 260 274 261 275 await this.sendResponse(message, aiResponse); 262 276
+5 -4
src/handlers/initialzeCommands.ts
··· 1 1 import * as config from '@/config'; 2 2 import BotClient, { srcDir } from '@/services/Client'; 3 3 import { SlashCommandProps, RemindCommandProps } from '@/types/command'; 4 + import logger from '@/utils/logger'; 4 5 import { REST, Routes, SlashCommandBuilder, ContextMenuCommandBuilder } from 'discord.js'; 5 6 import { readdirSync } from 'fs'; 6 7 import path from 'path'; ··· 17 18 } 18 19 19 20 export default async (c: BotClient) => { 20 - console.log('Processing commands...'); 21 + logger.info('Processing commands...'); 21 22 const cmdDir = path.join(srcDir, 'commands'); 22 23 const cmdCat = readdirSync(cmdDir); 23 24 const commands: (SlashCommandBuilder | ContextMenuCommandBuilder)[] = []; ··· 56 57 try { 57 58 const rest = new REST({ version: '10' }).setToken(config.TOKEN!); 58 59 await rest.put(Routes.applicationCommands(config.CLIENT_ID!), { body: commands }); 59 - console.log('✅ All commands registered successfully'); 60 + logger.info('All commands registered successfully'); 60 61 } catch (error) { 61 - console.error('Error on deploying commands:', error); 62 - console.log('Bot will continue running with existing commands'); 62 + logger.error('Error on deploying commands:', error); 63 + logger.info('Bot will continue running with existing commands'); 63 64 } 64 65 };
+6 -5
src/index.ts
··· 13 13 import apiKeysRoutes from './routes/apiKeys'; 14 14 import remindersRoutes from './routes/reminders'; 15 15 import { resetOldStrikes } from './utils/userStrikes'; 16 + import logger from './utils/logger'; 16 17 17 18 config(); 18 19 19 20 process.on('uncaughtException', (err) => { 20 - console.error('Uncaught Exception:', err); 21 + logger.error('Uncaught Exception:', err); 21 22 process.exit(1); 22 23 }); 23 24 process.on('unhandledRejection', (reason, promise) => { 24 - console.error('🔥 Unhandled Rejection at:', promise); 25 - console.error('📄 Reason:', reason); 25 + logger.error('🔥 Unhandled Rejection at:', promise); 26 + logger.error('📄 Reason:', reason); 26 27 }); 27 28 28 29 const app = e(); ··· 92 93 93 94 setInterval( 94 95 () => { 95 - resetOldStrikes().catch(console.error); 96 + resetOldStrikes().catch(logger.error); 96 97 }, 97 98 60 * 60 * 1000, 98 99 ); 99 100 100 101 app.listen(PORT, () => { 101 - console.log('Aethel is live on', `http://localhost:${PORT}`); 102 + logger.debug('Aethel is live on', `http://localhost:${PORT}`); 102 103 });
+6 -5
src/services/Client.ts
··· 8 8 import path from 'path'; 9 9 import { fileURLToPath } from 'url'; 10 10 import { dirname } from 'path'; 11 + import logger from '@/utils/logger'; 11 12 12 13 const __filename = fileURLToPath(import.meta.url); 13 14 const __dirname = dirname(__filename); ··· 71 72 } 72 73 73 74 private async setupLocalization() { 74 - console.log('Loading localization files...'); 75 + logger.info('Loading localization files...'); 75 76 const localesDir = path.join(srcDir, '..', 'locales'); 76 77 77 78 try { ··· 83 84 const data = await promises.readFile(localeFile, { encoding: 'utf8' }); 84 85 const localeKey = locale.split('.')[0]; 85 86 this.t.set(localeKey, JSON.parse(data)); 86 - console.log(`Loaded locale: ${localeKey}`); 87 + logger.debug(`Loaded locale: ${localeKey}`); 87 88 } catch (error) { 88 - console.error(`Failed to load locale file ${locale}:`, error); 89 + logger.error(`Failed to load locale file ${locale}:`, error); 89 90 } 90 91 }); 91 92 92 93 await Promise.all(localePromises); 93 - console.log(`Loaded ${this.t.size} locale(s)`); 94 + logger.info(`Loaded ${this.t.size} locale(s)`); 94 95 } catch (error) { 95 - console.error('Failed to read locales directory:', error); 96 + logger.error('Failed to read locales directory:', error); 96 97 throw new Error('Failed to initialize localization'); 97 98 } 98 99 }
+91 -4
src/services/social/SocialMediaManager.ts
··· 64 64 subscription: SocialMediaSubscription, 65 65 ): Promise<void> { 66 66 try { 67 + if (this.isNSFWPost(post)) { 68 + logger.warn( 69 + `Skipping NSFW ${post.platform} post from ${post.author} in guild ${subscription.guildId}`, 70 + ); 71 + return; 72 + } 73 + 74 + const guild = this.client.guilds.cache.get(subscription.guildId); 75 + if (!guild) { 76 + console.warn( 77 + `Bot is not in guild ${subscription.guildId}. Skipping notification for ${post.platform} post.`, 78 + ); 79 + return; 80 + } 81 + 67 82 const channel = await this.client.channels.fetch(subscription.channelId); 83 + if (!channel) { 84 + console.warn( 85 + `Channel ${subscription.channelId} not found. Bot may not have access. Skipping notification.`, 86 + ); 87 + return; 88 + } 89 + 68 90 if (!this.isTextBasedAndSendable(channel)) { 69 91 console.error( 70 - `Channel ${subscription.channelId} not found or not text-capable. Skipping notification.`, 92 + `Channel ${subscription.channelId} is not text-capable. Skipping notification.`, 93 + ); 94 + return; 95 + } 96 + 97 + const isGuildChannel = 'guild' in channel && channel.guild !== null; 98 + if (isGuildChannel && channel.guild.id !== subscription.guildId) { 99 + console.warn( 100 + `Channel ${subscription.channelId} does not belong to guild ${subscription.guildId}. Skipping notification.`, 71 101 ); 72 102 return; 73 103 } 74 104 75 105 const embed = this.createEmbed(post); 76 106 await channel.send({ embeds: [embed] }); 107 + console.info( 108 + `Successfully sent notification for ${post.platform} post in guild ${subscription.guildId}`, 109 + ); 77 110 } catch (error) { 78 111 console.error('Error sending notification:', error); 79 112 } 80 113 } 81 114 115 + private isNSFWPost(post: SocialMediaPost): boolean { 116 + const nsfwTags = ['#nsfw', '#adult', '#18+', '#porn', '#xxx', '#nudity', '#sensitive']; 117 + const lowerText = post.text?.toLowerCase() || ''; 118 + 119 + if (nsfwTags.some((tag) => lowerText.includes(tag))) { 120 + logger.debug(`Post from ${post.author} contains NSFW tag in text`); 121 + return true; 122 + } 123 + 124 + if (post.platform === 'fediverse' && post.sensitive === true) { 125 + logger.debug(`Fediverse post from ${post.author} is marked as sensitive`); 126 + return true; 127 + } 128 + 129 + if ( 130 + post.platform === 'bluesky' && 131 + post.labels && 132 + post.labels.some((label) => 133 + ['nsfw', 'sexual', 'nudity', 'porn', 'explicit'].includes(label.val), 134 + ) 135 + ) { 136 + logger.debug( 137 + `Bluesky post from ${post.author} has NSFW content label: ${post.labels.map((l) => l.val).join(', ')}`, 138 + ); 139 + return true; 140 + } 141 + 142 + if (post.platform === 'fediverse' && post.spoiler_text && post.spoiler_text.length > 0) { 143 + const spoilerText = post.spoiler_text.toLowerCase(); 144 + if ( 145 + nsfwTags.some((tag) => spoilerText.includes(tag.replace('#', ''))) || 146 + spoilerText.includes('nsfw') || 147 + spoilerText.includes('adult') || 148 + spoilerText.includes('18+') 149 + ) { 150 + logger.debug( 151 + `Fediverse post from ${post.author} has NSFW content warning: ${post.spoiler_text}`, 152 + ); 153 + return true; 154 + } 155 + } 156 + 157 + return false; 158 + } 159 + 82 160 private createEmbed(post: SocialMediaPost): EmbedBuilder { 83 161 const authorIcon = post.authorAvatarUrl ?? this.getPlatformIcon(post.platform); 84 162 const authorName = ··· 171 249 private isRunning = false; 172 250 private inProgress = false; 173 251 private pollInterval: NodeJS.Timeout | null = null; 174 - private readonly POLL_INTERVAL_MS = 60 * 1000; 252 + private readonly POLL_INTERVAL_MS = 30 * 1000; 175 253 176 254 constructor( 177 255 private readonly client: Client, ··· 230 308 231 309 private async safeCheckForUpdates(): Promise<void> { 232 310 if (!this.isRunning || this.inProgress) { 311 + logger.debug('Poll already in progress, skipping...'); 233 312 return; 234 313 } 235 314 this.inProgress = true; 236 315 try { 237 - await this.checkForUpdates(); 316 + const updates = await this.socialService.checkForUpdates(); 317 + if (updates.length > 0) { 318 + logger.info(`Found ${updates.length} new social media posts`); 319 + await Promise.allSettled( 320 + updates.map(({ post, subscription }) => 321 + this.notificationService.sendNotification(post, subscription), 322 + ), 323 + ); 324 + } 238 325 } catch (error) { 239 - logger.error('Error during guarded social media check:', error); 326 + logger.error('Error during polling:', error); 240 327 } finally { 241 328 this.inProgress = false; 242 329 }
+110 -13
src/services/social/SocialMediaService.ts
··· 80 80 for (const sub of subscriptions) { 81 81 try { 82 82 const fetcher = this.fetchers.get(sub.platform as SocialPlatform); 83 - if (!fetcher) continue; 83 + if (!fetcher) { 84 + console.warn(`No fetcher found for platform: ${sub.platform}`); 85 + continue; 86 + } 84 87 85 88 const latestPost = await fetcher.fetchLatestPost(sub.accountHandle); 86 - if (latestPost) { 87 - if (!sub.lastPostTimestamp) { 88 - await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); 89 - continue; 90 - } 89 + if (!latestPost) { 90 + console.debug(`No posts found for ${sub.platform} account ${sub.accountHandle}`); 91 + continue; 92 + } 91 93 92 - if (this.isNewerPost(latestPost, sub)) { 93 - await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); 94 - newPosts.push({ 95 - post: latestPost, 96 - subscription: sub, 97 - }); 98 - } 94 + const normalizedUri = this.normalizeUri(latestPost.uri); 95 + 96 + if (!sub.lastPostTimestamp) { 97 + await this.updateLastPost(sub.id, normalizedUri, latestPost.timestamp); 98 + continue; 99 + } 100 + 101 + if (this.isNewerPostWithLogging(latestPost, sub, normalizedUri)) { 102 + await this.updateLastPost(sub.id, normalizedUri, latestPost.timestamp); 103 + newPosts.push({ 104 + post: { ...latestPost, uri: normalizedUri }, 105 + subscription: sub, 106 + }); 99 107 } 100 108 } catch (error) { 101 109 console.error( ··· 159 167 return post.uri !== subscription.lastPostUri; 160 168 } 161 169 170 + private isNewerPostWithLogging( 171 + post: SocialMediaPost, 172 + subscription: SocialMediaSubscription, 173 + normalizedUri: string, 174 + ): boolean { 175 + if (!subscription.lastPostTimestamp) return false; 176 + 177 + const isNewer = 178 + post.timestamp > subscription.lastPostTimestamp || 179 + (post.timestamp.getTime() === subscription.lastPostTimestamp?.getTime() && 180 + normalizedUri !== subscription.lastPostUri); 181 + 182 + return isNewer; 183 + } 184 + 185 + private normalizeUri(uri: string): string { 186 + return uri.trim().toLowerCase(); 187 + } 188 + 189 + public async debugSubscription( 190 + guildId: string, 191 + platform: SocialPlatform, 192 + accountHandle: string, 193 + ): Promise<{ 194 + subscription: SocialMediaSubscription | null; 195 + latestPost: SocialMediaPost | null; 196 + wouldAnnounce: boolean; 197 + reason: string; 198 + }> { 199 + const normalizedHandle = this.normalizeAccountHandle(platform, accountHandle); 200 + const result = await this.pool.query( 201 + `SELECT * FROM server_social_subscriptions WHERE guild_id = $1 AND platform = $2::social_platform AND account_handle = $3`, 202 + [guildId, platform, normalizedHandle], 203 + ); 204 + 205 + if (result.rows.length === 0) { 206 + return { 207 + subscription: null, 208 + latestPost: null, 209 + wouldAnnounce: false, 210 + reason: 'No subscription found', 211 + }; 212 + } 213 + 214 + const subscription = this.mapDbToSubscription(result.rows[0]); 215 + const fetcher = this.fetchers.get(platform); 216 + 217 + if (!fetcher) { 218 + return { 219 + subscription, 220 + latestPost: null, 221 + wouldAnnounce: false, 222 + reason: 'No fetcher available', 223 + }; 224 + } 225 + 226 + try { 227 + const latestPost = await fetcher.fetchLatestPost(accountHandle); 228 + if (!latestPost) { 229 + return { 230 + subscription, 231 + latestPost: null, 232 + wouldAnnounce: false, 233 + reason: 'No posts found', 234 + }; 235 + } 236 + 237 + const normalizedUri = this.normalizeUri(latestPost.uri); 238 + const wouldAnnounce = this.isNewerPostWithLogging(latestPost, subscription, normalizedUri); 239 + 240 + return { 241 + subscription, 242 + latestPost, 243 + wouldAnnounce, 244 + reason: wouldAnnounce ? 'New post detected' : 'Post already announced', 245 + }; 246 + } catch (error) { 247 + return { 248 + subscription, 249 + latestPost: null, 250 + wouldAnnounce: false, 251 + reason: `Error fetching post: ${error}`, 252 + }; 253 + } 254 + } 255 + 162 256 private mapDbToSubscription(row: { 163 257 id: number; 164 258 guild_id: string; ··· 188 282 private normalizeAccountHandle(platform: SocialPlatform, handle: string): string { 189 283 let h = handle.trim(); 190 284 if (platform === 'bluesky') { 285 + if (h.startsWith('did:')) { 286 + return h; 287 + } 191 288 h = h.startsWith('@') ? h.slice(1) : h; 192 289 h = h.toLowerCase(); 193 290 if (!h.includes('.')) {
+12 -1
src/services/social/fetchers/UnifiedFetcher.ts
··· 21 21 alt: string; 22 22 }>; 23 23 }; 24 + labels?: Array<{ 25 + src: string; 26 + uri: string; 27 + val: string; 28 + cts?: string; 29 + }>; 24 30 } 25 31 26 32 interface BlueskyFeedItem { ··· 157 163 const createdAt = post.record?.createdAt ?? new Date().toISOString(); 158 164 const author = post.author?.handle ?? 'unknown'; 159 165 166 + const postUri = post.uri; 167 + 160 168 return { 161 - uri: post.uri, 169 + uri: postUri, 162 170 text, 163 171 author, 164 172 timestamp: new Date(createdAt), ··· 166 174 mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, 167 175 authorAvatarUrl, 168 176 authorDisplayName, 177 + labels: post.labels, 169 178 }; 170 179 } 171 180 ··· 340 349 mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, 341 350 authorAvatarUrl: post.account.avatar, 342 351 authorDisplayName: post.account.display_name, 352 + sensitive: post.sensitive, 353 + spoiler_text: post.spoiler_text, 343 354 }; 344 355 } 345 356 }
+3
src/types/social.ts
··· 21 21 mediaUrls?: string[]; 22 22 authorAvatarUrl?: string; 23 23 authorDisplayName?: string; 24 + sensitive?: boolean; 25 + spoiler_text?: string; 26 + labels?: Array<{ val: string; src?: string }>; 24 27 } 25 28 26 29 export interface SocialMediaFetcher {
+2 -2
web/src/components/ThemeToggle.tsx
··· 11 11 return ( 12 12 <button 13 13 onClick={toggleTheme} 14 - className={`p-2 rounded-lg transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${ 14 + className={`p-3 rounded-full transition-all duration-200 hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${ 15 15 isDarkMode 16 16 ? 'bg-gray-800/80 hover:bg-gray-700/80 text-yellow-400' 17 17 : 'bg-white/80 hover:bg-gray-100/80 text-gray-700' 18 18 } ${className}`} 19 19 aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'} 20 20 > 21 - {isDarkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} 21 + {isDarkMode ? <Sun className="w-6 h-6" /> : <Moon className="w-6 h-6" />} 22 22 </button> 23 23 ); 24 24 }
+24 -11
web/src/index.css
··· 26 26 background: var(--gradient-dark); 27 27 } 28 28 29 + @media (prefers-reduced-motion: reduce) { 30 + * { 31 + animation-duration: 0.01ms !important; 32 + animation-iteration-count: 1 !important; 33 + transition-duration: 0.01ms !important; 34 + scroll-behavior: auto !important; 35 + } 36 + .blur-3xl, .backdrop-blur { 37 + backdrop-filter: none !important; 38 + filter: none !important; 39 + } 40 + } 41 + 29 42 .btn { 30 - @apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50; 43 + @apply inline-flex items-center justify-center font-semibold rounded-full px-6 py-3 transition-all focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-lg hover:shadow-xl; 31 44 } 32 45 33 - .btn-primary { 34 - @apply bg-blue-600 text-white hover:bg-blue-700; 46 + .btn-discord { 47 + @apply bg-[#5865F2] hover:bg-[#4752c4] text-white hover:-translate-y-0.5 focus:ring-[#5865F2]; 35 48 } 36 49 37 - .btn-secondary { 38 - @apply bg-gray-200 text-gray-900 hover:bg-gray-300 border border-gray-300; 50 + .btn-glass { 51 + @apply bg-white/80 dark:bg-gray-800/80 backdrop-blur hover:bg-white dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 hover:-translate-y-0.5 focus:ring-gray-200 dark:focus:ring-gray-600; 39 52 } 40 53 41 - .btn-danger { 42 - @apply bg-red-600 text-white hover:bg-red-700; 54 + .btn-accent { 55 + @apply bg-pink-500 hover:bg-pink-600 text-white hover:-translate-y-0.5 focus:ring-pink-500; 43 56 } 44 57 45 - .btn-success { 46 - @apply bg-green-600 text-white hover:bg-green-700; 58 + .feature-card { 59 + @apply relative rounded-2xl p-6 bg-white/80 dark:bg-gray-800/60 backdrop-blur ring-1 ring-gray-900/10 dark:ring-white/10 shadow-sm hover:shadow-xl transition-all hover:-translate-y-1 hover:ring-pink-300/30; 47 60 } 48 61 49 - .card { 50 - @apply bg-white/80 dark:bg-gray-800/80 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg; 62 + .badge { 63 + @apply px-3 py-1 rounded-full bg-white/70 dark:bg-white/5 text-gray-700 dark:text-gray-300 ring-1 ring-gray-900/5 dark:ring-white/10 text-xs; 51 64 } 52 65 53 66 .input {
+148 -145
web/src/pages/LandingPage.tsx
··· 5 5 FaceSmileIcon, 6 6 BellAlertIcon, 7 7 PhotoIcon, 8 - SparklesIcon, 9 8 SwatchIcon, 10 9 CheckCircleIcon, 11 10 UserIcon, 12 - ClockIcon, 13 11 QuestionMarkCircleIcon, 14 12 AcademicCapIcon, 15 13 } from '@heroicons/react/24/outline'; ··· 17 15 import { Link } from 'react-router-dom'; 18 16 import ThemeToggle from '../components/ThemeToggle'; 19 17 18 + const features = [ 19 + { 20 + icon: ChatBubbleLeftRightIcon, 21 + title: 'AI Chat', 22 + description: 'Instant answers with /ai — ask and get quick, helpful responses.', 23 + }, 24 + { 25 + icon: BookOpenIcon, 26 + title: 'Wikipedia', 27 + description: 'Use /wiki to pull answers right from Wikipedia.', 28 + }, 29 + { icon: CloudIcon, title: 'Weather', description: 'Forecasts for any city via /weather.' }, 30 + { 31 + icon: FaceSmileIcon, 32 + title: 'Fun', 33 + description: 'Lighten the mood with /joke, or ask the mystical /8ball.', 34 + }, 35 + { 36 + icon: BellAlertIcon, 37 + title: 'Reminders', 38 + description: 39 + 'Set reminders for important messages using /remind or in Right Click -> Apps -> Remind Me', 40 + }, 41 + { 42 + icon: PhotoIcon, 43 + title: 'Media Goodies', 44 + description: 'Cute pet pics with /dog and /cat, and media downloads via /cobalt.', 45 + }, 46 + { 47 + icon: AcademicCapIcon, 48 + title: 'Games', 49 + description: 'Start a multiplayer trivia session with /trivia.', 50 + }, 51 + { 52 + icon: CheckCircleIcon, 53 + title: 'Productivity', 54 + description: 'Manage tasks with /todo and keep yourself busy.', 55 + }, 56 + { 57 + icon: UserIcon, 58 + title: 'Utilities', 59 + description: 'Look up domains with /whois, check what time it is on any city with /time.', 60 + }, 61 + { 62 + icon: QuestionMarkCircleIcon, 63 + title: 'Discover', 64 + description: 'Use /help to see everything Aethel can do.', 65 + }, 66 + { 67 + icon: SwatchIcon, 68 + title: 'Constantly evolving', 69 + description: 'We release new updates almost every day.', 70 + }, 71 + ]; 72 + 20 73 export default function Home() { 21 74 return ( 22 - <div className="min-h-screen p-8 relative transition-colors duration-300"> 23 - <div className="absolute top-4 right-4 flex items-center space-x-3 z-10"> 24 - <ThemeToggle /> 25 - <a 26 - href="https://github.com/Aethel-Labs/aethel" 27 - target="_blank" 28 - rel="noopener noreferrer" 29 - className="p-3 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 text-black dark:text-white rounded-full transition-all transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-600 shadow-lg hover:shadow-xl" 30 - aria-label="View on GitHub" 31 - > 32 - <svg 33 - className="w-6 h-6" 34 - fill="currentColor" 35 - viewBox="0 0 24 24" 36 - aria-hidden="true" 75 + <div className="min-h-screen relative overflow-hidden bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-black transition-colors duration-300"> 76 + <a 77 + href="#main-content" 78 + className="sr-only focus:absolute focus:not-sr-only focus:top-3 focus:left-3 focus:bg-white dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100 focus:px-4 focus:py-2 focus:rounded-md focus:shadow" 79 + > 80 + Skip to content 81 + </a> 82 + 83 + <div className="pointer-events-none absolute -top-32 -right-32 h-96 w-96 rounded-full blur-xl md:blur-3xl opacity-30 bg-gradient-to-tr from-pink-400 to-purple-500 dark:opacity-20" /> 84 + <div className="pointer-events-none absolute -bottom-32 -left-32 h-[28rem] w-[28rem] rounded-full blur-xl md:blur-3xl opacity-30 bg-gradient-to-tr from-indigo-400 to-sky-500 dark:opacity-20" /> 85 + 86 + <header className="absolute top-4 right-4 z-10"> 87 + <div className="flex items-center space-x-3"> 88 + <ThemeToggle /> 89 + <a 90 + href="https://github.com/Aethel-Labs/aethel" 91 + target="_blank" 92 + rel="noopener noreferrer" 93 + className="p-3 bg-white/80 dark:bg-gray-800/80 backdrop-blur hover:bg-white dark:hover:bg-gray-700 text-black dark:text-white rounded-full transition-all transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-300 dark:focus:ring-gray-600 shadow-lg hover:shadow-xl" 94 + aria-label="View on GitHub" 37 95 > 38 - <path 39 - fillRule="evenodd" 40 - d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" 41 - clipRule="evenodd" 42 - /> 43 - </svg> 44 - </a> 45 - </div> 46 - <main className="max-w-4xl mx-auto"> 47 - <div className="text-center mb-16 pt-8"> 96 + <svg 97 + className="w-6 h-6" 98 + fill="currentColor" 99 + viewBox="0 0 24 24" 100 + aria-hidden="true" 101 + > 102 + <path 103 + fillRule="evenodd" 104 + d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" 105 + clipRule="evenodd" 106 + /> 107 + </svg> 108 + </a> 109 + </div> 110 + </header> 111 + 112 + <main 113 + id="main-content" 114 + className="relative max-w-6xl mx-auto px-6 sm:px-8 pt-24 pb-20" 115 + > 116 + <section className="text-center"> 48 117 <img 49 118 src="/bot_icon.png" 50 119 alt="Aethel Bot Logo" 51 - className="mx-auto mb-8 rounded-2xl w-40 h-40 object-contain" 120 + className="mx-auto mb-8 w-28 h-28 md:w-36 md:h-36 rounded-2xl object-contain shadow-xl ring-1 ring-black/5 dark:ring-white/10" 52 121 style={{ imageRendering: 'auto' }} 122 + width={256} 123 + height={256} 124 + loading="eager" 125 + decoding="async" 53 126 /> 54 - <h1 className="text-4xl md:text-6xl font-bold mb-4 text-gray-800 dark:text-gray-100"> 127 + <h1 className="text-4xl md:text-6xl font-extrabold tracking-tight mb-4 text-gray-900 dark:text-gray-100"> 55 128 Aethel 56 129 </h1> 57 - <p className="text-xl text-gray-600 dark:text-gray-300 mb-8"> 58 - A useful and multipurpose bot for Discord 130 + <p className="max-w-2xl mx-auto text-lg md:text-xl text-gray-600 dark:text-gray-300 leading-relaxed"> 131 + An amazing, feature-rich open-source Discord bot with useful and fun commands to have a 132 + good time with friends.{' '} 59 133 </p> 60 - <div className="flex flex-wrap justify-center gap-4"> 134 + <div className="mt-8 flex flex-wrap justify-center gap-3 md:gap-4"> 61 135 <a 62 - href="https://discord.com/oauth2/authorize?client_id=1371031984230371369" 63 - className="bg-[#5865F2] hover:bg-[#4752c4] text-white font-bold py-3 px-8 rounded-full inline-flex items-center space-x-2 transition-all transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] shadow-lg hover:shadow-xl" 136 + href="https://discord.com/oauth2/authorize?client_id=1371031984230371369&scope=bot%20applications.commands" 137 + className="btn btn-discord" 64 138 target="_blank" 65 139 rel="noopener noreferrer" 66 140 > ··· 68 142 </a> 69 143 <Link 70 144 to="/status" 71 - className="bg-white/90 dark:bg-gray-800/90 hover:bg-white dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 font-bold py-3 px-8 rounded-full inline-flex items-center space-x-2 transition-all transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-200 dark:focus:ring-gray-600 shadow-lg hover:shadow-xl" 145 + className="btn btn-glass" 72 146 > 73 147 <span>View Status</span> 74 148 </Link> 75 149 <Link 76 150 to="/login" 77 - className="bg-pink-500 hover:bg-pink-600 text-white font-bold py-3 px-8 rounded-full inline-flex items-center space-x-2 transition-all transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pink-500 shadow-lg hover:shadow-xl" 151 + className="btn btn-accent" 78 152 > 79 153 <span>Dashboard</span> 80 154 </Link> 81 155 </div> 82 - </div> 83 - 84 - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 85 - <div className="command-card group"> 86 - <ChatBubbleLeftRightIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 87 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/ai</h3> 88 - <p className="text-gray-600 dark:text-gray-300"> 89 - Chat with AI, ask questions, get answers! 90 - </p> 91 - </div> 92 - 93 - <div className="command-card group"> 94 - <BookOpenIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 95 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/wiki</h3> 96 - <p className="text-gray-600 dark:text-gray-300">Get answers directly from Wikipedia</p> 97 - </div> 98 - 99 - <div className="command-card group"> 100 - <CloudIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 101 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100"> 102 - /weather 103 - </h3> 104 - <p className="text-gray-600 dark:text-gray-300"> 105 - Get the weather forecast for any city 106 - </p> 107 - </div> 108 - 109 - <div className="command-card group"> 110 - <FaceSmileIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 111 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/joke</h3> 112 - <p className="text-gray-600 dark:text-gray-300"> 113 - Get a random joke and bright up your day 114 - </p> 115 - </div> 116 - 117 - <div className="command-card group"> 118 - <BellAlertIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 119 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/remind</h3> 120 - <p className="text-gray-600 dark:text-gray-300"> 121 - Set a reminder for a message or something important, supports message interactions 122 - </p> 123 - </div> 124 - 125 - <div className="command-card group"> 126 - <PhotoIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 127 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100"> 128 - /dog & /cat 129 - </h3> 130 - <p className="text-gray-600 dark:text-gray-300">Get random cute pet images</p> 131 - </div> 132 - 133 - <div className="command-card group"> 134 - <SparklesIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 135 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/8ball</h3> 136 - <p className="text-gray-600 dark:text-gray-300"> 137 - Ask the magic 8ball a question and get a mysterious answer 138 - </p> 139 - </div> 140 - 141 - <div className="command-card group"> 142 - <AcademicCapIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 143 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/trivia</h3> 144 - <p className="text-gray-600 dark:text-gray-300"> 145 - Start a multiplayer trivia game with friends 146 - </p> 147 - </div> 148 - 149 - <div className="command-card group"> 150 - <SwatchIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 151 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/cobalt</h3> 152 - <p className="text-gray-600 dark:text-gray-300"> 153 - Download videos and media from various platforms using Cobalt 154 - </p> 156 + <div className="mt-6 flex flex-wrap justify-center gap-2 text-xs md:text-sm"> 157 + <span className="badge">Open Source</span> 158 + <span className="badge">Privacy Friendly</span> 159 + <span className="badge">AI features</span> 155 160 </div> 161 + </section> 156 162 157 - <div className="command-card group"> 158 - <CheckCircleIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 159 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/todo</h3> 160 - <p className="text-gray-600 dark:text-gray-300"> 161 - Create and manage your todo list and tasks 162 - </p> 163 - </div> 164 - 165 - <div className="command-card group"> 166 - <UserIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 167 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/whois</h3> 168 - <p className="text-gray-600 dark:text-gray-300">Get domain and IP information</p> 163 + <section className="mt-16 md:mt-20"> 164 + <h2 className="text-center text-2xl md:text-3xl font-bold text-gray-800 dark:text-gray-100 mb-8"> 165 + What you can do with Aethel 166 + </h2> 167 + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> 168 + {features.map(({ icon: Icon, title, description }) => ( 169 + <div 170 + key={title} 171 + className="feature-card group" 172 + > 173 + <Icon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 174 + <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100"> 175 + {title} 176 + </h3> 177 + <p className="text-gray-600 dark:text-gray-300">{description}</p> 178 + </div> 179 + ))} 169 180 </div> 170 - 171 - <div className="command-card group"> 172 - <ClockIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 173 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/time</h3> 174 - <p className="text-gray-600 dark:text-gray-300">Get the current time for any cities</p> 175 - </div> 176 - 177 - <div className="command-card group"> 178 - <QuestionMarkCircleIcon className="w-8 h-8 text-pink-500 mb-3 group-hover:scale-110 transition-transform" /> 179 - <h3 className="text-xl font-semibold mb-2 text-gray-800 dark:text-gray-100">/help</h3> 180 - <p className="text-gray-600 dark:text-gray-300"> 181 - Show all available commands and their usage 182 - </p> 183 - </div> 184 - </div> 181 + </section> 185 182 186 183 <div className="mt-16 text-center"> 187 - <p className="text-gray-600 dark:text-gray-300 mb-4"> 188 - You might use this bot in DMs and servers that allow external applications! 184 + <p className="text-gray-600 dark:text-gray-300 mb-2"> 185 + Use Aethel in DMs or servers that allow external applications. 189 186 </p> 190 187 </div> 191 188 </main> 192 189 193 - <footer className="mt-16 text-center text-gray-500 dark:text-gray-400 pb-8"> 190 + <footer className="relative border-t border-gray-200/70 dark:border-white/10 bg-white/60 dark:bg-gray-900/40 backdrop-blur py-10 text-center text-gray-600 dark:text-gray-300"> 194 191 <div className="flex flex-wrap justify-center gap-6 mb-6"> 195 192 <Link 196 193 to="/legal/privacy" ··· 205 202 Terms of Service 206 203 </Link> 207 204 </div> 208 - 209 205 <div className="mb-4"> 210 206 <p className="text-xs text-gray-600 dark:text-gray-400 mb-2">Powered by</p> 211 207 <a ··· 217 213 <img 218 214 src="/royale_logo.svg" 219 215 alt="Royale Hosting" 220 - className="h-6 mx-auto dark:hidden" 216 + className="h-8 mx-auto dark:hidden object-contain" 217 + width={160} 218 + height={32} 219 + loading="lazy" 220 + decoding="async" 221 221 /> 222 222 <img 223 223 src="/royale_logo_dark.svg" 224 224 alt="Royale Hosting" 225 - className="h-6 mx-auto hidden dark:block" 225 + className="h-8 mx-auto hidden dark:block object-contain" 226 + width={160} 227 + height={32} 228 + loading="lazy" 229 + decoding="async" 226 230 /> 227 231 </a> 228 232 </div> 229 - 230 - <p className="hover:text-pink-500 transition-colors text-gray-600 dark:text-gray-300"> 231 - Made with ♥ by scanash and the Aethel Labs community 233 + <p className="hover:text-pink-500 transition-colors"> 234 + Made with ♥ by scanash and the Aethel Labs contributors 232 235 </p> 233 236 </footer> 234 237 </div>