Skip to content

Commit 4e97443

Browse files
committed
Add Telegram audio transcription via Groq
1 parent f7b942f commit 4e97443

2 files changed

Lines changed: 276 additions & 2 deletions

File tree

scripts/telegram_provider_bot.py

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import re
1414
import subprocess
1515
import sys
16+
import tempfile
1617
import time
1718
import urllib.error
1819
import urllib.request
@@ -26,8 +27,23 @@
2627
ACCESS_FILE = TELEGRAM_STATE / "access.json"
2728
DIRECT_STATE = TELEGRAM_STATE / "direct_state.json"
2829
CHAT_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")
2932
MAX_MEMORY_MESSAGES = 14
3033
MAX_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

3248
SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
3349
(
@@ -37,6 +53,7 @@
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}\nUse: /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+
75169
def 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+
87300
def 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":

tests/backend/test_telegram_provider_bot.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,44 @@ def test_clear_chat_memory_removes_history(self) -> None:
3838
self.assertEqual(bot.load_chat_memory("456"), [])
3939

4040
def test_append_chat_memory_redacts_secrets(self) -> None:
41-
bot.append_chat_memory("789", "user", "API key: sk-abcdefghijklmnopqrstu", speaker="Felipe")
41+
bot.append_chat_memory("789", "user", "API key: sk-abcdefghijklmnopqrstu gsk_abcdefghijklmnopqrstu", speaker="Felipe")
4242
entries = bot.load_chat_memory("789")
4343

4444
self.assertEqual(len(entries), 1)
4545
self.assertIn("[REDACTED]", entries[0]["text"])
4646
self.assertNotIn("sk-abcdefghijklmnopqrstu", entries[0]["text"])
47+
self.assertNotIn("gsk_abcdefghijklmnopqrstu", entries[0]["text"])
48+
49+
def test_message_audio_file_id_detects_voice_audio_and_audio_documents(self) -> None:
50+
self.assertEqual(bot.message_audio_file_id({"voice": {"file_id": "voice-id"}}), "voice-id")
51+
self.assertEqual(bot.message_audio_file_id({"audio": {"file_id": "audio-id"}}), "audio-id")
52+
self.assertEqual(
53+
bot.message_audio_file_id({"document": {"file_id": "doc-id", "mime_type": "audio/ogg"}}),
54+
"doc-id",
55+
)
56+
self.assertIsNone(bot.message_audio_file_id({"document": {"file_id": "doc-id", "mime_type": "image/png"}}))
57+
58+
def test_read_groq_api_key_prefers_environment(self) -> None:
59+
original = bot.os.environ.get("GROQ_API_KEY")
60+
try:
61+
bot.os.environ["GROQ_API_KEY"] = "gsk_test_key"
62+
self.assertEqual(bot.read_groq_api_key(), "gsk_test_key")
63+
finally:
64+
if original is None:
65+
bot.os.environ.pop("GROQ_API_KEY", None)
66+
else:
67+
bot.os.environ["GROQ_API_KEY"] = original
68+
69+
def test_groq_command_can_store_key_in_telegram_env(self) -> None:
70+
original_env = bot.TELEGRAM_ENV
71+
try:
72+
bot.TELEGRAM_ENV = bot.CHAT_MEMORY_DIR / ".env"
73+
response = bot.handle_groq_command("set gsk_test_key")
74+
self.assertEqual(response, "Groq configurado para transcricao de audio.")
75+
self.assertEqual(bot.read_env_value(bot.TELEGRAM_ENV, "GROQ_API_KEY"), "gsk_test_key")
76+
self.assertEqual(bot.handle_groq_command("status"), f"Groq configurado. Modelo de transcricao: {bot.GROQ_TRANSCRIPTION_MODEL}")
77+
finally:
78+
bot.TELEGRAM_ENV = original_env
4779

4880

4981
if __name__ == "__main__":

0 commit comments

Comments
 (0)