diff --git a/.clinerules b/.clinerules new file mode 100644 index 0000000..da8e3fb --- /dev/null +++ b/.clinerules @@ -0,0 +1,83 @@ +# === ROLE === +You are an Open Source Mentor Bot helping users work with an AOSSIE project template. + +# === PRIMARY BEHAVIOR === +- Always ask at least 1 clarifying question before answering (unless trivial) +- Guide users step-by-step instead of dumping full answers +- Help fill TODO sections in project templates + +# === CONTEXT AWARENESS === +You are working with a GitHub template repo that includes: +- README with TODO sections (project name, description, user flow) +- Setup instructions (install, run, env config) +- Contribution guidelines + +# === QUESTION STRATEGY === +When user asks something: +1. Ask what exactly they want to build +2. Ask their tech stack (Node / Python / Flutter etc.) +3. Ask their goal (GSoC / learning / hackathon / production) + +Examples: +- "What are you building using this template?" +- "Which tech stack are you planning to use?" +- "Is this for GSoC or a personal project?" + +# === TASK-SPECIFIC BEHAVIOR === + +## If user says "setup" +- Ask: + - "Which language/framework are you using?" +- Then guide: + - Clone repo + - Install dependencies + - Setup .env + - Run dev server + +## If user says "README" +- Ask: + - "What is your project idea?" +- Then help fill: + - Project name + - Description + - User flow + - Features + +## If user says "contribute" +- Ask: + - "Are you contributing or creating your own project?" +- Then guide: + - Fork repo + - Create branch + - Follow CONTRIBUTING.md + +## If user says "error" +- Ask: + - "What error are you getting?" + - "Share logs/code snippet" +- Then debug step-by-step + +# === RESPONSE STYLE === +- Keep answers short (max 6 lines) +- Prefer bullet points +- Ask → then guide → then suggest next step + +# === EXAMPLES === + +User: "help me setup" +Bot: +- "Which tech stack are you using?" +- Then guide setup steps + +User: "write README" +Bot: +- "What is your project idea?" +- Then generate structured README + +# === RESTRICTIONS === +- Do not assume missing info +- Always ask before generating full solutions +- Avoid long explanations unless asked + +# === GOAL === +Act like a mentor helping users complete an AOSSIE template repo step-by-step. \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..598b488 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DISCORD_TOKEN=your_discord_bot_token_here +DISCORD_CHANNEL_ID=your_channel_id_here +OLLAMA_MODEL=llama3.2 +SKILL_FILE_PATH=.clinerules \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9308a4b..8b4e782 100644 --- a/.gitignore +++ b/.gitignore @@ -324,3 +324,6 @@ TSWLatexianTemp* # option is specified. Footnotes are the stored in a file with suffix Notes.bib. # Uncomment the next line to have this generated file ignored. #*Notes.bib + +venv/ +.env \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..e576997 --- /dev/null +++ b/bot.py @@ -0,0 +1,163 @@ +import os +import logging +import discord +import httpx +import asyncio +from dotenv import load_dotenv + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('aossie-bot') + +# Load environment variables +load_dotenv() + +DISCORD_TOKEN = os.getenv('DISCORD_TOKEN') +DISCORD_CHANNEL_ID = os.getenv('DISCORD_CHANNEL_ID') +DISCORD_CHANNEL_ID_INT = None +OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'llama3.2') +SKILL_FILE_PATH = os.getenv('SKILL_FILE_PATH', '.clinerules') +OLLAMA_URL = "http://localhost:11434/api/generate" + +# Initialize bot with intents +intents = discord.Intents.default() +intents.message_content = True +client = discord.Client(intents=intents) + +# Lock to prevent Ollama requests from clashing +ollama_lock = asyncio.Lock() + +def load_skill_context() -> str: + """Load context from the local skill file.""" + try: + if os.path.exists(SKILL_FILE_PATH): + with open(SKILL_FILE_PATH, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + logger.error(f"Error loading skill file {SKILL_FILE_PATH}: {e}") + return "" + +async def generate_ollama_response(prompt: str, context: str) -> str: + """Send prompt to local Ollama instance and return the response.""" + if context: + system_prompt = f"You are a helpful contributor assistant for AOSSIE.\n\nContext guidelines:\n{context}" + else: + system_prompt = "You are a helpful contributor assistant for AOSSIE." + + payload = { + "model": OLLAMA_MODEL, + "prompt": prompt, + "system": system_prompt, + "stream": False + } + + try: + async with httpx.AsyncClient(timeout=120.0) as http_client: + response = await http_client.post(OLLAMA_URL, json=payload) + response.raise_for_status() + data = response.json() + return data.get("response", "Error: No response text found in Ollama reply.") + except httpx.TimeoutException: + logger.error("Ollama request timed out.") + return "I'm sorry, the local AI model timed out while thinking. Please try again later." + except httpx.RequestError as e: + logger.error(f"Ollama request error: {e}") + return f"I'm sorry, I couldn't reach the local AI engine. Ensure Ollama is running at localhost:11434." + except Exception as e: + logger.error(f"Unexpected error during Ollama generation: {e}") + return "An unexpected error occurred while generating the response." + +async def process_message(message: discord.Message): + """Process a single message and generate a reply safely.""" + if message.author.bot or message.channel.id != DISCORD_CHANNEL_ID_INT: + return + + # Use lock to ensure only one message is processed by Ollama at a time + async with ollama_lock: + async with message.channel.typing(): + skill_context = load_skill_context() + response_text = await generate_ollama_response(message.content, skill_context) + + if len(response_text) > 1900: + response_text = response_text[:1896] + "..." + + await message.reply(response_text) + +async def wait_for_ollama(): + """Wait until Ollama is up and responding.""" + logger.info("Waiting for Ollama to be ready...") + while True: + try: + async with httpx.AsyncClient(timeout=5.0) as http_client: + response = await http_client.get("http://localhost:11434/") + if response.status_code == 200: + logger.info("Ollama is ready!") + return + except httpx.RequestError: + pass + logger.info("Ollama not reachable yet. Retrying in 10 seconds...") + await asyncio.sleep(10) + +@client.event +async def on_ready(): + logger.info(f"Logged in as {client.user.name} ({client.user.id})") + + # Wait for Ollama to be ready before processing the backlog + await wait_for_ollama() + + logger.info("Checking for missed messages...") + + try: + channel = await client.fetch_channel(DISCORD_CHANNEL_ID_INT) + + # Find the last message sent by the bot + last_bot_msg = None + async for msg in channel.history(limit=50): + if msg.author.id == client.user.id: + last_bot_msg = msg + break + + messages_to_process = [] + if last_bot_msg: + # Fetch messages after the bot's last message + async for msg in channel.history(after=last_bot_msg, oldest_first=True): + if not msg.author.bot: + messages_to_process.append(msg) + else: + # If no bot message found, just process the last 5 user messages + async for msg in channel.history(limit=5, oldest_first=True): + if not msg.author.bot: + messages_to_process.append(msg) + + logger.info(f"Found {len(messages_to_process)} missed messages. Processing...") + for msg in messages_to_process: + await process_message(msg) + + except Exception as e: + logger.error(f"Error fetching missed messages: {e}") + + logger.info("AOSSIE Contributor Assistant MVP is fully ready.") + +@client.event +async def on_message(message: discord.Message): + await process_message(message) + +if __name__ == "__main__": + if not DISCORD_TOKEN: + logger.critical("DISCORD_TOKEN is missing from environment. Exiting.") + exit(1) + + if not DISCORD_CHANNEL_ID: + logger.critical("DISCORD_CHANNEL_ID is missing from environment. Exiting.") + exit(1) + + try: + DISCORD_CHANNEL_ID_INT = int(DISCORD_CHANNEL_ID) + except ValueError: + logger.critical( + f"DISCORD_CHANNEL_ID '{DISCORD_CHANNEL_ID}' is not a valid integer. Exiting." + ) + exit(1) + + logger.info("Starting bot...") + client.run(DISCORD_TOKEN) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..66958f5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +discord.py==2.3.2 +httpx==0.27.0 +python-dotenv==1.2.2 +idna==3.15 \ No newline at end of file