Skip to content

Commit 0bc34bf

Browse files
committed
Add Telegram audio transcription via Groq
1 parent f7b942f commit 0bc34bf

2 files changed

Lines changed: 245 additions & 2 deletions

File tree

scripts/telegram_provider_bot.py

Lines changed: 212 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,6 +27,8 @@
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
3134

@@ -37,6 +40,7 @@
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}\nUse: /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+
75156
def 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+
87269
def 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":

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)