1313import re
1414import subprocess
1515import sys
16+ import tempfile
1617import time
1718import urllib .error
1819import urllib .request
2627ACCESS_FILE = TELEGRAM_STATE / "access.json"
2728DIRECT_STATE = TELEGRAM_STATE / "direct_state.json"
2829CHAT_MEMORY_DIR = TELEGRAM_STATE / "memory"
30+ GROQ_TRANSCRIPTION_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
31+ GROQ_TRANSCRIPTION_MODEL = os .environ .get ("GROQ_TRANSCRIPTION_MODEL" , "whisper-large-v3-turbo" )
2932MAX_MEMORY_MESSAGES = 14
3033MAX_MEMORY_CHARS = 9000
34+ GROQ_AUDIO_SUFFIXES = {
35+ ".oga" : ".ogg" ,
36+ ".opus" : ".opus" ,
37+ ".ogg" : ".ogg" ,
38+ ".mp3" : ".mp3" ,
39+ ".m4a" : ".m4a" ,
40+ ".mp4" : ".mp4" ,
41+ ".mpeg" : ".mpeg" ,
42+ ".mpga" : ".mpga" ,
43+ ".wav" : ".wav" ,
44+ ".webm" : ".webm" ,
45+ ".flac" : ".flac" ,
46+ }
3147
3248SECRET_PATTERNS : tuple [tuple [re .Pattern [str ], str ], ...] = (
3349 (
3753 r"\1=[REDACTED]" ,
3854 ),
3955 (re .compile (r"\bsk-[A-Za-z0-9]{16,}\b" ), "[REDACTED]" ),
56+ (re .compile (r"\bgsk_[A-Za-z0-9]{16,}\b" ), "[REDACTED]" ),
4057 (re .compile (r"\bgh[pousr]_[A-Za-z0-9]{16,}\b" ), "[REDACTED]" ),
4158 (re .compile (r"\boxb-[A-Za-z0-9-]{16,}\b" ), "[REDACTED]" ),
4259)
@@ -72,6 +89,83 @@ def read_telegram_token() -> str:
7289 raise RuntimeError (f"TELEGRAM_BOT_TOKEN missing in { TELEGRAM_ENV } " )
7390
7491
92+ def read_env_value (path : Path , key : str ) -> str | None :
93+ try :
94+ for line in path .read_text (encoding = "utf-8" ).splitlines ():
95+ line = line .strip ()
96+ if not line or line .startswith ("#" ) or "=" not in line :
97+ continue
98+ name , value = line .split ("=" , 1 )
99+ if name .strip () == key :
100+ return value .strip ().strip ('"' ).strip ("'" )
101+ except OSError :
102+ return None
103+ return None
104+
105+
106+ def read_groq_api_key () -> str :
107+ if os .environ .get ("GROQ_API_KEY" ):
108+ return os .environ ["GROQ_API_KEY" ]
109+ for path in (ROOT / ".env" , TELEGRAM_ENV ):
110+ value = read_env_value (path , "GROQ_API_KEY" )
111+ if value :
112+ return value
113+ config = read_json (PROVIDERS_PATH , {})
114+ for provider in config .get ("providers" , {}).values ():
115+ env = provider .get ("env_vars" , {})
116+ value = env .get ("GROQ_API_KEY" )
117+ if value :
118+ return str (value )
119+ raise RuntimeError ("GROQ_API_KEY nao configurada" )
120+
121+
122+ def write_env_value (path : Path , key : str , value : str ) -> None :
123+ path .parent .mkdir (parents = True , exist_ok = True )
124+ lines : list [str ] = []
125+ replaced = False
126+ try :
127+ existing = path .read_text (encoding = "utf-8" ).splitlines ()
128+ except OSError :
129+ existing = []
130+ for line in existing :
131+ if line .strip ().startswith (f"{ key } =" ):
132+ lines .append (f"{ key } ={ value } " )
133+ replaced = True
134+ else :
135+ lines .append (line )
136+ if not replaced :
137+ lines .append (f"{ key } ={ value } " )
138+ path .write_text ("\n " .join (lines ).rstrip () + "\n " , encoding = "utf-8" )
139+
140+
141+ def parse_groq_command (text : str ) -> str | None :
142+ if not text .startswith ("/groq" ):
143+ return None
144+ parts = text .split (maxsplit = 2 )
145+ if len (parts ) == 1 :
146+ return "status"
147+ action = parts [1 ].strip ().lower ()
148+ if action in {"status" , "set" }:
149+ return action if action == "status" else f"set { parts [2 ].strip () if len (parts ) > 2 else '' } "
150+ return "help"
151+
152+
153+ def handle_groq_command (command : str ) -> str :
154+ if command == "status" :
155+ try :
156+ read_groq_api_key ()
157+ return f"Groq configurado. Modelo de transcricao: { GROQ_TRANSCRIPTION_MODEL } "
158+ except Exception as exc :
159+ return f"Groq nao configurado: { exc } \n Use: /groq set <GROQ_API_KEY>"
160+ if command .startswith ("set " ):
161+ value = command .split (" " , 1 )[1 ].strip ()
162+ if not value .startswith ("gsk_" ):
163+ return "Chave Groq invalida. Ela deve comecar com gsk_."
164+ write_env_value (TELEGRAM_ENV , "GROQ_API_KEY" , value )
165+ return "Groq configurado para transcricao de audio."
166+ return "Comandos: /groq status | /groq set <GROQ_API_KEY>"
167+
168+
75169def api (token : str , method : str , payload : dict | None = None , timeout : int = 35 ) -> dict :
76170 url = f"https://api.telegram.org/bot{ token } /{ method } "
77171 data = None
@@ -84,6 +178,125 @@ def api(token: str, method: str, payload: dict | None = None, timeout: int = 35)
84178 return json .loads (resp .read ().decode ("utf-8" ))
85179
86180
181+ def download_telegram_file (token : str , file_id : str ) -> tuple [Path , str ]:
182+ data = api (token , "getFile" , {"file_id" : file_id }, timeout = 20 )
183+ file_path = data .get ("result" , {}).get ("file_path" )
184+ if not file_path :
185+ raise RuntimeError ("Telegram nao retornou file_path" )
186+ suffix = GROQ_AUDIO_SUFFIXES .get (Path (file_path ).suffix .lower (), ".ogg" )
187+ url = f"https://api.telegram.org/file/bot{ token } /{ file_path } "
188+ with urllib .request .urlopen (url , timeout = 60 ) as resp :
189+ audio_bytes = resp .read ()
190+ tmp = tempfile .NamedTemporaryFile (prefix = "telegram-audio-" , suffix = suffix , delete = False )
191+ try :
192+ tmp .write (audio_bytes )
193+ return Path (tmp .name ), suffix
194+ finally :
195+ tmp .close ()
196+
197+
198+ def multipart_form_data (fields : dict [str , str ], files : dict [str , tuple [str , bytes , str ]]) -> tuple [bytes , str ]:
199+ boundary = f"----evonexus-{ int (time .time () * 1000 )} "
200+ chunks : list [bytes ] = []
201+ for name , value in fields .items ():
202+ chunks .extend ([
203+ f"--{ boundary } \r \n " .encode ("utf-8" ),
204+ f'Content-Disposition: form-data; name="{ name } "\r \n \r \n ' .encode ("utf-8" ),
205+ str (value ).encode ("utf-8" ),
206+ b"\r \n " ,
207+ ])
208+ for name , (filename , content , content_type ) in files .items ():
209+ chunks .extend ([
210+ f"--{ boundary } \r \n " .encode ("utf-8" ),
211+ (
212+ f'Content-Disposition: form-data; name="{ name } "; filename="{ filename } "\r \n '
213+ f"Content-Type: { content_type } \r \n \r \n "
214+ ).encode ("utf-8" ),
215+ content ,
216+ b"\r \n " ,
217+ ])
218+ chunks .append (f"--{ boundary } --\r \n " .encode ("utf-8" ))
219+ return b"" .join (chunks ), boundary
220+
221+
222+ def transcribe_audio (audio_path : Path ) -> str :
223+ api_key = read_groq_api_key ()
224+ upload_suffix = GROQ_AUDIO_SUFFIXES .get (audio_path .suffix .lower (), ".ogg" )
225+ upload_path = audio_path
226+ temp_upload : Path | None = None
227+ if audio_path .suffix .lower () != upload_suffix :
228+ temp = tempfile .NamedTemporaryFile (prefix = "groq-upload-" , suffix = upload_suffix , delete = False )
229+ try :
230+ temp .write (audio_path .read_bytes ())
231+ temp_upload = Path (temp .name )
232+ upload_path = temp_upload
233+ finally :
234+ temp .close ()
235+ try :
236+ proc = subprocess .run (
237+ [
238+ "curl" ,
239+ "-sS" ,
240+ "-H" ,
241+ f"Authorization: Bearer { api_key } " ,
242+ "-F" ,
243+ f"file=@{ upload_path } " ,
244+ "-F" ,
245+ f"model={ GROQ_TRANSCRIPTION_MODEL } " ,
246+ "-F" ,
247+ "response_format=json" ,
248+ "-F" ,
249+ "language=pt" ,
250+ GROQ_TRANSCRIPTION_URL ,
251+ ],
252+ text = True ,
253+ capture_output = True ,
254+ timeout = 120 ,
255+ )
256+ finally :
257+ if temp_upload :
258+ try :
259+ temp_upload .unlink ()
260+ except OSError :
261+ pass
262+ if proc .returncode != 0 :
263+ raise RuntimeError ((proc .stderr or proc .stdout or "curl failed" ).strip ()[:240 ])
264+ data = json .loads (proc .stdout )
265+ if data .get ("error" ):
266+ raise RuntimeError (str (data ["error" ].get ("message" ) or data ["error" ])[:240 ])
267+ text = str (data .get ("text" ) or "" ).strip ()
268+ if not text :
269+ raise RuntimeError ("Groq nao retornou transcricao" )
270+ return text
271+
272+
273+ def message_audio_file_id (message : dict ) -> str | None :
274+ voice = message .get ("voice" ) or {}
275+ audio = message .get ("audio" ) or {}
276+ document = message .get ("document" ) or {}
277+ mime_type = str (document .get ("mime_type" ) or "" )
278+ if voice .get ("file_id" ):
279+ return str (voice ["file_id" ])
280+ if audio .get ("file_id" ):
281+ return str (audio ["file_id" ])
282+ if mime_type .startswith ("audio/" ) and document .get ("file_id" ):
283+ return str (document ["file_id" ])
284+ return None
285+
286+
287+ def handle_audio_message (token : str , chat_id : str , file_id : str ) -> str :
288+ audio_path : Path | None = None
289+ try :
290+ audio_path , _suffix = download_telegram_file (token , file_id )
291+ return transcribe_audio (audio_path )
292+ finally :
293+ if audio_path :
294+ try :
295+ audio_path .unlink ()
296+ except OSError :
297+ pass
298+
299+
87300def allowed_chat (chat_id : str , from_id : str ) -> bool :
88301 access = read_json (ACCESS_FILE , {"allowFrom" : [], "groups" : {}, "dmPolicy" : "pairing" })
89302 if chat_id in {str (x ) for x in access .get ("allowFrom" , [])}:
@@ -368,18 +581,47 @@ def main() -> int:
368581 or " " .join (part for part in [sender .get ("first_name" ), sender .get ("last_name" )] if part )
369582 or None
370583 )
371- if not text or not chat_id :
584+ if not chat_id :
372585 continue
373586 if not allowed_chat (chat_id , from_id ):
374587 log (f"dropped non-allowlisted chat={ chat_id } " )
375588 continue
589+ audio_file_id = message_audio_file_id (message )
590+ if audio_file_id :
591+ api (token , "sendChatAction" , {"chat_id" : chat_id , "action" : "typing" }, timeout = 10 )
592+ try :
593+ transcription = handle_audio_message (token , chat_id , audio_file_id )
594+ api (
595+ token ,
596+ "sendMessage" ,
597+ {"chat_id" : chat_id , "text" : f"Transcricao:\n \n { transcription [:3600 ]} " },
598+ )
599+ prompt = build_prompt (chat_id , transcription , speaker = sender_name )
600+ answer , used = invoke_provider (prompt )
601+ except Exception as exc :
602+ answer = f"Falhei ao transcrever/processar audio: { exc } "
603+ used = "error"
604+ log (f"audio chat={ chat_id } via { used } " )
605+ api (token , "sendMessage" , {"chat_id" : chat_id , "text" : answer [:3900 ]})
606+ if used != "error" :
607+ append_chat_memory (chat_id , "user" , f"[audio transcrito] { transcription } " , speaker = sender_name )
608+ append_chat_memory (chat_id , "assistant" , answer , speaker = "Magneto" )
609+ continue
610+ if not text :
611+ continue
376612 if text .startswith ("/start" ):
377613 api (token , "sendMessage" , {"chat_id" : chat_id , "text" : "EvoNexus online. Pode mandar." })
378614 continue
379615 if text .startswith ("/new" ):
380616 clear_chat_memory (chat_id )
381617 api (token , "sendMessage" , {"chat_id" : chat_id , "text" : "Sessao nova iniciada. Memoria local limpa." })
382618 continue
619+ groq_command = parse_groq_command (text )
620+ if groq_command is not None :
621+ answer = handle_groq_command (groq_command )
622+ api (token , "sendMessage" , {"chat_id" : chat_id , "text" : answer })
623+ log (f"groq-command chat={ chat_id } { groq_command .split (' ' , 1 )[0 ]} " )
624+ continue
383625 provider_command = parse_provider_command (text )
384626 if provider_command is not None :
385627 if provider_command == "status" :
0 commit comments