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
3134
3740 r"\1=[REDACTED]" ,
3841 ),
3942 (re .compile (r"\bsk-[A-Za-z0-9]{16,}\b" ), "[REDACTED]" ),
43+ (re .compile (r"\bgsk_[A-Za-z0-9]{16,}\b" ), "[REDACTED]" ),
4044 (re .compile (r"\bgh[pousr]_[A-Za-z0-9]{16,}\b" ), "[REDACTED]" ),
4145 (re .compile (r"\boxb-[A-Za-z0-9-]{16,}\b" ), "[REDACTED]" ),
4246)
@@ -72,6 +76,83 @@ def read_telegram_token() -> str:
7276 raise RuntimeError (f"TELEGRAM_BOT_TOKEN missing in { TELEGRAM_ENV } " )
7377
7478
79+ def read_env_value (path : Path , key : str ) -> str | None :
80+ try :
81+ for line in path .read_text (encoding = "utf-8" ).splitlines ():
82+ line = line .strip ()
83+ if not line or line .startswith ("#" ) or "=" not in line :
84+ continue
85+ name , value = line .split ("=" , 1 )
86+ if name .strip () == key :
87+ return value .strip ().strip ('"' ).strip ("'" )
88+ except OSError :
89+ return None
90+ return None
91+
92+
93+ def read_groq_api_key () -> str :
94+ if os .environ .get ("GROQ_API_KEY" ):
95+ return os .environ ["GROQ_API_KEY" ]
96+ for path in (ROOT / ".env" , TELEGRAM_ENV ):
97+ value = read_env_value (path , "GROQ_API_KEY" )
98+ if value :
99+ return value
100+ config = read_json (PROVIDERS_PATH , {})
101+ for provider in config .get ("providers" , {}).values ():
102+ env = provider .get ("env_vars" , {})
103+ value = env .get ("GROQ_API_KEY" )
104+ if value :
105+ return str (value )
106+ raise RuntimeError ("GROQ_API_KEY nao configurada" )
107+
108+
109+ def write_env_value (path : Path , key : str , value : str ) -> None :
110+ path .parent .mkdir (parents = True , exist_ok = True )
111+ lines : list [str ] = []
112+ replaced = False
113+ try :
114+ existing = path .read_text (encoding = "utf-8" ).splitlines ()
115+ except OSError :
116+ existing = []
117+ for line in existing :
118+ if line .strip ().startswith (f"{ key } =" ):
119+ lines .append (f"{ key } ={ value } " )
120+ replaced = True
121+ else :
122+ lines .append (line )
123+ if not replaced :
124+ lines .append (f"{ key } ={ value } " )
125+ path .write_text ("\n " .join (lines ).rstrip () + "\n " , encoding = "utf-8" )
126+
127+
128+ def parse_groq_command (text : str ) -> str | None :
129+ if not text .startswith ("/groq" ):
130+ return None
131+ parts = text .split (maxsplit = 2 )
132+ if len (parts ) == 1 :
133+ return "status"
134+ action = parts [1 ].strip ().lower ()
135+ if action in {"status" , "set" }:
136+ return action if action == "status" else f"set { parts [2 ].strip () if len (parts ) > 2 else '' } "
137+ return "help"
138+
139+
140+ def handle_groq_command (command : str ) -> str :
141+ if command == "status" :
142+ try :
143+ read_groq_api_key ()
144+ return f"Groq configurado. Modelo de transcricao: { GROQ_TRANSCRIPTION_MODEL } "
145+ except Exception as exc :
146+ return f"Groq nao configurado: { exc } \n Use: /groq set <GROQ_API_KEY>"
147+ if command .startswith ("set " ):
148+ value = command .split (" " , 1 )[1 ].strip ()
149+ if not value .startswith ("gsk_" ):
150+ return "Chave Groq invalida. Ela deve comecar com gsk_."
151+ write_env_value (TELEGRAM_ENV , "GROQ_API_KEY" , value )
152+ return "Groq configurado para transcricao de audio."
153+ return "Comandos: /groq status | /groq set <GROQ_API_KEY>"
154+
155+
75156def api (token : str , method : str , payload : dict | None = None , timeout : int = 35 ) -> dict :
76157 url = f"https://api.telegram.org/bot{ token } /{ method } "
77158 data = None
@@ -84,6 +165,107 @@ def api(token: str, method: str, payload: dict | None = None, timeout: int = 35)
84165 return json .loads (resp .read ().decode ("utf-8" ))
85166
86167
168+ def download_telegram_file (token : str , file_id : str ) -> tuple [Path , str ]:
169+ data = api (token , "getFile" , {"file_id" : file_id }, timeout = 20 )
170+ file_path = data .get ("result" , {}).get ("file_path" )
171+ if not file_path :
172+ raise RuntimeError ("Telegram nao retornou file_path" )
173+ suffix = Path (file_path ).suffix or ".oga"
174+ url = f"https://api.telegram.org/file/bot{ token } /{ file_path } "
175+ with urllib .request .urlopen (url , timeout = 60 ) as resp :
176+ audio_bytes = resp .read ()
177+ tmp = tempfile .NamedTemporaryFile (prefix = "telegram-audio-" , suffix = suffix , delete = False )
178+ try :
179+ tmp .write (audio_bytes )
180+ return Path (tmp .name ), suffix
181+ finally :
182+ tmp .close ()
183+
184+
185+ def multipart_form_data (fields : dict [str , str ], files : dict [str , tuple [str , bytes , str ]]) -> tuple [bytes , str ]:
186+ boundary = f"----evonexus-{ int (time .time () * 1000 )} "
187+ chunks : list [bytes ] = []
188+ for name , value in fields .items ():
189+ chunks .extend ([
190+ f"--{ boundary } \r \n " .encode ("utf-8" ),
191+ f'Content-Disposition: form-data; name="{ name } "\r \n \r \n ' .encode ("utf-8" ),
192+ str (value ).encode ("utf-8" ),
193+ b"\r \n " ,
194+ ])
195+ for name , (filename , content , content_type ) in files .items ():
196+ chunks .extend ([
197+ f"--{ boundary } \r \n " .encode ("utf-8" ),
198+ (
199+ f'Content-Disposition: form-data; name="{ name } "; filename="{ filename } "\r \n '
200+ f"Content-Type: { content_type } \r \n \r \n "
201+ ).encode ("utf-8" ),
202+ content ,
203+ b"\r \n " ,
204+ ])
205+ chunks .append (f"--{ boundary } --\r \n " .encode ("utf-8" ))
206+ return b"" .join (chunks ), boundary
207+
208+
209+ def transcribe_audio (audio_path : Path ) -> str :
210+ api_key = read_groq_api_key ()
211+ content = audio_path .read_bytes ()
212+ body , boundary = multipart_form_data (
213+ {
214+ "model" : GROQ_TRANSCRIPTION_MODEL ,
215+ "response_format" : "json" ,
216+ "language" : "pt" ,
217+ },
218+ {
219+ "file" : (audio_path .name , content , "application/octet-stream" ),
220+ },
221+ )
222+ req = urllib .request .Request (
223+ GROQ_TRANSCRIPTION_URL ,
224+ data = body ,
225+ headers = {
226+ "Authorization" : f"Bearer { api_key } " ,
227+ "Content-Type" : f"multipart/form-data; boundary={ boundary } " ,
228+ },
229+ )
230+ try :
231+ with urllib .request .urlopen (req , timeout = 120 ) as resp :
232+ data = json .loads (resp .read ().decode ("utf-8" ))
233+ except urllib .error .HTTPError as exc :
234+ body_text = exc .read ().decode ("utf-8" , "ignore" )
235+ raise RuntimeError (f"Groq retornou HTTP { exc .code } : { body_text [:240 ]} " ) from exc
236+ text = str (data .get ("text" ) or "" ).strip ()
237+ if not text :
238+ raise RuntimeError ("Groq nao retornou transcricao" )
239+ return text
240+
241+
242+ def message_audio_file_id (message : dict ) -> str | None :
243+ voice = message .get ("voice" ) or {}
244+ audio = message .get ("audio" ) or {}
245+ document = message .get ("document" ) or {}
246+ mime_type = str (document .get ("mime_type" ) or "" )
247+ if voice .get ("file_id" ):
248+ return str (voice ["file_id" ])
249+ if audio .get ("file_id" ):
250+ return str (audio ["file_id" ])
251+ if mime_type .startswith ("audio/" ) and document .get ("file_id" ):
252+ return str (document ["file_id" ])
253+ return None
254+
255+
256+ def handle_audio_message (token : str , chat_id : str , file_id : str ) -> str :
257+ audio_path : Path | None = None
258+ try :
259+ audio_path , _suffix = download_telegram_file (token , file_id )
260+ return transcribe_audio (audio_path )
261+ finally :
262+ if audio_path :
263+ try :
264+ audio_path .unlink ()
265+ except OSError :
266+ pass
267+
268+
87269def allowed_chat (chat_id : str , from_id : str ) -> bool :
88270 access = read_json (ACCESS_FILE , {"allowFrom" : [], "groups" : {}, "dmPolicy" : "pairing" })
89271 if chat_id in {str (x ) for x in access .get ("allowFrom" , [])}:
@@ -368,18 +550,47 @@ def main() -> int:
368550 or " " .join (part for part in [sender .get ("first_name" ), sender .get ("last_name" )] if part )
369551 or None
370552 )
371- if not text or not chat_id :
553+ if not chat_id :
372554 continue
373555 if not allowed_chat (chat_id , from_id ):
374556 log (f"dropped non-allowlisted chat={ chat_id } " )
375557 continue
558+ audio_file_id = message_audio_file_id (message )
559+ if audio_file_id :
560+ api (token , "sendChatAction" , {"chat_id" : chat_id , "action" : "typing" }, timeout = 10 )
561+ try :
562+ transcription = handle_audio_message (token , chat_id , audio_file_id )
563+ api (
564+ token ,
565+ "sendMessage" ,
566+ {"chat_id" : chat_id , "text" : f"Transcricao:\n \n { transcription [:3600 ]} " },
567+ )
568+ prompt = build_prompt (chat_id , transcription , speaker = sender_name )
569+ answer , used = invoke_provider (prompt )
570+ except Exception as exc :
571+ answer = f"Falhei ao transcrever/processar audio: { exc } "
572+ used = "error"
573+ log (f"audio chat={ chat_id } via { used } " )
574+ api (token , "sendMessage" , {"chat_id" : chat_id , "text" : answer [:3900 ]})
575+ if used != "error" :
576+ append_chat_memory (chat_id , "user" , f"[audio transcrito] { transcription } " , speaker = sender_name )
577+ append_chat_memory (chat_id , "assistant" , answer , speaker = "Magneto" )
578+ continue
579+ if not text :
580+ continue
376581 if text .startswith ("/start" ):
377582 api (token , "sendMessage" , {"chat_id" : chat_id , "text" : "EvoNexus online. Pode mandar." })
378583 continue
379584 if text .startswith ("/new" ):
380585 clear_chat_memory (chat_id )
381586 api (token , "sendMessage" , {"chat_id" : chat_id , "text" : "Sessao nova iniciada. Memoria local limpa." })
382587 continue
588+ groq_command = parse_groq_command (text )
589+ if groq_command is not None :
590+ answer = handle_groq_command (groq_command )
591+ api (token , "sendMessage" , {"chat_id" : chat_id , "text" : answer })
592+ log (f"groq-command chat={ chat_id } { groq_command .split (' ' , 1 )[0 ]} " )
593+ continue
383594 provider_command = parse_provider_command (text )
384595 if provider_command is not None :
385596 if provider_command == "status" :
0 commit comments