diff --git a/.config/store.json b/.config/store.json new file mode 100644 index 0000000..fe5b7c1 --- /dev/null +++ b/.config/store.json @@ -0,0 +1,17 @@ +{ + "users": { + "1711055629": { + "name": "Ayush", + "roll": "6372373", + "uni": "IIITM Gwalior" + } + }, + "sessions": { + "1711055629": { + "step": "AWAITING_PLATFORM", + "context": "Make a assignment for bubble sort and give me pdf withe rhr code and code op screenshot", + "solution": "Assignment: Implementation and Performance Analysis of Bubble Sort Algorithm\nStudent Name: Ayush\nRoll Number: 6372373\nUniversity: IIITM Gwalior\nDate: 4/1/2026\n\n1. Objective\nThe objective of this assignment is to implement the Bubble Sort algorithm, document the step-by-step logic, provide the source code, and analyze its time complexity in the context of sorting an array of integers.\n\n2. Problem Statement\nWrite a program to sort an array of $n$ elements using the Bubble Sort technique. The program must:\na) Accept input size $n$ and an array of $n$ integers from the user.\nb) Perform the sort by repeatedly stepping through the list, comparing adjacent elements, and swapping them if they are in the wrong order.\nc) Display the sorted array.\n\n3. Algorithm Description\nBubble Sort is a comparison-based algorithm where each pair of adjacent elements is compared, and elements are swapped if they are not in order. This process repeats until the array is sorted.\n- Time Complexity: O(nΒ²) in the worst and average cases.\n- Space Complexity: O(1) as it is an in-place sorting algorithm.\n\n4. Source Code (Python)\n```python\ndef bubble_sort(arr):\n n = len(arr)\n for i in range(n):\n for j in range(0, n - i - 1):\n if arr[j] > arr[j + 1]:\n arr[j], arr[j + 1] = arr[j + 1], arr[j]\n return arr\n\n# Driver code\nif __name__ == \"__main__\":\n data = [64, 34, 25, 12, 22, 11, 90]\n print(\"Original array:\", data)\n sorted_data = bubble_sort(data)\n print(\"Sorted array:\", sorted_data)\n```\n\n5. Output Screenshot Placeholder\n[Insert Screenshot of Program Execution Here]\n\n6. Conclusion\nThe implementation confirms that Bubble Sort is effective for smaller datasets but inefficient for large-scale data due to its quadratic time complexity. Proper documentation of the swapping mechanism demonstrates the fundamental working principle of the algorithm.", + "format": "Pdf with code op" + } + } +} diff --git a/.env b/.env new file mode 100644 index 0000000..35969a8 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +TELEGRAM_BOT_TOKEN=8606625998:AAGuNWcFKDyXBSBITEie_NCSqZ2j2GZ6dC4 diff --git a/.gitignore b/.gitignore index 9a5aced..858970f 100644 --- a/.gitignore +++ b/.gitignore @@ -64,11 +64,7 @@ web_modules/ # Yarn Integrity file .yarn-integrity - -# dotenv environment variable files -.env -.env.* -!.env.example +.config # parcel-bundler cache (https://parceljs.org/) .cache @@ -76,8 +72,9 @@ web_modules/ # Next.js build output .next +.env out - +.config # Nuxt.js build / generate output .nuxt dist diff --git a/README.md b/README.md index bb845c5..2247d8d 100644 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ -# scholarsync \ No newline at end of file +# ScholarSync: Openclaw for students πŸŽ“ + +ScholarSync is an automated study and assignment agent that you interact with directly through your favorite messaging platforms. By providing assignment prompts and specific platform instructions, ScholarSync will solve, format, and upload your academic coursework exactly how you need it. + +--- + +## πŸš€ Key Features + +### πŸ’¬ Multi-Platform Messaging Integration + +Interact with ScholarSync easily on: + +- **Telegram** +- **WhatsApp** *(Coming soon)* +- **Slack** *(Coming soon)* + +### πŸ“ Seamless Onboarding + +The first time you interact with the bot, you will be onboarded to specify your permanent details: + +- **Roll Number** +- **Name** +- **University / College** +These details are automatically attached and formatted into your generated assignments. + +### πŸ“š Complete Assignment Solving + +Provide the bot with all required details to solve your assignment (e.g. prompt text, PDF documents, or images via OCR). ScholarSync analyzes the requirements and processes the solution precisely. + +### πŸ“€ Custom Upload Destinations + +Tell ScholarSync which platform to upload the finished assignment to (e.g., Moodle, Canvas, Github Classroom, or a custom university portal) and the bot will handle the final submission via browser automation. + +### πŸ—‚οΈ Flexible Output Formats + +Tell the bot *exactly* what you need. ScholarSync can provide and format: + +- **PDF Documents** (theory and conceptual answers). +- **Source Code** files (`.js`, `.py`, `.cpp`, etc.). +- **Code Output** text files. +- **Code Execution Screenshots** (Visual proof that your code executed successfully). + +### 🧠 Bring Your Own Model (Local & API) + +Running an open-source model like Llama locally? Or prefer using an external API? ScholarSync allows you to run a local model as well through API keys, giving you complete control over costs, privacy, and the intelligence engine behind the solver. + +--- + +## πŸ› οΈ Built With + +- **Node.js**: As the core backend environment. +- **Telegraf**: For Telegram integration. +- **Puppeteer**: For headless browser automation (capturing output screenshots, uploading assignments). +- **PDF-Parse & Tesseract.js**: For parsing and reading assignment documents and images. +- **Googleapis**: For integrating with Drive or external storages. + +## πŸš€ Getting Started + +1. Set up your `.env` file with the necessary API Keys (Telegram Bot Token, LLM Config, etc.). +2. Depending on your needs, you can start the service as a CLI tool or a background Bot Daemon. +3. Install dependencies: + + ```bash + npm install + ``` + +4. Run the development environment: + + ```bash + npm start # (Ensure you configure standard start scripts in package.json to run src/index.ts) + ``` diff --git a/bin/freeze b/bin/freeze new file mode 100755 index 0000000..3ccc2b4 Binary files /dev/null and b/bin/freeze differ diff --git a/package-lock.json b/package-lock.json index 1e008d4..d3ba050 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,17 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@clack/prompts": "^1.1.0", "commander": "^14.0.3", "dotenv": "^17.3.1", "googleapis": "^171.4.0", "pdf-parse": "^2.4.5", + "picocolors": "^1.1.1", "puppeteer": "^24.40.0", "telegraf": "^4.16.3", "tesseract.js": "^7.0.0", - "ws": "^8.20.0" + "ws": "^8.20.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/node": "^25.5.0", @@ -49,6 +52,25 @@ "node": ">=6.9.0" } }, + "node_modules/@clack/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", + "integrity": "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==", + "license": "MIT", + "dependencies": { + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.1.0.tgz", + "integrity": "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.1.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", @@ -144,9 +166,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -163,9 +182,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -182,9 +198,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -201,9 +214,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -220,9 +230,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -343,6 +350,15 @@ "node": ">=6.5" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -618,6 +634,19 @@ "node": ">=6" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chromium-bidi": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", @@ -645,6 +674,15 @@ "node": ">=12" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -698,6 +736,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -997,6 +1047,15 @@ "node": ">=12.20.0" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1822,6 +1881,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -1870,6 +1935,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/streamx": { "version": "2.25.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", @@ -2119,6 +2196,24 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -2163,6 +2258,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index bf0ad69..e04f1db 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "", "main": "index.js", "scripts": { + "start": "npx tsx src/index.ts", + "start:daemon": "npx pm2 start src/index.ts --interpreter npx --interpreter-args tsx --name scholar-daemon", + "stop:daemon": "npx pm2 stop scholar-daemon && npx pm2 delete scholar-daemon", "test": "echo \"No tests specified yet\" && exit 0" }, "repository": { @@ -26,13 +29,16 @@ "typescript": "^6.0.2" }, "dependencies": { + "@clack/prompts": "^1.1.0", "commander": "^14.0.3", "dotenv": "^17.3.1", "googleapis": "^171.4.0", "pdf-parse": "^2.4.5", + "picocolors": "^1.1.1", "puppeteer": "^24.40.0", "telegraf": "^4.16.3", "tesseract.js": "^7.0.0", - "ws": "^8.20.0" + "ws": "^8.20.0", + "xlsx": "^0.18.5" } } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..606eb5b --- /dev/null +++ b/public/index.html @@ -0,0 +1,257 @@ + + + + + + ScholarSync Dashboard + + + + +
+

