1+ import os
2+ import logging
3+ import discord
4+ import httpx
5+ import asyncio
6+ from dotenv import load_dotenv
7+
8+ # Configure logging
9+ logging .basicConfig (level = logging .INFO )
10+ logger = logging .getLogger ('aossie-bot' )
11+
12+ # Load environment variables
13+ load_dotenv ()
14+
15+ DISCORD_TOKEN = os .getenv ('DISCORD_TOKEN' )
16+ DISCORD_CHANNEL_ID = os .getenv ('DISCORD_CHANNEL_ID' )
17+ DISCORD_CHANNEL_ID_INT = None
18+ OLLAMA_MODEL = os .getenv ('OLLAMA_MODEL' , 'llama3.2' )
19+ SKILL_FILE_PATH = os .getenv ('SKILL_FILE_PATH' , '.clinerules' )
20+ OLLAMA_URL = "http://localhost:11434/api/generate"
21+
22+ # Initialize bot with intents
23+ intents = discord .Intents .default ()
24+ intents .message_content = True
25+ client = discord .Client (intents = intents )
26+
27+ # Lock to prevent Ollama requests from clashing
28+ ollama_lock = asyncio .Lock ()
29+
30+ def load_skill_context () -> str :
31+ """Load context from the local skill file."""
32+ try :
33+ if os .path .exists (SKILL_FILE_PATH ):
34+ with open (SKILL_FILE_PATH , 'r' , encoding = 'utf-8' ) as f :
35+ return f .read ()
36+ except Exception as e :
37+ logger .error (f"Error loading skill file { SKILL_FILE_PATH } : { e } " )
38+ return ""
39+
40+ async def generate_ollama_response (prompt : str , context : str ) -> str :
41+ """Send prompt to local Ollama instance and return the response."""
42+ if context :
43+ system_prompt = f"You are a helpful contributor assistant for AOSSIE.\n \n Context guidelines:\n { context } "
44+ else :
45+ system_prompt = "You are a helpful contributor assistant for AOSSIE."
46+
47+ payload = {
48+ "model" : OLLAMA_MODEL ,
49+ "prompt" : prompt ,
50+ "system" : system_prompt ,
51+ "stream" : False
52+ }
53+
54+ try :
55+ async with httpx .AsyncClient (timeout = 120.0 ) as http_client :
56+ response = await http_client .post (OLLAMA_URL , json = payload )
57+ response .raise_for_status ()
58+ data = response .json ()
59+ return data .get ("response" , "Error: No response text found in Ollama reply." )
60+ except httpx .TimeoutException :
61+ logger .error ("Ollama request timed out." )
62+ return "I'm sorry, the local AI model timed out while thinking. Please try again later."
63+ except httpx .RequestError as e :
64+ logger .error (f"Ollama request error: { e } " )
65+ return f"I'm sorry, I couldn't reach the local AI engine. Ensure Ollama is running at localhost:11434."
66+ except Exception as e :
67+ logger .error (f"Unexpected error during Ollama generation: { e } " )
68+ return "An unexpected error occurred while generating the response."
69+
70+ async def process_message (message : discord .Message ):
71+ """Process a single message and generate a reply safely."""
72+ if message .author .bot or message .channel .id != DISCORD_CHANNEL_ID_INT :
73+ return
74+
75+ # Use lock to ensure only one message is processed by Ollama at a time
76+ async with ollama_lock :
77+ async with message .channel .typing ():
78+ skill_context = load_skill_context ()
79+ response_text = await generate_ollama_response (message .content , skill_context )
80+
81+ if len (response_text ) > 1900 :
82+ response_text = response_text [:1896 ] + "..."
83+
84+ await message .reply (response_text )
85+
86+ async def wait_for_ollama ():
87+ """Wait until Ollama is up and responding."""
88+ logger .info ("Waiting for Ollama to be ready..." )
89+ while True :
90+ try :
91+ async with httpx .AsyncClient (timeout = 5.0 ) as http_client :
92+ response = await http_client .get ("http://localhost:11434/" )
93+ if response .status_code == 200 :
94+ logger .info ("Ollama is ready!" )
95+ return
96+ except httpx .RequestError :
97+ pass
98+ logger .info ("Ollama not reachable yet. Retrying in 10 seconds..." )
99+ await asyncio .sleep (10 )
100+
101+ @client .event
102+ async def on_ready ():
103+ logger .info (f"Logged in as { client .user .name } ({ client .user .id } )" )
104+
105+ # Wait for Ollama to be ready before processing the backlog
106+ await wait_for_ollama ()
107+
108+ logger .info ("Checking for missed messages..." )
109+
110+ try :
111+ channel = await client .fetch_channel (DISCORD_CHANNEL_ID_INT )
112+
113+ # Find the last message sent by the bot
114+ last_bot_msg = None
115+ async for msg in channel .history (limit = 50 ):
116+ if msg .author .id == client .user .id :
117+ last_bot_msg = msg
118+ break
119+
120+ messages_to_process = []
121+ if last_bot_msg :
122+ # Fetch messages after the bot's last message
123+ async for msg in channel .history (after = last_bot_msg , oldest_first = True ):
124+ if not msg .author .bot :
125+ messages_to_process .append (msg )
126+ else :
127+ # If no bot message found, just process the last 5 user messages
128+ async for msg in channel .history (limit = 5 , oldest_first = True ):
129+ if not msg .author .bot :
130+ messages_to_process .append (msg )
131+
132+ logger .info (f"Found { len (messages_to_process )} missed messages. Processing..." )
133+ for msg in messages_to_process :
134+ await process_message (msg )
135+
136+ except Exception as e :
137+ logger .error (f"Error fetching missed messages: { e } " )
138+
139+ logger .info ("AOSSIE Contributor Assistant MVP is fully ready." )
140+
141+ @client .event
142+ async def on_message (message : discord .Message ):
143+ await process_message (message )
144+
145+ if __name__ == "__main__" :
146+ if not DISCORD_TOKEN :
147+ logger .critical ("DISCORD_TOKEN is missing from environment. Exiting." )
148+ exit (1 )
149+
150+ if not DISCORD_CHANNEL_ID :
151+ logger .critical ("DISCORD_CHANNEL_ID is missing from environment. Exiting." )
152+ exit (1 )
153+
154+ try :
155+ DISCORD_CHANNEL_ID_INT = int (DISCORD_CHANNEL_ID )
156+ except ValueError :
157+ logger .critical (
158+ f"DISCORD_CHANNEL_ID '{ DISCORD_CHANNEL_ID } ' is not a valid integer. Exiting."
159+ )
160+ exit (1 )
161+
162+ logger .info ("Starting bot..." )
163+ client .run (DISCORD_TOKEN )
0 commit comments