From 20a19006dfc0f8d1139a41c530a18db2957076f7 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Tue, 12 Aug 2025 11:26:46 -0800 Subject: [PATCH] feat: New landing page, blocked NSFW for social command, and unallowed words check for AI's response. --- Dockerfile | 4 +- src/commands/utilities/ai.ts | 22 +- src/events/messageCreate.ts | 16 +- src/handlers/initialzeCommands.ts | 9 +- src/index.ts | 11 +- src/services/Client.ts | 11 +- src/services/social/SocialMediaManager.ts | 95 +++++- src/services/social/SocialMediaService.ts | 125 +++++++- .../social/fetchers/UnifiedFetcher.ts | 13 +- src/types/social.ts | 3 + web/src/components/ThemeToggle.tsx | 4 +- web/src/index.css | 35 ++- web/src/pages/LandingPage.tsx | 295 +++++++++--------- 13 files changed, 448 insertions(+), 195 deletions(-) diff --git a/Dockerfile b/Dockerfile index cb40356..a8ef596 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,12 +4,14 @@ ARG SOURCE_COMMIT ARG VITE_BOT_API_URL ARG VITE_STATUS_API_KEY ARG VITE_FRONTEND_URL +ARG STATUS_API_KEY ENV SOURCE_COMMIT=${SOURCE_COMMIT} ENV NODE_ENV=production ENV VITE_BOT_API_URL=${VITE_BOT_API_URL} ENV VITE_STATUS_API_KEY=${VITE_STATUS_API_KEY} ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL} +ENV STATUS_API_KEY=${STATUS_API_KEY} WORKDIR /app @@ -54,7 +56,7 @@ ARG VITE_FRONTEND_URL ENV SOURCE_COMMIT=${SOURCE_COMMIT} ENV NODE_ENV=production ENV VITE_BOT_API_URL=${VITE_BOT_API_URL} -ENV STATUS_API_KEY=${VITE_STATUS_API_KEY} +ENV STATUS_API_KEY=${STATUS_API_KEY} ENV VITE_STATUS_API_KEY=${STATUS_API_KEY} ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL} diff --git a/src/commands/utilities/ai.ts b/src/commands/utilities/ai.ts index 28d55c3..df37346 100644 --- a/src/commands/utilities/ai.ts +++ b/src/commands/utilities/ai.ts @@ -439,7 +439,7 @@ async function processAIRequest( const prompt = interaction.options.getString('prompt')!; commandLogger.logFromInteraction( interaction, - `prompt: "${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}"`, + `AI command executed - prompt content hidden for privacy`, ); const invokerId = getInvokerId(interaction); @@ -479,6 +479,16 @@ async function processAIRequest( const aiResponse = await makeAIRequest(config, conversation); if (!aiResponse) return; + const { getUnallowedWordCategory } = await import('@/utils/validation'); + const category = getUnallowedWordCategory(aiResponse.content); + if (category) { + logger.warn(`AI response contained unallowed words in category: ${category}`); + await interaction.editReply( + 'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.', + ); + return; + } + const updatedConversation = [ ...conversation.filter((msg) => msg.role !== 'system'), { role: 'assistant', content: aiResponse.content }, @@ -516,6 +526,16 @@ async function sendAIResponse( fullResponse += aiResponse.content; + const { getUnallowedWordCategory } = await import('@/utils/validation'); + const category = getUnallowedWordCategory(fullResponse); + if (category) { + logger.warn(`AI response contained unallowed words in category: ${category}`); + await interaction.editReply( + 'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.', + ); + return; + } + const urlProcessedResponse = processUrls(fullResponse); const chunks = splitResponseIntoChunks(urlProcessedResponse); diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 1b9bcac..c96e0ef 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -57,7 +57,9 @@ export default class MessageCreateEvent { logger.info(isDM ? 'Processing DM message...' : 'Processing mention in server...'); try { - logger.debug(`${isDM ? 'DM' : 'Message'} received (${message.content.length} characters)`); + logger.debug( + `${isDM ? 'DM' : 'Message'} received (${message.content.length} characters) - content hidden for privacy`, + ); const conversationKey = getConversationKey(message); const conversation = conversations.get(conversationKey) || []; @@ -258,6 +260,18 @@ export default class MessageCreateEvent { aiResponse.content = processUrls(aiResponse.content); aiResponse.content = aiResponse.content.replace(/@(everyone|here)/gi, '@\u200b$1'); + const { getUnallowedWordCategory } = await import('@/utils/validation'); + const category = getUnallowedWordCategory(aiResponse.content); + if (category) { + logger.warn(`AI response contained unallowed words in category: ${category}`); + await message.reply({ + content: + 'Sorry, I cannot provide that response as it contains prohibited content. Please try a different prompt.', + allowedMentions: { parse: ['users'] as const }, + }); + return; + } + await this.sendResponse(message, aiResponse); updatedConversation.push({ diff --git a/src/handlers/initialzeCommands.ts b/src/handlers/initialzeCommands.ts index 3c99809..1c09950 100644 --- a/src/handlers/initialzeCommands.ts +++ b/src/handlers/initialzeCommands.ts @@ -1,6 +1,7 @@ import * as config from '@/config'; import BotClient, { srcDir } from '@/services/Client'; import { SlashCommandProps, RemindCommandProps } from '@/types/command'; +import logger from '@/utils/logger'; import { REST, Routes, SlashCommandBuilder, ContextMenuCommandBuilder } from 'discord.js'; import { readdirSync } from 'fs'; import path from 'path'; @@ -17,7 +18,7 @@ function buildLocalizations(client: BotClient, commandKey: string, field: 'name' } export default async (c: BotClient) => { - console.log('Processing commands...'); + logger.info('Processing commands...'); const cmdDir = path.join(srcDir, 'commands'); const cmdCat = readdirSync(cmdDir); const commands: (SlashCommandBuilder | ContextMenuCommandBuilder)[] = []; @@ -56,9 +57,9 @@ export default async (c: BotClient) => { try { const rest = new REST({ version: '10' }).setToken(config.TOKEN!); await rest.put(Routes.applicationCommands(config.CLIENT_ID!), { body: commands }); - console.log('✅ All commands registered successfully'); + logger.info('All commands registered successfully'); } catch (error) { - console.error('Error on deploying commands:', error); - console.log('Bot will continue running with existing commands'); + logger.error('Error on deploying commands:', error); + logger.info('Bot will continue running with existing commands'); } }; diff --git a/src/index.ts b/src/index.ts index 3de2379..3406e6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,16 +13,17 @@ import todosRoutes from './routes/todos'; import apiKeysRoutes from './routes/apiKeys'; import remindersRoutes from './routes/reminders'; import { resetOldStrikes } from './utils/userStrikes'; +import logger from './utils/logger'; config(); process.on('uncaughtException', (err) => { - console.error('Uncaught Exception:', err); + logger.error('Uncaught Exception:', err); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { - console.error('🔥 Unhandled Rejection at:', promise); - console.error('📄 Reason:', reason); + logger.error('🔥 Unhandled Rejection at:', promise); + logger.error('📄 Reason:', reason); }); const app = e(); @@ -92,11 +93,11 @@ app.get('*', (req, res) => { setInterval( () => { - resetOldStrikes().catch(console.error); + resetOldStrikes().catch(logger.error); }, 60 * 60 * 1000, ); app.listen(PORT, () => { - console.log('Aethel is live on', `http://localhost:${PORT}`); + logger.debug('Aethel is live on', `http://localhost:${PORT}`); }); diff --git a/src/services/Client.ts b/src/services/Client.ts index 3c39192..7b2f46e 100644 --- a/src/services/Client.ts +++ b/src/services/Client.ts @@ -8,6 +8,7 @@ import { promises, readdirSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import logger from '@/utils/logger'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -71,7 +72,7 @@ export default class BotClient extends Client { } private async setupLocalization() { - console.log('Loading localization files...'); + logger.info('Loading localization files...'); const localesDir = path.join(srcDir, '..', 'locales'); try { @@ -83,16 +84,16 @@ export default class BotClient extends Client { const data = await promises.readFile(localeFile, { encoding: 'utf8' }); const localeKey = locale.split('.')[0]; this.t.set(localeKey, JSON.parse(data)); - console.log(`Loaded locale: ${localeKey}`); + logger.debug(`Loaded locale: ${localeKey}`); } catch (error) { - console.error(`Failed to load locale file ${locale}:`, error); + logger.error(`Failed to load locale file ${locale}:`, error); } }); await Promise.all(localePromises); - console.log(`Loaded ${this.t.size} locale(s)`); + logger.info(`Loaded ${this.t.size} locale(s)`); } catch (error) { - console.error('Failed to read locales directory:', error); + logger.error('Failed to read locales directory:', error); throw new Error('Failed to initialize localization'); } } diff --git a/src/services/social/SocialMediaManager.ts b/src/services/social/SocialMediaManager.ts index 623925f..220a6d2 100644 --- a/src/services/social/SocialMediaManager.ts +++ b/src/services/social/SocialMediaManager.ts @@ -64,21 +64,99 @@ class NotificationService { subscription: SocialMediaSubscription, ): Promise { try { + if (this.isNSFWPost(post)) { + logger.warn( + `Skipping NSFW ${post.platform} post from ${post.author} in guild ${subscription.guildId}`, + ); + return; + } + + const guild = this.client.guilds.cache.get(subscription.guildId); + if (!guild) { + console.warn( + `Bot is not in guild ${subscription.guildId}. Skipping notification for ${post.platform} post.`, + ); + return; + } + const channel = await this.client.channels.fetch(subscription.channelId); + if (!channel) { + console.warn( + `Channel ${subscription.channelId} not found. Bot may not have access. Skipping notification.`, + ); + return; + } + if (!this.isTextBasedAndSendable(channel)) { console.error( - `Channel ${subscription.channelId} not found or not text-capable. Skipping notification.`, + `Channel ${subscription.channelId} is not text-capable. Skipping notification.`, + ); + return; + } + + const isGuildChannel = 'guild' in channel && channel.guild !== null; + if (isGuildChannel && channel.guild.id !== subscription.guildId) { + console.warn( + `Channel ${subscription.channelId} does not belong to guild ${subscription.guildId}. Skipping notification.`, ); return; } const embed = this.createEmbed(post); await channel.send({ embeds: [embed] }); + console.info( + `Successfully sent notification for ${post.platform} post in guild ${subscription.guildId}`, + ); } catch (error) { console.error('Error sending notification:', error); } } + private isNSFWPost(post: SocialMediaPost): boolean { + const nsfwTags = ['#nsfw', '#adult', '#18+', '#porn', '#xxx', '#nudity', '#sensitive']; + const lowerText = post.text?.toLowerCase() || ''; + + if (nsfwTags.some((tag) => lowerText.includes(tag))) { + logger.debug(`Post from ${post.author} contains NSFW tag in text`); + return true; + } + + if (post.platform === 'fediverse' && post.sensitive === true) { + logger.debug(`Fediverse post from ${post.author} is marked as sensitive`); + return true; + } + + if ( + post.platform === 'bluesky' && + post.labels && + post.labels.some((label) => + ['nsfw', 'sexual', 'nudity', 'porn', 'explicit'].includes(label.val), + ) + ) { + logger.debug( + `Bluesky post from ${post.author} has NSFW content label: ${post.labels.map((l) => l.val).join(', ')}`, + ); + return true; + } + + if (post.platform === 'fediverse' && post.spoiler_text && post.spoiler_text.length > 0) { + const spoilerText = post.spoiler_text.toLowerCase(); + if ( + nsfwTags.some((tag) => spoilerText.includes(tag.replace('#', ''))) || + spoilerText.includes('nsfw') || + spoilerText.includes('adult') || + spoilerText.includes('18+') + ) { + logger.debug( + `Fediverse post from ${post.author} has NSFW content warning: ${post.spoiler_text}`, + ); + return true; + } + } + + return false; + } + private createEmbed(post: SocialMediaPost): EmbedBuilder { const authorIcon = post.authorAvatarUrl ?? this.getPlatformIcon(post.platform); const authorName = @@ -171,7 +249,7 @@ class SocialMediaPoller { private isRunning = false; private inProgress = false; private pollInterval: NodeJS.Timeout | null = null; - private readonly POLL_INTERVAL_MS = 60 * 1000; + private readonly POLL_INTERVAL_MS = 30 * 1000; constructor( private readonly client: Client, @@ -230,13 +308,22 @@ class SocialMediaPoller { private async safeCheckForUpdates(): Promise { if (!this.isRunning || this.inProgress) { + logger.debug('Poll already in progress, skipping...'); return; } this.inProgress = true; try { - await this.checkForUpdates(); + const updates = await this.socialService.checkForUpdates(); + if (updates.length > 0) { + logger.info(`Found ${updates.length} new social media posts`); + await Promise.allSettled( + updates.map(({ post, subscription }) => + this.notificationService.sendNotification(post, subscription), + ), + ); + } } catch (error) { - logger.error('Error during guarded social media check:', error); + logger.error('Error during polling:', error); } finally { this.inProgress = false; } diff --git a/src/services/social/SocialMediaService.ts b/src/services/social/SocialMediaService.ts index c8266f8..b992958 100644 --- a/src/services/social/SocialMediaService.ts +++ b/src/services/social/SocialMediaService.ts @@ -80,22 +80,30 @@ export class SocialMediaService { for (const sub of subscriptions) { try { const fetcher = this.fetchers.get(sub.platform as SocialPlatform); - if (!fetcher) continue; + if (!fetcher) { + console.warn(`No fetcher found for platform: ${sub.platform}`); + continue; + } const latestPost = await fetcher.fetchLatestPost(sub.accountHandle); - if (latestPost) { - if (!sub.lastPostTimestamp) { - await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); - continue; - } - - if (this.isNewerPost(latestPost, sub)) { - await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); - newPosts.push({ - post: latestPost, - subscription: sub, - }); - } + if (!latestPost) { + console.debug(`No posts found for ${sub.platform} account ${sub.accountHandle}`); + continue; + } + + const normalizedUri = this.normalizeUri(latestPost.uri); + + if (!sub.lastPostTimestamp) { + await this.updateLastPost(sub.id, normalizedUri, latestPost.timestamp); + continue; + } + + if (this.isNewerPostWithLogging(latestPost, sub, normalizedUri)) { + await this.updateLastPost(sub.id, normalizedUri, latestPost.timestamp); + newPosts.push({ + post: { ...latestPost, uri: normalizedUri }, + subscription: sub, + }); } } catch (error) { console.error( @@ -159,6 +167,92 @@ export class SocialMediaService { return post.uri !== subscription.lastPostUri; } + private isNewerPostWithLogging( + post: SocialMediaPost, + subscription: SocialMediaSubscription, + normalizedUri: string, + ): boolean { + if (!subscription.lastPostTimestamp) return false; + + const isNewer = + post.timestamp > subscription.lastPostTimestamp || + (post.timestamp.getTime() === subscription.lastPostTimestamp?.getTime() && + normalizedUri !== subscription.lastPostUri); + + return isNewer; + } + + private normalizeUri(uri: string): string { + return uri.trim().toLowerCase(); + } + + public async debugSubscription( + guildId: string, + platform: SocialPlatform, + accountHandle: string, + ): Promise<{ + subscription: SocialMediaSubscription | null; + latestPost: SocialMediaPost | null; + wouldAnnounce: boolean; + reason: string; + }> { + const normalizedHandle = this.normalizeAccountHandle(platform, accountHandle); + const result = await this.pool.query( + `SELECT * FROM server_social_subscriptions WHERE guild_id = $1 AND platform = $2::social_platform AND account_handle = $3`, + [guildId, platform, normalizedHandle], + ); + + if (result.rows.length === 0) { + return { + subscription: null, + latestPost: null, + wouldAnnounce: false, + reason: 'No subscription found', + }; + } + + const subscription = this.mapDbToSubscription(result.rows[0]); + const fetcher = this.fetchers.get(platform); + + if (!fetcher) { + return { + subscription, + latestPost: null, + wouldAnnounce: false, + reason: 'No fetcher available', + }; + } + + try { + const latestPost = await fetcher.fetchLatestPost(accountHandle); + if (!latestPost) { + return { + subscription, + latestPost: null, + wouldAnnounce: false, + reason: 'No posts found', + }; + } + + const normalizedUri = this.normalizeUri(latestPost.uri); + const wouldAnnounce = this.isNewerPostWithLogging(latestPost, subscription, normalizedUri); + + return { + subscription, + latestPost, + wouldAnnounce, + reason: wouldAnnounce ? 'New post detected' : 'Post already announced', + }; + } catch (error) { + return { + subscription, + latestPost: null, + wouldAnnounce: false, + reason: `Error fetching post: ${error}`, + }; + } + } + private mapDbToSubscription(row: { id: number; guild_id: string; @@ -188,6 +282,9 @@ export class SocialMediaService { private normalizeAccountHandle(platform: SocialPlatform, handle: string): string { let h = handle.trim(); if (platform === 'bluesky') { + if (h.startsWith('did:')) { + return h; + } h = h.startsWith('@') ? h.slice(1) : h; h = h.toLowerCase(); if (!h.includes('.')) { diff --git a/src/services/social/fetchers/UnifiedFetcher.ts b/src/services/social/fetchers/UnifiedFetcher.ts index 6a32d89..4dc6a6c 100644 --- a/src/services/social/fetchers/UnifiedFetcher.ts +++ b/src/services/social/fetchers/UnifiedFetcher.ts @@ -21,6 +21,12 @@ interface BlueskyPost { alt: string; }>; }; + labels?: Array<{ + src: string; + uri: string; + val: string; + cts?: string; + }>; } interface BlueskyFeedItem { @@ -157,8 +163,10 @@ export class BlueskyFetcher implements SocialMediaFetcher { const createdAt = post.record?.createdAt ?? new Date().toISOString(); const author = post.author?.handle ?? 'unknown'; + const postUri = post.uri; + return { - uri: post.uri, + uri: postUri, text, author, timestamp: new Date(createdAt), @@ -166,6 +174,7 @@ export class BlueskyFetcher implements SocialMediaFetcher { mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, authorAvatarUrl, authorDisplayName, + labels: post.labels, }; } @@ -340,6 +349,8 @@ export class FediverseFetcher implements SocialMediaFetcher { mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, authorAvatarUrl: post.account.avatar, authorDisplayName: post.account.display_name, + sensitive: post.sensitive, + spoiler_text: post.spoiler_text, }; } } diff --git a/src/types/social.ts b/src/types/social.ts index 4d58fca..b0da96e 100644 --- a/src/types/social.ts +++ b/src/types/social.ts @@ -21,6 +21,9 @@ export interface SocialMediaPost { mediaUrls?: string[]; authorAvatarUrl?: string; authorDisplayName?: string; + sensitive?: boolean; + spoiler_text?: string; + labels?: Array<{ val: string; src?: string }>; } export interface SocialMediaFetcher { diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index 4e5f466..75709ea 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -11,14 +11,14 @@ export default function ThemeToggle({ className = '' }: ThemeToggleProps) { return ( ); } diff --git a/web/src/index.css b/web/src/index.css index d097b58..8d00b49 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -26,28 +26,41 @@ body.dark { background: var(--gradient-dark); } +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + .blur-3xl, .backdrop-blur { + backdrop-filter: none !important; + filter: none !important; + } +} + .btn { - @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; + @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; } -.btn-primary { - @apply bg-blue-600 text-white hover:bg-blue-700; +.btn-discord { + @apply bg-[#5865F2] hover:bg-[#4752c4] text-white hover:-translate-y-0.5 focus:ring-[#5865F2]; } -.btn-secondary { - @apply bg-gray-200 text-gray-900 hover:bg-gray-300 border border-gray-300; +.btn-glass { + @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; } -.btn-danger { - @apply bg-red-600 text-white hover:bg-red-700; +.btn-accent { + @apply bg-pink-500 hover:bg-pink-600 text-white hover:-translate-y-0.5 focus:ring-pink-500; } -.btn-success { - @apply bg-green-600 text-white hover:bg-green-700; +.feature-card { + @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; } -.card { - @apply bg-white/80 dark:bg-gray-800/80 rounded-lg border border-gray-200 dark:border-gray-700 shadow-lg; +.badge { + @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; } .input { diff --git a/web/src/pages/LandingPage.tsx b/web/src/pages/LandingPage.tsx index d5b2055..378abff 100644 --- a/web/src/pages/LandingPage.tsx +++ b/web/src/pages/LandingPage.tsx @@ -5,11 +5,9 @@ import { FaceSmileIcon, BellAlertIcon, PhotoIcon, - SparklesIcon, SwatchIcon, CheckCircleIcon, UserIcon, - ClockIcon, QuestionMarkCircleIcon, AcademicCapIcon, } from '@heroicons/react/24/outline'; @@ -17,50 +15,126 @@ import { import { Link } from 'react-router-dom'; import ThemeToggle from '../components/ThemeToggle'; +const features = [ + { + icon: ChatBubbleLeftRightIcon, + title: 'AI Chat', + description: 'Instant answers with /ai — ask and get quick, helpful responses.', + }, + { + icon: BookOpenIcon, + title: 'Wikipedia', + description: 'Use /wiki to pull answers right from Wikipedia.', + }, + { icon: CloudIcon, title: 'Weather', description: 'Forecasts for any city via /weather.' }, + { + icon: FaceSmileIcon, + title: 'Fun', + description: 'Lighten the mood with /joke, or ask the mystical /8ball.', + }, + { + icon: BellAlertIcon, + title: 'Reminders', + description: + 'Set reminders for important messages using /remind or in Right Click -> Apps -> Remind Me', + }, + { + icon: PhotoIcon, + title: 'Media Goodies', + description: 'Cute pet pics with /dog and /cat, and media downloads via /cobalt.', + }, + { + icon: AcademicCapIcon, + title: 'Games', + description: 'Start a multiplayer trivia session with /trivia.', + }, + { + icon: CheckCircleIcon, + title: 'Productivity', + description: 'Manage tasks with /todo and keep yourself busy.', + }, + { + icon: UserIcon, + title: 'Utilities', + description: 'Look up domains with /whois, check what time it is on any city with /time.', + }, + { + icon: QuestionMarkCircleIcon, + title: 'Discover', + description: 'Use /help to see everything Aethel can do.', + }, + { + icon: SwatchIcon, + title: 'Constantly evolving', + description: 'We release new updates almost every day.', + }, +]; + export default function Home() { return ( -
-
- - -
+
+ +
+ -
-
+ + +
+
+ +
+
Aethel Bot Logo -

