diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7b08ecd --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +BOT_TOKEN=bottokenhere +ADMIN_ID=adminidhere \ No newline at end of file diff --git a/.gitignore b/.gitignore index c6bba59..b1f6333 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,9 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# bun +bun.lockb + +# database +responses.json \ No newline at end of file diff --git a/README.md b/README.md index eb6d398..551f42c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ # request-bot A Telegram bot which takes requests for modules.lol + +# Setting up and self hosting +1. **Install dependancies** + + ```bash + bun install + ``` + **OR** + ```bash + npm install + ``` + +2. **Change variables** + + Copy `.env.example` to `.env` and open the file in a text editor. + + Replace `ADMIN_ID` with your Telegram user ID. This will be used for admin-only commands. + + Replace `BOT_TOKEN` with your Telegram bot token you created through @BotFather + +3. **Start the bot** + + ```bash + bun index.js + ``` + **OR** + ```bash + node index.js + ``` \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..106092f --- /dev/null +++ b/index.js @@ -0,0 +1,254 @@ +require('dotenv').config(); +const { Telegraf, Markup } = require('telegraf'); +const fs = require('fs'); +const moment = require('moment'); + +const bot = new Telegraf(process.env.BOT_TOKEN); + +const questions = [ + "Are you submitting an app (1) or a module (2)? You may only respond with 1 or 2.", + "What is the name of the app or module?", + "Where can I find an icon of the app or module?", + "What is the GitHub/Source of the app or module?", + "Do you have any other comments?" +]; + +const labels = ["type", "name", "icon", "source", "comments"]; +let userResponses = {}; +const userSummary = {}; + +if (fs.existsSync('responses.json')) { + const data = fs.readFileSync('responses.json'); + userResponses = JSON.parse(data); +} + +bot.use((ctx, next) => { + console.log(ctx.message); + return next(); +}); + +bot.start((ctx) => ctx.reply(`Hey there! You can call me RequestBot. Did you know I'm FOSS?! Use /help if you need any :)`)); + +bot.help((ctx) => { + let helpMessage = 'Commands:\n\n' + + '/start - Start bot (not really useful)\n' + + '/help - Get help (you know this one!)\n' + + '/new - Start a new request\n' + + '/status - Check the status of your requests\n' + + '/check [requestid] - View details of a specific request\n\n'; + if (ctx.from.id.toString() === process.env.ADMIN_ID) { + helpMessage += 'Admin Commands:\n\n' + + '/requests - List all requests\n' + + '/view [id] - View details of a user and their requests\n' + + '/view [userid] [requestid] - View details of a user\'s request\n' + + '/accept [userid] [requestid] - Accept a request\n' + + '/decline [userid] [requestid] - Decline a request\n'; + } + ctx.replyWithHTML(helpMessage); +}); + +bot.command('new', (ctx) => { + const chatId = ctx.chat.id; + if (!userResponses[chatId]) { + userResponses[chatId] = []; + } + userResponses[chatId].push({ + step: 0, + responses: {}, + timestamp: Math.floor(Date.now() / 1000), + username: ctx.from.username, + firstName: ctx.from.first_name, + status: 'pending' + }); + ctx.reply(questions[0]); + fs.writeFileSync('responses.json', JSON.stringify(userResponses, null, 2)); +}); + +bot.command('status', (ctx) => { + const chatId = ctx.chat.id; + if (userResponses[chatId] && userResponses[chatId].length > 0) { + let responseText = 'My Requests:\n\n'; + userResponses[chatId].forEach((request, index) => { + responseText += `[${index + 1}] ${request.status.toUpperCase()} - ${request.responses.name || 'N/A'}\n`; + }); + responseText += '\nUse /check [id] to view the request.'; + ctx.replyWithHTML(responseText); + } else { + ctx.reply(`You don't have any requests to view.`); + } +}); + +bot.command('check', (ctx) => { + const text = ctx.message.text; + const args = text.split(' '); + if (args.length === 2) { + const chatId = ctx.chat.id; + const requestId = parseInt(args[1]) - 1; + if (userResponses[chatId] && userResponses[chatId][requestId]) { + const request = userResponses[chatId][requestId]; + let responseText = `My Request - #${requestId + 1}\n\n`; + responseText += `Timestamp: ${moment.unix(request.timestamp).format('MMMM Do YYYY, h:mm:ss a')}\n`; + responseText += `Status: ${request.status}\n\n`; + responseText += `Request:\n\n`; + for (const [key, value] of Object.entries(request.responses)) { + responseText += `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value || 'N/A'}\n`; + } + ctx.replyWithHTML(responseText); + } else { + ctx.reply('Invalid request ID.'); + } + } else { + ctx.reply('Invalid command. Try doing /check [requestid].'); + } +}); + +bot.command('requests', (ctx) => { + if (ctx.from.id.toString() === process.env.ADMIN_ID) { + if (Object.keys(userResponses).length === 0) { + ctx.reply(`Sorry, there aren't any requests yet!`); + return; + } + let responseText = 'Requests:\n\n'; + let index = 1; + let hasPendingRequests = false; + for (const [chatId, requests] of Object.entries(userResponses)) { + const pendingRequests = requests.filter(request => request.status === 'pending').length; + if (pendingRequests > 0) { + responseText += `[${index}] ${pendingRequests} new requests from @${requests[0].username}\n`; + userSummary[index] = { chatId, username: requests[0].username, totalRequests: requests.length, pendingRequests }; + index++; + hasPendingRequests = true; + } + } + if (!hasPendingRequests) { + ctx.reply(`Sorry, there aren't any requests yet!`); + } else { + responseText += '\nPlease select a user to view with /view [id].'; + ctx.replyWithHTML(responseText); + } + } else { + ctx.reply(`Hey, you aren't allowed to use that command!`); + } +}); + +bot.command('view', (ctx) => { + const text = ctx.message.text; + const args = text.split(' '); + if (args.length === 2 && ctx.from.id.toString() === process.env.ADMIN_ID && userSummary[args[1]]) { + const summary = userSummary[args[1]]; + const lastRequest = userResponses[summary.chatId][userResponses[summary.chatId].length - 1]; + const lastRequestTime = moment.unix(lastRequest.timestamp).fromNow(); + let responseText = `@${summary.username}\nUser ID: ${summary.chatId}\n\n`; + responseText += `Requests Sent (Lifetime): ${summary.totalRequests}\n`; + responseText += `New Requests (Pending): ${summary.pendingRequests}\n`; + responseText += `Request Last Sent: ${lastRequestTime}\n\n`; + userResponses[summary.chatId].forEach((request, index) => { + if (request.status === 'pending') { + responseText += `[${index + 1}] ${request.responses.name || 'N/A'}\n`; + } + }); + ctx.replyWithHTML(responseText); + } else if (args.length === 3 && ctx.from.id.toString() === process.env.ADMIN_ID) { + const chatId = args[1]; + const requestId = parseInt(args[2]) - 1; + if (userResponses[chatId] && userResponses[chatId][requestId]) { + const request = userResponses[chatId][requestId]; + let responseText = `Request from @${request.username}\n\n`; + responseText += `Timestamp: ${moment.unix(request.timestamp).format('MMMM Do YYYY, h:mm:ss a')}\n`; + responseText += `Status: ${request.status}\n\n`; + responseText += `Request:\n\n`; + for (const [key, value] of Object.entries(request.responses)) { + responseText += `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value || 'N/A'}\n`; + } + ctx.replyWithHTML(responseText); + } else { + ctx.reply('Invalid user ID or request ID.'); + } + } else { + ctx.reply(`You entered an invalid command or aren't allowed to use this!`); + } +}); + +bot.command('accept', (ctx) => { + const text = ctx.message.text; + const args = text.split(' '); + if (args.length === 3 && ctx.from.id.toString() === process.env.ADMIN_ID) { + const chatId = args[1]; + const requestId = parseInt(args[2]) - 1; + if (userResponses[chatId] && userResponses[chatId][requestId]) { + userResponses[chatId][requestId].status = 'accepted'; + fs.writeFileSync('responses.json', JSON.stringify(userResponses, null, 2)); + ctx.reply(`Request ${requestId + 1} from @${userResponses[chatId][requestId].username} has been accepted.`); + } else { + ctx.reply('Invalid user ID or request ID.'); + } + } else { + ctx.reply(`You entered an invalid command or aren't allowed to use this!`); + } +}); + +bot.command('decline', (ctx) => { + const text = ctx.message.text; + const args = text.split(' '); + if (args.length === 3 && ctx.from.id.toString() === process.env.ADMIN_ID) { + const chatId = args[1]; + const requestId = parseInt(args[2]) - 1; + if (userResponses[chatId] && userResponses[chatId][requestId]) { + userResponses[chatId][requestId].status = 'declined'; + fs.writeFileSync('responses.json', JSON.stringify(userResponses, null, 2)); + ctx.reply(`Request ${requestId + 1} from @${userResponses[chatId][requestId].username} has been declined.`); + } else { + ctx.reply('Invalid user ID or request ID.'); + } + } else { + ctx.reply(`You entered an invalid command or aren't allowed to use this!`); + } +}); + +bot.on('text', (ctx) => { + const chatId = ctx.chat.id; + const text = ctx.message.text; + + if (userResponses[chatId] && userResponses[chatId].length > 0 && userResponses[chatId][userResponses[chatId].length - 1].step < questions.length) { + const currentRequest = userResponses[chatId][userResponses[chatId].length - 1]; + if (currentRequest.step === 0 && (text !== '1' && text !== '2')) { + ctx.reply('Please respond with either 1 or 2.'); + return; + } + if (currentRequest.step === 3 && !text.match(/https?:\/\/[^\s]+/)) { + ctx.reply('Please provide a valid link. A link should look like: https://example.com'); + return; + } + + currentRequest.responses[labels[currentRequest.step]] = text; + currentRequest.step += 1; + + if (currentRequest.step < questions.length) { + ctx.reply(questions[currentRequest.step]); + } else { + fs.writeFileSync('responses.json', JSON.stringify(userResponses, null, 2)); + ctx.reply('Thank you for your responses!'); + } + } else if (text.startsWith('/')) { + ctx.reply('Please complete the current process by answering the questions.'); + } else { + ctx.reply(`Why are you talking to me? Create a request and stop blabbing about "${text}"`); + } +}); + +bot.action(/setstatus_(.+)/, (ctx) => { + if (ctx.from.id.toString() === process.env.ADMIN_ID) { + const [chatId, requestIndex, status] = ctx.match[1].split('_'); + userResponses[chatId][requestIndex].status = status; + fs.writeFileSync('responses.json', JSON.stringify(userResponses, null, 2)); + ctx.reply(`Status of request ${parseInt(requestIndex) + 1} from ${userResponses[chatId][requestIndex].firstName} (@${userResponses[chatId][requestIndex].username}) has been updated to ${status}.`); + } else { + ctx.reply(`Hey, you aren't allowed to use that command!`); + } +}); + +bot.launch().then(() => { + console.log('Bot is up'); +}).catch(err => { + console.error('FAIL:', err); +}); \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c7b95ac --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "request-bot", + "module": "index.js", + "scripts": { + "start": "node index.js" + }, + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "dotenv": "^16.4.7", + "moment": "^2.30.1", + "telegraf": "^4.16.3" + } +} \ No newline at end of file