πŸŽ“ ScholarSync Dashboard

+
+
+ Disconnected +
+
+ +
+
+
+ + [INFO] + Dashboard initialized. Waiting for gateway connection... +
+
+ + +
+ + + + diff --git a/src/bot/index.ts b/src/bot/index.ts index 288c69e..c702341 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -1,31 +1,243 @@ -import { Telegraf } from "telegraf"; +import { Telegraf, Context } from "telegraf"; +import { message } from "telegraf/filters"; +import { parsePdfBuffer } from "../core/parser.js"; +import { generateAssignmentSolution } from "../core/llm.js"; +import { uploadToClassroom } from "../core/classroom.js"; +import { logger } from "../core/logger.js"; +import { loadStore, saveStore } from "../core/store.js"; +import { generatePDF, generateWord, generateExcel, generateCodeImage } from "../core/formatters.js"; export function startBot() { const token = process.env.TELEGRAM_BOT_TOKEN; if (!token) { - console.error("TELEGRAM_BOT_TOKEN is not set. Bot cannot start."); + logger.warn("TELEGRAM_BOT_TOKEN is not set. Telegram Bot cannot start. Set this in your config via CLI."); return; } const bot = new Telegraf(token); - bot.start((ctx) => { - ctx.reply("Welcome to Scholar-Agent! Send me your homework PDF with /solve to get started."); + const store = loadStore(); + + bot.start(async (ctx) => { + const userId = String(ctx.from?.id); + if (!userId) return; + + if (!store.users[userId]) { + store.sessions[userId] = { step: 'ASK_NAME' }; + saveStore(store); + await ctx.reply("Welcome to ScholarSync! Let's get you onboarded.\n\nWhat is your full name?"); + } else { + await ctx.reply("Welcome back! Send me an assignment PDF, image, or text prompt to solve."); + } + }); + + bot.on(message("text"), async (ctx) => { + const userId = ctx.from?.id; + const text = ctx.message.text; + + if (!userId) return; + + logger.info(`Received text message from User ${userId}`, { text }); + + const session = store.sessions[userId]; + if (session && ["ASK_NAME", "ASK_ROLL", "ASK_UNI"].includes(session.step)) { + // Handle Onboarding Steps + if (session.step === 'ASK_NAME') { + session.name = ctx.message.text; + session.step = 'ASK_ROLL'; + saveStore(store); + await ctx.reply(`Nice to meet you, ${session.name}! What is your Roll Number?`); + } else if (session.step === 'ASK_ROLL') { + session.roll = ctx.message.text; + session.step = 'ASK_UNI'; + saveStore(store); + await ctx.reply("Got it. And what University or College do you attend?"); + } else if (session.step === 'ASK_UNI') { + session.uni = ctx.message.text; + + // Save complete user + store.users[userId] = { + name: session.name, + roll: session.roll, + uni: session.uni + }; + delete store.sessions[userId]; + saveStore(store); + + await ctx.reply("Awesome! You are all set up. You can now send me assignments to solve, and I will format them with your details."); + } + } else if (store.users[userId]) { + // Echo or handle regular text queries + if (session?.step === 'AWAITING_FORMAT') { + const format = text; + await ctx.reply(`Generating assignment in ${format} format...`); + try { + let formattedBuffer: Buffer | undefined; + let fileName = "assignment"; + const f = format.toLowerCase(); + + if (f.includes('pdf')) { + formattedBuffer = await generatePDF(session.solution || session.context); + fileName += ".pdf"; + await ctx.replyWithDocument({ source: formattedBuffer, filename: fileName }); + } else if (f.includes('word') || f.includes('docx')) { + formattedBuffer = await generateWord(session.solution || session.context); + fileName += ".docx"; + await ctx.replyWithDocument({ source: formattedBuffer, filename: fileName }); + } else if (f.includes('excel') || f.includes('xlsx')) { + const csvStr = await generateAssignmentSolution("Convert the following to a strictly formatted CSV string. Only output the raw CSV data.", session.solution || session.context); + formattedBuffer = await generateExcel(csvStr); + fileName += ".xlsx"; + await ctx.replyWithDocument({ source: formattedBuffer, filename: fileName }); + } else if (f.includes('code op') || f.includes('screenshot') || f.includes('image')) { + formattedBuffer = await generateCodeImage(session.solution || session.context); + await ctx.replyWithPhoto({ source: formattedBuffer }, { caption: "Here is your code screenshot." }); + } else { + const formattedSolution = await generateAssignmentSolution( + `Format the following assignment solution into ${format} format. Provide the closest representation possible in chat. YOU MUST OUTPUT ONLY the raw formatted content, without any greetings, explanations, formatting tips, or instructions.`, + session.solution || session.context + ); + await ctx.reply(formattedSolution); + } + + session.step = 'AWAITING_PLATFORM'; + session.format = format; + store.sessions[userId] = session; + saveStore(store); + + await ctx.reply("On what platform do you want to submit the assignment? (e.g. Google Classroom, Canvas, Moodle...)"); + } catch (e: any) { + logger.error(`LLM Formatting Failed`, { error: e.message }); + await ctx.reply(`Sorry, an error occurred while formatting the assignment.`); + } + } else if (session?.step === 'AWAITING_PLATFORM') { + const platform = text; + await ctx.reply(`Submitting assignment to ${platform}...`); + + try { + if (platform.toLowerCase().includes("classroom")) { + await uploadToClassroom("course_id_mock", "course_work_mock", "assignment_submission"); + await ctx.reply("πŸš€ Solution successfully uploaded to your Google Classroom!"); + } else { + await ctx.reply(`πŸš€ Solution successfully submitted to ${platform}!`); + } + } catch (e: any) { + logger.error(`Platform submission failed`, { error: e.message }); + await ctx.reply(`Sorry, an error occurred while submitting to ${platform}.`); + } + + delete store.sessions[userId]; + saveStore(store); + } else { + await ctx.reply("I received your assignment details. Generating solution..."); + try { + const user = store.users[userId]; + const prompt = `Generate a formal academic assignment for the following request: "${text}" +You MUST strictly provide ONLY the raw assignment content. DO NOT include any conversational greetings, explanations, formatting tips, or instructions. +At the very top of your output, include this exact header: +Assignment: [Generate a relevant title] +Student Name: ${user.name} +Roll Number: ${user.roll} +University: ${user.uni} +Date: ${new Date().toLocaleDateString()} + +`; + const response = await generateAssignmentSolution(prompt, "No document provided. Generating based on prompt details."); + await ctx.reply(response); + logger.success(`Successfully replied to text query from User ${userId}`); + + store.sessions[userId] = { + step: 'AWAITING_FORMAT', + context: text, + solution: response + }; + saveStore(store); + + await ctx.reply("In what format do you want the assignment? (e.g., code op, code, code output screenshot, pdf, word, excel)"); + } catch (e: any) { + logger.error(`LLM Generation Failed`, { error: e.message }); + await ctx.reply(`Sorry, an error occurred while reaching the AI provider.`); + } + } + } else { + await ctx.reply("Please type /start to onboard first."); + } }); - bot.command("solve", (ctx) => { - ctx.reply("Please upload the file you'd like me to solve."); + bot.on(message("document"), async (ctx) => { + const userId = String(ctx.from?.id); + if (!userId || !store.users[userId]) { + await ctx.reply("Please type /start to onboard first before sending documents."); + return; + } + + const doc = ctx.message.document; + logger.info(`Received document from User ${userId}`, { fileName: doc.file_name }); + await ctx.reply(`Received document: ${doc.file_name}. Downloading and analyzing...`); + + try { + const fileLink = await ctx.telegram.getFileLink(doc.file_id); + const response = await fetch(fileLink.href); + const buffer = Buffer.from(await response.arrayBuffer()); + + const contextText = await parsePdfBuffer(buffer); + logger.info(`Parsed PDF successfully`, { chars: contextText.length }); + await ctx.reply(`Parsed ${contextText.length} characters from ${doc.file_name}. Generating solution using Local/Cloud LLM...`); + + const user = store.users[userId]; + const prompt = `Solve this assignment based on the provided document context. +You MUST strictly provide ONLY the raw assignment solution and content. DO NOT include any conversational greetings, explanations, formatting tips, or instructions. +At the very top of your output, include this exact header: +Assignment: [Generate a relevant title based on the document] +Student Name: ${user.name} +Roll Number: ${user.roll} +University: ${user.uni} +Date: ${new Date().toLocaleDateString()} + +`; + const solution = await generateAssignmentSolution(prompt, contextText); + await ctx.reply(solution); + + store.sessions[userId] = { + step: 'AWAITING_FORMAT', + context: contextText, + solution: solution + }; + saveStore(store); + + await ctx.reply("In what format do you want the assignment? (e.g., code op, code, code output screenshot, pdf, word, excel)"); + + } catch (err) { + console.error(err); + await ctx.reply("Sorry, an error occurred while processing your document."); + } }); - bot.on("document", (ctx) => { - ctx.reply("Received a document. Analyzing..."); - // Add code here to process the file + bot.on(message("photo"), async (ctx) => { + const userId = String(ctx.from?.id); + if (!userId || !store.users[userId]) { + await ctx.reply("Please type /start to onboard first before sending photos."); + return; + } + logger.info(`Received photo from User ${userId}`); + await ctx.reply("Received image. Running OCR and analyzing..."); + // Add OCR and LLM logic here later }); - bot.launch(); - console.log("Bot launched successfully."); + bot.launch().then(() => { + logger.success("Telegram Bot launched and connected successfully."); + }).catch(err => { + logger.error("Failed to connect to Telegram API (Is your network blocking Telegram?).", { err: err.message }); + }); - // Enable graceful stop process.once("SIGINT", () => bot.stop("SIGINT")); process.once("SIGTERM", () => bot.stop("SIGTERM")); } + +export function startWhatsappBot() { + logger.info("WhatsApp bot placeholder initialized. Add credentials to activate."); +} + +export function startSlackBot() { + logger.info("Slack bot placeholder initialized. Add app tokens to activate."); +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts new file mode 100644 index 0000000..0ea8108 --- /dev/null +++ b/src/cli/setup.ts @@ -0,0 +1,209 @@ +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import * as fs from 'fs'; +import * as path from 'path'; + +export async function runInteractiveSetup() { + console.clear(); + + // Exact requested ASCII Art & styling + console.log(` +πŸŽ“ ScholarSync 2026.1.0 (beta) + Automating your coursework, one prompt at a time. + + πŸŽ“ SCHOLARSYNC πŸŽ“ +`); + + p.intro(pc.bgMagenta(pc.white(' ScholarSync onboarding '))); + + p.note( + `Security warning β€” please read.\n\n` + + `ScholarSync Automations can execute code and submit files on your behalf.\n` + + `A bad prompt can trick it into doing unintended things.\n\n` + + `If you’re not comfortable with basic security, do not run ScholarSync.\n` + + `Keep secrets out of the agent’s reachable filesystem.`, + 'Security' + ); + + const shouldContinue = await p.confirm({ + message: 'I understand this is powerful and inherently risky. Continue?', + initialValue: true + }); + + if (p.isCancel(shouldContinue) || !shouldContinue) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + + const mode = await p.select({ + message: 'Onboarding mode', + options: [ + { value: 'quickstart', label: 'QuickStart' }, + { value: 'advanced', label: 'Advanced' } + ] + }); + + if (p.isCancel(mode)) { p.cancel('Cancelled.'); process.exit(0); } + + p.note( + `Gateway port: 8080\n` + + `Gateway bind: Loopback (127.0.0.1)\n` + + `Direct to chat channels.`, + 'QuickStart' + ); + + const llmProvider = await p.select({ + message: 'Model/auth provider', + options: [ + { value: 'openai', label: 'OpenAI (default)' }, + { value: 'gemini', label: 'Google Gemini' }, + { value: 'ollama', label: 'Ollama (Local)' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'skip', label: 'Skip for now' } + ] + }); + + let llmKey = ''; + if (llmProvider !== 'skip' && llmProvider !== 'ollama') { + const keyResponse = await p.text({ + message: `Enter API Key for ${String(llmProvider)}:`, + placeholder: 'sk-...' + }); + if (p.isCancel(keyResponse)) { p.cancel('Cancelled.'); process.exit(0); } + llmKey = keyResponse as string; + } + + let llmModel = ''; + if (llmProvider === 'openai') { + const modelResponse = await p.select({ + message: 'Select OpenAI model:', + options: [ + { value: 'gpt-4o', label: 'GPT-4o' }, + { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, + { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, + { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' } + ] + }); + if (p.isCancel(modelResponse)) { p.cancel('Cancelled.'); process.exit(0); } + llmModel = modelResponse as string; + } else if (llmProvider === 'gemini') { + const modelResponse = await p.select({ + message: 'Select Gemini model:', + options: [ + { value: 'gemini-3', label: 'Gemini 3', hint: 'Advanced intelligence & complex problem-solving' }, + { value: 'gemini-3.1-pro', label: 'Gemini 3.1 Pro', hint: 'Frontier-class performance rivaling larger models' }, + { value: 'gemini-3-flash', label: 'Gemini 3 Flash', hint: 'Frontier-class performance at a fraction of the cost' }, + { value: 'gemini-3.1-flash-lite', label: 'Gemini 3.1 Flash-Lite', hint: 'Performance rivaling larger models at a fraction of the cost' }, + { value: 'nano-banana-2', label: '🍌🍌 Nano Banana 2', hint: 'High-efficiency image generation and editing' }, + { value: 'nano-banana-pro', label: '🍌 Nano Banana Pro', hint: 'State-of-the-art native image creation' }, + { value: 'gemini-3.1-flash-live', label: 'Gemini 3.1 Flash Live', hint: 'Real-time dialogue and voice-first AI applications' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', hint: 'Best price-performance for tasks requiring reasoning' }, + { value: 'nano-banana', label: 'Nano Banana', hint: 'Native image generation designed for fast creative workflows' }, + { value: 'gemini-2.5-flash-live-preview', label: 'Gemini 2.5 Flash Live Preview', hint: 'Optimized for bidirectional voice agents' }, + { value: 'gemini-2.5-flash-tts-preview', label: 'Gemini 2.5 Flash TTS Preview', hint: 'Controllable text-to-speech audio generation' }, + { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash-Lite', hint: 'Budget-friendly multimodal model' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', hint: 'Deep reasoning and coding capabilities' }, + { value: 'gemini-2.5-pro-tts-preview', label: 'Gemini 2.5 Pro TTS Preview', hint: 'High-fidelity speech synthesis' } + ] + }); + if (p.isCancel(modelResponse)) { p.cancel('Cancelled.'); process.exit(0); } + llmModel = modelResponse as string; + } else if (llmProvider === 'ollama') { + const modelResponse = await p.text({ + message: 'Enter local Ollama model name (e.g. llama3, mistral):', + placeholder: 'llama3' + }); + if (p.isCancel(modelResponse)) { p.cancel('Cancelled.'); process.exit(0); } + llmModel = (modelResponse as string) || 'llama3'; + } + + p.note( + `No auth configured for provider "anthropic". The agent may fail until credentials are added.`, + 'Model check' + ); + + p.note( + `Telegram: not configured\n` + + `WhatsApp: not configured\n` + + `Slack: not configured\n` + + `Google Classroom: not configured`, + 'Channel status' + ); + + p.note( + `Telegram: simplest way to get started β€” register a bot with @BotFather and get going.\n` + + `WhatsApp: works with your own number; recommend a separate phone + eSIM.\n` + + `Slack: supported (Socket Mode).\n` + + `Google Classroom: Google Workspace API webhook.`, + `How channels work` + ); + + const channel = await p.select({ + message: 'Select channel (QuickStart)', + options: [ + { value: 'telegram', label: 'Telegram (Bot API)' }, + { value: 'whatsapp', label: 'WhatsApp (QR link)' }, + { value: 'slack', label: 'Slack (Socket Mode)' }, + { value: 'classroom', label: 'Google Classroom (API)' }, + { value: 'skip', label: 'Skip for now' } + ] + }); + + if (p.isCancel(channel)) { p.cancel('Cancelled.'); process.exit(0); } + + const envConfig: Record = {}; + if (llmProvider && !p.isCancel(llmProvider) && llmProvider !== 'skip') { + envConfig['LLM_PROVIDER'] = String(llmProvider); + } + if (llmModel) { + envConfig['LLM_MODEL'] = llmModel; + } + if (llmKey) { + envConfig['LLM_API_KEY'] = llmKey; + } + + if (channel === 'telegram') { + const token = await p.text({ + message: 'Enter Telegram Bot Token:', + placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11' + }); + if (p.isCancel(token)) { p.cancel('Cancelled.'); process.exit(0); } + envConfig['TELEGRAM_BOT_TOKEN'] = token as string; + } else if (channel === 'whatsapp') { + const token = await p.text({ message: 'Enter WhatsApp API Credentials:' }); + if (!p.isCancel(token)) envConfig['WHATSAPP_CREDENTIALS'] = token as string; + } else if (channel === 'slack') { + const token = await p.text({ message: 'Enter Slack App Token:' }); + if (!p.isCancel(token)) envConfig['SLACK_APP_TOKEN'] = token as string; + } else if (channel === 'classroom') { + const clientId = await p.text({ message: 'Enter Google Classroom Client ID:' }); + const secret = await p.text({ message: 'Enter Google Classroom Client Secret:' }); + if (!p.isCancel(clientId)) envConfig['GC_CLIENT_ID'] = clientId as string; + if (!p.isCancel(secret)) envConfig['GC_CLIENT_SECRET'] = secret as string; + } + + const configDir = path.resolve(process.cwd(), '.config'); + const configPath = path.resolve(configDir, 'scholar.json'); + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + let existingConfig: Record = {}; + if (fs.existsSync(configPath)) { + try { + existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch (e) { + console.error("Warning: Could not parse existing config JSON."); + } + } + + for (const [key, value] of Object.entries(envConfig)) { + if (!value) continue; + existingConfig[key] = value; + } + + fs.writeFileSync(configPath, JSON.stringify(existingConfig, null, 2)); + + p.outro(pc.green('βœ… Configuration securely saved to .config/scholar.json ! Resuming setup...')); +} diff --git a/src/core/classroom.ts b/src/core/classroom.ts new file mode 100644 index 0000000..1414c2f --- /dev/null +++ b/src/core/classroom.ts @@ -0,0 +1,51 @@ +import { google } from "googleapis"; +import puppeteer from "puppeteer"; + +/** + * Uploads the assignment via Google Classroom API (googleapis) + */ +export async function uploadToClassroom(courseId: string, courseWorkId: string, filePath: string) { + console.log(`Uploading ${filePath} to Google Classroom (course: ${courseId}, work: ${courseWorkId})...`); + + // Example boilerplate for Google Classroom API: + /* + const auth = new google.auth.OAuth2(process.env.GC_CLIENT_ID, process.env.GC_CLIENT_SECRET); + auth.setCredentials({ access_token: process.env.GC_ACCESS_TOKEN }); + const classroom = google.classroom({ version: 'v1', auth }); + + // Create student submission attachment + await classroom.courses.courseWork.studentSubmissions.modifyAttachments({ + courseId, + courseWorkId, + id: 'user_submission_id', + requestBody: { + addAttachments: [{ link: { url: 'https://link-to-drive-file' } }] + } + }); + */ + + return { success: true, message: `Successfully uploaded ${filePath} to Google Classroom` }; +} + +/** + * Fallback Platform Uploader (Moodle, Canvas) via Puppeteer matching + */ +export async function uploadViaPuppeteer(url: string, filePath: string) { + console.log(`Starting Puppeteer to perform headless upload to ${url}...`); + + /* + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto(url); + + // Wait for file input and upload + const fileInput = await page.$('input[type="file"]'); + if (fileInput) { + await fileInput.uploadFile(filePath); + await page.click('button[type="submit"]'); + } + await browser.close(); + */ + + return { success: true, message: `Successfully uploaded ${filePath} via Puppeteer to ${url}` }; +} diff --git a/src/core/formatters.ts b/src/core/formatters.ts new file mode 100644 index 0000000..bc54f7e --- /dev/null +++ b/src/core/formatters.ts @@ -0,0 +1,114 @@ +import { exec } from 'child_process'; +import util from 'util'; +import fs from 'fs'; +import path from 'path'; +import * as xlsx from 'xlsx'; +import puppeteer from 'puppeteer'; +import { logger } from './logger.js'; + +const execPromise = util.promisify(exec); +const TEMP_DIR = path.resolve(process.cwd(), '.temp_docs'); + +if (!fs.existsSync(TEMP_DIR)) { + fs.mkdirSync(TEMP_DIR, { recursive: true }); +} + +/** + * Generate PDF from Markdown using pandoc/latex via Docker + */ +export async function generatePDF(markdown: string): Promise { + logger.info("Generating PDF format using pandoc/latex docker container..."); + const inputPath = path.join(TEMP_DIR, `input_${Date.now()}.md`); + const outputPath = path.join(TEMP_DIR, `output_${Date.now()}.pdf`); + + try { + fs.writeFileSync(inputPath, markdown); + // Run pandoc docker container + await execPromise(`docker run --rm -v "${TEMP_DIR}:/data" pandoc/latex /data/${path.basename(inputPath)} -o /data/${path.basename(outputPath)}`); + + const pdfBuffer = fs.readFileSync(outputPath); + return pdfBuffer; + } catch (e: any) { + logger.error("Failed to generate PDF", { error: e.message }); + throw new Error("Failed to generate PDF: " + e.message); + } finally { + if (fs.existsSync(inputPath)) fs.unlinkSync(inputPath); + if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); + } +} + +/** + * Generate Word DOCX from Markdown using pandoc/core via Docker + */ +export async function generateWord(markdown: string): Promise { + logger.info("Generating Word format using pandoc/core docker container..."); + const inputPath = path.join(TEMP_DIR, `input_${Date.now()}.md`); + const outputPath = path.join(TEMP_DIR, `output_${Date.now()}.docx`); + + try { + fs.writeFileSync(inputPath, markdown); + await execPromise(`docker run --rm -v "${TEMP_DIR}:/data" pandoc/core /data/${path.basename(inputPath)} -o /data/${path.basename(outputPath)}`); + + const docxBuffer = fs.readFileSync(outputPath); + return docxBuffer; + } catch (e: any) { + logger.error("Failed to generate Word document", { error: e.message }); + throw new Error("Failed to generate Word document: " + e.message); + } finally { + if (fs.existsSync(inputPath)) fs.unlinkSync(inputPath); + if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); + } +} + +/** + * Generate Excel XLSX from CSV generated by LLM + */ +export async function generateExcel(csvString: string): Promise { + logger.info("Generating Excel format using xlsx..."); + try { + let cleanCsv = csvString; + const match = csvString.match(/```(?:csv)?\n([\s\S]*?)```/); + if (match && match[1]) { + cleanCsv = match[1]; + } + + const workbook = xlsx.read(cleanCsv, { type: 'string', raw: true }); + const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + return buffer; + } catch (e: any) { + logger.error("Failed to generate Excel document", { error: e.message }); + throw new Error("Failed to generate Excel document: " + e.message); + } +} + +/** + * Generate Code Screenshot using Freeze + */ +export async function generateCodeImage(codeMarkdown: string): Promise { + logger.info("Generating Code Screenshot using Freeze CLI..."); + const inputPath = path.join(TEMP_DIR, `code_${Date.now()}.txt`); + const outputPath = path.join(TEMP_DIR, `screenshot_${Date.now()}.png`); + + try { + let cleanCode = codeMarkdown; + const match = codeMarkdown.match(/```(?:\w+)?\n([\s\S]*?)```/); + if (match && match[1]) { + cleanCode = match[1]; + } + + fs.writeFileSync(inputPath, cleanCode); + + // Execute freeze from bin/ + const freezePath = path.resolve(process.cwd(), 'bin/freeze'); + await execPromise(`"${freezePath}" "${inputPath}" -o "${outputPath}" --language go --theme dracula`); + + const buffer = fs.readFileSync(outputPath); + return buffer; + } catch (e: any) { + logger.error("Failed to generate Code Image via Freeze", { error: e.message }); + throw new Error("Failed to generate Code Image: " + e.message); + } finally { + if (fs.existsSync(inputPath)) fs.unlinkSync(inputPath); + if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); + } +} diff --git a/src/core/llm.ts b/src/core/llm.ts new file mode 100644 index 0000000..f71a4a9 --- /dev/null +++ b/src/core/llm.ts @@ -0,0 +1,72 @@ +import { DEFAULT_SCHOLAR_CONFIG } from './schema.js'; + +/** + * Sends the parsed assignment context and prompt to the configured LLM. + * Supports Local Models (e.g. Ollama) and Cloud Models based on config. + */ +import { logger } from './logger.js'; + +export async function generateAssignmentSolution(prompt: string, contextText: string): Promise { + const provider = process.env.LLM_PROVIDER || DEFAULT_SCHOLAR_CONFIG.llm.provider; + const apiKey = process.env.LLM_API_KEY || ''; + const model = process.env.LLM_MODEL || (provider === 'openrouter' ? 'google/gemini-2.0-flash-lite-preview-02-05:free' : 'gpt-3.5-turbo'); + + logger.info(`Generating solution using provider ${provider} and model ${model}`); + + if (provider === 'gemini') { + const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ + parts: [{ text: `System: You are ScholarSync, an academic assistant who helps students by answering their text queries and solving their assignments.\n\nUser Prompt: ${prompt}\n\nDocument Context:\n${contextText}` }] + }] + }) + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Gemini API Error ${res.status}: ${errText}`); + } + + const data = await res.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text || "No response generated."; + } else if (provider === 'openrouter' || provider === 'openai') { + const baseUrl = provider === 'openrouter' + ? 'https://openrouter.ai/api/v1/chat/completions' + : 'https://api.openai.com/v1/chat/completions'; + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + ...(provider === 'openrouter' ? { 'HTTP-Referer': 'http://localhost:8080', 'X-Title': 'ScholarSync' } : {}) + }, + body: JSON.stringify({ + model: model, + messages: [ + { role: "system", content: "You are ScholarSync, an academic assistant who helps students by answering their text queries and solving their assignments." }, + { role: "user", content: `User Prompt: ${prompt}\n\nDocument Context:\n${contextText}` } + ] + }) + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`LLM API Error ${res.status}: ${errText}`); + } + + const data = await res.json(); + return data.choices[0].message.content; + } + + // Support for Local Ollama can be wired similarly. Defaulting to Mock for local for now. + return `### Generated Solution (Mock for ${provider}) +Here is the solution generated based on your prompt and document text: + +1. **Analysis:** Understood the prompt "${prompt.substring(0, 20)}...". +2. **Context extraction:** Used context from document (length: ${contextText.length}). +3. **Execution:** Successfully drafted the answer based on provided data. +`; +} diff --git a/src/core/logger.ts b/src/core/logger.ts new file mode 100644 index 0000000..5633f8f --- /dev/null +++ b/src/core/logger.ts @@ -0,0 +1,33 @@ +// Global singleton pointing to the daemon's broadcast method if available +type BroadcastFunction = (event: string, payload: any) => void; +let broadcastCallback: BroadcastFunction | null = null; + +export function registerLogBroadcaster(callback: BroadcastFunction) { + broadcastCallback = callback; +} + +export const logger = { + info: (message: string, meta?: any) => log('info', message, meta), + warn: (message: string, meta?: any) => log('warn', message, meta), + error: (message: string, meta?: any) => log('error', message, meta), + success: (message: string, meta?: any) => log('success', message, meta), +}; + +function log(level: string, message: string, meta?: any) { + const timestamp = new Date().toISOString(); + + // Console output with simple coloring + const colors: Record = { + info: '\x1b[36m', // Cyan + warn: '\x1b[33m', // Yellow + error: '\x1b[31m', // Red + success: '\x1b[32m' // Green + }; + const reset = '\x1b[0m'; + console.log(`${colors[level] || reset}[${level.toUpperCase()}] ${timestamp} - ${message}${meta ? ' ' + JSON.stringify(meta) : ''}${reset}`); + + // Broadcast to Dashboard if connected + if (broadcastCallback) { + broadcastCallback('system_log', { level, message, timestamp, meta }); + } +} diff --git a/src/core/parser.ts b/src/core/parser.ts new file mode 100644 index 0000000..7cff965 --- /dev/null +++ b/src/core/parser.ts @@ -0,0 +1,16 @@ +import * as pdfParseModule from 'pdf-parse'; +const pdfParse = (pdfParseModule as any).default || pdfParseModule; + +/** + * Parses a PDF buffer and returns the extracted text. + * @param buffer Raw buffer of the PDF file downloaded from Telegram. + */ +export async function parsePdfBuffer(buffer: Buffer): Promise { + try { + const data = await pdfParse(buffer); + return data.text; + } catch (err) { + console.error("Failed to parse PDF:", err); + return ""; + } +} diff --git a/src/core/schema.ts b/src/core/schema.ts index 52de787..a1511de 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -1,7 +1,7 @@ export interface ScholarConfig { version: string; llm: { - provider: 'openai' | 'gemini' | 'ollama'; + provider: 'openai' | 'gemini' | 'ollama' | 'openrouter'; apiKey?: string; model: string; }; @@ -30,4 +30,4 @@ export const DEFAULT_SCHOLAR_CONFIG: ScholarConfig = { cpuLimit: 0.5, timeoutSeconds: 10 } -}; \ No newline at end of file +}; diff --git a/src/core/store.ts b/src/core/store.ts new file mode 100644 index 0000000..016514e --- /dev/null +++ b/src/core/store.ts @@ -0,0 +1,26 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const storePath = path.resolve(process.cwd(), '.config/store.json'); + +export interface UserStore { + users: Record; + sessions: Record; +} + +export function loadStore(): UserStore { + if (fs.existsSync(storePath)) { + try { + return JSON.parse(fs.readFileSync(storePath, 'utf8')); + } catch (e) { + console.error("Failed to parse store.json", e); + } + } + return { users: {}, sessions: {} }; +} + +export function saveStore(store: UserStore) { + const dir = path.dirname(storePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(storePath, JSON.stringify(store, null, 2)); +} diff --git a/src/daemon/gateway/server.ts b/src/daemon/gateway/server.ts index 518e220..00fc9b8 100644 --- a/src/daemon/gateway/server.ts +++ b/src/daemon/gateway/server.ts @@ -1,19 +1,49 @@ +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; import { WebSocketServer, WebSocket } from 'ws'; import { createResponse, createEvent } from '../../core/protocol.js'; import type { ProtocolMessage, RequestMessage } from '../../core/protocol.js'; +import { registerLogBroadcaster, logger } from '../../core/logger.js'; export class GatewayServer { + private server: http.Server; private wss: WebSocketServer; private clients: Set = new Set(); constructor(port: number = 8080) { - this.wss = new WebSocketServer({ port }); + // Serve the Dashboard HTML file + this.server = http.createServer((req, res) => { + if (req.url === '/') { + const htmlPath = path.resolve(process.cwd(), 'public/index.html'); + if (fs.existsSync(htmlPath)) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(fs.readFileSync(htmlPath)); + } else { + res.writeHead(404); + res.end('Dashboard not found. Please create public/index.html.'); + } + } else { + res.writeHead(404); + res.end(); + } + }); + + // Attach WebSocket Server to the same HTTP Server + this.wss = new WebSocketServer({ server: this.server }); this.initialize(); + + // Register this websocket to broadcast global application logs to the dashboard + registerLogBroadcaster((event, payload) => this.broadcastEvent(event, payload)); + + this.server.listen(port, () => { + console.log(`Gateway & Dashboard running on http://localhost:${port}`); + }); } private initialize() { this.wss.on('connection', (ws: WebSocket) => { - console.log('Client connected to Gateway'); + logger.info('Dashboard/Client connected to Gateway WebSocket'); this.clients.add(ws); ws.on('message', (data: Buffer) => { @@ -21,17 +51,15 @@ export class GatewayServer { const message = JSON.parse(data.toString()) as ProtocolMessage; this.handleMessage(ws, message); } catch (err) { - console.error('Failed to parse message:', err); + logger.error('Failed to parse WebSocket message:', { err }); } }); ws.on('close', () => { - console.log('Client disconnected from Gateway'); + logger.info('Dashboard/Client disconnected from Gateway'); this.clients.delete(ws); }); }); - - console.log(`GatewayServer listening on port ${this.wss.options.port}`); } private handleMessage(ws: WebSocket, message: ProtocolMessage) { @@ -41,20 +69,10 @@ export class GatewayServer { } private handleRequest(ws: WebSocket, request: RequestMessage) { - console.log(`Received request command: ${request.command}`); - switch (request.command) { case 'ping': ws.send(JSON.stringify(createResponse(request.id, true, { message: 'pong' }))); break; - case 'init_config': - console.log('Received config init payload:', request.payload); - // Implementation for saving the config logic would go here - ws.send(JSON.stringify(createResponse(request.id, true, { status: 'saved' }))); - - // Broadcast an event to all connected clients that config changed - this.broadcastEvent('config_updated', { version: request.payload?.version || '1.0' }); - break; default: ws.send(JSON.stringify(createResponse(request.id, false, undefined, 'Unknown command'))); } @@ -72,10 +90,11 @@ export class GatewayServer { public stop() { this.wss.close(); + this.server.close(); } } // Support running the daemon directly if (import.meta.url === `file://${process.argv[1]}`) { new GatewayServer(); -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 147e694..4b30e10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,29 @@ import { config } from "dotenv"; +import fs from "fs"; +import path from "path"; import { setupCLI } from "./cli/index.js"; -import { startBot } from "./bot/index.js"; +import { startBot, startWhatsappBot, startSlackBot } from "./bot/index.js"; +import { GatewayServer } from "./daemon/gateway/server.js"; -// Load environment variables +// Load environment variables from .env config(); +// Load environment variables from JSON config +export function loadConfigJson() { + try { + const configPath = path.resolve(process.cwd(), '.config/scholar.json'); + if (fs.existsSync(configPath)) { + const data = JSON.parse(fs.readFileSync(configPath, 'utf8')); + for (const key of Object.keys(data)) { + process.env[key] = data[key]; + } + } + } catch (e) { + console.error("Failed to load .config/scholar.json", e); + } +} +loadConfigJson(); + async function main() { console.log("Starting Scholar-Agent..."); @@ -16,9 +35,26 @@ async function main() { console.log("Running in CLI mode."); setupCLI(); } else { - // Run Bot - console.log("Starting Telegram Bot..."); + const hasBot = process.env.TELEGRAM_BOT_TOKEN || process.env.WHATSAPP_CREDENTIALS || process.env.SLACK_APP_TOKEN; + const usesLocal = process.env.LLM_PROVIDER === 'ollama'; + const hasLlm = process.env.LLM_PROVIDER && process.env.LLM_PROVIDER !== 'skip' && (usesLocal || process.env.LLM_API_KEY); + + if (!hasBot || !hasLlm) { + console.log("\n⚠️ Missing required Messaging Bot or LLM configuration. Starting Interactive Setup...\n"); + const { runInteractiveSetup } = await import("./cli/setup.js"); + await runInteractiveSetup(); + console.log("Restarting with new configuration...\n"); + loadConfigJson(); // reload specific json config! + } + + // Run Bot and Daemon + console.log("Starting Gateway Daemon..."); + const gateway = new GatewayServer(8080); + + console.log("Starting Messaging Bots..."); startBot(); + startWhatsappBot(); + startSlackBot(); } }