+

Aethel

-

- A useful and multipurpose bot for Discord +

+ An amazing, feature-rich open-source Discord bot with useful and fun commands to have a + good time with friends.{' '}

- - -
-
- -

/ai

-

- Chat with AI, ask questions, get answers! -

+
+ Open Source + Privacy Friendly + AI features
- -
- -

/wiki

-

Get answers directly from Wikipedia

+
+ +
+

+ What you can do with Aethel +

+
+ {features.map(({ icon: Icon, title, description }) => ( +
+ +

+ {title} +

+

{description}

+
+ ))}
- -
- -

- /weather -

-

- Get the weather forecast for any city -

-
- -
- -

/joke

-

- Get a random joke and bright up your day -

-
- -
- -

/remind

-

- Set a reminder for a message or something important, supports message interactions -

-
- -
- -

- /dog & /cat -

-

Get random cute pet images

-
- -
- -

/8ball

-

- Ask the magic 8ball a question and get a mysterious answer -

-
- -
- -

/trivia

-

- Start a multiplayer trivia game with friends -

-
- -
- -

/cobalt

-

- Download videos and media from various platforms using Cobalt -

-
- -
- -

/todo

-

- Create and manage your todo list and tasks -

-
- -
- -

/whois

-

Get domain and IP information

-
- -
- -

/time

-

Get the current time for any cities

-
- -
- -

/help

-

- Show all available commands and their usage -

-
-
+
-

- You might use this bot in DMs and servers that allow external applications! +

+ Use Aethel in DMs or servers that allow external applications.

-
-- 2.51.2