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