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

feat: more privacy, better eslint and prettier config

+29 -2
.prettierrc
··· 3 "singleQuote": true, 4 "tabWidth": 2, 5 "useTabs": false, 6 - "trailingComma": "es5", 7 "bracketSpacing": true, 8 "arrowParens": "always", 9 "printWidth": 100, 10 - "endOfLine": "auto" 11 }
··· 3 "singleQuote": true, 4 "tabWidth": 2, 5 "useTabs": false, 6 + "trailingComma": "all", 7 "bracketSpacing": true, 8 + "bracketSameLine": false, 9 "arrowParens": "always", 10 "printWidth": 100, 11 + "endOfLine": "lf", 12 + "quoteProps": "as-needed", 13 + "singleAttributePerLine": true, 14 + "embeddedLanguageFormatting": "auto", 15 + "experimentalTernaries": false, 16 + "overrides": [ 17 + { 18 + "files": ["*.md", "*.mdx"], 19 + "options": { 20 + "printWidth": 80, 21 + "proseWrap": "always" 22 + } 23 + }, 24 + { 25 + "files": ["*.json", "*.jsonc"], 26 + "options": { 27 + "trailingComma": "none" 28 + } 29 + }, 30 + { 31 + "files": ["*.yml", "*.yaml"], 32 + "options": { 33 + "tabWidth": 2, 34 + "singleQuote": false 35 + } 36 + } 37 + ] 38 }
+4 -2
CODE_OF_CONDUCT.md
··· 2 3 ## Our Pledge 4 5 - We as members, contributors, and leaders pledge to make participation in our project and our community a harassment-free experience for everyone. 6 7 ## Our Standards 8 ··· 21 22 ## Enforcement 23 24 - Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the maintainer at scan@scanash.com.
··· 2 3 ## Our Pledge 4 5 + We as members, contributors, and leaders pledge to make participation in our 6 + project and our community a harassment-free experience for everyone. 7 8 ## Our Standards 9 ··· 22 23 ## Enforcement 24 25 + Instances of abusive, harassing, or otherwise unacceptable behavior may be 26 + reported by contacting the maintainer at scan@scanash.com.
+20 -17
README.md
··· 4 5 [![Node.js](https://img.shields.io/badge/node-%3E=16.9.0-green?logo=node.js)](https://nodejs.org/) 6 7 - A privacy-conscious, production-ready Discord user-installed bot with AI chat, reminders, and utility commands. Built with Node.js, Discord.js v14, PostgreSQL, and robust security best practices. 8 9 --- 10 11 ## Features 12 13 - - **AI Chat**: `/ai` command with custom API key support (OpenRouter, OpenAI, Grok) 14 - **Reminders**: `/remind` command for scheduling reminders 15 - - **Utilities**: `/weather`, `/wiki`, `/joke`, `/cat`, `/dog`, `/8ball`, `/whois` 16 - **Ephemeral Replies** for sensitive commands 17 - **Encrypted API Key Storage** (AES-256-GCM) 18 - **Rate Limiting & Logging** ··· 92 93 - Use `/ai` for AI chat, with optional custom API key for private usage 94 - Use `/remind` to schedule reminders 95 - - Use utility commands: `/weather`, `/wiki`, `/joke`, `/cat`, `/dog`, `/8ball`, `/whois` 96 - Sensitive commands use ephemeral replies for privacy 97 - Use `/ai use_custom_api:true` to set your own (encrypted) API key 98 ··· 105 106 ## 🌐 Translations & Localization 107 108 - Aethel supports multiple languages! You can help improve or add new translations for the bot. 109 110 ### Supported Languages 111 112 - - English (en-US) 113 - <a href="http://translate.aethel.xyz/engage/aethel/en/"> 114 <img src="http://translate.aethel.xyz/widgets/aethel/en/svg-badge.svg" alt="English translation status" /> 115 </a> 116 - - Spanish (es-ES) 117 - <a href="http://translate.aethel.xyz/engage/aethel/es/"> 118 <img src="http://translate.aethel.xyz/widgets/aethel/es/svg-badge.svg" alt="Spanish translation status" /> 119 </a> 120 - Spanish (Latin America) (es-419) 121 <a href="http://translate.aethel.xyz/engage/aethel/es_419/"> 122 <img src="http://translate.aethel.xyz/widgets/aethel/es_419/svg-badge.svg" alt="Spanish (Latin America) translation status" /> 123 </a> 124 - - German (de-DE) 125 - <a href="http://translate.aethel.xyz/engage/aethel/de/"> 126 <img src="http://translate.aethel.xyz/widgets/aethel/de/svg-badge.svg" alt="German translation status" /> 127 </a> 128 - - French (fr-FR) 129 - <a href="http://translate.aethel.xyz/engage/aethel/fr/"> 130 <img src="http://translate.aethel.xyz/widgets/aethel/fr/svg-badge.svg" alt="French translation status" /> 131 </a> 132 - Portuguese (Brazil) (pt-BR) 133 <a href="http://translate.aethel.xyz/engage/aethel/pt_BR/"> 134 <img src="http://translate.aethel.xyz/widgets/aethel/pt_BR/svg-badge.svg" alt="Portuguese (Brazil) translation status" /> 135 </a> 136 - - Japanese (ja) 137 - <a href="http://translate.aethel.xyz/engage/aethel/ja/"> 138 <img src="http://translate.aethel.xyz/widgets/aethel/ja/svg-badge.svg" alt="Japanese translation status" /> 139 </a> 140 141 ### Contribute a Translation 142 143 - We use [Weblate](https://translate.aethel.xyz/projects/aethel/) for collaborative translation. Anyone can contribute: 144 145 - - Visit the [Aethel Weblate project](https://translate.aethel.xyz/projects/aethel/) 146 - Sign in or register (free) 147 - Pick your language and start translating or reviewing existing translations 148
··· 4 5 [![Node.js](https://img.shields.io/badge/node-%3E=16.9.0-green?logo=node.js)](https://nodejs.org/) 6 7 + A privacy-conscious, production-ready Discord user-installed bot with AI chat, 8 + reminders, and utility commands. Built with Node.js, Discord.js v14, PostgreSQL, 9 + and robust security best practices. 10 11 --- 12 13 ## Features 14 15 + - **AI Chat**: `/ai` command with custom API key support (OpenRouter, OpenAI, 16 + Grok) 17 - **Reminders**: `/remind` command for scheduling reminders 18 + - **Utilities**: `/weather`, `/wiki`, `/joke`, `/cat`, `/dog`, `/8ball`, 19 + `/whois` 20 - **Ephemeral Replies** for sensitive commands 21 - **Encrypted API Key Storage** (AES-256-GCM) 22 - **Rate Limiting & Logging** ··· 96 97 - Use `/ai` for AI chat, with optional custom API key for private usage 98 - Use `/remind` to schedule reminders 99 + - Use utility commands: `/weather`, `/wiki`, `/joke`, `/cat`, `/dog`, `/8ball`, 100 + `/whois` 101 - Sensitive commands use ephemeral replies for privacy 102 - Use `/ai use_custom_api:true` to set your own (encrypted) API key 103 ··· 110 111 ## 🌐 Translations & Localization 112 113 + Aethel supports multiple languages! You can help improve or add new translations 114 + for the bot. 115 116 ### Supported Languages 117 118 + - English (en-US) <a href="http://translate.aethel.xyz/engage/aethel/en/"> 119 <img src="http://translate.aethel.xyz/widgets/aethel/en/svg-badge.svg" alt="English translation status" /> 120 </a> 121 + - Spanish (es-ES) <a href="http://translate.aethel.xyz/engage/aethel/es/"> 122 <img src="http://translate.aethel.xyz/widgets/aethel/es/svg-badge.svg" alt="Spanish translation status" /> 123 </a> 124 - Spanish (Latin America) (es-419) 125 <a href="http://translate.aethel.xyz/engage/aethel/es_419/"> 126 <img src="http://translate.aethel.xyz/widgets/aethel/es_419/svg-badge.svg" alt="Spanish (Latin America) translation status" /> 127 </a> 128 + - German (de-DE) <a href="http://translate.aethel.xyz/engage/aethel/de/"> 129 <img src="http://translate.aethel.xyz/widgets/aethel/de/svg-badge.svg" alt="German translation status" /> 130 </a> 131 + - French (fr-FR) <a href="http://translate.aethel.xyz/engage/aethel/fr/"> 132 <img src="http://translate.aethel.xyz/widgets/aethel/fr/svg-badge.svg" alt="French translation status" /> 133 </a> 134 - Portuguese (Brazil) (pt-BR) 135 <a href="http://translate.aethel.xyz/engage/aethel/pt_BR/"> 136 <img src="http://translate.aethel.xyz/widgets/aethel/pt_BR/svg-badge.svg" alt="Portuguese (Brazil) translation status" /> 137 </a> 138 + - Japanese (ja) <a href="http://translate.aethel.xyz/engage/aethel/ja/"> 139 <img src="http://translate.aethel.xyz/widgets/aethel/ja/svg-badge.svg" alt="Japanese translation status" /> 140 </a> 141 142 ### Contribute a Translation 143 144 + We use [Weblate](https://translate.aethel.xyz/projects/aethel/) for 145 + collaborative translation. Anyone can contribute: 146 147 + - Visit the 148 + [Aethel Weblate project](https://translate.aethel.xyz/projects/aethel/) 149 - Sign in or register (free) 150 - Pick your language and start translating or reviewing existing translations 151
+24 -12
SECURITY.md
··· 11 12 ## Reporting a Vulnerability 13 14 - We take security vulnerabilities seriously. If you discover a security vulnerability in Aethel, please report it responsibly. 15 16 ### How to Report 17 ··· 25 ### What to Expect 26 27 - **Acknowledgment**: We will acknowledge receipt of your report within 48 hours 28 - - **Initial Assessment**: We will provide an initial assessment within 5 business days 29 - - **Updates**: We will keep you informed of our progress throughout the investigation 30 - **Resolution**: We aim to resolve critical vulnerabilities within 30 days 31 32 ### Responsible Disclosure ··· 35 36 - We will work with you to understand and resolve the issue 37 - We will credit you for the discovery (unless you prefer to remain anonymous) 38 - - We ask that you do not publicly disclose the vulnerability until we have had a chance to address it 39 40 ## Security Measures 41 42 ### Current Security Implementations 43 44 - - **SSRF Protection**: API endpoints are restricted to whitelisted hosts to prevent Server-Side Request Forgery attacks 45 - **Input Validation**: All user inputs are validated and sanitized 46 - **Encryption**: Sensitive data like API keys are encrypted before storage 47 - **Authentication**: Secure token-based authentication for API access ··· 49 50 ### Allowed API Hosts 51 52 - For security reasons, custom API endpoints are restricted to the following trusted hosts: 53 54 - `api.openai.com` 55 - `openrouter.ai` ··· 59 60 When contributing to or using Aethel: 61 62 - 1. **Never commit secrets**: Do not include API keys, passwords, or other sensitive information in code 63 - 2. **Use environment variables**: Store sensitive configuration in environment variables 64 3. **Validate inputs**: Always validate and sanitize user inputs 65 4. **Follow least privilege**: Grant minimal necessary permissions 66 - 5. **Keep dependencies updated**: Regularly update dependencies to patch known vulnerabilities 67 68 ## Security Audits 69 70 - We regularly review our codebase for security vulnerabilities and welcome security audits from the community. 71 72 ### Automated Security Checks 73 ··· 77 78 ## Contact 79 80 - For security-related questions or concerns, please contact the project maintainers at scan@scanash.com 81 82 --- 83 84 - **Note**: This security policy is subject to change. Please check back regularly for updates.
··· 11 12 ## Reporting a Vulnerability 13 14 + We take security vulnerabilities seriously. If you discover a security 15 + vulnerability in Aethel, please report it responsibly. 16 17 ### How to Report 18 ··· 26 ### What to Expect 27 28 - **Acknowledgment**: We will acknowledge receipt of your report within 48 hours 29 + - **Initial Assessment**: We will provide an initial assessment within 5 30 + business days 31 + - **Updates**: We will keep you informed of our progress throughout the 32 + investigation 33 - **Resolution**: We aim to resolve critical vulnerabilities within 30 days 34 35 ### Responsible Disclosure ··· 38 39 - We will work with you to understand and resolve the issue 40 - We will credit you for the discovery (unless you prefer to remain anonymous) 41 + - We ask that you do not publicly disclose the vulnerability until we have had a 42 + chance to address it 43 44 ## Security Measures 45 46 ### Current Security Implementations 47 48 + - **SSRF Protection**: API endpoints are restricted to whitelisted hosts to 49 + prevent Server-Side Request Forgery attacks 50 - **Input Validation**: All user inputs are validated and sanitized 51 - **Encryption**: Sensitive data like API keys are encrypted before storage 52 - **Authentication**: Secure token-based authentication for API access ··· 54 55 ### Allowed API Hosts 56 57 + For security reasons, custom API endpoints are restricted to the following 58 + trusted hosts: 59 60 - `api.openai.com` 61 - `openrouter.ai` ··· 65 66 When contributing to or using Aethel: 67 68 + 1. **Never commit secrets**: Do not include API keys, passwords, or other 69 + sensitive information in code 70 + 2. **Use environment variables**: Store sensitive configuration in environment 71 + variables 72 3. **Validate inputs**: Always validate and sanitize user inputs 73 4. **Follow least privilege**: Grant minimal necessary permissions 74 + 5. **Keep dependencies updated**: Regularly update dependencies to patch known 75 + vulnerabilities 76 77 ## Security Audits 78 79 + We regularly review our codebase for security vulnerabilities and welcome 80 + security audits from the community. 81 82 ### Automated Security Checks 83 ··· 87 88 ## Contact 89 90 + For security-related questions or concerns, please contact the project 91 + maintainers at scan@scanash.com 92 93 --- 94 95 + **Note**: This security policy is subject to change. Please check back regularly 96 + for updates.
+92 -24
eslint.config.mjs
··· 1 - import js from "@eslint/js"; 2 - import globals from "globals"; 3 - import tseslint from "typescript-eslint"; 4 - import { defineConfig } from "eslint/config"; 5 6 - 7 - export default defineConfig([ 8 - { 9 - files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], 10 - plugins: { js }, 11 - extends: ["js/recommended"], 12 - ignores: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**"] 13 }, 14 - { files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], languageOptions: { globals: globals.browser } }, 15 ...tseslint.configs.recommended, 16 { 17 - files: ["**/*.{ts,tsx}"], 18 - ignores: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**"], 19 rules: { 20 - "@typescript-eslint/no-unused-vars": [ 21 - "error", 22 { 23 - "argsIgnorePattern": "^_", 24 - "varsIgnorePattern": "^_", 25 - "caughtErrorsIgnorePattern": "^_" 26 - } 27 ], 28 - "@typescript-eslint/no-explicit-any": "warn" 29 - } 30 - } 31 - ]);
··· 1 + import js from '@eslint/js'; 2 + import globals from 'globals'; 3 + import tseslint from 'typescript-eslint'; 4 5 + export default [ 6 + { 7 + ignores: [ 8 + '**/node_modules/**', 9 + '**/dist/**', 10 + '**/build/**', 11 + '**/.next/**', 12 + '**/coverage/**', 13 + '**/*.min.js', 14 + '**/logs/**', 15 + ], 16 }, 17 + 18 + js.configs.recommended, 19 ...tseslint.configs.recommended, 20 + 21 { 22 + files: ['**/*.{js,mjs,cjs,ts,mts,cts,tsx}'], 23 + languageOptions: { 24 + globals: { 25 + ...globals.node, 26 + ...globals.es2022, 27 + }, 28 + ecmaVersion: 2022, 29 + sourceType: 'module', 30 + }, 31 rules: { 32 + 'no-console': 'off', 33 + 'no-debugger': 'error', 34 + 'no-alert': 'error', 35 + 'no-eval': 'error', 36 + 'no-implied-eval': 'error', 37 + 38 + eqeqeq: ['error', 'always'], 39 + curly: ['warn', 'multi-line'], 40 + 'no-else-return': 'warn', 41 + 'no-empty-function': 'warn', 42 + 'no-return-assign': 'error', 43 + 'no-throw-literal': 'error', 44 + 45 + 'no-unreachable': 'error', 46 + 'no-unused-expressions': 'error', 47 + 'no-useless-return': 'warn', 48 + 49 + '@typescript-eslint/no-unused-vars': [ 50 + 'error', 51 { 52 + argsIgnorePattern: '^_', 53 + varsIgnorePattern: '^_', 54 + caughtErrorsIgnorePattern: '^_', 55 + destructuredArrayIgnorePattern: '^_', 56 + }, 57 ], 58 + '@typescript-eslint/no-explicit-any': 'warn', 59 + '@typescript-eslint/explicit-function-return-type': 'off', 60 + '@typescript-eslint/explicit-module-boundary-types': 'off', 61 + '@typescript-eslint/no-inferrable-types': 'warn', 62 + '@typescript-eslint/no-non-null-assertion': 'off', 63 + '@typescript-eslint/prefer-for-of': 'error', 64 + '@typescript-eslint/consistent-type-imports': 'off', 65 + 66 + 'no-unused-vars': 'off', 67 + 'no-undef': 'off', 68 + 'no-redeclare': 'off', 69 + '@typescript-eslint/no-redeclare': 'error', 70 + 71 + 'no-process-exit': 'off', 72 + 'no-new-require': 'error', 73 + 'no-path-concat': 'error', 74 + 'no-sync': 'warn', 75 + }, 76 + }, 77 + 78 + { 79 + files: ['**/*.config.{js,mjs,ts}', '**/eslint.config.mjs', '**/src/index.ts'], 80 + rules: { 81 + 'no-console': 'off', 82 + 'no-process-exit': 'off', 83 + }, 84 + }, 85 + 86 + { 87 + files: ['**/*.test.{js,ts}', '**/*.spec.{js,ts}', '**/tests/**/*.{js,ts}'], 88 + languageOptions: { 89 + globals: { 90 + ...globals.jest, 91 + ...globals.node, 92 + }, 93 + }, 94 + rules: { 95 + '@typescript-eslint/no-explicit-any': 'off', 96 + '@typescript-eslint/explicit-function-return-type': 'off', 97 + }, 98 + }, 99 + ];
+6 -6
src/commands/fun/8ball.ts
··· 73 'en-US': 'Your yes/no question for the magic 8-ball', 74 }) 75 .setRequired(true) 76 - .setMaxLength(200) 77 ) 78 .setContexts([ 79 InteractionContextType.BotDM, ··· 84 85 execute: async ( 86 client: import('@/services/Client').default, 87 - interaction: import('discord.js').ChatInputCommandInteraction 88 ) => { 89 try { 90 const cooldownCheck = await checkCooldown( 91 cooldownManager, 92 interaction.user.id, 93 client, 94 - interaction.locale 95 ); 96 if (cooldownCheck.onCooldown) { 97 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 110 const question = sanitizeInput(interaction.options.getString('question')); 111 const translatedResponse = await client.getLocaleText( 112 `commands.8ball.responces.${random(responses)}`, 113 - interaction.locale 114 ); 115 commandLogger.logFromInteraction( 116 interaction, 117 - `question: "${question?.substring(0, 50)}${question && question.length > 50 ? '...' : ''}"` 118 ); 119 const [title, questionLabel, answerLabel] = await Promise.all([ 120 await client.getLocaleText('commands.8ball.says', interaction.locale), ··· 126 .setAccentColor(0x8b5cf6) 127 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`# 🔮 ${title}`)) 128 .addTextDisplayComponents( 129 - new TextDisplayBuilder().setContent(`**${questionLabel}**\n> ${question}\n\n`) 130 ) 131 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`## ✨ ${answerLabel}`)) 132 .addTextDisplayComponents(new TextDisplayBuilder().setContent(translatedResponse));
··· 73 'en-US': 'Your yes/no question for the magic 8-ball', 74 }) 75 .setRequired(true) 76 + .setMaxLength(200), 77 ) 78 .setContexts([ 79 InteractionContextType.BotDM, ··· 84 85 execute: async ( 86 client: import('@/services/Client').default, 87 + interaction: import('discord.js').ChatInputCommandInteraction, 88 ) => { 89 try { 90 const cooldownCheck = await checkCooldown( 91 cooldownManager, 92 interaction.user.id, 93 client, 94 + interaction.locale, 95 ); 96 if (cooldownCheck.onCooldown) { 97 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 110 const question = sanitizeInput(interaction.options.getString('question')); 111 const translatedResponse = await client.getLocaleText( 112 `commands.8ball.responces.${random(responses)}`, 113 + interaction.locale, 114 ); 115 commandLogger.logFromInteraction( 116 interaction, 117 + `question: "${question?.substring(0, 50)}${question && question.length > 50 ? '...' : ''}"`, 118 ); 119 const [title, questionLabel, answerLabel] = await Promise.all([ 120 await client.getLocaleText('commands.8ball.says', interaction.locale), ··· 126 .setAccentColor(0x8b5cf6) 127 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`# 🔮 ${title}`)) 128 .addTextDisplayComponents( 129 + new TextDisplayBuilder().setContent(`**${questionLabel}**\n> ${question}\n\n`), 130 ) 131 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`## ✨ ${answerLabel}`)) 132 .addTextDisplayComponents(new TextDisplayBuilder().setContent(translatedResponse));
+7 -7
src/commands/fun/cat.ts
··· 64 cooldownManager, 65 interaction.user.id, 66 client, 67 - interaction.locale 68 ); 69 if (cooldownCheck.onCooldown) { 70 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 96 ? await client.getLocaleText('reddit.from', interaction.locale, { 97 subreddit: catData.subreddit, 98 }) 99 - : '' 100 - ) 101 ) 102 .addMediaGalleryComponents( 103 - new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder().setURL(catData.url)) 104 ) 105 .addActionRowComponents( 106 new ActionRowBuilder<ButtonBuilder>().addComponents( ··· 108 .setStyle(ButtonStyle.Danger) 109 .setLabel(refreshLabel) 110 .setEmoji({ name: '🐱' }) 111 - .setCustomId('refresh_cat') 112 - ) 113 ); 114 115 try { ··· 135 const errorMsg = await client.getLocaleText('unexpectederror', interaction.locale); 136 137 const errorContainer = new ContainerBuilder().addTextDisplayComponents( 138 - new TextDisplayBuilder().setContent(errorMsg) 139 ); 140 141 if (!interaction.replied && !interaction.deferred) {
··· 64 cooldownManager, 65 interaction.user.id, 66 client, 67 + interaction.locale, 68 ); 69 if (cooldownCheck.onCooldown) { 70 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 96 ? await client.getLocaleText('reddit.from', interaction.locale, { 97 subreddit: catData.subreddit, 98 }) 99 + : '', 100 + ), 101 ) 102 .addMediaGalleryComponents( 103 + new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder().setURL(catData.url)), 104 ) 105 .addActionRowComponents( 106 new ActionRowBuilder<ButtonBuilder>().addComponents( ··· 108 .setStyle(ButtonStyle.Danger) 109 .setLabel(refreshLabel) 110 .setEmoji({ name: '🐱' }) 111 + .setCustomId('refresh_cat'), 112 + ), 113 ); 114 115 try { ··· 135 const errorMsg = await client.getLocaleText('unexpectederror', interaction.locale); 136 137 const errorContainer = new ContainerBuilder().addTextDisplayComponents( 138 + new TextDisplayBuilder().setContent(errorMsg), 139 ); 140 141 if (!interaction.replied && !interaction.deferred) {
+7 -7
src/commands/fun/dog.ts
··· 64 cooldownManager, 65 interaction.user.id, 66 client, 67 - interaction.locale 68 ); 69 if (cooldownCheck.onCooldown) { 70 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 94 ? await client.getLocaleText('reddit.from', interaction.locale, { 95 subreddit: dogData.subreddit, 96 }) 97 - : '' 98 - ) 99 ) 100 .addMediaGalleryComponents( 101 - new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder().setURL(dogData.url)) 102 ) 103 .addActionRowComponents( 104 new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents( ··· 106 .setStyle(ButtonStyle.Secondary) 107 .setLabel(refreshLabel) 108 .setEmoji({ name: '🐶' }) 109 - .setCustomId('refresh_dog') 110 - ) 111 ); 112 113 await interaction.editReply({ ··· 128 const errorMsg = await client.getLocaleText('unexpectederror', interaction.locale); 129 130 const errorContainer = new ContainerBuilder().addTextDisplayComponents( 131 - new TextDisplayBuilder().setContent(errorMsg) 132 ); 133 134 if (!interaction.replied && !interaction.deferred) {
··· 64 cooldownManager, 65 interaction.user.id, 66 client, 67 + interaction.locale, 68 ); 69 if (cooldownCheck.onCooldown) { 70 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 94 ? await client.getLocaleText('reddit.from', interaction.locale, { 95 subreddit: dogData.subreddit, 96 }) 97 + : '', 98 + ), 99 ) 100 .addMediaGalleryComponents( 101 + new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder().setURL(dogData.url)), 102 ) 103 .addActionRowComponents( 104 new ActionRowBuilder<MessageActionRowComponentBuilder>().addComponents( ··· 106 .setStyle(ButtonStyle.Secondary) 107 .setLabel(refreshLabel) 108 .setEmoji({ name: '🐶' }) 109 + .setCustomId('refresh_dog'), 110 + ), 111 ); 112 113 await interaction.editReply({ ··· 128 const errorMsg = await client.getLocaleText('unexpectederror', interaction.locale); 129 130 const errorContainer = new ContainerBuilder().addTextDisplayComponents( 131 + new TextDisplayBuilder().setContent(errorMsg), 132 ); 133 134 if (!interaction.replied && !interaction.deferred) {
+6 -6
src/commands/fun/joke.ts
··· 79 value: 'programming', 80 name_localizations: { 'es-ES': 'Programación', 'es-419': 'Programación' }, 81 }, 82 - { name: 'Dad', value: 'dad', name_localizations: { 'es-ES': 'Papá', 'es-419': 'Papá' } } 83 - ) 84 ) 85 .setContexts([ 86 InteractionContextType.BotDM, ··· 95 cooldownManager, 96 interaction.user.id, 97 client, 98 - interaction.locale 99 ); 100 if (cooldownCheck.onCooldown) { 101 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 114 interaction.locale, 115 { 116 type: await client.getLocaleText(`commands.joke.type.${joke.type}`, interaction.locale), 117 - } 118 ); 119 120 const waitingFooter = await client.getLocaleText( ··· 122 interaction.locale, 123 { 124 seconds: 3, 125 - } 126 ); 127 128 const embed = new EmbedBuilder() ··· 136 setTimeout(async () => { 137 try { 138 embed.setDescription( 139 - `${sanitizeInput(joke.setup)}\n\n*${sanitizeInput(joke.punchline)}*` 140 ); 141 embed.setFooter({ text: 'Ba dum tss! 🥁' }); 142 await interaction.editReply({ embeds: [embed] });
··· 79 value: 'programming', 80 name_localizations: { 'es-ES': 'Programación', 'es-419': 'Programación' }, 81 }, 82 + { name: 'Dad', value: 'dad', name_localizations: { 'es-ES': 'Papá', 'es-419': 'Papá' } }, 83 + ), 84 ) 85 .setContexts([ 86 InteractionContextType.BotDM, ··· 95 cooldownManager, 96 interaction.user.id, 97 client, 98 + interaction.locale, 99 ); 100 if (cooldownCheck.onCooldown) { 101 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 114 interaction.locale, 115 { 116 type: await client.getLocaleText(`commands.joke.type.${joke.type}`, interaction.locale), 117 + }, 118 ); 119 120 const waitingFooter = await client.getLocaleText( ··· 122 interaction.locale, 123 { 124 seconds: 3, 125 + }, 126 ); 127 128 const embed = new EmbedBuilder() ··· 136 setTimeout(async () => { 137 try { 138 embed.setDescription( 139 + `${sanitizeInput(joke.setup)}\n\n*${sanitizeInput(joke.punchline)}*`, 140 ); 141 embed.setFooter({ text: 'Ba dum tss! 🥁' }); 142 await interaction.editReply({ embeds: [embed] });
+22 -22
src/commands/fun/trivia.ts
··· 172 async function fetchTriviaQuestions( 173 client: import('@/services/Client').default, 174 locale: string, 175 - playerCount: number 176 ): Promise<TriviaQuestion[]> { 177 try { 178 const totalQuestions = Math.min(Math.max(playerCount * 2 + 1, 5), 50); 179 180 const response = await dynamicFetch( 181 - `https://opentdb.com/api.php?amount=${totalQuestions}&type=multiple` 182 ); 183 const data: TriviaAPIResponse = await response.json(); 184 ··· 256 async function startTriviaGame( 257 interaction: ChatInputCommandInteraction | ButtonInteraction, 258 session: GameSession, 259 - client: import('@/services/Client').default 260 ) { 261 try { 262 session.questions = await fetchTriviaQuestions( 263 client, 264 interaction.locale, 265 - session.players.size 266 ); 267 session.originalQuestionCount = session.questions.length; 268 session.isActive = true; ··· 274 } catch { 275 const errorMsg = await client.getLocaleText( 276 'commands.trivia.messages.failed_start', 277 - interaction.locale 278 ); 279 await interaction.editReply({ 280 content: errorMsg, ··· 286 async function askQuestion( 287 interaction: ChatInputCommandInteraction | ButtonInteraction, 288 session: GameSession, 289 - client: import('@/services/Client').default 290 ) { 291 if (session.currentQuestionIndex >= session.questions.length) { 292 const sortedScores = Array.from(session.scores.entries()).sort(([, a], [, b]) => b - a); ··· 300 301 const tiedGameText = await client.getLocaleText( 302 'commands.trivia.messages.tied_game', 303 - interaction.locale 304 ); 305 await interaction.editReply({ 306 content: tiedGameText || '🤝 Tied game! Keep going until someone scores!', ··· 349 '\n' + 350 difficultyText.replace( 351 '{difficulty}', 352 - question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1) 353 ) + 354 '\n\n' + 355 questionText.replace('{question}', question.question); ··· 365 async function endGame( 366 interaction: ChatInputCommandInteraction | ButtonInteraction, 367 session: GameSession, 368 - client: import('@/services/Client').default 369 ) { 370 const sortedScores = Array.from(session.scores.entries()).sort(([, a], [, b]) => b - a); 371 ··· 412 413 execute: async ( 414 client: import('@/services/Client').default, 415 - interaction: ChatInputCommandInteraction 416 ) => { 417 try { 418 const channelId = interaction.channelId; ··· 420 if (gameManager.has(channelId)) { 421 const errorMsg = await client.getLocaleText( 422 'commands.trivia.messages.game_exists', 423 - interaction.locale 424 ); 425 return interaction.reply({ 426 content: errorMsg, ··· 471 472 handleButton: async ( 473 client: import('@/services/Client').default, 474 - interaction: ButtonInteraction 475 ) => { 476 try { 477 const channelId = interaction.channelId; ··· 480 if (!session) { 481 const errorMsg = await client.getLocaleText( 482 'commands.trivia.messages.no_active_game', 483 - interaction.locale 484 ); 485 return interaction.reply({ 486 content: errorMsg, ··· 494 if (!session.queueOpen) { 495 const errorMsg = await client.getLocaleText( 496 'commands.trivia.messages.game_started', 497 - interaction.locale 498 ); 499 return interaction.reply({ 500 content: '❌ ' + errorMsg, ··· 505 if (session.players.has(interaction.user.id)) { 506 const errorMsg = await client.getLocaleText( 507 'commands.trivia.messages.already_joined', 508 - interaction.locale 509 ); 510 return interaction.reply({ 511 content: '❌ ' + errorMsg, ··· 535 if (interaction.user.id !== session.gameCreator) { 536 const errorMsg = await client.getLocaleText( 537 'commands.trivia.messages.only_creator_start', 538 - interaction.locale 539 ); 540 return interaction.reply({ 541 content: '❌ ' + errorMsg, ··· 546 if (session.players.size < 1) { 547 const errorMsg = await client.getLocaleText( 548 'commands.trivia.messages.need_players', 549 - interaction.locale 550 ); 551 return interaction.reply({ 552 content: '❌ ' + errorMsg, ··· 556 557 const startingMsg = await client.getLocaleText( 558 'commands.trivia.messages.starting_game', 559 - interaction.locale 560 ); 561 await interaction.update({ 562 content: startingMsg, ··· 568 if (interaction.user.id !== session.gameCreator) { 569 const errorMsg = await client.getLocaleText( 570 'commands.trivia.messages.only_creator_cancel', 571 - interaction.locale 572 ); 573 return interaction.reply({ 574 content: '❌ ' + errorMsg, ··· 580 581 const cancelMsg = await client.getLocaleText( 582 'commands.trivia.messages.game_cancelled', 583 - interaction.locale 584 ); 585 await interaction.update({ 586 content: cancelMsg, ··· 590 if (!session.isActive) { 591 const errorMsg = await client.getLocaleText( 592 'commands.trivia.messages.no_active_question', 593 - interaction.locale 594 ); 595 return interaction.reply({ 596 content: errorMsg, ··· 601 if (interaction.user.id !== session.currentPlayer) { 602 const errorMsg = await client.getLocaleText( 603 'commands.trivia.messages.not_your_turn', 604 - interaction.locale 605 ); 606 return interaction.reply({ 607 content: '❌ ' + errorMsg,
··· 172 async function fetchTriviaQuestions( 173 client: import('@/services/Client').default, 174 locale: string, 175 + playerCount: number, 176 ): Promise<TriviaQuestion[]> { 177 try { 178 const totalQuestions = Math.min(Math.max(playerCount * 2 + 1, 5), 50); 179 180 const response = await dynamicFetch( 181 + `https://opentdb.com/api.php?amount=${totalQuestions}&type=multiple`, 182 ); 183 const data: TriviaAPIResponse = await response.json(); 184 ··· 256 async function startTriviaGame( 257 interaction: ChatInputCommandInteraction | ButtonInteraction, 258 session: GameSession, 259 + client: import('@/services/Client').default, 260 ) { 261 try { 262 session.questions = await fetchTriviaQuestions( 263 client, 264 interaction.locale, 265 + session.players.size, 266 ); 267 session.originalQuestionCount = session.questions.length; 268 session.isActive = true; ··· 274 } catch { 275 const errorMsg = await client.getLocaleText( 276 'commands.trivia.messages.failed_start', 277 + interaction.locale, 278 ); 279 await interaction.editReply({ 280 content: errorMsg, ··· 286 async function askQuestion( 287 interaction: ChatInputCommandInteraction | ButtonInteraction, 288 session: GameSession, 289 + client: import('@/services/Client').default, 290 ) { 291 if (session.currentQuestionIndex >= session.questions.length) { 292 const sortedScores = Array.from(session.scores.entries()).sort(([, a], [, b]) => b - a); ··· 300 301 const tiedGameText = await client.getLocaleText( 302 'commands.trivia.messages.tied_game', 303 + interaction.locale, 304 ); 305 await interaction.editReply({ 306 content: tiedGameText || '🤝 Tied game! Keep going until someone scores!', ··· 349 '\n' + 350 difficultyText.replace( 351 '{difficulty}', 352 + question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1), 353 ) + 354 '\n\n' + 355 questionText.replace('{question}', question.question); ··· 365 async function endGame( 366 interaction: ChatInputCommandInteraction | ButtonInteraction, 367 session: GameSession, 368 + client: import('@/services/Client').default, 369 ) { 370 const sortedScores = Array.from(session.scores.entries()).sort(([, a], [, b]) => b - a); 371 ··· 412 413 execute: async ( 414 client: import('@/services/Client').default, 415 + interaction: ChatInputCommandInteraction, 416 ) => { 417 try { 418 const channelId = interaction.channelId; ··· 420 if (gameManager.has(channelId)) { 421 const errorMsg = await client.getLocaleText( 422 'commands.trivia.messages.game_exists', 423 + interaction.locale, 424 ); 425 return interaction.reply({ 426 content: errorMsg, ··· 471 472 handleButton: async ( 473 client: import('@/services/Client').default, 474 + interaction: ButtonInteraction, 475 ) => { 476 try { 477 const channelId = interaction.channelId; ··· 480 if (!session) { 481 const errorMsg = await client.getLocaleText( 482 'commands.trivia.messages.no_active_game', 483 + interaction.locale, 484 ); 485 return interaction.reply({ 486 content: errorMsg, ··· 494 if (!session.queueOpen) { 495 const errorMsg = await client.getLocaleText( 496 'commands.trivia.messages.game_started', 497 + interaction.locale, 498 ); 499 return interaction.reply({ 500 content: '❌ ' + errorMsg, ··· 505 if (session.players.has(interaction.user.id)) { 506 const errorMsg = await client.getLocaleText( 507 'commands.trivia.messages.already_joined', 508 + interaction.locale, 509 ); 510 return interaction.reply({ 511 content: '❌ ' + errorMsg, ··· 535 if (interaction.user.id !== session.gameCreator) { 536 const errorMsg = await client.getLocaleText( 537 'commands.trivia.messages.only_creator_start', 538 + interaction.locale, 539 ); 540 return interaction.reply({ 541 content: '❌ ' + errorMsg, ··· 546 if (session.players.size < 1) { 547 const errorMsg = await client.getLocaleText( 548 'commands.trivia.messages.need_players', 549 + interaction.locale, 550 ); 551 return interaction.reply({ 552 content: '❌ ' + errorMsg, ··· 556 557 const startingMsg = await client.getLocaleText( 558 'commands.trivia.messages.starting_game', 559 + interaction.locale, 560 ); 561 await interaction.update({ 562 content: startingMsg, ··· 568 if (interaction.user.id !== session.gameCreator) { 569 const errorMsg = await client.getLocaleText( 570 'commands.trivia.messages.only_creator_cancel', 571 + interaction.locale, 572 ); 573 return interaction.reply({ 574 content: '❌ ' + errorMsg, ··· 580 581 const cancelMsg = await client.getLocaleText( 582 'commands.trivia.messages.game_cancelled', 583 + interaction.locale, 584 ); 585 await interaction.update({ 586 content: cancelMsg, ··· 590 if (!session.isActive) { 591 const errorMsg = await client.getLocaleText( 592 'commands.trivia.messages.no_active_question', 593 + interaction.locale, 594 ); 595 return interaction.reply({ 596 content: errorMsg, ··· 601 if (interaction.user.id !== session.currentPlayer) { 602 const errorMsg = await client.getLocaleText( 603 'commands.trivia.messages.not_your_turn', 604 + interaction.locale, 605 ); 606 return interaction.reply({ 607 content: '❌ ' + errorMsg,
+15 -15
src/commands/info/help.ts
··· 63 64 .addMediaGalleryComponents( 65 new MediaGalleryBuilder().addItems( 66 - new MediaGalleryItemBuilder().setURL('https://aethel.xyz/aethel_banner_white.png') 67 - ) 68 ) 69 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`# ${title || 'Aethel Bot'}`)) 70 .addTextDisplayComponents( 71 - new TextDisplayBuilder().setContent(description || 'Get information about Aethel') 72 ) 73 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 74 .addTextDisplayComponents( 75 new TextDisplayBuilder().setContent( 76 - `\n## **${linksSocialText || 'Links & Social Media'}**` 77 - ) 78 ) 79 .addTextDisplayComponents( 80 new TextDisplayBuilder().setContent( 81 - '[Website](https://aethel.xyz) • [GitHub](https://github.com/aethel-labs/aethel) • [Bluesky](https://bsky.app/profile/aethel.xyz)' 82 - ) 83 ) 84 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 85 .addTextDisplayComponents( 86 - new TextDisplayBuilder().setContent(`\n## **${featuresText || 'Features'}**`) 87 ) 88 .addTextDisplayComponents( 89 new TextDisplayBuilder().setContent( ··· 92 '**AI Integration** - Powered by OpenAI and other providers\n' + 93 '**Reminders** - Never forget important tasks\n' + 94 '**Utilities** - Weather, help, and productivity tools\n' + 95 - '**Multi-language** - Supports multiple languages' 96 - ) 97 ) 98 99 .addSeparatorComponents( 100 - new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true) 101 ) 102 .addTextDisplayComponents( 103 new TextDisplayBuilder().setContent( 104 - `-# ${dashboardText || 'Dashboard available at https://aethel.xyz/login for To-Dos, Reminders and custom AI API key management'}` 105 - ) 106 ) 107 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 108 .addActionRowComponents( ··· 114 new ButtonBuilder() 115 .setStyle(ButtonStyle.Link) 116 .setLabel(supportServerText || 'Support') 117 - .setURL('https://discord.gg/63stE8pEaK') 118 - ) 119 ); 120 121 await interaction.reply({
··· 63 64 .addMediaGalleryComponents( 65 new MediaGalleryBuilder().addItems( 66 + new MediaGalleryItemBuilder().setURL('https://aethel.xyz/aethel_banner_white.png'), 67 + ), 68 ) 69 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`# ${title || 'Aethel Bot'}`)) 70 .addTextDisplayComponents( 71 + new TextDisplayBuilder().setContent(description || 'Get information about Aethel'), 72 ) 73 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 74 .addTextDisplayComponents( 75 new TextDisplayBuilder().setContent( 76 + `\n## **${linksSocialText || 'Links & Social Media'}**`, 77 + ), 78 ) 79 .addTextDisplayComponents( 80 new TextDisplayBuilder().setContent( 81 + '[Website](https://aethel.xyz) • [GitHub](https://github.com/aethel-labs/aethel) • [Bluesky](https://bsky.app/profile/aethel.xyz)', 82 + ), 83 ) 84 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 85 .addTextDisplayComponents( 86 + new TextDisplayBuilder().setContent(`\n## **${featuresText || 'Features'}**`), 87 ) 88 .addTextDisplayComponents( 89 new TextDisplayBuilder().setContent( ··· 92 '**AI Integration** - Powered by OpenAI and other providers\n' + 93 '**Reminders** - Never forget important tasks\n' + 94 '**Utilities** - Weather, help, and productivity tools\n' + 95 + '**Multi-language** - Supports multiple languages', 96 + ), 97 ) 98 99 .addSeparatorComponents( 100 + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true), 101 ) 102 .addTextDisplayComponents( 103 new TextDisplayBuilder().setContent( 104 + `-# ${dashboardText || 'Dashboard available at https://aethel.xyz/login for To-Dos, Reminders and custom AI API key management'}`, 105 + ), 106 ) 107 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 108 .addActionRowComponents( ··· 114 new ButtonBuilder() 115 .setStyle(ButtonStyle.Link) 116 .setLabel(supportServerText || 'Support') 117 + .setURL('https://discord.gg/63stE8pEaK'), 118 + ), 119 ); 120 121 await interaction.reply({
+33 -33
src/commands/utilities/ai.ts
··· 113 const after = text[startIdx + url.length]; 114 if (before === '<' && after === '>') return url + (punctuation || ''); 115 return `<${url}>${punctuation || ''}`; 116 - } 117 ); 118 } 119 ··· 138 client?: BotClient, 139 model?: string, 140 username?: string, 141 - interaction?: ChatInputCommandInteraction 142 ): string { 143 const now = new Date(); 144 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; ··· 160 let supportedCommands = '/help - Show all available commands and their usage'; 161 if (client?.commands) { 162 const commandEntries = Array.from(client.commands.entries()).sort(([a], [b]) => 163 - a.localeCompare(b) 164 ); 165 supportedCommands = commandEntries 166 .map( 167 - ([name, command]) => `/${name} - ${command.data.description || 'No description available'}` 168 ) 169 .join('\n'); 170 } ··· 218 detail?: 'low' | 'high' | 'auto'; 219 }; 220 }>, 221 - systemPrompt: string 222 ): ConversationMessage[] { 223 let conversation = existingConversation.filter((msg) => msg.role !== 'system'); 224 conversation.push({ role: 'user', content: prompt }); ··· 231 return conversation; 232 } 233 234 - function splitResponseIntoChunks(response: string, maxLength: number = 2000): string[] { 235 if (response.length <= maxLength) return [response]; 236 237 const chunks: string[] = []; ··· 272 userId: string, 273 apiKey: string | null, 274 model: string | null, 275 - apiUrl: string | null 276 ): Promise<void> { 277 if (apiKey === null) { 278 await pool.query( 279 `UPDATE users SET api_key_encrypted = NULL, custom_model = NULL, custom_api_url = NULL, updated_at = now() WHERE user_id = $1`, 280 - [userId] 281 ); 282 logger.info(`Cleared API credentials for user ${userId}`); 283 } else { ··· 291 VALUES ($1, $2, $3, $4, now()) 292 ON CONFLICT (user_id) DO UPDATE SET 293 api_key_encrypted = $2, custom_model = $3, custom_api_url = $4, updated_at = now()`, 294 - [userId, encrypted, model?.trim() || null, apiUrl?.trim() || null] 295 ); 296 logger.info(`Successfully saved encrypted API credentials for user ${userId}`); 297 } ··· 340 } 341 } 342 343 - async function incrementAndCheckDailyLimit(userId: string, limit: number = 20): Promise<boolean> { 344 const today = new Date().toISOString().slice(0, 10); 345 const client = await pool.connect(); 346 try { ··· 351 const res = await client.query( 352 `INSERT INTO ai_usage (user_id, usage_date, count) VALUES ($1, $2, 1) 353 ON CONFLICT (user_id, usage_date) DO UPDATE SET count = ai_usage.count + 1 RETURNING count`, 354 - [userId, today] 355 ); 356 await client.query('COMMIT'); 357 return res.rows[0].count <= limit; ··· 366 async function testApiKey( 367 apiKey: string, 368 model: string, 369 - apiUrl: string 370 ): Promise<{ success: boolean; error?: string }> { 371 try { 372 const client = getOpenAIClient(apiKey, apiUrl); ··· 397 398 async function makeAIRequest( 399 config: ReturnType<typeof getApiConfiguration>, 400 - conversation: ConversationMessage[] 401 ): Promise<AIResponse | null> { 402 try { 403 const client = getOpenAIClient(config.finalApiKey!, config.finalApiUrl); ··· 429 430 async function processAIRequest( 431 client: BotClient, 432 - interaction: ChatInputCommandInteraction 433 ): Promise<void> { 434 try { 435 if (!interaction.deferred && !interaction.replied) { ··· 439 const prompt = interaction.options.getString('prompt')!; 440 commandLogger.logFromInteraction( 441 interaction, 442 - `prompt: "${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}"` 443 ); 444 445 const { apiKey, model, apiUrl } = await getUserCredentials(interaction.user.id); ··· 452 if (!allowed) { 453 await interaction.editReply( 454 '❌ ' + 455 - (await client.getLocaleText('commands.ai.process.dailylimit', interaction.locale)) 456 ); 457 return; 458 } 459 } 460 } else if (!config.finalApiKey) { 461 await interaction.editReply( 462 - '❌ ' + (await client.getLocaleText('commands.ai.process.noapikey', interaction.locale)) 463 ); 464 return; 465 } ··· 471 client, 472 config.finalModel, 473 interaction.user.tag, 474 - interaction 475 ); 476 const conversation = buildConversation(conversationArray, prompt, systemPrompt); 477 ··· 505 async function sendAIResponse( 506 interaction: ChatInputCommandInteraction, 507 aiResponse: AIResponse, 508 - client: BotClient 509 ): Promise<void> { 510 let fullResponse = ''; 511 ··· 584 'es-419': 'Tu mensaje para la IA', 585 'en-US': 'Your message to the AI', 586 }) 587 - .setRequired(true) 588 ) 589 .addBooleanOption((option) => 590 - option.setName('use_custom_api').setDescription('Use your own API key?').setRequired(false) 591 ) 592 .addBooleanOption((option) => 593 - option.setName('reset').setDescription('Reset your AI chat history').setRequired(false) 594 ), 595 596 async execute(client: BotClient, interaction: ChatInputCommandInteraction) { ··· 647 .setLabel(await client.getLocaleText('commands.ai.modal.apikey', interaction.locale)) 648 .setStyle(TextInputStyle.Short) 649 .setPlaceholder( 650 - await client.getLocaleText('commands.ai.modal.apikeyplaceholder', interaction.locale) 651 ) 652 .setRequired(true); 653 ··· 656 .setLabel(await client.getLocaleText('commands.ai.modal.apiurl', interaction.locale)) 657 .setStyle(TextInputStyle.Short) 658 .setPlaceholder( 659 - await client.getLocaleText('commands.ai.modal.apiurlplaceholder', interaction.locale) 660 ) 661 .setRequired(true); 662 ··· 665 .setLabel(await client.getLocaleText('commands.ai.modal.model', interaction.locale)) 666 .setStyle(TextInputStyle.Short) 667 .setPlaceholder( 668 - await client.getLocaleText('commands.ai.modal.modelplaceholder', interaction.locale) 669 ) 670 .setRequired(true); 671 672 modal.addComponents( 673 new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput), 674 new ActionRowBuilder<TextInputBuilder>().addComponents(apiUrlInput), 675 - new ActionRowBuilder<TextInputBuilder>().addComponents(modelInput) 676 ); 677 678 await interaction.showModal(modal); ··· 701 702 if (!pendingRequest) { 703 return interaction.editReply( 704 - await client.getLocaleText('commands.ai.nopendingrequest', interaction.locale) 705 ); 706 } 707 ··· 715 parsedUrl = new URL(apiUrl); 716 } catch { 717 await interaction.editReply( 718 - 'API URL is invalid. Please use a supported API endpoint (OpenAI, OpenRouter, or Google Gemini).' 719 ); 720 return; 721 } 722 723 if (!ALLOWED_API_HOSTS.includes(parsedUrl.hostname)) { 724 await interaction.editReply( 725 - 'API URL not allowed. Please use a supported API endpoint (OpenAI, OpenRouter, or Google Gemini).' 726 ); 727 return; 728 } 729 730 await interaction.editReply( 731 - await client.getLocaleText('commands.ai.testing', interaction.locale) 732 ); 733 const testResult = await testApiKey(apiKey, model, apiUrl); 734 735 if (!testResult.success) { 736 const errorMessage = await client.getLocaleText( 737 'commands.ai.testfailed', 738 - interaction.locale 739 ); 740 await interaction.editReply( 741 - errorMessage.replace('{error}', testResult.error || 'Unknown error') 742 ); 743 return; 744 } 745 746 await setUserApiKey(userId, apiKey, model, apiUrl); 747 await interaction.editReply( 748 - await client.getLocaleText('commands.ai.testsuccess', interaction.locale) 749 ); 750 751 if (!originalInteraction.deferred && !originalInteraction.replied) {
··· 113 const after = text[startIdx + url.length]; 114 if (before === '<' && after === '>') return url + (punctuation || ''); 115 return `<${url}>${punctuation || ''}`; 116 + }, 117 ); 118 } 119 ··· 138 client?: BotClient, 139 model?: string, 140 username?: string, 141 + interaction?: ChatInputCommandInteraction, 142 ): string { 143 const now = new Date(); 144 const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; ··· 160 let supportedCommands = '/help - Show all available commands and their usage'; 161 if (client?.commands) { 162 const commandEntries = Array.from(client.commands.entries()).sort(([a], [b]) => 163 + a.localeCompare(b), 164 ); 165 supportedCommands = commandEntries 166 .map( 167 + ([name, command]) => `/${name} - ${command.data.description || 'No description available'}`, 168 ) 169 .join('\n'); 170 } ··· 218 detail?: 'low' | 'high' | 'auto'; 219 }; 220 }>, 221 + systemPrompt: string, 222 ): ConversationMessage[] { 223 let conversation = existingConversation.filter((msg) => msg.role !== 'system'); 224 conversation.push({ role: 'user', content: prompt }); ··· 231 return conversation; 232 } 233 234 + function splitResponseIntoChunks(response: string, maxLength = 2000): string[] { 235 if (response.length <= maxLength) return [response]; 236 237 const chunks: string[] = []; ··· 272 userId: string, 273 apiKey: string | null, 274 model: string | null, 275 + apiUrl: string | null, 276 ): Promise<void> { 277 if (apiKey === null) { 278 await pool.query( 279 `UPDATE users SET api_key_encrypted = NULL, custom_model = NULL, custom_api_url = NULL, updated_at = now() WHERE user_id = $1`, 280 + [userId], 281 ); 282 logger.info(`Cleared API credentials for user ${userId}`); 283 } else { ··· 291 VALUES ($1, $2, $3, $4, now()) 292 ON CONFLICT (user_id) DO UPDATE SET 293 api_key_encrypted = $2, custom_model = $3, custom_api_url = $4, updated_at = now()`, 294 + [userId, encrypted, model?.trim() || null, apiUrl?.trim() || null], 295 ); 296 logger.info(`Successfully saved encrypted API credentials for user ${userId}`); 297 } ··· 340 } 341 } 342 343 + async function incrementAndCheckDailyLimit(userId: string, limit = 20): Promise<boolean> { 344 const today = new Date().toISOString().slice(0, 10); 345 const client = await pool.connect(); 346 try { ··· 351 const res = await client.query( 352 `INSERT INTO ai_usage (user_id, usage_date, count) VALUES ($1, $2, 1) 353 ON CONFLICT (user_id, usage_date) DO UPDATE SET count = ai_usage.count + 1 RETURNING count`, 354 + [userId, today], 355 ); 356 await client.query('COMMIT'); 357 return res.rows[0].count <= limit; ··· 366 async function testApiKey( 367 apiKey: string, 368 model: string, 369 + apiUrl: string, 370 ): Promise<{ success: boolean; error?: string }> { 371 try { 372 const client = getOpenAIClient(apiKey, apiUrl); ··· 397 398 async function makeAIRequest( 399 config: ReturnType<typeof getApiConfiguration>, 400 + conversation: ConversationMessage[], 401 ): Promise<AIResponse | null> { 402 try { 403 const client = getOpenAIClient(config.finalApiKey!, config.finalApiUrl); ··· 429 430 async function processAIRequest( 431 client: BotClient, 432 + interaction: ChatInputCommandInteraction, 433 ): Promise<void> { 434 try { 435 if (!interaction.deferred && !interaction.replied) { ··· 439 const prompt = interaction.options.getString('prompt')!; 440 commandLogger.logFromInteraction( 441 interaction, 442 + `prompt: "${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}"`, 443 ); 444 445 const { apiKey, model, apiUrl } = await getUserCredentials(interaction.user.id); ··· 452 if (!allowed) { 453 await interaction.editReply( 454 '❌ ' + 455 + (await client.getLocaleText('commands.ai.process.dailylimit', interaction.locale)), 456 ); 457 return; 458 } 459 } 460 } else if (!config.finalApiKey) { 461 await interaction.editReply( 462 + '❌ ' + (await client.getLocaleText('commands.ai.process.noapikey', interaction.locale)), 463 ); 464 return; 465 } ··· 471 client, 472 config.finalModel, 473 interaction.user.tag, 474 + interaction, 475 ); 476 const conversation = buildConversation(conversationArray, prompt, systemPrompt); 477 ··· 505 async function sendAIResponse( 506 interaction: ChatInputCommandInteraction, 507 aiResponse: AIResponse, 508 + client: BotClient, 509 ): Promise<void> { 510 let fullResponse = ''; 511 ··· 584 'es-419': 'Tu mensaje para la IA', 585 'en-US': 'Your message to the AI', 586 }) 587 + .setRequired(true), 588 ) 589 .addBooleanOption((option) => 590 + option.setName('use_custom_api').setDescription('Use your own API key?').setRequired(false), 591 ) 592 .addBooleanOption((option) => 593 + option.setName('reset').setDescription('Reset your AI chat history').setRequired(false), 594 ), 595 596 async execute(client: BotClient, interaction: ChatInputCommandInteraction) { ··· 647 .setLabel(await client.getLocaleText('commands.ai.modal.apikey', interaction.locale)) 648 .setStyle(TextInputStyle.Short) 649 .setPlaceholder( 650 + await client.getLocaleText('commands.ai.modal.apikeyplaceholder', interaction.locale), 651 ) 652 .setRequired(true); 653 ··· 656 .setLabel(await client.getLocaleText('commands.ai.modal.apiurl', interaction.locale)) 657 .setStyle(TextInputStyle.Short) 658 .setPlaceholder( 659 + await client.getLocaleText('commands.ai.modal.apiurlplaceholder', interaction.locale), 660 ) 661 .setRequired(true); 662 ··· 665 .setLabel(await client.getLocaleText('commands.ai.modal.model', interaction.locale)) 666 .setStyle(TextInputStyle.Short) 667 .setPlaceholder( 668 + await client.getLocaleText('commands.ai.modal.modelplaceholder', interaction.locale), 669 ) 670 .setRequired(true); 671 672 modal.addComponents( 673 new ActionRowBuilder<TextInputBuilder>().addComponents(apiKeyInput), 674 new ActionRowBuilder<TextInputBuilder>().addComponents(apiUrlInput), 675 + new ActionRowBuilder<TextInputBuilder>().addComponents(modelInput), 676 ); 677 678 await interaction.showModal(modal); ··· 701 702 if (!pendingRequest) { 703 return interaction.editReply( 704 + await client.getLocaleText('commands.ai.nopendingrequest', interaction.locale), 705 ); 706 } 707 ··· 715 parsedUrl = new URL(apiUrl); 716 } catch { 717 await interaction.editReply( 718 + 'API URL is invalid. Please use a supported API endpoint (OpenAI, OpenRouter, or Google Gemini).', 719 ); 720 return; 721 } 722 723 if (!ALLOWED_API_HOSTS.includes(parsedUrl.hostname)) { 724 await interaction.editReply( 725 + 'API URL not allowed. Please use a supported API endpoint (OpenAI, OpenRouter, or Google Gemini).', 726 ); 727 return; 728 } 729 730 await interaction.editReply( 731 + await client.getLocaleText('commands.ai.testing', interaction.locale), 732 ); 733 const testResult = await testApiKey(apiKey, model, apiUrl); 734 735 if (!testResult.success) { 736 const errorMessage = await client.getLocaleText( 737 'commands.ai.testfailed', 738 + interaction.locale, 739 ); 740 await interaction.editReply( 741 + errorMessage.replace('{error}', testResult.error || 'Unknown error'), 742 ); 743 return; 744 } 745 746 await setUserApiKey(userId, apiKey, model, apiUrl); 747 await interaction.editReply( 748 + await client.getLocaleText('commands.ai.testsuccess', interaction.locale), 749 ); 750 751 if (!originalInteraction.deferred && !originalInteraction.replied) {
+30 -30
src/commands/utilities/cobalt.ts
··· 59 'es-419': 'La URL del video a descargar', 60 'en-US': 'The URL of the video to download', 61 }) 62 - .setRequired(true) 63 ) 64 .addStringOption((option) => 65 option ··· 84 { name: '1440p', value: '1440' }, 85 { name: '2160p (4K)', value: '2160' }, 86 { name: '4320p (8K)', value: '4320' }, 87 - { name: 'Max', value: 'max' } 88 - ) 89 ) 90 .addBooleanOption((option) => 91 option ··· 100 'es-ES': 'Descargar solo audio', 101 'es-419': 'Descargar solo audio', 102 'en-US': 'Download audio only', 103 - }) 104 ) 105 .addBooleanOption((option) => 106 option ··· 115 'es-ES': 'Silenciar audio', 116 'es-419': 'Silenciar audio', 117 'en-US': 'Mute audio', 118 - }) 119 ) 120 .addBooleanOption((option) => 121 option ··· 130 'es-ES': 'Descargar como GIF de Twitter', 131 'es-419': 'Descargar como GIF de Twitter', 132 'en-US': 'Download as Twitter GIF', 133 - }) 134 ) 135 .addBooleanOption((option) => 136 option ··· 145 'es-ES': 'Incluir audio original de TikTok', 146 'es-419': 'Incluir audio original de TikTok', 147 'en-US': 'Include TikTok original audio', 148 - }) 149 ) 150 .addStringOption((option) => 151 option ··· 165 { name: 'MP3', value: 'mp3' }, 166 { name: 'OGG', value: 'ogg' }, 167 { name: 'WAV', value: 'wav' }, 168 - { name: 'Best', value: 'best' } 169 - ) 170 ) 171 .setContexts([ 172 InteractionContextType.BotDM, ··· 221 222 const buttonLabel = await client.getLocaleText( 223 'commands.cobalt.button_label', 224 - interaction.locale 225 ); 226 227 const container = new ContainerBuilder() ··· 230 new MediaGalleryBuilder().addItems( 231 new MediaGalleryItemBuilder() 232 .setDescription(data.filename || 'Downloaded media') 233 - .setURL(downloadUrl) 234 - ) 235 ) 236 237 .addSectionComponents( 238 new SectionBuilder() 239 .addTextDisplayComponents((textDisplay) => 240 - textDisplay.setContent(`-# Took: ${processingTime}s`) 241 ) 242 .setButtonAccessory((button) => 243 - button.setLabel(buttonLabel).setStyle(ButtonStyle.Link).setURL(downloadUrl) 244 - ) 245 ); 246 247 await interaction.editReply({ ··· 251 } else if (data.status === 'error') { 252 const unknownError = await client.getLocaleText( 253 'commands.cobalt.unknown_error', 254 - interaction.locale 255 ); 256 let errorText = unknownError; 257 ··· 266 interaction.locale, 267 { 268 error: errorText, 269 - } 270 ); 271 272 const errorContainer = new ContainerBuilder() 273 .setAccentColor(0xff4757) 274 .addSectionComponents( 275 new SectionBuilder().addTextDisplayComponents((textDisplay) => 276 - textDisplay.setContent(`❌ ${errorMessage}`) 277 - ) 278 ); 279 280 await interaction.editReply({ ··· 285 const multipleItemsMessage = await client.getLocaleText( 286 'commands.cobalt.multiple_items', 287 interaction.locale, 288 - { url } 289 ); 290 291 const pickerContainer = new ContainerBuilder() ··· 293 .addSectionComponents( 294 new SectionBuilder().addTextDisplayComponents((textDisplay) => 295 textDisplay.setContent( 296 - `⚠️ ${multipleItemsMessage || 'Multiple items found. Please provide a more specific URL.'}` 297 - ) 298 - ) 299 ); 300 301 await interaction.editReply({ ··· 305 } else if (data.status === 'local-processing') { 306 const notSupportedMessage = await client.getLocaleText( 307 'commands.cobalt.local_processing_not_supported', 308 - interaction.locale 309 ); 310 311 const processingContainer = new ContainerBuilder() ··· 313 .addSectionComponents( 314 new SectionBuilder().addTextDisplayComponents((textDisplay) => 315 textDisplay.setContent( 316 - `⚠️ ${notSupportedMessage || 'Local processing is not supported. Please try a different URL or option.'}` 317 - ) 318 - ) 319 ); 320 321 await interaction.editReply({ ··· 325 } else { 326 const unknownResponseMessage = await client.getLocaleText( 327 'commands.cobalt.unknown_response', 328 - interaction.locale 329 ); 330 331 const unknownContainer = new ContainerBuilder() 332 .setAccentColor(0xff4757) 333 .addSectionComponents( 334 new SectionBuilder().addTextDisplayComponents((textDisplay) => 335 - textDisplay.setContent(`❓ ${unknownResponseMessage}`) 336 - ) 337 ); 338 339 await interaction.editReply({
··· 59 'es-419': 'La URL del video a descargar', 60 'en-US': 'The URL of the video to download', 61 }) 62 + .setRequired(true), 63 ) 64 .addStringOption((option) => 65 option ··· 84 { name: '1440p', value: '1440' }, 85 { name: '2160p (4K)', value: '2160' }, 86 { name: '4320p (8K)', value: '4320' }, 87 + { name: 'Max', value: 'max' }, 88 + ), 89 ) 90 .addBooleanOption((option) => 91 option ··· 100 'es-ES': 'Descargar solo audio', 101 'es-419': 'Descargar solo audio', 102 'en-US': 'Download audio only', 103 + }), 104 ) 105 .addBooleanOption((option) => 106 option ··· 115 'es-ES': 'Silenciar audio', 116 'es-419': 'Silenciar audio', 117 'en-US': 'Mute audio', 118 + }), 119 ) 120 .addBooleanOption((option) => 121 option ··· 130 'es-ES': 'Descargar como GIF de Twitter', 131 'es-419': 'Descargar como GIF de Twitter', 132 'en-US': 'Download as Twitter GIF', 133 + }), 134 ) 135 .addBooleanOption((option) => 136 option ··· 145 'es-ES': 'Incluir audio original de TikTok', 146 'es-419': 'Incluir audio original de TikTok', 147 'en-US': 'Include TikTok original audio', 148 + }), 149 ) 150 .addStringOption((option) => 151 option ··· 165 { name: 'MP3', value: 'mp3' }, 166 { name: 'OGG', value: 'ogg' }, 167 { name: 'WAV', value: 'wav' }, 168 + { name: 'Best', value: 'best' }, 169 + ), 170 ) 171 .setContexts([ 172 InteractionContextType.BotDM, ··· 221 222 const buttonLabel = await client.getLocaleText( 223 'commands.cobalt.button_label', 224 + interaction.locale, 225 ); 226 227 const container = new ContainerBuilder() ··· 230 new MediaGalleryBuilder().addItems( 231 new MediaGalleryItemBuilder() 232 .setDescription(data.filename || 'Downloaded media') 233 + .setURL(downloadUrl), 234 + ), 235 ) 236 237 .addSectionComponents( 238 new SectionBuilder() 239 .addTextDisplayComponents((textDisplay) => 240 + textDisplay.setContent(`-# Took: ${processingTime}s`), 241 ) 242 .setButtonAccessory((button) => 243 + button.setLabel(buttonLabel).setStyle(ButtonStyle.Link).setURL(downloadUrl), 244 + ), 245 ); 246 247 await interaction.editReply({ ··· 251 } else if (data.status === 'error') { 252 const unknownError = await client.getLocaleText( 253 'commands.cobalt.unknown_error', 254 + interaction.locale, 255 ); 256 let errorText = unknownError; 257 ··· 266 interaction.locale, 267 { 268 error: errorText, 269 + }, 270 ); 271 272 const errorContainer = new ContainerBuilder() 273 .setAccentColor(0xff4757) 274 .addSectionComponents( 275 new SectionBuilder().addTextDisplayComponents((textDisplay) => 276 + textDisplay.setContent(`❌ ${errorMessage}`), 277 + ), 278 ); 279 280 await interaction.editReply({ ··· 285 const multipleItemsMessage = await client.getLocaleText( 286 'commands.cobalt.multiple_items', 287 interaction.locale, 288 + { url }, 289 ); 290 291 const pickerContainer = new ContainerBuilder() ··· 293 .addSectionComponents( 294 new SectionBuilder().addTextDisplayComponents((textDisplay) => 295 textDisplay.setContent( 296 + `⚠️ ${multipleItemsMessage || 'Multiple items found. Please provide a more specific URL.'}`, 297 + ), 298 + ), 299 ); 300 301 await interaction.editReply({ ··· 305 } else if (data.status === 'local-processing') { 306 const notSupportedMessage = await client.getLocaleText( 307 'commands.cobalt.local_processing_not_supported', 308 + interaction.locale, 309 ); 310 311 const processingContainer = new ContainerBuilder() ··· 313 .addSectionComponents( 314 new SectionBuilder().addTextDisplayComponents((textDisplay) => 315 textDisplay.setContent( 316 + `⚠️ ${notSupportedMessage || 'Local processing is not supported. Please try a different URL or option.'}`, 317 + ), 318 + ), 319 ); 320 321 await interaction.editReply({ ··· 325 } else { 326 const unknownResponseMessage = await client.getLocaleText( 327 'commands.cobalt.unknown_response', 328 + interaction.locale, 329 ); 330 331 const unknownContainer = new ContainerBuilder() 332 .setAccentColor(0xff4757) 333 .addSectionComponents( 334 new SectionBuilder().addTextDisplayComponents((textDisplay) => 335 + textDisplay.setContent(`❓ ${unknownResponseMessage}`), 336 + ), 337 ); 338 339 await interaction.editReply({
+46 -50
src/commands/utilities/remind.ts
··· 91 ...reminder, 92 created_at: reminder.created_at || new Date(reminder.expires_at), 93 }), 94 - timeUntilExpiry 95 ); 96 97 activeReminders.set(reminder.reminder_id, { ··· 100 }); 101 102 logger.debug( 103 - `Scheduled reminder ${reminder.reminder_id} for ${new Date(expiresAt).toISOString()}` 104 ); 105 } 106 ··· 131 ...reminder, 132 created_at: reminder.created_at || new Date(), 133 }), 134 - timeUntilExpiry 135 ); 136 137 activeReminders.set(reminder.reminder_id, { ··· 140 }); 141 142 logger.info( 143 - `Scheduled reminder ${reminder.reminder_id} for ${new Date(expiresAt).toISOString()}` 144 ); 145 return true; 146 } catch (error) { ··· 166 167 const minutes = Math.floor( 168 (new Date(reminder.expires_at).getTime() - new Date(reminder.created_at!).getTime()) / 169 - (60 * 1000) 170 ); 171 172 const reminderTitle = ··· 174 const reminderDesc = await client.getLocaleText( 175 'commands.remind.remindyou', 176 reminder.locale, 177 - { message: reminder.message } 178 ); 179 180 const timeElapsedText = ··· 192 name: originalTimeText, 193 value: `<t:${Math.floor(new Date(reminder.created_at!).getTime() / 1000)}:f>`, 194 inline: true, 195 - } 196 ) 197 .setFooter({ text: `ID: ${reminder.reminder_id.slice(-6)}` }) 198 .setTimestamp(); ··· 201 const originalMessageText = await client.getLocaleText('common.ogmessage', reminder.locale); 202 const jumpToMessageText = await client.getLocaleText( 203 'common.jumptomessage', 204 - reminder.locale 205 ); 206 207 reminderEmbed.addFields({ ··· 233 { 234 error, 235 reminderId: reminder.reminder_id, 236 - } 237 ); 238 } finally { 239 try { ··· 271 } 272 } 273 }, 274 - 60 * 60 * 1000 275 ); 276 277 function createCommandBuilder() { ··· 298 'es-ES': 'Cuándo recordarte (ej: 1h, 30m, 5h30m)', 299 'es-419': 'Cuándo recordarte (ej: 1h, 30m, 5h30m)', 300 }) 301 - .setRequired(true) 302 ) 303 .addStringOption((option) => 304 option ··· 312 'es-ES': 'Sobre qué quieres que te recuerde', 313 'es-419': 'Sobre qué quieres que te recuerde', 314 }) 315 - .setRequired(true) 316 ) 317 .setContexts([ 318 InteractionContextType.BotDM, ··· 360 361 commandLogger.logFromInteraction( 362 interaction, 363 - `time: ${timeStr}, message: "${message.substring(0, 50)}${message.length > 50 ? '...' : ''}"` 364 ); 365 366 if (!validateTimeString(timeStr)) { 367 logger.warn(`Invalid time format from ${userTag}: ${timeStr}`); 368 const errorMsg = await client.getLocaleText( 369 'commands.remind.errors.invalidformat', 370 - interaction.locale 371 ); 372 return await interaction.editReply({ 373 content: errorMsg, ··· 379 logger.warn(`Invalid message from ${userTag} - Length: ${message?.length}`); 380 const errorMsg = await client.getLocaleText( 381 'commands.remind.errors.providevalidchars', 382 - interaction.locale 383 ); 384 return await interaction.editReply({ 385 content: errorMsg, ··· 392 logger.warn(`Reminder time too short from ${userTag}: ${timeStr}`); 393 const errorMsg = await client.getLocaleText( 394 'commands.remind.errors.atleastoneminute', 395 - interaction.locale 396 ); 397 return await interaction.editReply({ 398 content: errorMsg, ··· 403 logger.warn(`Reminder time too long from ${userTag}: ${minutes} minutes`); 404 const errorMsg = await client.getLocaleText( 405 'commands.remind.errors.notlongerthanaday', 406 - interaction.locale 407 ); 408 return await interaction.editReply({ 409 content: errorMsg, ··· 436 ...reminderData, 437 created_at: new Date(), 438 }), 439 - minutes * 60 * 1000 440 ); 441 442 activeReminders.set(reminderId, { ··· 447 const embed = new EmbedBuilder() 448 .setColor(0xfaa0a0) 449 .setTitle( 450 - '⏰ ' + (await client.getLocaleText('commands.remind.reminderset', interaction.locale)) 451 ) 452 .setDescription( 453 await client.getLocaleText('commands.remind.iwillremindyou', interaction.locale, { 454 message, 455 time: formatTimeString(minutes), 456 - }) 457 ) 458 .addFields( 459 { ··· 468 (await client.getLocaleText('commands.remind.willtrigger', interaction.locale)), 469 value: `<t:${Math.floor(expiresAt.getTime() / 1000)}:R>`, 470 inline: true, 471 - } 472 ) 473 .setFooter({ 474 text: await client.getLocaleText('commands.remind.reminderid', interaction.locale, { ··· 487 userTag, 488 expiresAt: expiresAt.toISOString(), 489 messagePreview: message.length > 50 ? `${message.substring(0, 50)}...` : message, 490 - } 491 ); 492 - return; 493 } catch (error) { 494 if (error instanceof DatabaseError) { 495 logger.error(`Database error saving reminder: ${error.message}`, { ··· 506 }); 507 const errorMessage = await client.getLocaleText( 508 'commands.remind.errors.failedtosave', 509 - interaction.locale 510 ); 511 throw new Error(errorMessage); 512 } ··· 551 messageId: message.id, 552 channelId: message.channelId, 553 guildId: message.guildId, 554 - } 555 ); 556 557 const modalTitle = await client.getLocaleText( 558 'commands.remind.modal.setreminder', 559 - interaction.locale 560 ); 561 const timeLabel = await client.getLocaleText( 562 'commands.remind.modal.whento', 563 - interaction.locale 564 ); 565 const modal = new ModalBuilder().setCustomId(modalId).setTitle(modalTitle); 566 ··· 583 userId: user.id, 584 userTag: user.tag, 585 messageId: message?.id, 586 - } 587 ); 588 589 let errorMessage: string; ··· 595 } else { 596 errorMessage = await client.getLocaleText( 597 'commands.remind.errors.modalfailed', 598 - interaction.locale 599 ); 600 errorTitle = '❌ ' + (await client.getLocaleText('error', interaction.locale)); 601 } ··· 631 logger.warn(`No message info found for modal ID: ${modalId}`, { userId: user.id }); 632 const errorMsg = await client.getLocaleText( 633 'commands.remind.errors.expired', 634 - interaction.locale 635 ); 636 return await modalInteraction.editReply({ 637 content: errorMsg, ··· 647 logger.warn(`Invalid time format from ${user.tag} in modal: ${timeStr}`); 648 const errorMsg = await client.getLocaleText( 649 'commands.remind.errors.invalidformat', 650 - interaction.locale 651 ); 652 return await modalInteraction.editReply({ 653 content: errorMsg, ··· 660 logger.warn(`Invalid time duration from ${user.tag} in modal: ${minutes} minutes`); 661 const errorMsg = await client.getLocaleText( 662 'commands.remind.errors.notlongerthanaday', 663 - interaction.locale 664 ); 665 return await modalInteraction.editReply({ 666 content: errorMsg, ··· 701 logger.error(`Database error saving reminder: ${error.message}`, { error }); 702 return await modalInteraction.editReply({ 703 content: error.userMessage, 704 - }); 705 - } else { 706 - logger.error(`Error saving reminder to database: ${(error as Error).message}`, { error }); 707 - const errorMsg = await client.getLocaleText( 708 - 'commands.remind.errors.failedtosave', 709 - interaction.locale 710 - ); 711 - return await modalInteraction.editReply({ 712 - content: errorMsg, 713 }); 714 } 715 } 716 717 const timeoutId = setTimeout( ··· 724 message_url: messageInfo.url, 725 }, 726 }), 727 - minutes * 60 * 1000 728 ); 729 730 activeReminders.set(reminderId, { ··· 734 735 const jumpToMessageField = await client.getLocaleText( 736 'common.jumptomessage', 737 - interaction.locale 738 ); 739 740 const embed = new EmbedBuilder() 741 .setColor(0xfaa0a0) 742 .setTitle( 743 - '⏰ ' + (await client.getLocaleText('commands.remind.reminderset', interaction.locale)) 744 ) 745 // .setTitle(reminderSetTitle) 746 .setDescription( 747 await client.getLocaleText('commands.remind.contextiwillremindyou', interaction.locale, { 748 time: formatTimeString(minutes), 749 - }) 750 ) 751 // .setDescription(reminderSetDesc) 752 .addFields( ··· 758 name: await client.getLocaleText('commands.remind.willtrigger', interaction.locale), 759 value: `<t:${Math.floor(expiresAt.getTime() / 1000)}:R>`, 760 inline: true, 761 - } 762 ) 763 .setFooter({ 764 text: await client.getLocaleText('commands.remind.reminderid', interaction.locale, { ··· 775 channelId: messageInfo.channelId, 776 minutes, 777 }); 778 - return; 779 } catch (error) { 780 logger.error( 781 `Error handling reminder modal for ${user.tag} (${user.id}): ${(error as Error).message}`, 782 { 783 error, 784 modalId, 785 - } 786 ); 787 788 try { 789 const errorMsg = await client.getLocaleText( 790 'commands.remind.errors.base', 791 - interaction.locale 792 ); 793 await modalInteraction.editReply({ 794 content: errorMsg, ··· 796 } catch (replyError) { 797 logger.error('Failed to send error response to user:', { error: replyError }); 798 } 799 - return; 800 } 801 }, 802 } as RemindCommandProps;
··· 91 ...reminder, 92 created_at: reminder.created_at || new Date(reminder.expires_at), 93 }), 94 + timeUntilExpiry, 95 ); 96 97 activeReminders.set(reminder.reminder_id, { ··· 100 }); 101 102 logger.debug( 103 + `Scheduled reminder ${reminder.reminder_id} for ${new Date(expiresAt).toISOString()}`, 104 ); 105 } 106 ··· 131 ...reminder, 132 created_at: reminder.created_at || new Date(), 133 }), 134 + timeUntilExpiry, 135 ); 136 137 activeReminders.set(reminder.reminder_id, { ··· 140 }); 141 142 logger.info( 143 + `Scheduled reminder ${reminder.reminder_id} for ${new Date(expiresAt).toISOString()}`, 144 ); 145 return true; 146 } catch (error) { ··· 166 167 const minutes = Math.floor( 168 (new Date(reminder.expires_at).getTime() - new Date(reminder.created_at!).getTime()) / 169 + (60 * 1000), 170 ); 171 172 const reminderTitle = ··· 174 const reminderDesc = await client.getLocaleText( 175 'commands.remind.remindyou', 176 reminder.locale, 177 + { message: reminder.message }, 178 ); 179 180 const timeElapsedText = ··· 192 name: originalTimeText, 193 value: `<t:${Math.floor(new Date(reminder.created_at!).getTime() / 1000)}:f>`, 194 inline: true, 195 + }, 196 ) 197 .setFooter({ text: `ID: ${reminder.reminder_id.slice(-6)}` }) 198 .setTimestamp(); ··· 201 const originalMessageText = await client.getLocaleText('common.ogmessage', reminder.locale); 202 const jumpToMessageText = await client.getLocaleText( 203 'common.jumptomessage', 204 + reminder.locale, 205 ); 206 207 reminderEmbed.addFields({ ··· 233 { 234 error, 235 reminderId: reminder.reminder_id, 236 + }, 237 ); 238 } finally { 239 try { ··· 271 } 272 } 273 }, 274 + 60 * 60 * 1000, 275 ); 276 277 function createCommandBuilder() { ··· 298 'es-ES': 'Cuándo recordarte (ej: 1h, 30m, 5h30m)', 299 'es-419': 'Cuándo recordarte (ej: 1h, 30m, 5h30m)', 300 }) 301 + .setRequired(true), 302 ) 303 .addStringOption((option) => 304 option ··· 312 'es-ES': 'Sobre qué quieres que te recuerde', 313 'es-419': 'Sobre qué quieres que te recuerde', 314 }) 315 + .setRequired(true), 316 ) 317 .setContexts([ 318 InteractionContextType.BotDM, ··· 360 361 commandLogger.logFromInteraction( 362 interaction, 363 + `time: ${timeStr}, message: "${message.substring(0, 50)}${message.length > 50 ? '...' : ''}"`, 364 ); 365 366 if (!validateTimeString(timeStr)) { 367 logger.warn(`Invalid time format from ${userTag}: ${timeStr}`); 368 const errorMsg = await client.getLocaleText( 369 'commands.remind.errors.invalidformat', 370 + interaction.locale, 371 ); 372 return await interaction.editReply({ 373 content: errorMsg, ··· 379 logger.warn(`Invalid message from ${userTag} - Length: ${message?.length}`); 380 const errorMsg = await client.getLocaleText( 381 'commands.remind.errors.providevalidchars', 382 + interaction.locale, 383 ); 384 return await interaction.editReply({ 385 content: errorMsg, ··· 392 logger.warn(`Reminder time too short from ${userTag}: ${timeStr}`); 393 const errorMsg = await client.getLocaleText( 394 'commands.remind.errors.atleastoneminute', 395 + interaction.locale, 396 ); 397 return await interaction.editReply({ 398 content: errorMsg, ··· 403 logger.warn(`Reminder time too long from ${userTag}: ${minutes} minutes`); 404 const errorMsg = await client.getLocaleText( 405 'commands.remind.errors.notlongerthanaday', 406 + interaction.locale, 407 ); 408 return await interaction.editReply({ 409 content: errorMsg, ··· 436 ...reminderData, 437 created_at: new Date(), 438 }), 439 + minutes * 60 * 1000, 440 ); 441 442 activeReminders.set(reminderId, { ··· 447 const embed = new EmbedBuilder() 448 .setColor(0xfaa0a0) 449 .setTitle( 450 + '⏰ ' + (await client.getLocaleText('commands.remind.reminderset', interaction.locale)), 451 ) 452 .setDescription( 453 await client.getLocaleText('commands.remind.iwillremindyou', interaction.locale, { 454 message, 455 time: formatTimeString(minutes), 456 + }), 457 ) 458 .addFields( 459 { ··· 468 (await client.getLocaleText('commands.remind.willtrigger', interaction.locale)), 469 value: `<t:${Math.floor(expiresAt.getTime() / 1000)}:R>`, 470 inline: true, 471 + }, 472 ) 473 .setFooter({ 474 text: await client.getLocaleText('commands.remind.reminderid', interaction.locale, { ··· 487 userTag, 488 expiresAt: expiresAt.toISOString(), 489 messagePreview: message.length > 50 ? `${message.substring(0, 50)}...` : message, 490 + }, 491 ); 492 } catch (error) { 493 if (error instanceof DatabaseError) { 494 logger.error(`Database error saving reminder: ${error.message}`, { ··· 505 }); 506 const errorMessage = await client.getLocaleText( 507 'commands.remind.errors.failedtosave', 508 + interaction.locale, 509 ); 510 throw new Error(errorMessage); 511 } ··· 550 messageId: message.id, 551 channelId: message.channelId, 552 guildId: message.guildId, 553 + }, 554 ); 555 556 const modalTitle = await client.getLocaleText( 557 'commands.remind.modal.setreminder', 558 + interaction.locale, 559 ); 560 const timeLabel = await client.getLocaleText( 561 'commands.remind.modal.whento', 562 + interaction.locale, 563 ); 564 const modal = new ModalBuilder().setCustomId(modalId).setTitle(modalTitle); 565 ··· 582 userId: user.id, 583 userTag: user.tag, 584 messageId: message?.id, 585 + }, 586 ); 587 588 let errorMessage: string; ··· 594 } else { 595 errorMessage = await client.getLocaleText( 596 'commands.remind.errors.modalfailed', 597 + interaction.locale, 598 ); 599 errorTitle = '❌ ' + (await client.getLocaleText('error', interaction.locale)); 600 } ··· 630 logger.warn(`No message info found for modal ID: ${modalId}`, { userId: user.id }); 631 const errorMsg = await client.getLocaleText( 632 'commands.remind.errors.expired', 633 + interaction.locale, 634 ); 635 return await modalInteraction.editReply({ 636 content: errorMsg, ··· 646 logger.warn(`Invalid time format from ${user.tag} in modal: ${timeStr}`); 647 const errorMsg = await client.getLocaleText( 648 'commands.remind.errors.invalidformat', 649 + interaction.locale, 650 ); 651 return await modalInteraction.editReply({ 652 content: errorMsg, ··· 659 logger.warn(`Invalid time duration from ${user.tag} in modal: ${minutes} minutes`); 660 const errorMsg = await client.getLocaleText( 661 'commands.remind.errors.notlongerthanaday', 662 + interaction.locale, 663 ); 664 return await modalInteraction.editReply({ 665 content: errorMsg, ··· 700 logger.error(`Database error saving reminder: ${error.message}`, { error }); 701 return await modalInteraction.editReply({ 702 content: error.userMessage, 703 }); 704 } 705 + logger.error(`Error saving reminder to database: ${(error as Error).message}`, { error }); 706 + const errorMsg = await client.getLocaleText( 707 + 'commands.remind.errors.failedtosave', 708 + interaction.locale, 709 + ); 710 + return await modalInteraction.editReply({ 711 + content: errorMsg, 712 + }); 713 } 714 715 const timeoutId = setTimeout( ··· 722 message_url: messageInfo.url, 723 }, 724 }), 725 + minutes * 60 * 1000, 726 ); 727 728 activeReminders.set(reminderId, { ··· 732 733 const jumpToMessageField = await client.getLocaleText( 734 'common.jumptomessage', 735 + interaction.locale, 736 ); 737 738 const embed = new EmbedBuilder() 739 .setColor(0xfaa0a0) 740 .setTitle( 741 + '⏰ ' + (await client.getLocaleText('commands.remind.reminderset', interaction.locale)), 742 ) 743 // .setTitle(reminderSetTitle) 744 .setDescription( 745 await client.getLocaleText('commands.remind.contextiwillremindyou', interaction.locale, { 746 time: formatTimeString(minutes), 747 + }), 748 ) 749 // .setDescription(reminderSetDesc) 750 .addFields( ··· 756 name: await client.getLocaleText('commands.remind.willtrigger', interaction.locale), 757 value: `<t:${Math.floor(expiresAt.getTime() / 1000)}:R>`, 758 inline: true, 759 + }, 760 ) 761 .setFooter({ 762 text: await client.getLocaleText('commands.remind.reminderid', interaction.locale, { ··· 773 channelId: messageInfo.channelId, 774 minutes, 775 }); 776 } catch (error) { 777 logger.error( 778 `Error handling reminder modal for ${user.tag} (${user.id}): ${(error as Error).message}`, 779 { 780 error, 781 modalId, 782 + }, 783 ); 784 785 try { 786 const errorMsg = await client.getLocaleText( 787 'commands.remind.errors.base', 788 + interaction.locale, 789 ); 790 await modalInteraction.editReply({ 791 content: errorMsg, ··· 793 } catch (replyError) { 794 logger.error('Failed to send error response to user:', { error: replyError }); 795 } 796 } 797 }, 798 } as RemindCommandProps;
+6 -6
src/commands/utilities/time.ts
··· 61 'es-419': 'La ciudad (ej: Londres, Nueva York)', 62 }) 63 .setRequired(true) 64 - .setAutocomplete(true) 65 ) 66 .setContexts([ 67 InteractionContextType.BotDM, ··· 93 const flag = iso2ToDiscordFlag(match.iso2); 94 const fieldDay = await client.getLocaleText( 95 'commands.time.embed.field_day_of_week', 96 - locale 97 ); 98 const fieldTime = await client.getLocaleText('commands.time.embed.field_time', locale); 99 const fieldDate = await client.getLocaleText('commands.time.embed.field_date', locale); ··· 126 const description = await client.getLocaleText( 127 'commands.time.embed.multi_city_description', 128 locale, 129 - { city } 130 ); 131 const fields = await Promise.all( 132 shown.map(async (match) => { ··· 137 const flag = iso2ToDiscordFlag(match.iso2); 138 const fieldDay = await client.getLocaleText( 139 'commands.time.embed.field_day_of_week', 140 - locale 141 ); 142 const fieldTime = await client.getLocaleText('commands.time.embed.field_time', locale); 143 const fieldDate = await client.getLocaleText('commands.time.embed.field_date', locale); ··· 146 value: `${fieldDay}: **${day}**\n${fieldTime}: **${time}**\n${fieldDate}: **${date}**`, 147 inline: false, 148 }; 149 - }) 150 ); 151 const title = await client.getLocaleText('commands.time.embed.title_multi', locale, { 152 city, ··· 186 async autocomplete(client, interaction: AutocompleteInteraction) { 187 const focused = interaction.options.getFocused(); 188 const filtered = POPULAR_CITIES.filter((city) => 189 - city.toLowerCase().includes(focused.toLowerCase()) 190 ).slice(0, 25); 191 await interaction.respond(filtered.map((city) => ({ name: city, value: city }))); 192 },
··· 61 'es-419': 'La ciudad (ej: Londres, Nueva York)', 62 }) 63 .setRequired(true) 64 + .setAutocomplete(true), 65 ) 66 .setContexts([ 67 InteractionContextType.BotDM, ··· 93 const flag = iso2ToDiscordFlag(match.iso2); 94 const fieldDay = await client.getLocaleText( 95 'commands.time.embed.field_day_of_week', 96 + locale, 97 ); 98 const fieldTime = await client.getLocaleText('commands.time.embed.field_time', locale); 99 const fieldDate = await client.getLocaleText('commands.time.embed.field_date', locale); ··· 126 const description = await client.getLocaleText( 127 'commands.time.embed.multi_city_description', 128 locale, 129 + { city }, 130 ); 131 const fields = await Promise.all( 132 shown.map(async (match) => { ··· 137 const flag = iso2ToDiscordFlag(match.iso2); 138 const fieldDay = await client.getLocaleText( 139 'commands.time.embed.field_day_of_week', 140 + locale, 141 ); 142 const fieldTime = await client.getLocaleText('commands.time.embed.field_time', locale); 143 const fieldDate = await client.getLocaleText('commands.time.embed.field_date', locale); ··· 146 value: `${fieldDay}: **${day}**\n${fieldTime}: **${time}**\n${fieldDate}: **${date}**`, 147 inline: false, 148 }; 149 + }), 150 ); 151 const title = await client.getLocaleText('commands.time.embed.title_multi', locale, { 152 city, ··· 186 async autocomplete(client, interaction: AutocompleteInteraction) { 187 const focused = interaction.options.getFocused(); 188 const filtered = POPULAR_CITIES.filter((city) => 189 + city.toLowerCase().includes(focused.toLowerCase()), 190 ).slice(0, 25); 191 await interaction.respond(filtered.map((city) => ({ name: city, value: city }))); 192 },
+8 -8
src/commands/utilities/todo.ts
··· 21 try { 22 const { rows } = await pool.query( 23 'SELECT item FROM todos WHERE user_id = $1 AND done = FALSE ORDER BY created_at ASC', 24 - [userId] 25 ); 26 return rows.map((r) => r.item); 27 } catch (error) { ··· 33 try { 34 const { rows } = await pool.query( 35 'SELECT item FROM todos WHERE user_id = $1 AND done = TRUE ORDER BY completed_at ASC', 36 - [userId] 37 ); 38 return rows.map((r) => r.item); 39 } catch (error) { ··· 74 'es-ES': 'La tarea a agregar', 75 'es-419': 'La tarea a agregar', 76 }) 77 - .setRequired(true) 78 - ) 79 ) 80 .addSubcommand((sub) => 81 sub ··· 98 'es-419': 'Selecciona una tarea para marcar como hecha', 99 }) 100 .setRequired(true) 101 - .setAutocomplete(true) 102 - ) 103 ) 104 .addSubcommand((sub) => 105 sub ··· 112 .setDescriptionLocalizations({ 113 'es-ES': 'Ver tu lista de tareas', 114 'es-419': 'Ver tu lista de tareas', 115 - }) 116 ) 117 .setContexts([ 118 InteractionContextType.BotDM, ··· 154 try { 155 const { rowCount } = await pool.query( 156 'UPDATE todos SET done = TRUE, completed_at = NOW() WHERE user_id = $1 AND item = $2 AND done = FALSE', 157 - [userId, item] 158 ); 159 if (rowCount === 0) { 160 await interaction.reply({
··· 21 try { 22 const { rows } = await pool.query( 23 'SELECT item FROM todos WHERE user_id = $1 AND done = FALSE ORDER BY created_at ASC', 24 + [userId], 25 ); 26 return rows.map((r) => r.item); 27 } catch (error) { ··· 33 try { 34 const { rows } = await pool.query( 35 'SELECT item FROM todos WHERE user_id = $1 AND done = TRUE ORDER BY completed_at ASC', 36 + [userId], 37 ); 38 return rows.map((r) => r.item); 39 } catch (error) { ··· 74 'es-ES': 'La tarea a agregar', 75 'es-419': 'La tarea a agregar', 76 }) 77 + .setRequired(true), 78 + ), 79 ) 80 .addSubcommand((sub) => 81 sub ··· 98 'es-419': 'Selecciona una tarea para marcar como hecha', 99 }) 100 .setRequired(true) 101 + .setAutocomplete(true), 102 + ), 103 ) 104 .addSubcommand((sub) => 105 sub ··· 112 .setDescriptionLocalizations({ 113 'es-ES': 'Ver tu lista de tareas', 114 'es-419': 'Ver tu lista de tareas', 115 + }), 116 ) 117 .setContexts([ 118 InteractionContextType.BotDM, ··· 154 try { 155 const { rowCount } = await pool.query( 156 'UPDATE todos SET done = TRUE, completed_at = NOW() WHERE user_id = $1 AND item = $2 AND done = FALSE', 157 + [userId, item], 158 ); 159 if (rowCount === 0) { 160 await interaction.reply({
+4 -4
src/commands/utilities/weather.ts
··· 52 const data = (await response.json()) as WeatherAPIResponse; 53 if (!response.ok || data.cod !== 200) { 54 const error = new Error( 55 - (data as WeatherErrorResponse).message ?? 'Failed to fetch weather data' 56 ); 57 throw error; 58 } ··· 89 'en-US': 'City name (e.g., London, New York, Tokyo)', 90 }) 91 .setRequired(true) 92 - .setMaxLength(100) 93 ) 94 .setContexts([ 95 InteractionContextType.BotDM, ··· 104 cooldownManager, 105 interaction.user.id, 106 client, 107 - interaction.locale 108 ); 109 if (cooldownCheck.onCooldown) { 110 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 188 { name: weatherText, value: description, inline: true }, 189 { name: humidityText, value: `${data.main.humidity}%`, inline: true }, 190 { name: windText, value: `${windSpeed} ${windUnit}`, inline: true }, 191 - { name: pressureText, value: `${data.main.pressure} hPa`, inline: true } 192 ) 193 .setFooter({ text: footer + ' Open Weather' }) 194 .setTimestamp();
··· 52 const data = (await response.json()) as WeatherAPIResponse; 53 if (!response.ok || data.cod !== 200) { 54 const error = new Error( 55 + (data as WeatherErrorResponse).message ?? 'Failed to fetch weather data', 56 ); 57 throw error; 58 } ··· 89 'en-US': 'City name (e.g., London, New York, Tokyo)', 90 }) 91 .setRequired(true) 92 + .setMaxLength(100), 93 ) 94 .setContexts([ 95 InteractionContextType.BotDM, ··· 104 cooldownManager, 105 interaction.user.id, 106 client, 107 + interaction.locale, 108 ); 109 if (cooldownCheck.onCooldown) { 110 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 188 { name: weatherText, value: description, inline: true }, 189 { name: humidityText, value: `${data.main.humidity}%`, inline: true }, 190 { name: windText, value: `${windSpeed} ${windUnit}`, inline: true }, 191 + { name: pressureText, value: `${data.main.pressure} hPa`, inline: true }, 192 ) 193 .setFooter({ text: footer + ' Open Weather' }) 194 .setTimestamp();
+5 -5
src/commands/utilities/whois.ts
··· 34 async function withRetry<T>( 35 fn: () => Promise<T>, 36 maxRetries = MAX_RETRIES, 37 - baseDelay = 1000 38 ): Promise<T> { 39 let lastError: any; 40 ··· 466 } 467 468 throw new Error( 469 - 'Could not retrieve WHOIS information. The domain may not exist or the WHOIS server may be temporarily unavailable.' 470 ); 471 }; 472 ··· 481 .setName('whois') 482 .setDescription('Look up WHOIS information for a domain or IP address') 483 .addStringOption((option) => 484 - option.setName('query').setDescription('Domain or IP address to look up').setRequired(true) 485 ) 486 .setContexts([ 487 InteractionContextType.BotDM, ··· 495 cooldownManager, 496 interaction.user.id, 497 client, 498 - interaction.locale 499 ); 500 if (cooldownCheck.onCooldown) { 501 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 527 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`# 🔍 WHOIS Lookup`)) 528 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`## ${sanitizedQuery}`)) 529 .addTextDisplayComponents( 530 - new TextDisplayBuilder().setContent(formattedData || 'No WHOIS data available') 531 ), 532 ]; 533
··· 34 async function withRetry<T>( 35 fn: () => Promise<T>, 36 maxRetries = MAX_RETRIES, 37 + baseDelay = 1000, 38 ): Promise<T> { 39 let lastError: any; 40 ··· 466 } 467 468 throw new Error( 469 + 'Could not retrieve WHOIS information. The domain may not exist or the WHOIS server may be temporarily unavailable.', 470 ); 471 }; 472 ··· 481 .setName('whois') 482 .setDescription('Look up WHOIS information for a domain or IP address') 483 .addStringOption((option) => 484 + option.setName('query').setDescription('Domain or IP address to look up').setRequired(true), 485 ) 486 .setContexts([ 487 InteractionContextType.BotDM, ··· 495 cooldownManager, 496 interaction.user.id, 497 client, 498 + interaction.locale, 499 ); 500 if (cooldownCheck.onCooldown) { 501 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 527 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`# 🔍 WHOIS Lookup`)) 528 .addTextDisplayComponents(new TextDisplayBuilder().setContent(`## ${sanitizedQuery}`)) 529 .addTextDisplayComponents( 530 + new TextDisplayBuilder().setContent(formattedData || 'No WHOIS data available'), 531 ), 532 ]; 533
+5 -5
src/commands/utilities/wiki.ts
··· 93 'en-US': 'What do you want to search for?', 94 }) 95 .setRequired(true) 96 - .setMaxLength(200) 97 ) 98 .setContexts([ 99 InteractionContextType.BotDM, ··· 108 cooldownManager, 109 interaction.user.id, 110 client, 111 - interaction.locale 112 ); 113 if (cooldownCheck.onCooldown) { 114 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 134 if (extract.length > MAX_EXTRACT_LENGTH) { 135 const truncatedText = await client.getLocaleText( 136 'commands.wiki.readmoreonwiki', 137 - interaction.locale 138 ); 139 extract = 140 extract.substring(0, MAX_EXTRACT_LENGTH - truncatedText.length - 2) + ··· 144 145 const readMore = await client.getLocaleText( 146 'commands.wiki.readmoreonwiki', 147 - interaction.locale 148 ); 149 const title = await client.getLocaleText('commands.wiki.pedia', interaction.locale, { 150 article: article.title, ··· 153 const embed = new EmbedBuilder() 154 .setTitle(title) 155 .setURL( 156 - `https://${wikiLang}.wikipedia.org/wiki/${encodeURIComponent(article.title.replace(/ /g, '_'))}` 157 ) 158 .setDescription(extract) 159 .setColor(0x4285f4)
··· 93 'en-US': 'What do you want to search for?', 94 }) 95 .setRequired(true) 96 + .setMaxLength(200), 97 ) 98 .setContexts([ 99 InteractionContextType.BotDM, ··· 108 cooldownManager, 109 interaction.user.id, 110 client, 111 + interaction.locale, 112 ); 113 if (cooldownCheck.onCooldown) { 114 return interaction.reply(createCooldownResponse(cooldownCheck.message!)); ··· 134 if (extract.length > MAX_EXTRACT_LENGTH) { 135 const truncatedText = await client.getLocaleText( 136 'commands.wiki.readmoreonwiki', 137 + interaction.locale, 138 ); 139 extract = 140 extract.substring(0, MAX_EXTRACT_LENGTH - truncatedText.length - 2) + ··· 144 145 const readMore = await client.getLocaleText( 146 'commands.wiki.readmoreonwiki', 147 + interaction.locale, 148 ); 149 const title = await client.getLocaleText('commands.wiki.pedia', interaction.locale, { 150 article: article.title, ··· 153 const embed = new EmbedBuilder() 154 .setTitle(title) 155 .setURL( 156 + `https://${wikiLang}.wikipedia.org/wiki/${encodeURIComponent(article.title.replace(/ /g, '_'))}`, 157 ) 158 .setDescription(extract) 159 .setColor(0x4285f4)
+53 -53
src/events/interactionCreate.ts
··· 58 content: `You have been banned from using Aethel commands for 7 days due to repeated use of unallowed language. Ban expires: <t:${Math.floor(new Date(banned_until).getTime() / 1000)}:F>.`, 59 flags: MessageFlags.Ephemeral, 60 }); 61 - } else { 62 - return i.reply({ 63 - content: `Your request was flagged by Aethel for ${category}. You have ${strike_count}/5 strikes. For more information, visit https://aethel.xyz/legal/terms`, 64 - flags: MessageFlags.Ephemeral, 65 - }); 66 } 67 } 68 } 69 } ··· 164 ? await this.client.getLocaleText('reddit.from', i.locale, { 165 subreddit: data.subreddit, 166 }) 167 - : '' 168 - ) 169 ) 170 .addMediaGalleryComponents( 171 - new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder().setURL(data.url)) 172 ) 173 .addActionRowComponents( 174 new ActionRowBuilder<ButtonBuilder>().addComponents( ··· 176 .setStyle(ButtonStyle.Danger) 177 .setLabel(refreshLabel) 178 .setEmoji({ name: '🐱' }) 179 - .setCustomId('refresh_cat') 180 - ) 181 ); 182 183 await i.update({ ··· 187 } else { 188 const container = new ContainerBuilder().addTextDisplayComponents( 189 new TextDisplayBuilder().setContent( 190 - await this.client.getLocaleText('commands.cat.error', i.locale) 191 - ) 192 ); 193 194 await i.update({ ··· 200 logger.error('Error refreshing cat image:', error); 201 const container = new ContainerBuilder().addTextDisplayComponents( 202 new TextDisplayBuilder().setContent( 203 - await this.client.getLocaleText('commands.cat.error', i.locale) 204 - ) 205 ); 206 207 await i.update({ ··· 224 225 for (const cmd of this.client.commands.values()) { 226 const ClientApplicationCommandCache = this.client.application?.commands.cache.find( 227 - (command) => command.name == cmd.data.name 228 ); 229 const category = cmd.category || 'Uncategorized'; 230 if (!commandCategories.has(category)) { ··· 233 234 const localizedDescription = await this.client.getLocaleText( 235 `commands.${cmd.data.name}.description`, 236 - i.locale 237 ); 238 commandCategories 239 .get(category)! 240 .push( 241 - `</${ClientApplicationCommandCache?.name}:${ClientApplicationCommandCache?.id}> - ${localizedDescription}` 242 ); 243 } 244 245 const container = new ContainerBuilder() 246 .setAccentColor(0x5865f2) 247 .addTextDisplayComponents( 248 - new TextDisplayBuilder().setContent('# 📋 **Available Commands**') 249 ); 250 251 for (const [category, cmds] of commandCategories.entries()) { 252 const localizedCategory = await this.client.getLocaleText( 253 `categories.${category}`, 254 - i.locale 255 ); 256 257 container.addTextDisplayComponents( 258 - new TextDisplayBuilder().setContent(`\n## 📂 ${localizedCategory}`) 259 ); 260 261 container.addTextDisplayComponents( 262 new TextDisplayBuilder().setContent( 263 - cmds.map((line) => line.replace(/\u007F/g, '')).join('\n') 264 - ) 265 ); 266 } 267 ··· 273 .setStyle(ButtonStyle.Secondary) 274 .setLabel(backLabel) 275 .setEmoji({ name: '⬅️' }) 276 - .setCustomId(`help_back_${i.user.id}`) 277 - ) 278 ); 279 280 await i.update({ ··· 317 318 .addMediaGalleryComponents( 319 new MediaGalleryBuilder().addItems( 320 - new MediaGalleryItemBuilder().setURL('https://aethel.xyz/aethel_banner_white.png') 321 - ) 322 ) 323 .addTextDisplayComponents( 324 - new TextDisplayBuilder().setContent(`# ${title || 'Aethel Bot'}`) 325 ) 326 .addTextDisplayComponents( 327 - new TextDisplayBuilder().setContent(description || 'Get information about Aethel') 328 ) 329 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 330 .addTextDisplayComponents( 331 new TextDisplayBuilder().setContent( 332 - `\n## **${linksSocialText || 'Links & Social Media'}**` 333 - ) 334 ) 335 .addTextDisplayComponents( 336 new TextDisplayBuilder().setContent( 337 - '[Website](https://aethel.xyz) • [GitHub](https://github.com/aethel-labs/aethel) • [Bluesky](https://bsky.app/profile/aethel.xyz)' 338 - ) 339 ) 340 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 341 .addTextDisplayComponents( 342 - new TextDisplayBuilder().setContent(`\n## **${featuresText || 'Features'}**`) 343 ) 344 .addTextDisplayComponents( 345 new TextDisplayBuilder().setContent( ··· 348 '**AI Integration** - Powered by OpenAI and other providers\n' + 349 '**Reminders** - Never forget important tasks\n' + 350 '**Utilities** - Weather, help, and productivity tools\n' + 351 - '**Multi-language** - Supports multiple languages' 352 - ) 353 ) 354 355 .addSeparatorComponents( 356 - new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true) 357 ) 358 .addTextDisplayComponents( 359 new TextDisplayBuilder().setContent( 360 - `-# ${dashboardText || 'Dashboard available at https://aethel.xyz/login for To-Dos, Reminders and custom AI API key management'}` 361 - ) 362 ) 363 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 364 .addActionRowComponents( ··· 370 new ButtonBuilder() 371 .setStyle(ButtonStyle.Link) 372 .setLabel(supportServerText || 'Support') 373 - .setURL('https://discord.gg/63stE8pEaK') 374 - ) 375 ); 376 377 await i.update({ ··· 386 if (!response.ok) { 387 const container = new ContainerBuilder().addTextDisplayComponents( 388 new TextDisplayBuilder().setContent( 389 - await this.client.getLocaleText('commands.dog.error', i.locale) 390 - ) 391 ); 392 393 return await i.update({ ··· 428 ? await this.client.getLocaleText('reddit.from', i.locale, { 429 subreddit: data!.subreddit, 430 }) 431 - : '' 432 - ) 433 ) 434 .addMediaGalleryComponents( 435 new MediaGalleryBuilder().addItems( 436 - new MediaGalleryItemBuilder().setURL(data!.url) 437 - ) 438 ) 439 .addActionRowComponents( 440 new ActionRowBuilder<ButtonBuilder>().addComponents( ··· 442 .setStyle(ButtonStyle.Secondary) 443 .setLabel(refreshLabel) 444 .setEmoji({ name: '🐶' }) 445 - .setCustomId('refresh_dog') 446 - ) 447 ); 448 449 await i.update({ ··· 453 } else { 454 const container = new ContainerBuilder().addTextDisplayComponents( 455 new TextDisplayBuilder().setContent( 456 - await this.client.getLocaleText('commands.dog.error', i.locale) 457 - ) 458 ); 459 460 await i.update({ ··· 466 logger.error('Error refreshing dog image:', error); 467 const container = new ContainerBuilder().addTextDisplayComponents( 468 new TextDisplayBuilder().setContent( 469 - await this.client.getLocaleText('commands.dog.error', i.locale) 470 - ) 471 ); 472 473 await i.update({ ··· 483 components: [], 484 }); 485 } 486 - return; 487 } 488 }; 489 }
··· 58 content: `You have been banned from using Aethel commands for 7 days due to repeated use of unallowed language. Ban expires: <t:${Math.floor(new Date(banned_until).getTime() / 1000)}:F>.`, 59 flags: MessageFlags.Ephemeral, 60 }); 61 } 62 + return i.reply({ 63 + content: `Your request was flagged by Aethel for ${category}. You have ${strike_count}/5 strikes. For more information, visit https://aethel.xyz/legal/terms`, 64 + flags: MessageFlags.Ephemeral, 65 + }); 66 } 67 } 68 } ··· 163 ? await this.client.getLocaleText('reddit.from', i.locale, { 164 subreddit: data.subreddit, 165 }) 166 + : '', 167 + ), 168 ) 169 .addMediaGalleryComponents( 170 + new MediaGalleryBuilder().addItems( 171 + new MediaGalleryItemBuilder().setURL(data.url), 172 + ), 173 ) 174 .addActionRowComponents( 175 new ActionRowBuilder<ButtonBuilder>().addComponents( ··· 177 .setStyle(ButtonStyle.Danger) 178 .setLabel(refreshLabel) 179 .setEmoji({ name: '🐱' }) 180 + .setCustomId('refresh_cat'), 181 + ), 182 ); 183 184 await i.update({ ··· 188 } else { 189 const container = new ContainerBuilder().addTextDisplayComponents( 190 new TextDisplayBuilder().setContent( 191 + await this.client.getLocaleText('commands.cat.error', i.locale), 192 + ), 193 ); 194 195 await i.update({ ··· 201 logger.error('Error refreshing cat image:', error); 202 const container = new ContainerBuilder().addTextDisplayComponents( 203 new TextDisplayBuilder().setContent( 204 + await this.client.getLocaleText('commands.cat.error', i.locale), 205 + ), 206 ); 207 208 await i.update({ ··· 225 226 for (const cmd of this.client.commands.values()) { 227 const ClientApplicationCommandCache = this.client.application?.commands.cache.find( 228 + (command) => command.name === cmd.data.name, 229 ); 230 const category = cmd.category || 'Uncategorized'; 231 if (!commandCategories.has(category)) { ··· 234 235 const localizedDescription = await this.client.getLocaleText( 236 `commands.${cmd.data.name}.description`, 237 + i.locale, 238 ); 239 commandCategories 240 .get(category)! 241 .push( 242 + `</${ClientApplicationCommandCache?.name}:${ClientApplicationCommandCache?.id}> - ${localizedDescription}`, 243 ); 244 } 245 246 const container = new ContainerBuilder() 247 .setAccentColor(0x5865f2) 248 .addTextDisplayComponents( 249 + new TextDisplayBuilder().setContent('# 📋 **Available Commands**'), 250 ); 251 252 for (const [category, cmds] of commandCategories.entries()) { 253 const localizedCategory = await this.client.getLocaleText( 254 `categories.${category}`, 255 + i.locale, 256 ); 257 258 container.addTextDisplayComponents( 259 + new TextDisplayBuilder().setContent(`\n## 📂 ${localizedCategory}`), 260 ); 261 262 container.addTextDisplayComponents( 263 new TextDisplayBuilder().setContent( 264 + cmds.map((line) => line.replace(/\u007F/g, '')).join('\n'), 265 + ), 266 ); 267 } 268 ··· 274 .setStyle(ButtonStyle.Secondary) 275 .setLabel(backLabel) 276 .setEmoji({ name: '⬅️' }) 277 + .setCustomId(`help_back_${i.user.id}`), 278 + ), 279 ); 280 281 await i.update({ ··· 318 319 .addMediaGalleryComponents( 320 new MediaGalleryBuilder().addItems( 321 + new MediaGalleryItemBuilder().setURL('https://aethel.xyz/aethel_banner_white.png'), 322 + ), 323 ) 324 .addTextDisplayComponents( 325 + new TextDisplayBuilder().setContent(`# ${title || 'Aethel Bot'}`), 326 ) 327 .addTextDisplayComponents( 328 + new TextDisplayBuilder().setContent(description || 'Get information about Aethel'), 329 ) 330 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 331 .addTextDisplayComponents( 332 new TextDisplayBuilder().setContent( 333 + `\n## **${linksSocialText || 'Links & Social Media'}**`, 334 + ), 335 ) 336 .addTextDisplayComponents( 337 new TextDisplayBuilder().setContent( 338 + '[Website](https://aethel.xyz) • [GitHub](https://github.com/aethel-labs/aethel) • [Bluesky](https://bsky.app/profile/aethel.xyz)', 339 + ), 340 ) 341 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 342 .addTextDisplayComponents( 343 + new TextDisplayBuilder().setContent(`\n## **${featuresText || 'Features'}**`), 344 ) 345 .addTextDisplayComponents( 346 new TextDisplayBuilder().setContent( ··· 349 '**AI Integration** - Powered by OpenAI and other providers\n' + 350 '**Reminders** - Never forget important tasks\n' + 351 '**Utilities** - Weather, help, and productivity tools\n' + 352 + '**Multi-language** - Supports multiple languages', 353 + ), 354 ) 355 356 .addSeparatorComponents( 357 + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setDivider(true), 358 ) 359 .addTextDisplayComponents( 360 new TextDisplayBuilder().setContent( 361 + `-# ${dashboardText || 'Dashboard available at https://aethel.xyz/login for To-Dos, Reminders and custom AI API key management'}`, 362 + ), 363 ) 364 .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large)) 365 .addActionRowComponents( ··· 371 new ButtonBuilder() 372 .setStyle(ButtonStyle.Link) 373 .setLabel(supportServerText || 'Support') 374 + .setURL('https://discord.gg/63stE8pEaK'), 375 + ), 376 ); 377 378 await i.update({ ··· 387 if (!response.ok) { 388 const container = new ContainerBuilder().addTextDisplayComponents( 389 new TextDisplayBuilder().setContent( 390 + await this.client.getLocaleText('commands.dog.error', i.locale), 391 + ), 392 ); 393 394 return await i.update({ ··· 429 ? await this.client.getLocaleText('reddit.from', i.locale, { 430 subreddit: data!.subreddit, 431 }) 432 + : '', 433 + ), 434 ) 435 .addMediaGalleryComponents( 436 new MediaGalleryBuilder().addItems( 437 + new MediaGalleryItemBuilder().setURL(data!.url), 438 + ), 439 ) 440 .addActionRowComponents( 441 new ActionRowBuilder<ButtonBuilder>().addComponents( ··· 443 .setStyle(ButtonStyle.Secondary) 444 .setLabel(refreshLabel) 445 .setEmoji({ name: '🐶' }) 446 + .setCustomId('refresh_dog'), 447 + ), 448 ); 449 450 await i.update({ ··· 454 } else { 455 const container = new ContainerBuilder().addTextDisplayComponents( 456 new TextDisplayBuilder().setContent( 457 + await this.client.getLocaleText('commands.dog.error', i.locale), 458 + ), 459 ); 460 461 await i.update({ ··· 467 logger.error('Error refreshing dog image:', error); 468 const container = new ContainerBuilder().addTextDisplayComponents( 469 new TextDisplayBuilder().setContent( 470 + await this.client.getLocaleText('commands.dog.error', i.locale), 471 + ), 472 ); 473 474 await i.update({ ··· 484 components: [], 485 }); 486 } 487 } 488 }; 489 }
+13 -13
src/events/messageCreate.ts
··· 46 47 if (!isDM && !isMentioned) { 48 logger.debug( 49 - `Ignoring message - not a DM and bot not mentioned (channel type: ${message.channel.type})` 50 ); 51 return; 52 } ··· 63 const hasImageAttachments = message.attachments.some( 64 (att) => 65 att.contentType?.startsWith('image/') || 66 - att.name?.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i) 67 ); 68 69 const hasImageUrls = false; ··· 87 : userCustomModel || 'moonshotai/kimi-k2'; 88 89 logger.info( 90 - `Using model: ${selectedModel} for message with images: ${hasImages}${userCustomModel ? ' (user custom model)' : ' (default model)'}` 91 ); 92 93 const systemPrompt = buildSystemPrompt( 94 isDM, 95 this.client, 96 selectedModel, 97 - message.author.username 98 ); 99 100 let messageContent: ··· 112 const imageAttachments = message.attachments.filter( 113 (att) => 114 att.contentType?.startsWith('image/') || 115 - att.name?.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i) 116 ); 117 118 const contentArray: Array<{ ··· 164 const updatedConversation = buildConversation( 165 filteredConversation, 166 messageContent, 167 - systemPrompt 168 ); 169 170 const { apiKey: userApiKey, apiUrl: userApiUrl } = await getUserCredentials( 171 - message.author.id 172 ); 173 const config = getApiConfiguration(userApiKey ?? null, selectedModel, userApiUrl ?? null); 174 ··· 178 const allowed = await incrementAndCheckDailyLimit(message.author.id, 10); 179 if (!allowed) { 180 await message.reply( 181 - "❌ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command." 182 ); 183 return; 184 } ··· 231 const fallbackConversation = buildConversation( 232 cleanedConversation, 233 fallbackContent, 234 - buildSystemPrompt(isDM, this.client, fallbackModel, message.author.username) 235 ); 236 237 const fallbackConfig = getApiConfiguration( 238 userApiKey ?? null, 239 fallbackModel, 240 - userApiUrl ?? null 241 ); 242 aiResponse = await makeAIRequest(fallbackConfig, fallbackConversation); 243 ··· 248 249 if (!aiResponse) { 250 await message.reply( 251 - 'Sorry, I encountered an error processing your message. Please try again later.' 252 ); 253 return; 254 } ··· 267 } catch (error) { 268 logger.error( 269 `Error processing ${isDM ? 'DM' : 'server message'}:`, 270 - error instanceof Error ? error.message : String(error) 271 ); 272 try { 273 await message.reply( 274 - 'Sorry, I encountered an error processing your message. Please try again later.' 275 ); 276 } catch (replyError) { 277 logger.error('Failed to send error message:', replyError);
··· 46 47 if (!isDM && !isMentioned) { 48 logger.debug( 49 + `Ignoring message - not a DM and bot not mentioned (channel type: ${message.channel.type})`, 50 ); 51 return; 52 } ··· 63 const hasImageAttachments = message.attachments.some( 64 (att) => 65 att.contentType?.startsWith('image/') || 66 + att.name?.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i), 67 ); 68 69 const hasImageUrls = false; ··· 87 : userCustomModel || 'moonshotai/kimi-k2'; 88 89 logger.info( 90 + `Using model: ${selectedModel} for message with images: ${hasImages}${userCustomModel ? ' (user custom model)' : ' (default model)'}`, 91 ); 92 93 const systemPrompt = buildSystemPrompt( 94 isDM, 95 this.client, 96 selectedModel, 97 + message.author.username, 98 ); 99 100 let messageContent: ··· 112 const imageAttachments = message.attachments.filter( 113 (att) => 114 att.contentType?.startsWith('image/') || 115 + att.name?.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i), 116 ); 117 118 const contentArray: Array<{ ··· 164 const updatedConversation = buildConversation( 165 filteredConversation, 166 messageContent, 167 + systemPrompt, 168 ); 169 170 const { apiKey: userApiKey, apiUrl: userApiUrl } = await getUserCredentials( 171 + message.author.id, 172 ); 173 const config = getApiConfiguration(userApiKey ?? null, selectedModel, userApiUrl ?? null); 174 ··· 178 const allowed = await incrementAndCheckDailyLimit(message.author.id, 10); 179 if (!allowed) { 180 await message.reply( 181 + "❌ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command.", 182 ); 183 return; 184 } ··· 231 const fallbackConversation = buildConversation( 232 cleanedConversation, 233 fallbackContent, 234 + buildSystemPrompt(isDM, this.client, fallbackModel, message.author.username), 235 ); 236 237 const fallbackConfig = getApiConfiguration( 238 userApiKey ?? null, 239 fallbackModel, 240 + userApiUrl ?? null, 241 ); 242 aiResponse = await makeAIRequest(fallbackConfig, fallbackConversation); 243 ··· 248 249 if (!aiResponse) { 250 await message.reply( 251 + 'Sorry, I encountered an error processing your message. Please try again later.', 252 ); 253 return; 254 } ··· 267 } catch (error) { 268 logger.error( 269 `Error processing ${isDM ? 'DM' : 'server message'}:`, 270 + error instanceof Error ? error.message : String(error), 271 ); 272 try { 273 await message.reply( 274 + 'Sorry, I encountered an error processing your message. Please try again later.', 275 ); 276 } catch (replyError) { 277 logger.error('Failed to send error message:', replyError);
+1 -1
src/handlers/initialzeCommands.ts
··· 23 const commands: (SlashCommandBuilder | ContextMenuCommandBuilder)[] = []; 24 for (const cat of cmdCat) { 25 const commandFiles = readdirSync(path.join(cmdDir, cat)).filter( 26 - (f) => f.endsWith('.js') || f.endsWith('.ts') 27 ); 28 for (const file of commandFiles) { 29 const commandPath = path.join(cmdDir, cat, file);
··· 23 const commands: (SlashCommandBuilder | ContextMenuCommandBuilder)[] = []; 24 for (const cat of cmdCat) { 25 const commandFiles = readdirSync(path.join(cmdDir, cat)).filter( 26 + (f) => f.endsWith('.js') || f.endsWith('.ts'), 27 ); 28 for (const file of commandFiles) { 29 const commandPath = path.join(cmdDir, cat, file);
+4 -4
src/index.ts
··· 37 allowedHeaders: ['Content-Type', 'X-API-Key', 'Authorization', 'Cache-Control', 'Pragma'], 38 credentials: true, 39 maxAge: 86400, 40 - }) 41 ); 42 app.set('trust proxy', 1); 43 app.use( ··· 47 message: { error: 'Too many requests, please try again later.' }, 48 standardHeaders: true, 49 legacyHeaders: false, 50 - }) 51 ); 52 53 app.use(e.json({ limit: '10mb' })); ··· 65 } else { 66 res.setHeader( 67 'Content-Security-Policy', 68 - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:" 69 ); 70 } 71 next(); ··· 94 () => { 95 resetOldStrikes().catch(console.error); 96 }, 97 - 60 * 60 * 1000 98 ); 99 100 app.listen(PORT, () => {
··· 37 allowedHeaders: ['Content-Type', 'X-API-Key', 'Authorization', 'Cache-Control', 'Pragma'], 38 credentials: true, 39 maxAge: 86400, 40 + }), 41 ); 42 app.set('trust proxy', 1); 43 app.use( ··· 47 message: { error: 'Too many requests, please try again later.' }, 48 standardHeaders: true, 49 legacyHeaders: false, 50 + }), 51 ); 52 53 app.use(e.json({ limit: '10mb' })); ··· 65 } else { 66 res.setHeader( 67 'Content-Security-Policy', 68 + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:", 69 ); 70 } 71 next(); ··· 94 () => { 95 resetOldStrikes().catch(console.error); 96 }, 97 + 60 * 60 * 1000, 98 ); 99 100 app.listen(PORT, () => {
+2 -3
src/middlewares/auth.ts
··· 32 } else if (error instanceof jwt.JsonWebTokenError) { 33 logger.debug('Invalid JWT token used'); 34 return res.status(401).json({ error: 'Invalid token' }); 35 - } else { 36 - logger.error('JWT verification error:', error); 37 - return res.status(500).json({ error: 'Token verification failed' }); 38 } 39 } 40 }; 41
··· 32 } else if (error instanceof jwt.JsonWebTokenError) { 33 logger.debug('Invalid JWT token used'); 34 return res.status(401).json({ error: 'Invalid token' }); 35 } 36 + logger.error('JWT verification error:', error); 37 + return res.status(500).json({ error: 'Token verification failed' }); 38 } 39 }; 40
+1 -1
src/middlewares/verifyApiKey.ts
··· 3 4 const authenticateApiKey: RequestHandler = (req, res, next) => { 5 const apiKey = req.headers['x-api-key']; 6 - if (!apiKey || typeof apiKey != 'string') { 7 res.status(401).json({ error: 'Unauthorized: Missing API key' }); 8 return; 9 }
··· 3 4 const authenticateApiKey: RequestHandler = (req, res, next) => { 5 const apiKey = req.headers['x-api-key']; 6 + if (!apiKey || typeof apiKey !== 'string') { 7 res.status(401).json({ error: 'Unauthorized: Missing API key' }); 8 return; 9 }
+3 -3
src/routes/apiKeys.ts
··· 119 logger.error('Error updating API key:', error); 120 res.status(500).json({ error: 'Internal server error' }); 121 } 122 - } 123 ); 124 125 router.put( ··· 179 logger.error('Error updating API key:', error); 180 res.status(500).json({ error: 'Internal server error' }); 181 } 182 - } 183 ); 184 185 router.delete('/', async (req, res) => { ··· 293 294 res.status(500).json({ error: 'API key test failed due to server error' }); 295 } 296 - } 297 ); 298 299 export default router;
··· 119 logger.error('Error updating API key:', error); 120 res.status(500).json({ error: 'Internal server error' }); 121 } 122 + }, 123 ); 124 125 router.put( ··· 179 logger.error('Error updating API key:', error); 180 res.status(500).json({ error: 'Internal server error' }); 181 } 182 + }, 183 ); 184 185 router.delete('/', async (req, res) => { ··· 293 294 res.status(500).json({ error: 'API key test failed due to server error' }); 295 } 296 + }, 297 ); 298 299 export default router;
+2 -2
src/routes/auth.ts
··· 80 'INSERT INTO users (user_id, language, created_at) VALUES ($1, $2, NOW())'; 81 await pool.query(insertQuery, [discordUser.id, 'en']); 82 logger.info( 83 - `New user created: ${discordUser.username}#${discordUser.discriminator} (${discordUser.id})` 84 ); 85 } 86 ··· 92 avatar: discordUser.avatar, 93 }, 94 JWT_SECRET, 95 - { expiresIn: '7d' } 96 ); 97 98 const redirectUrl = new URL(`${FRONTEND_URL}/login`);
··· 80 'INSERT INTO users (user_id, language, created_at) VALUES ($1, $2, NOW())'; 81 await pool.query(insertQuery, [discordUser.id, 'en']); 82 logger.info( 83 + `New user created: ${discordUser.username}#${discordUser.discriminator} (${discordUser.id})`, 84 ); 85 } 86 ··· 92 avatar: discordUser.avatar, 93 }, 94 JWT_SECRET, 95 + { expiresIn: '7d' }, 96 ); 97 98 const redirectUrl = new URL(`${FRONTEND_URL}/login`);
+8 -8
src/routes/reminders.ts
··· 74 75 if (scheduled) { 76 logger.info( 77 - `Successfully scheduled reminder ${savedReminder.reminder_id} from dashboard` 78 ); 79 } else { 80 logger.warn(`Failed to schedule reminder ${savedReminder.reminder_id} from dashboard`); ··· 145 if (user) { 146 const minutes = Math.floor( 147 (new Date(reminder.expires_at).getTime() - new Date(reminder.created_at!).getTime()) / 148 - (60 * 1000) 149 ); 150 151 const reminderTitle = ··· 153 const reminderDesc = await client.getLocaleText( 154 'commands.remind.remindyou', 155 reminder.locale, 156 - { message: reminder.message } 157 ); 158 159 const timeElapsedText = ··· 171 name: originalTimeText, 172 value: `<t:${Math.floor(new Date(reminder.created_at!).getTime() / 1000)}:f>`, 173 inline: true, 174 - } 175 ) 176 .setFooter({ text: `ID: ${reminder.reminder_id.slice(-6)}` }) 177 .setTimestamp(); ··· 179 if (reminder.metadata?.message_url) { 180 const originalMessageText = await client.getLocaleText( 181 'common.ogmessage', 182 - reminder.locale 183 ); 184 const jumpToMessageText = await client.getLocaleText( 185 'common.jumptomessage', 186 - reminder.locale 187 ); 188 189 reminderEmbed.addFields({ ··· 196 if (reminder.message.includes('http') && !reminder.metadata?.message_url) { 197 const messageLinkText = await client.getLocaleText( 198 'common.messagelink', 199 - reminder.locale 200 ); 201 reminderEmbed.addFields({ 202 name: messageLinkText, ··· 220 { 221 error: notificationError, 222 reminderId: reminder.reminder_id, 223 - } 224 ); 225 } 226
··· 74 75 if (scheduled) { 76 logger.info( 77 + `Successfully scheduled reminder ${savedReminder.reminder_id} from dashboard`, 78 ); 79 } else { 80 logger.warn(`Failed to schedule reminder ${savedReminder.reminder_id} from dashboard`); ··· 145 if (user) { 146 const minutes = Math.floor( 147 (new Date(reminder.expires_at).getTime() - new Date(reminder.created_at!).getTime()) / 148 + (60 * 1000), 149 ); 150 151 const reminderTitle = ··· 153 const reminderDesc = await client.getLocaleText( 154 'commands.remind.remindyou', 155 reminder.locale, 156 + { message: reminder.message }, 157 ); 158 159 const timeElapsedText = ··· 171 name: originalTimeText, 172 value: `<t:${Math.floor(new Date(reminder.created_at!).getTime() / 1000)}:f>`, 173 inline: true, 174 + }, 175 ) 176 .setFooter({ text: `ID: ${reminder.reminder_id.slice(-6)}` }) 177 .setTimestamp(); ··· 179 if (reminder.metadata?.message_url) { 180 const originalMessageText = await client.getLocaleText( 181 'common.ogmessage', 182 + reminder.locale, 183 ); 184 const jumpToMessageText = await client.getLocaleText( 185 'common.jumptomessage', 186 + reminder.locale, 187 ); 188 189 reminderEmbed.addFields({ ··· 196 if (reminder.message.includes('http') && !reminder.metadata?.message_url) { 197 const messageLinkText = await client.getLocaleText( 198 'common.messagelink', 199 + reminder.locale, 200 ); 201 reminderEmbed.addFields({ 202 name: messageLinkText, ··· 220 { 221 error: notificationError, 222 reminderId: reminder.reminder_id, 223 + }, 224 ); 225 } 226
+2 -2
src/routes/todos.ts
··· 63 logger.error('Error creating todo:', error); 64 res.status(500).json({ error: 'Internal server error' }); 65 } 66 - } 67 ); 68 69 router.put( ··· 105 logger.error('Error updating todo:', error); 106 res.status(500).json({ error: 'Internal server error' }); 107 } 108 - } 109 ); 110 111 router.delete('/:id', async (req, res) => {
··· 63 logger.error('Error creating todo:', error); 64 res.status(500).json({ error: 'Internal server error' }); 65 } 66 + }, 67 ); 68 69 router.put( ··· 105 logger.error('Error updating todo:', error); 106 res.status(500).json({ error: 'Internal server error' }); 107 } 108 + }, 109 ); 110 111 router.delete('/:id', async (req, res) => {
+11 -17
src/utils/commandLogger.ts
··· 3 4 export interface CommandLogOptions { 5 commandName: string; 6 - userId: string; 7 - username: string; 8 additionalInfo?: string; 9 - guildId?: string; 10 - channelId?: string; 11 } 12 13 export function logUserAction(options: CommandLogOptions): void { 14 - const { commandName, userId, username, guildId, channelId, additionalInfo } = options; 15 - 16 - let logMessage = `User ${username} (${userId}) used ${commandName} command`; 17 18 - if (guildId) { 19 - logMessage += ` in guild ${guildId}`; 20 - } 21 22 - if (channelId) { 23 - logMessage += ` in channel ${channelId}`; 24 } 25 26 if (additionalInfo) { ··· 33 export function logUserActionFromInteraction( 34 interaction: CommandInteraction, 35 commandName: string, 36 - additionalInfo?: string 37 ): void { 38 logUserAction({ 39 commandName, 40 - userId: interaction.user.id, 41 - username: interaction.user.tag, 42 - guildId: interaction.guildId || undefined, 43 - channelId: interaction.channelId, 44 additionalInfo, 45 }); 46 }
··· 3 4 export interface CommandLogOptions { 5 commandName: string; 6 additionalInfo?: string; 7 + isGuild?: boolean; 8 + isDM?: boolean; 9 } 10 11 export function logUserAction(options: CommandLogOptions): void { 12 + const { commandName, isGuild, isDM, additionalInfo } = options; 13 14 + let logMessage = `User executed ${commandName} command`; 15 16 + if (isGuild) { 17 + logMessage += ` in guild`; 18 + } else if (isDM) { 19 + logMessage += ` in DM`; 20 } 21 22 if (additionalInfo) { ··· 29 export function logUserActionFromInteraction( 30 interaction: CommandInteraction, 31 commandName: string, 32 + additionalInfo?: string, 33 ): void { 34 logUserAction({ 35 commandName, 36 + isGuild: !!interaction.guildId, 37 + isDM: !interaction.guildId, 38 additionalInfo, 39 }); 40 }
+2 -2
src/utils/cooldown.ts
··· 19 } 20 } 21 }, 22 - 5 * 60 * 1000 23 ); 24 25 export function createCooldownManager(commandName: string, cooldownTime: number): CooldownManager { ··· 35 manager: CooldownManager, 36 userId: string, 37 client: BotClient, 38 - locale: string 39 ): Promise<{ onCooldown: boolean; timeLeft?: number; message?: string }> { 40 const now = Date.now(); 41 const cooldownEnd = manager.cooldowns.get(userId) || 0;
··· 19 } 20 } 21 }, 22 + 5 * 60 * 1000, 23 ); 24 25 export function createCooldownManager(commandName: string, cooldownTime: number): CooldownManager { ··· 35 manager: CooldownManager, 36 userId: string, 37 client: BotClient, 38 + locale: string, 39 ): Promise<{ onCooldown: boolean; timeLeft?: number; message?: string }> { 40 const now = Date.now(); 41 const cooldownEnd = manager.cooldowns.get(userId) || 0;
+4 -4
src/utils/dynamicFetch.ts
··· 11 message: string, 12 public readonly status?: number, 13 public readonly statusText?: string, 14 - public readonly url?: string 15 ) { 16 super(message); 17 this.name = 'FetchError'; ··· 70 `Request timeout after ${timeout}ms`, 71 undefined, 72 undefined, 73 - url.toString() 74 ); 75 } 76 ··· 87 `Client error: ${error.message}`, 88 error.status, 89 'statusText' in error ? (error.statusText as string) : undefined, 90 - url.toString() 91 ); 92 } 93 } ··· 117 `Request failed after ${retries + 1} attempts: ${lastError?.message || 'Unknown error'}`, 118 undefined, 119 undefined, 120 - url.toString() 121 ); 122 }; 123
··· 11 message: string, 12 public readonly status?: number, 13 public readonly statusText?: string, 14 + public readonly url?: string, 15 ) { 16 super(message); 17 this.name = 'FetchError'; ··· 70 `Request timeout after ${timeout}ms`, 71 undefined, 72 undefined, 73 + url.toString(), 74 ); 75 } 76 ··· 87 `Client error: ${error.message}`, 88 error.status, 89 'statusText' in error ? (error.statusText as string) : undefined, 90 + url.toString(), 91 ); 92 } 93 } ··· 117 `Request failed after ${retries + 1} attempts: ${lastError?.message || 'Unknown error'}`, 118 undefined, 119 undefined, 120 + url.toString(), 121 ); 122 }; 123
+6 -6
src/utils/encrypt.ts
··· 10 class EncryptionError extends Error { 11 constructor( 12 message: string, 13 - public readonly operation: 'encrypt' | 'decrypt' 14 ) { 15 super(message); 16 this.name = 'EncryptionError'; ··· 54 if (!encrypted || typeof encrypted !== 'string') { 55 throw new EncryptionError( 56 'Invalid input: encrypted data must be a non-empty string', 57 - 'decrypt' 58 ); 59 } 60 ··· 86 if (iv.length !== IV_LENGTH) { 87 throw new EncryptionError( 88 `Invalid IV length: expected ${IV_LENGTH}, got ${iv.length}`, 89 - 'decrypt' 90 ); 91 } 92 93 if (tag.length !== 16) { 94 throw new EncryptionError( 95 `Invalid auth tag length: expected 16, got ${tag.length}`, 96 - 'decrypt' 97 ); 98 } 99 ··· 113 if (error instanceof Error) { 114 if (error.message.includes('Unsupported state or unable to authenticate data')) { 115 logger.warn( 116 - 'Authentication failed during decryption - data may be corrupted or key changed' 117 ); 118 throw new EncryptionError( 119 'Authentication failed - data may be corrupted or encryption key changed', 120 - 'decrypt' 121 ); 122 } 123 if (error.message.includes('Invalid key length')) {
··· 10 class EncryptionError extends Error { 11 constructor( 12 message: string, 13 + public readonly operation: 'encrypt' | 'decrypt', 14 ) { 15 super(message); 16 this.name = 'EncryptionError'; ··· 54 if (!encrypted || typeof encrypted !== 'string') { 55 throw new EncryptionError( 56 'Invalid input: encrypted data must be a non-empty string', 57 + 'decrypt', 58 ); 59 } 60 ··· 86 if (iv.length !== IV_LENGTH) { 87 throw new EncryptionError( 88 `Invalid IV length: expected ${IV_LENGTH}, got ${iv.length}`, 89 + 'decrypt', 90 ); 91 } 92 93 if (tag.length !== 16) { 94 throw new EncryptionError( 95 `Invalid auth tag length: expected 16, got ${tag.length}`, 96 + 'decrypt', 97 ); 98 } 99 ··· 113 if (error instanceof Error) { 114 if (error.message.includes('Unsupported state or unable to authenticate data')) { 115 logger.warn( 116 + 'Authentication failed during decryption - data may be corrupted or key changed', 117 ); 118 throw new EncryptionError( 119 'Authentication failed - data may be corrupted or encryption key changed', 120 + 'decrypt', 121 ); 122 } 123 if (error.message.includes('Invalid key length')) {
+3 -1
src/utils/getGitCommitHash.ts
··· 39 return cachedCommitHash || getGitCommitHashSync(); 40 } 41 42 - initializeGitCommitHash().catch(() => {}); 43 44 export default getGitCommitHash;
··· 39 return cachedCommitHash || getGitCommitHashSync(); 40 } 41 42 + initializeGitCommitHash().catch((error) => { 43 + console.warn('Failed to initialize git commit hash:', error.message); 44 + }); 45 46 export default getGitCommitHash;
+23 -7
src/utils/logger.ts
··· 3 import path from 'path'; 4 5 const sanitizeFormat = winston.format((info) => { 6 - const sensitiveKeys = ['password', 'token', 'api_key', 'secret', 'authorization']; 7 8 const sanitize = (obj: unknown): unknown => { 9 if (typeof obj !== 'object' || obj === null) return obj; ··· 12 for (const key in sanitized) { 13 if (sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive))) { 14 sanitized[key] = '[REDACTED]'; 15 } else if (typeof sanitized[key] === 'object') { 16 sanitized[key] = sanitize(sanitized[key]); 17 } ··· 31 winston.format.errors({ stack: true }), 32 sanitizeFormat(), 33 winston.format.splat(), 34 - winston.format.json() 35 ), 36 defaultMeta: { 37 service: 'Aethel', ··· 45 winston.format.printf(({ timestamp, level, message, service, ...meta }) => { 46 const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''; 47 return `${timestamp} [${service}] ${level}: ${message} ${metaStr}`; 48 - }) 49 ), 50 }), 51 ], ··· 62 maxsize: 5242880, 63 maxFiles: 5, 64 format: winston.format.combine(winston.format.timestamp(), winston.format.json()), 65 - }) 66 ); 67 68 logger.add( ··· 71 maxsize: 5242880, 72 maxFiles: 5, 73 format: winston.format.combine(winston.format.timestamp(), winston.format.json()), 74 - }) 75 ); 76 } 77 78 logger.exceptions.handle( 79 new winston.transports.Console({ 80 format: winston.format.combine(winston.format.colorize(), winston.format.simple()), 81 - }) 82 ); 83 84 logger.rejections.handle( 85 new winston.transports.Console({ 86 format: winston.format.combine(winston.format.colorize(), winston.format.simple()), 87 - }) 88 ); 89 90 export default logger;
··· 3 import path from 'path'; 4 5 const sanitizeFormat = winston.format((info) => { 6 + const sensitiveKeys = [ 7 + 'password', 8 + 'token', 9 + 'api_key', 10 + 'secret', 11 + 'authorization', 12 + 'userid', 13 + 'user_id', 14 + 'username', 15 + 'user_tag', 16 + 'guildid', 17 + 'guild_id', 18 + 'channelid', 19 + 'channel_id', 20 + ]; 21 22 const sanitize = (obj: unknown): unknown => { 23 if (typeof obj !== 'object' || obj === null) return obj; ··· 26 for (const key in sanitized) { 27 if (sensitiveKeys.some((sensitive) => key.toLowerCase().includes(sensitive))) { 28 sanitized[key] = '[REDACTED]'; 29 + } else if (typeof sanitized[key] === 'string') { 30 + sanitized[key] = (sanitized[key] as string).replace(/\b\d{17,19}\b/g, '[ID_REDACTED]'); 31 } else if (typeof sanitized[key] === 'object') { 32 sanitized[key] = sanitize(sanitized[key]); 33 } ··· 47 winston.format.errors({ stack: true }), 48 sanitizeFormat(), 49 winston.format.splat(), 50 + winston.format.json(), 51 ), 52 defaultMeta: { 53 service: 'Aethel', ··· 61 winston.format.printf(({ timestamp, level, message, service, ...meta }) => { 62 const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''; 63 return `${timestamp} [${service}] ${level}: ${message} ${metaStr}`; 64 + }), 65 ), 66 }), 67 ], ··· 78 maxsize: 5242880, 79 maxFiles: 5, 80 format: winston.format.combine(winston.format.timestamp(), winston.format.json()), 81 + }), 82 ); 83 84 logger.add( ··· 87 maxsize: 5242880, 88 maxFiles: 5, 89 format: winston.format.combine(winston.format.timestamp(), winston.format.json()), 90 + }), 91 ); 92 } 93 94 logger.exceptions.handle( 95 new winston.transports.Console({ 96 format: winston.format.combine(winston.format.colorize(), winston.format.simple()), 97 + }), 98 ); 99 100 logger.rejections.handle( 101 new winston.transports.Console({ 102 format: winston.format.combine(winston.format.colorize(), winston.format.simple()), 103 + }), 104 ); 105 106 export default logger;
+2 -3
src/utils/memoryManager.ts
··· 47 const result = [...entry]; 48 delete (result as unknown as Record<string, unknown>).timestamp; 49 return result as V; 50 - } else { 51 - const { timestamp: _timestamp, ...valueWithoutTimestamp } = entry; 52 - return valueWithoutTimestamp as V; 53 } 54 } 55 56 delete(key: K): boolean {
··· 47 const result = [...entry]; 48 delete (result as unknown as Record<string, unknown>).timestamp; 49 return result as V; 50 } 51 + const { timestamp: _timestamp, ...valueWithoutTimestamp } = entry; 52 + return valueWithoutTimestamp as V; 53 } 54 55 delete(key: K): boolean {
+6 -6
src/utils/reminderDb.ts
··· 45 return new DatabaseError( 46 errorMessage, 47 'Unable to connect to the database. Please try again later.', 48 - true 49 ); 50 case '42703': 51 return new DatabaseError( 52 errorMessage, 53 'Database schema error. Please contact support.', 54 - true 55 ); 56 case '23505': 57 return new DatabaseError(errorMessage, 'This reminder already exists.', true); ··· 59 return new DatabaseError( 60 errorMessage, 61 'Missing required information. Please try again.', 62 - true 63 ); 64 case '23503': 65 return new DatabaseError(errorMessage, 'Invalid reference. Please try again.', true); ··· 67 return new DatabaseError( 68 errorMessage, 69 'A database error occurred. Please try again later.', 70 - false 71 ); 72 } 73 } ··· 75 async function ensureUserRegistered( 76 userId: string, 77 userTag: string, 78 - language: string = 'en' 79 ): Promise<void> { 80 const query = ` 81 SELECT ensure_user_registered($1, $2, $3) ··· 92 await ensureUserRegistered( 93 reminderData.user_id, 94 reminderData.user_tag, 95 - reminderData.locale || 'en' 96 ); 97 98 const query = `
··· 45 return new DatabaseError( 46 errorMessage, 47 'Unable to connect to the database. Please try again later.', 48 + true, 49 ); 50 case '42703': 51 return new DatabaseError( 52 errorMessage, 53 'Database schema error. Please contact support.', 54 + true, 55 ); 56 case '23505': 57 return new DatabaseError(errorMessage, 'This reminder already exists.', true); ··· 59 return new DatabaseError( 60 errorMessage, 61 'Missing required information. Please try again.', 62 + true, 63 ); 64 case '23503': 65 return new DatabaseError(errorMessage, 'Invalid reference. Please try again.', true); ··· 67 return new DatabaseError( 68 errorMessage, 69 'A database error occurred. Please try again later.', 70 + false, 71 ); 72 } 73 } ··· 75 async function ensureUserRegistered( 76 userId: string, 77 userTag: string, 78 + language = 'en', 79 ): Promise<void> { 80 const query = ` 81 SELECT ensure_user_registered($1, $2, $3) ··· 92 await ensureUserRegistered( 93 reminderData.user_id, 94 reminderData.user_tag, 95 + reminderData.locale || 'en', 96 ); 97 98 const query = `
+5 -5
src/utils/userStrikes.ts
··· 9 export class StrikeError extends Error { 10 constructor( 11 message: string, 12 - public readonly userId?: string 13 ) { 14 super(message); 15 this.name = 'StrikeError'; ··· 24 try { 25 const res = await pgClient.query( 26 'SELECT strike_count, banned_until FROM user_strikes WHERE user_id = $1', 27 - [userId] 28 ); 29 30 if (res.rows.length === 0) { ··· 56 last_strike_at = NOW() 57 RETURNING strike_count, banned_until; 58 `, 59 - [userId] 60 ); 61 62 if (res.rows.length === 0) { ··· 137 `UPDATE user_strikes 138 SET strike_count = 0, banned_until = NULL 139 WHERE last_strike_at < NOW() - INTERVAL '3 days' 140 - RETURNING user_id` 141 ); 142 143 const resetCount = res.rows.length; ··· 161 try { 162 const res = await pgClient.query( 163 'UPDATE user_strikes SET strike_count = 0, banned_until = NULL WHERE user_id = $1', 164 - [userId] 165 ); 166 167 logger.info('Cleared user strikes', { userId });
··· 9 export class StrikeError extends Error { 10 constructor( 11 message: string, 12 + public readonly userId?: string, 13 ) { 14 super(message); 15 this.name = 'StrikeError'; ··· 24 try { 25 const res = await pgClient.query( 26 'SELECT strike_count, banned_until FROM user_strikes WHERE user_id = $1', 27 + [userId], 28 ); 29 30 if (res.rows.length === 0) { ··· 56 last_strike_at = NOW() 57 RETURNING strike_count, banned_until; 58 `, 59 + [userId], 60 ); 61 62 if (res.rows.length === 0) { ··· 137 `UPDATE user_strikes 138 SET strike_count = 0, banned_until = NULL 139 WHERE last_strike_at < NOW() - INTERVAL '3 days' 140 + RETURNING user_id`, 141 ); 142 143 const resetCount = res.rows.length; ··· 161 try { 162 const res = await pgClient.query( 163 'UPDATE user_strikes SET strike_count = 0, banned_until = NULL WHERE user_id = $1', 164 + [userId], 165 ); 166 167 logger.info('Cleared user strikes', { userId });
+1 -1
src/utils/validation.ts
··· 9 10 function validateCommandOptions( 11 interaction: ChatInputCommandInteraction, 12 - requiredOptions: string[] = [] 13 ): ValidationResult { 14 for (const option of requiredOptions) { 15 const value = interaction.options.getString(option);
··· 9 10 function validateCommandOptions( 11 interaction: ChatInputCommandInteraction, 12 + requiredOptions: string[] = [], 13 ): ValidationResult { 14 for (const option of requiredOptions) { 15 const value = interaction.options.getString(option);
+60 -11
web/src/App.tsx
··· 22 return ( 23 <Routes> 24 {/* Public routes */} 25 - <Route path="/" element={<LandingPage />} /> 26 <Route 27 path="/login" 28 - element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />} 29 /> 30 - <Route path="/status" element={<StatusPage />} /> 31 - <Route path="/legal/privacy" element={<PrivacyPage />} /> 32 - <Route path="/legal/terms" element={<TermsPage />} /> 33 34 {/* Protected routes */} 35 {isAuthenticated ? ( ··· 38 element={ 39 <Layout> 40 <Routes> 41 - <Route path="/dashboard" element={<DashboardPage />} /> 42 - <Route path="/todos" element={<TodosPage />} /> 43 - <Route path="/reminders" element={<RemindersPage />} /> 44 - <Route path="/api-keys" element={<ApiKeysPage />} /> 45 - <Route path="*" element={<Navigate to="/dashboard" replace />} /> 46 </Routes> 47 </Layout> 48 } 49 /> 50 ) : ( 51 - <Route path="*" element={<Navigate to="/" replace />} /> 52 )} 53 </Routes> 54 );
··· 22 return ( 23 <Routes> 24 {/* Public routes */} 25 + <Route 26 + path="/" 27 + element={<LandingPage />} 28 + /> 29 <Route 30 path="/login" 31 + element={ 32 + isAuthenticated ? ( 33 + <Navigate 34 + to="/dashboard" 35 + replace 36 + /> 37 + ) : ( 38 + <LoginPage /> 39 + ) 40 + } 41 /> 42 + <Route 43 + path="/status" 44 + element={<StatusPage />} 45 + /> 46 + <Route 47 + path="/legal/privacy" 48 + element={<PrivacyPage />} 49 + /> 50 + <Route 51 + path="/legal/terms" 52 + element={<TermsPage />} 53 + /> 54 55 {/* Protected routes */} 56 {isAuthenticated ? ( ··· 59 element={ 60 <Layout> 61 <Routes> 62 + <Route 63 + path="/dashboard" 64 + element={<DashboardPage />} 65 + /> 66 + <Route 67 + path="/todos" 68 + element={<TodosPage />} 69 + /> 70 + <Route 71 + path="/reminders" 72 + element={<RemindersPage />} 73 + /> 74 + <Route 75 + path="/api-keys" 76 + element={<ApiKeysPage />} 77 + /> 78 + <Route 79 + path="*" 80 + element={ 81 + <Navigate 82 + to="/dashboard" 83 + replace 84 + /> 85 + } 86 + /> 87 </Routes> 88 </Layout> 89 } 90 /> 91 ) : ( 92 + <Route 93 + path="*" 94 + element={ 95 + <Navigate 96 + to="/" 97 + replace 98 + /> 99 + } 100 + /> 101 )} 102 </Routes> 103 );
+4 -1
web/src/components/Layout.tsx
··· 33 mobileMenuOpen ? 'opacity-100' : 'opacity-0 pointer-events-none' 34 }`} 35 > 36 - <div className="fixed inset-0 bg-black/70" onClick={() => setMobileMenuOpen(false)} /> 37 <div 38 className={`fixed inset-y-0 left-0 flex w-72 flex-col bg-white/90 dark:bg-gray-800/90 backdrop-blur-md shadow-xl transform transition-transform duration-200 ease-out ${ 39 mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
··· 33 mobileMenuOpen ? 'opacity-100' : 'opacity-0 pointer-events-none' 34 }`} 35 > 36 + <div 37 + className="fixed inset-0 bg-black/70" 38 + onClick={() => setMobileMenuOpen(false)} 39 + /> 40 <div 41 className={`fixed inset-y-0 left-0 flex w-72 flex-col bg-white/90 dark:bg-gray-800/90 backdrop-blur-md shadow-xl transform transition-transform duration-200 ease-out ${ 42 mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
+6 -1
web/src/components/LegalLayout.tsx
··· 32 to="/" 33 className="inline-flex items-center px-6 py-3 bg-white/90 hover:bg-white dark:bg-gray-800/90 dark:hover:bg-gray-800 text-gray-800 dark:text-gray-100 font-medium rounded-full shadow-md hover:shadow-lg transition-all mb-8" 34 > 35 - <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 36 <path 37 strokeLinecap="round" 38 strokeLinejoin="round"
··· 32 to="/" 33 className="inline-flex items-center px-6 py-3 bg-white/90 hover:bg-white dark:bg-gray-800/90 dark:hover:bg-gray-800 text-gray-800 dark:text-gray-100 font-medium rounded-full shadow-md hover:shadow-lg transition-all mb-8" 34 > 35 + <svg 36 + className="w-4 h-4 mr-2" 37 + fill="none" 38 + stroke="currentColor" 39 + viewBox="0 0 24 24" 40 + > 41 <path 42 strokeLinecap="round" 43 strokeLinejoin="round"
+2 -2
web/src/lib/api.ts
··· 16 }, 17 (error) => { 18 return Promise.reject(error); 19 - } 20 ); 21 22 api.interceptors.response.use( ··· 34 toast.error('An unexpected error occurred.'); 35 } 36 return Promise.reject(error); 37 - } 38 ); 39 40 export default api;
··· 16 }, 17 (error) => { 18 return Promise.reject(error); 19 + }, 20 ); 21 22 api.interceptors.response.use( ··· 34 toast.error('An unexpected error occurred.'); 35 } 36 return Promise.reject(error); 37 + }, 38 ); 39 40 export default api;
+1 -1
web/src/main.tsx
··· 23 <Toaster position="top-right" /> 24 </BrowserRouter> 25 </QueryClientProvider> 26 - </React.StrictMode> 27 );
··· 23 <Toaster position="top-right" /> 24 </BrowserRouter> 25 </QueryClientProvider> 26 + </React.StrictMode>, 27 );
+4 -1
web/src/pages/ApiKeysPage.tsx
··· 207 {apiKeyInfo?.hasApiKey ? 'Update' : 'Configure'} API Key 208 </h2> 209 210 - <form onSubmit={handleSubmit} className="space-y-4"> 211 <div> 212 <p className="text-sm text-gray-600 dark:text-gray-300 mb-4"> 213 <span className="text-red-600 dark:text-red-400">*</span> You must test the API key
··· 207 {apiKeyInfo?.hasApiKey ? 'Update' : 'Configure'} API Key 208 </h2> 209 210 + <form 211 + onSubmit={handleSubmit} 212 + className="space-y-4" 213 + > 214 <div> 215 <p className="text-sm text-gray-600 dark:text-gray-300 mb-4"> 216 <span className="text-red-600 dark:text-red-400">*</span> You must test the API key
+10 -4
web/src/pages/DashboardPage.tsx
··· 65 const recentTodos = activeTodosForDisplay.slice(0, 5); 66 const activeRemindersForDisplay = 67 reminders?.filter( 68 - (reminder: Reminder) => !reminder.completed && new Date(reminder.expires_at) >= new Date() 69 ) || []; 70 const recentReminders = activeRemindersForDisplay.slice(0, 5); 71 ··· 194 {recentTodos.length > 0 ? ( 195 <div className="space-y-3"> 196 {recentTodos.slice(0, 5).map((todo: Todo) => ( 197 - <div key={todo.id} className="stats-card"> 198 <div className="flex items-center justify-between"> 199 <div className="flex items-center space-x-3 flex-1"> 200 <div ··· 269 const daysOverdue = isOverdue 270 ? Math.ceil( 271 (Date.now() - new Date(reminder.expires_at).getTime()) / 272 - (1000 * 60 * 60 * 24) 273 ) 274 : 0; 275 276 return ( 277 - <div key={reminder.reminder_id} className="stats-card"> 278 <div className="flex items-center justify-between"> 279 <div className="flex items-center space-x-3 flex-1"> 280 <div
··· 65 const recentTodos = activeTodosForDisplay.slice(0, 5); 66 const activeRemindersForDisplay = 67 reminders?.filter( 68 + (reminder: Reminder) => !reminder.completed && new Date(reminder.expires_at) >= new Date(), 69 ) || []; 70 const recentReminders = activeRemindersForDisplay.slice(0, 5); 71 ··· 194 {recentTodos.length > 0 ? ( 195 <div className="space-y-3"> 196 {recentTodos.slice(0, 5).map((todo: Todo) => ( 197 + <div 198 + key={todo.id} 199 + className="stats-card" 200 + > 201 <div className="flex items-center justify-between"> 202 <div className="flex items-center space-x-3 flex-1"> 203 <div ··· 272 const daysOverdue = isOverdue 273 ? Math.ceil( 274 (Date.now() - new Date(reminder.expires_at).getTime()) / 275 + (1000 * 60 * 60 * 24), 276 ) 277 : 0; 278 279 return ( 280 + <div 281 + key={reminder.reminder_id} 282 + className="stats-card" 283 + > 284 <div className="flex items-center justify-between"> 285 <div className="flex items-center space-x-3 flex-1"> 286 <div
+19 -4
web/src/pages/LandingPage.tsx
··· 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 className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> 33 <path 34 fillRule="evenodd" 35 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" ··· 187 188 <footer className="mt-16 text-center text-gray-500 dark:text-gray-400 pb-8"> 189 <div className="flex flex-wrap justify-center gap-6 mb-6"> 190 - <Link to="/legal/privacy" className="hover:text-pink-500 transition-colors text-sm"> 191 Privacy Policy 192 </Link> 193 - <Link to="/legal/terms" className="hover:text-pink-500 transition-colors text-sm"> 194 Terms of Service 195 </Link> 196 </div> ··· 203 rel="noopener noreferrer" 204 className="inline-block hover:opacity-80 transition-opacity" 205 > 206 - <img src="/royale_logo.svg" alt="Royale Hosting" className="h-6 mx-auto dark:hidden" /> 207 <img 208 src="/royale_logo_dark.svg" 209 alt="Royale Hosting"
··· 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" 37 + > 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" ··· 192 193 <footer className="mt-16 text-center text-gray-500 dark:text-gray-400 pb-8"> 194 <div className="flex flex-wrap justify-center gap-6 mb-6"> 195 + <Link 196 + to="/legal/privacy" 197 + className="hover:text-pink-500 transition-colors text-sm" 198 + > 199 Privacy Policy 200 </Link> 201 + <Link 202 + to="/legal/terms" 203 + className="hover:text-pink-500 transition-colors text-sm" 204 + > 205 Terms of Service 206 </Link> 207 </div> ··· 214 rel="noopener noreferrer" 215 className="inline-block hover:opacity-80 transition-opacity" 216 > 217 + <img 218 + src="/royale_logo.svg" 219 + alt="Royale Hosting" 220 + className="h-6 mx-auto dark:hidden" 221 + /> 222 <img 223 src="/royale_logo_dark.svg" 224 alt="Royale Hosting"
+5 -1
web/src/pages/LoginPage.tsx
··· 68 onClick={handleDiscordLogin} 69 className="w-full flex items-center justify-center px-8 py-3 border border-transparent rounded-full shadow-sm text-white bg-[#5865F2] hover:bg-[#4752c4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-all transform hover:scale-105 font-bold shadow-lg hover:shadow-xl" 70 > 71 - <svg className="w-5 h-5 mr-3" viewBox="0 0 24 24" fill="currentColor"> 72 <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" /> 73 </svg> 74 Continue with Discord
··· 68 onClick={handleDiscordLogin} 69 className="w-full flex items-center justify-center px-8 py-3 border border-transparent rounded-full shadow-sm text-white bg-[#5865F2] hover:bg-[#4752c4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-all transform hover:scale-105 font-bold shadow-lg hover:shadow-xl" 70 > 71 + <svg 72 + className="w-5 h-5 mr-3" 73 + viewBox="0 0 24 24" 74 + fill="currentColor" 75 + > 76 <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" /> 77 </svg> 78 Continue with Discord
+4 -1
web/src/pages/PrivacyPage.tsx
··· 2 3 export default function PrivacyPolicy() { 4 return ( 5 - <LegalLayout title="Privacy Policy" lastUpdated="July 26, 2025"> 6 <div className="space-y-8"> 7 <section> 8 <h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
··· 2 3 export default function PrivacyPolicy() { 4 return ( 5 + <LegalLayout 6 + title="Privacy Policy" 7 + lastUpdated="July 26, 2025" 8 + > 9 <div className="space-y-8"> 10 <section> 11 <h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
+4 -1
web/src/pages/RemindersPage.tsx
··· 150 <h2 className="text-lg sm:text-xl font-semibold mb-4 sm:mb-6 text-gray-900 dark:text-gray-100"> 151 Create New Reminder 152 </h2> 153 - <form onSubmit={handleCreateReminder} className="space-y-4 sm:space-y-6"> 154 <div> 155 <label 156 htmlFor="message"
··· 150 <h2 className="text-lg sm:text-xl font-semibold mb-4 sm:mb-6 text-gray-900 dark:text-gray-100"> 151 Create New Reminder 152 </h2> 153 + <form 154 + onSubmit={handleCreateReminder} 155 + className="space-y-4 sm:space-y-6" 156 + > 157 <div> 158 <label 159 htmlFor="message"
+7 -2
web/src/pages/StatusPage.tsx
··· 9 headers: { 10 'X-API-Key': import.meta.env.VITE_STATUS_API_KEY || '', 11 }, 12 - } 13 ); 14 15 if (!response.ok) { ··· 287 to="/" 288 className="inline-flex items-center px-6 py-3 bg-white/90 hover:bg-white dark:bg-gray-800/90 dark:hover:bg-gray-800 text-gray-800 dark:text-gray-100 font-medium rounded-full shadow-md hover:shadow-lg transition-all" 289 > 290 - <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 291 <path 292 strokeLinecap="round" 293 strokeLinejoin="round"
··· 9 headers: { 10 'X-API-Key': import.meta.env.VITE_STATUS_API_KEY || '', 11 }, 12 + }, 13 ); 14 15 if (!response.ok) { ··· 287 to="/" 288 className="inline-flex items-center px-6 py-3 bg-white/90 hover:bg-white dark:bg-gray-800/90 dark:hover:bg-gray-800 text-gray-800 dark:text-gray-100 font-medium rounded-full shadow-md hover:shadow-lg transition-all" 289 > 290 + <svg 291 + className="w-4 h-4 mr-2" 292 + fill="none" 293 + stroke="currentColor" 294 + viewBox="0 0 24 24" 295 + > 296 <path 297 strokeLinecap="round" 298 strokeLinejoin="round"
+4 -1
web/src/pages/TermsPage.tsx
··· 2 3 export default function TermsOfService() { 4 return ( 5 - <LegalLayout title="Terms of Service" lastUpdated="June 16, 2025"> 6 <div className="space-y-8"> 7 <section> 8 <h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
··· 2 3 export default function TermsOfService() { 4 return ( 5 + <LegalLayout 6 + title="Terms of Service" 7 + lastUpdated="June 16, 2025" 8 + > 9 <div className="space-y-8"> 10 <section> 11 <h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
+4 -1
web/src/pages/TodosPage.tsx
··· 119 </div> 120 121 <div className="card p-6"> 122 - <form onSubmit={handleAddTodo} className="flex gap-3"> 123 <input 124 type="text" 125 value={newTodo}
··· 119 </div> 120 121 <div className="card p-6"> 122 + <form 123 + onSubmit={handleAddTodo} 124 + className="flex gap-3" 125 + > 126 <input 127 type="text" 128 value={newTodo}
+2 -2
web/src/stores/authStore.ts
··· 66 user: state.user, 67 isAuthenticated: state.isAuthenticated, 68 }), 69 - } 70 - ) 71 );
··· 66 user: state.user, 67 isAuthenticated: state.isAuthenticated, 68 }), 69 + }, 70 + ), 71 );
+2 -2
web/src/stores/themeStore.ts
··· 28 updateBodyClass(state.isDarkMode); 29 } 30 }, 31 - } 32 - ) 33 ); 34 35 function updateBodyClass(isDark: boolean) {
··· 28 updateBodyClass(state.isDarkMode); 29 } 30 }, 31 + }, 32 + ), 33 ); 34 35 function updateBodyClass(isDark: boolean) {