11import os
2+ import json
23import logging
34import discord
45import httpx
56import asyncio
7+ from datetime import datetime , timezone
8+ from pathlib import Path
69from dotenv import load_dotenv
710
811# Configure logging
1821OLLAMA_MODEL = os .getenv ('OLLAMA_MODEL' , 'llama3.2' )
1922SKILL_FILE_PATH = os .getenv ('SKILL_FILE_PATH' , '.clinerules' )
2023OLLAMA_URL = "http://localhost:11434/api/generate"
24+ GAP_LOG_PATH = Path ("gap_log.json" )
25+ MAX_RETRIES = 3
2126
2227# Initialize bot with intents
2328intents = discord .Intents .default ()
2732# Lock to prevent Ollama requests from clashing
2833ollama_lock = asyncio .Lock ()
2934
35+ THREAD_HISTORY_LIMIT = 10 # messages to pull from thread as conversation context
36+
37+
38+ def _load_gap_log ():
39+ if GAP_LOG_PATH .exists ():
40+ try :
41+ with open (GAP_LOG_PATH , "r" , encoding = "utf-8" ) as f :
42+ return json .load (f )
43+ except json .JSONDecodeError :
44+ logger .warning ("gap_log.json corrupted, starting fresh" )
45+ return []
46+
47+
48+ def _save_gap_log (entries ):
49+ GAP_LOG_PATH .parent .mkdir (parents = True , exist_ok = True )
50+ with open (GAP_LOG_PATH , "w" , encoding = "utf-8" ) as f :
51+ json .dump (entries , f , indent = 2 , default = str )
52+
53+ gap_log_lock = asyncio .Lock ()
54+
55+ async def _log_gap (query , reason , thread_id = None ):
56+ async with gap_log_lock :
57+ entry = {
58+ "timestamp" : datetime .now (timezone .utc ).isoformat (),
59+ "query" : query ,
60+ "reason" : reason ,
61+ }
62+ if thread_id :
63+ entry ["thread_id" ] = thread_id
64+ entries = _load_gap_log ()
65+ entries .append (entry )
66+ _save_gap_log (entries )
67+ logger .info (f"Gap logged: { reason } — query: { query [:80 ]} " )
68+
69+
3070def load_skill_context () -> str :
3171 """Load context from the local skill file."""
3272 try :
@@ -37,8 +77,9 @@ def load_skill_context() -> str:
3777 logger .error (f"Error loading skill file { SKILL_FILE_PATH } : { e } " )
3878 return ""
3979
40- async def generate_ollama_response (prompt : str , context : str ) -> str :
41- """Send prompt to local Ollama instance and return the response."""
80+
81+ async def generate_ollama_response (prompt : str , context : str ) -> tuple [str , bool ]:
82+ """Send prompt to local Ollama instance. Returns (response_text, used_llm_fallback)."""
4283 if context :
4384 system_prompt = f"You are a helpful contributor assistant for AOSSIE.\n \n Context guidelines:\n { context } "
4485 else :
@@ -51,37 +92,149 @@ async def generate_ollama_response(prompt: str, context: str) -> str:
5192 "stream" : False
5293 }
5394
95+ for attempt in range (1 , MAX_RETRIES + 1 ):
96+ try :
97+ async with httpx .AsyncClient (timeout = 120.0 ) as http_client :
98+ response = await http_client .post (OLLAMA_URL , json = payload )
99+ response .raise_for_status ()
100+ data = response .json ()
101+ text = data .get ("response" , "" )
102+ if text :
103+ return text , False # False = Ollama succeeded, no fallback gap
104+ logger .warning (f"Empty Ollama response (attempt { attempt } /{ MAX_RETRIES } )" )
105+ except httpx .TimeoutException :
106+ logger .error (f"Ollama timed out (attempt { attempt } /{ MAX_RETRIES } )" )
107+ except httpx .RequestError as e :
108+ logger .error (f"Ollama unreachable (attempt { attempt } /{ MAX_RETRIES } ): { e } " )
109+ except Exception as e :
110+ logger .error (f"Ollama error (attempt { attempt } /{ MAX_RETRIES } ): { e } " )
111+ if attempt < MAX_RETRIES :
112+ await asyncio .sleep (2 )
113+
114+ return "I'm sorry, the local AI model is currently unavailable. Please try again later or ask a maintainer." , True
115+
116+
117+ async def _build_conversation_context (thread : discord .Thread , current_author : discord .User , current_query : str ) -> str :
118+ """Pull recent thread history and format it as conversation context for Ollama."""
119+ history_parts = []
120+ try :
121+ async for msg in thread .history (limit = THREAD_HISTORY_LIMIT , oldest_first = True ):
122+ if msg .author .bot :
123+ history_parts .append (f"Bot: { msg .content [:300 ]} " )
124+ else :
125+ history_parts .append (f"{ msg .author .display_name } : { msg .content [:300 ]} " )
126+ except Exception as e :
127+ logger .error (f"Error fetching thread history for { thread .id } : { e } " )
128+
129+ if not history_parts :
130+ return ""
131+
132+ return (
133+ "Previous conversation in this thread:\n " +
134+ "\n " .join (history_parts ) +
135+ f"\n \n Current question from { current_author .display_name } : { current_query } "
136+ )
137+
138+
139+ async def _get_or_create_thread (message : discord .Message , channel : discord .TextChannel ) -> discord .Thread | None :
140+ """If message is already in a thread, return that thread. Otherwise create a new one.
141+ One thread per conversation — never reuses threads by user ID. Returns None on failure."""
142+ if isinstance (message .channel , discord .Thread ):
143+ thread = message .channel
144+ if not thread .archived and not thread .locked :
145+ return thread
146+ logger .warning (f"Thread { thread .id } is archived/locked — creating a new one" )
147+ return None # cannot create thread from message already in a thread
148+
54149 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."
150+ author = message . author
151+ thread = await message . create_thread (
152+ name = f"Q&A: { author . display_name } — { message . content [: 50 ] } " ,
153+ auto_archive_duration = 1440 , # 24 hours
154+ )
155+ logger . info ( f"Created thread { thread . id } for { author . name } — query: { message . content [: 80 ] } " )
156+ return thread
157+ except discord . Forbidden :
158+ logger . error ( f"Cannot create thread — missing permissions in channel { channel . id } " )
159+ except discord . HTTPException as e :
160+ logger . error ( f"Discord API error creating thread: { e } " )
66161 except Exception as e :
67- logger .error (f"Unexpected error during Ollama generation: { e } " )
68- return "An unexpected error occurred while generating the response."
162+ logger .error (f"Unexpected error creating thread for { message .author .id } : { e } " )
163+ return None
164+
69165
70166async 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 :
167+ """Process a single message: new messages in the main channel spawn a thread,
168+ messages in existing threads continue the conversation there."""
169+ if message .author .bot :
73170 return
74171
75- # Use lock to ensure only one message is processed by Ollama at a time
172+ is_in_thread = isinstance (message .channel , discord .Thread )
173+ is_in_configured_channel = message .channel .id == DISCORD_CHANNEL_ID_INT
174+
175+ if not is_in_thread and not is_in_configured_channel :
176+ return
177+
178+ author = message .author
179+
180+ if is_in_thread :
181+ thread = message .channel
182+ if thread .archived or thread .locked :
183+ logger .warning (f"Thread { thread .id } is archived/locked — cannot respond" )
184+ return
185+ else :
186+ channel = message .channel
187+ thread = await _get_or_create_thread (message , channel )
188+ if not thread :
189+ _log_gap (message .content , "thread_creation_failed" )
190+ try :
191+ await message .reply (
192+ "I couldn't create a thread to answer your question. Please ask a maintainer for help."
193+ )
194+ except Exception :
195+ pass
196+ return
197+
76198 async with ollama_lock :
77- async with message .channel .typing ():
199+ try :
200+ await asyncio .sleep (1 ) # let Discord register the new thread
201+ async with thread .typing ():
202+ pass
203+ except Exception as e :
204+ logger .warning (f"Could not trigger typing indicator in thread { thread .id } : { e } " )
205+
206+ try :
78207 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 ] + "..."
208+ conversation_context = await _build_conversation_context (thread , author , message .content )
209+
210+ if conversation_context :
211+ full_prompt = conversation_context
212+ else :
213+ full_prompt = message .content
214+
215+ response_text , used_fallback = await generate_ollama_response (full_prompt , skill_context )
216+
217+ if used_fallback or not skill_context :
218+ _log_gap (
219+ message .content ,
220+ "ollama_unavailable" if used_fallback else "no_skill_context" ,
221+ thread_id = thread .id ,
222+ )
223+ except Exception as e :
224+ logger .error (f"Unexpected error processing message from { author .name } : { e } " )
225+ response_text = "An unexpected error occurred. Please try again or ask a maintainer."
226+ _log_gap (message .content , f"processing_error: { e } " , thread_id = thread .id )
227+
228+ if len (response_text ) > 1900 :
229+ response_text = response_text [:1896 ] + "..."
230+
231+ try :
232+ await thread .send (response_text )
233+ except discord .Forbidden :
234+ logger .error (f"Cannot send message to thread { thread .id } " )
235+ except discord .HTTPException as e :
236+ logger .error (f"Error sending to thread { thread .id } : { e } " )
83237
84- await message .reply (response_text )
85238
86239async def wait_for_ollama ():
87240 """Wait until Ollama is up and responding."""
@@ -98,50 +251,51 @@ async def wait_for_ollama():
98251 logger .info ("Ollama not reachable yet. Retrying in 10 seconds..." )
99252 await asyncio .sleep (10 )
100253
254+
101255@client .event
102256async def on_ready ():
103257 logger .info (f"Logged in as { client .user .name } ({ client .user .id } )" )
104-
258+
105259 # Wait for Ollama to be ready before processing the backlog
106260 await wait_for_ollama ()
107-
261+
108262 logger .info ("Checking for missed messages..." )
109-
263+
110264 try :
111265 channel = await client .fetch_channel (DISCORD_CHANNEL_ID_INT )
112-
266+
113267 # Find the last message sent by the bot
114268 last_bot_msg = None
115269 async for msg in channel .history (limit = 50 ):
116270 if msg .author .id == client .user .id :
117271 last_bot_msg = msg
118272 break
119-
273+
120274 messages_to_process = []
121275 if last_bot_msg :
122- # Fetch messages after the bot's last message
123276 async for msg in channel .history (after = last_bot_msg , oldest_first = True ):
124277 if not msg .author .bot :
125278 messages_to_process .append (msg )
126279 else :
127- # If no bot message found, just process the last 5 user messages
128280 async for msg in channel .history (limit = 5 , oldest_first = True ):
129281 if not msg .author .bot :
130282 messages_to_process .append (msg )
131-
283+
132284 logger .info (f"Found { len (messages_to_process )} missed messages. Processing..." )
133285 for msg in messages_to_process :
134286 await process_message (msg )
135-
287+
136288 except Exception as e :
137289 logger .error (f"Error fetching missed messages: { e } " )
138290
139291 logger .info ("AOSSIE Contributor Assistant MVP is fully ready." )
140292
293+
141294@client .event
142295async def on_message (message : discord .Message ):
143296 await process_message (message )
144297
298+
145299if __name__ == "__main__" :
146300 if not DISCORD_TOKEN :
147301 logger .critical ("DISCORD_TOKEN is missing from environment. Exiting." )
@@ -160,4 +314,4 @@ async def on_message(message: discord.Message):
160314 exit (1 )
161315
162316 logger .info ("Starting bot..." )
163- client .run (DISCORD_TOKEN )
317+ client .run (DISCORD_TOKEN )
0 commit comments