# ---------------------------------------------------------------------------------
#░█▀▄░▄▀▀▄░█▀▄░█▀▀▄░█▀▀▄░█▀▀▀░▄▀▀▄░░░█▀▄▀█
#░█░░░█░░█░█░█░█▄▄▀░█▄▄█░█░▀▄░█░░█░░░█░▀░█
#░▀▀▀░░▀▀░░▀▀░░▀░▀▀░▀░░▀░▀▀▀▀░░▀▀░░░░▀░░▒▀
# Name: VoiceToText
# Description: Transcribes voice/video messages to text via free APIs (Google STT / Groq Whisper)
# Author: @codrago
# ---------------------------------------------------------------------------------
# 🔒 Licensed under the GNU AGPLv3
# 🌐 https://www.gnu.org/licenses/agpl-3.0.html
# ---------------------------------------------------------------------------------
# Commands: v2t, v2tauto, v2tlist, v2tkey
# scope: heroku_only
# meta developer: @codrago_m
# requires: aiohttp
# meta banner: https://raw.githubusercontent.com/coddrago/modules/refs/heads/main/banner.png
# ---------------------------------------------------------------------------------
__version__ = (1, 2, 0)
import asyncio
import json
import logging
import os
import tempfile
import aiohttp
from herokutl.tl.types import Message
from .. import loader, utils
logger = logging.getLogger(__name__)
GOOGLE_SPEECH_KEY = "AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw"
GOOGLE_SPEECH_URL = "http://www.google.com/speech-api/v2/recognize"
GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
GROQ_MODEL = "whisper-large-v3-turbo"
DEEPGRAM_URL = "https://api.deepgram.com/v1/listen"
MISTRAL_URL = "https://api.mistral.ai/v1/audio/transcriptions"
MISTRAL_MODEL = "voxtral-mini-latest"
PROVIDER_KEYS = {"groq", "deepgram", "mistral"}
@loader.tds
class VoiceToTextMod(loader.Module):
"""Transcribes voice/video messages to text via free APIs (Google STT / Groq Whisper / Deepgram / Mistral)"""
strings = {
"name": "VoiceToText",
"_cls_doc": "Transcribes voice/video messages to text via free APIs (Google STT / Groq Whisper / Deepgram / Mistral)",
"no_reply": "💢 Reply to a voice or video message",
"not_voice": "💢 This is not a voice or video message",
"processing": "✨ Transcribing...",
"result": "✨ Transcription:\n\n
{text}
",
"empty_result": "🫥 Could not recognize speech",
"all_failed": "💢 All providers failed to transcribe the audio\n{err}",
"ffmpeg_missing": "💢 ffmpeg not found on the system",
"auto_on": "✨ Auto-transcription enabled in this chat",
"auto_off": "🌙 Auto-transcription disabled in this chat",
"key_set": "✨ {provider} API key saved",
"key_removed": "🌙 {provider} API key removed",
"key_usage": "💢 Usage: {prefix}v2tkey <groq|deepgram|mistral> [api_key]",
"groq_api_key_doc": "Groq API key (free, console.groq.com) — fallback for long/complex audio",
"deepgram_api_key_doc": "Deepgram API key (console.deepgram.com) — fallback provider",
"mistral_api_key_doc": "Mistral API key (console.mistral.ai) — fallback provider (Voxtral)",
"lang_doc": "Recognition language (Google/Deepgram — ru-RU format, Groq/Mistral use the short code)",
"max_duration_doc": "Maximum audio duration in seconds for auto-transcription",
"list_header": '✨ Chats with auto-transcription: {count}',
"list_empty": '🫥 Auto-transcription is not enabled in any chat.\nEnable it in the desired chat: {prefix}v2tauto on',
"list_unavailable": "Chat unavailable",
"list_page": 'Page {page}/{pages} · {prefix}v2tlist <page>',
"list_usage": '💢 Enter a page from 1 to {pages}: {prefix}v2tlist <page>',
"_cmd_doc_v2t": "(reply to a voice/video message) — transcribe it to text",
"_cmd_doc_v2tauto": "[on/off] — enable/disable auto-transcription of incoming voice messages in this chat",
"_cmd_doc_v2tlist": "[page] — list chats with auto-transcription enabled",
"_cmd_doc_v2tkey": " [api_key] — set/remove a provider API key (fallback)",
}
strings_ru = {
"_cls_doc": "Расшифровывает голосовые/видеосообщения в текст через бесплатные API (Google STT / Groq Whisper / Deepgram / Mistral)",
"no_reply": "💢 Ответь на голосовое или видеосообщение",
"not_voice": "💢 Это не голосовое и не видеосообщение",
"processing": "✨ Расшифровываю...",
"result": "✨ Расшифровка:\n\n{text}
",
"empty_result": "🫥 Не удалось разобрать речь",
"all_failed": "💢 Все провайдеры не смогли расшифровать аудио\n{err}",
"ffmpeg_missing": "💢 ffmpeg не найден в системе",
"auto_on": "✨ Автотранскрибация включена в этом чате",
"auto_off": "🌙 Автотранскрибация выключена в этом чате",
"_cmd_doc_v2tauto": "[on/off] — включить/выключить автотранскрибацию входящих голосовых в текущем чате",
"key_set": "✨ {provider} API ключ сохранён",
"key_removed": "🌙 {provider} API ключ удалён",
"key_usage": "💢 Использование: {prefix}v2tkey <groq|deepgram|mistral> [api_key]",
"groq_api_key_doc": "Groq API ключ (бесплатный, console.groq.com) — резерв для длинных/сложных аудио",
"deepgram_api_key_doc": "Deepgram API ключ (console.deepgram.com) — резервный провайдер",
"mistral_api_key_doc": "Mistral API ключ (console.mistral.ai) — резервный провайдер (Voxtral)",
"lang_doc": "Язык распознавания (для Google/Deepgram — формат ru-RU, для Groq/Mistral берётся короткий код)",
"max_duration_doc": "Максимальная длительность аудио в секундах для автотранскрибации",
"list_header": '✨ Чаты с авторасшифровкой: {count}',
"list_empty": '🫥 Авторасшифровка нигде не включена.\nВключить в нужном чате: {prefix}v2tauto on',
"list_unavailable": "Чат недоступен",
"list_page": 'Страница {page}/{pages} · {prefix}v2tlist <страница>',
"list_usage": '💢 Укажи страницу от 1 до {pages}: {prefix}v2tlist <страница>',
"_cmd_doc_v2t": "(ответ на голосовое/видеосообщение) — расшифровать в текст",
"_cmd_doc_v2tlist": "[страница] — чаты, в которых включена авторасшифровка",
"_cmd_doc_v2tkey": " [api_key] — задать/удалить ключ резервного провайдера",
}
strings_ua = {
"_cls_doc": "Розшифровує голосові та відеоповідомлення за допомогою Google STT / Groq Whisper / Deepgram / Mistral",
"no_reply": '💢 Дай відповідь на голосове або відеоповідомлення',
"not_voice": '💢 Це не голосове і не відеоповідомлення',
"processing": '✨ Розшифровую...',
"result": '✨ Розшифровка:\n\n{text}
',
"empty_result": '🫥 Не вдалося розпізнати мовлення',
"all_failed": '💢 Жоден провайдер не зміг розшифрувати аудіо\n{err}',
"ffmpeg_missing": '💢 ffmpeg не знайдено в системі',
"auto_on": '✨ Авторозшифровку ввімкнено в цьому чаті',
"auto_off": '🌙 Авторозшифровку вимкнено в цьому чаті',
"key_set": '✨ API-ключ {provider} збережено',
"key_removed": '🌙 API-ключ {provider} видалено',
"key_usage": '💢 Використання: {prefix}v2tkey <groq|deepgram|mistral> [api_key]',
"groq_api_key_doc": "API-ключ Groq (console.groq.com) — резерв для довгих або складних аудіо",
"deepgram_api_key_doc": "API-ключ Deepgram (console.deepgram.com) — резервний провайдер",
"mistral_api_key_doc": "API-ключ Mistral (console.mistral.ai) — резервний провайдер (Voxtral)",
"lang_doc": "Мова розпізнавання (Google/Deepgram — формат ru-RU, Groq/Mistral — короткий код)",
"max_duration_doc": "Максимальна тривалість аудіо в секундах для авторозшифровки",
"list_header": '✨ Чати з авторозшифровкою: {count}',
"list_empty": '🫥 Авторозшифровку ніде не ввімкнено.\nУвімкнути в потрібному чаті: {prefix}v2tauto on',
"list_unavailable": "Чат недоступний",
"list_page": 'Сторінка {page}/{pages} · {prefix}v2tlist <сторінка>',
"list_usage": '💢 Вкажи сторінку від 1 до {pages}: {prefix}v2tlist <сторінка>',
"_cmd_doc_v2t": "(відповідь на голосове/відеоповідомлення) — розшифрувати в текст",
"_cmd_doc_v2tauto": "[on/off] — увімкнути/вимкнути авторозшифровку вхідних голосових у цьому чаті",
"_cmd_doc_v2tlist": "[сторінка] — чати, у яких увімкнено авторозшифровку",
"_cmd_doc_v2tkey": " [api_key] — задати/видалити ключ резервного провайдера",
}
strings_de = {
"_cls_doc": "Transkribiert Sprach- und Videonachrichten mit Google STT / Groq Whisper / Deepgram / Mistral",
"no_reply": '💢 Antworte auf eine Sprach- oder Videonachricht',
"not_voice": '💢 Dies ist keine Sprach- oder Videonachricht',
"processing": '✨ Transkription läuft...',
"result": '✨ Transkription:\n\n{text}
',
"empty_result": '🫥 Sprache konnte nicht erkannt werden',
"all_failed": '💢 Kein Anbieter konnte das Audio transkribieren\n{err}',
"ffmpeg_missing": '💢 ffmpeg wurde im System nicht gefunden',
"auto_on": '✨ Automatische Transkription in diesem Chat aktiviert',
"auto_off": '🌙 Automatische Transkription in diesem Chat deaktiviert',
"key_set": '✨ API-Schlüssel für {provider} gespeichert',
"key_removed": '🌙 API-Schlüssel für {provider} entfernt',
"key_usage": '💢 Verwendung: {prefix}v2tkey <groq|deepgram|mistral> [api_key]',
"groq_api_key_doc": "Groq-API-Schlüssel (console.groq.com) — Ersatz für lange oder komplexe Audios",
"deepgram_api_key_doc": "Deepgram-API-Schlüssel (console.deepgram.com) — Ersatzanbieter",
"mistral_api_key_doc": "Mistral-API-Schlüssel (console.mistral.ai) — Ersatzanbieter (Voxtral)",
"lang_doc": "Erkennungssprache (Google/Deepgram: Format ru-RU; Groq/Mistral: Sprachkürzel)",
"max_duration_doc": "Maximale Audiodauer in Sekunden für die automatische Transkription",
"list_header": '✨ Chats mit automatischer Transkription: {count}',
"list_empty": '🫥 Automatische Transkription ist in keinem Chat aktiviert.\nIm gewünschten Chat aktivieren: {prefix}v2tauto on',
"list_unavailable": "Chat nicht verfügbar",
"list_page": 'Seite {page}/{pages} · {prefix}v2tlist <Seite>',
"list_usage": '💢 Gib eine Seite von 1 bis {pages} an: {prefix}v2tlist <Seite>',
"_cmd_doc_v2t": "(Antwort auf eine Sprach-/Videonachricht) — in Text umwandeln",
"_cmd_doc_v2tauto": "[on/off] — automatische Transkription eingehender Sprachnachrichten in diesem Chat umschalten",
"_cmd_doc_v2tlist": "[Seite] — Chats mit aktivierter automatischer Transkription anzeigen",
"_cmd_doc_v2tkey": " [api_key] — API-Schlüssel eines Ersatzanbieters setzen/entfernen",
}
strings_jp = {
"_cls_doc": "Google STT / Groq Whisper / Deepgram / Mistral を使って音声・ビデオメッセージを文字起こしします",
"no_reply": '💢 音声またはビデオメッセージに返信してください',
"not_voice": '💢 これは音声・ビデオメッセージではありません',
"processing": '✨ 文字起こし中...',
"result": '✨ 文字起こし結果:\n\n{text}
',
"empty_result": '🫥 音声を認識できませんでした',
"all_failed": '💢 すべてのプロバイダーで文字起こしに失敗しました\n{err}',
"ffmpeg_missing": '💢 システムに ffmpeg が見つかりません',
"auto_on": '✨ このチャットで自動文字起こしを有効にしました',
"auto_off": '🌙 このチャットで自動文字起こしを無効にしました',
"key_set": '✨ {provider} の API キーを保存しました',
"key_removed": '🌙 {provider} の API キーを削除しました',
"key_usage": '💢 使い方: {prefix}v2tkey <groq|deepgram|mistral> [api_key]',
"groq_api_key_doc": "Groq API キー (console.groq.com) — 長い音声や複雑な音声用の予備",
"deepgram_api_key_doc": "Deepgram API キー (console.deepgram.com) — 予備プロバイダー",
"mistral_api_key_doc": "Mistral API キー (console.mistral.ai) — 予備プロバイダー (Voxtral)",
"lang_doc": "認識言語 (Google/Deepgram は ru-RU 形式、Groq/Mistral は短い言語コード)",
"max_duration_doc": "自動文字起こしの音声の最大長(秒)",
"list_header": '✨ 自動文字起こしが有効なチャット: {count}',
"list_empty": '🫥 自動文字起こしが有効なチャットはありません。\n対象のチャットで有効にする: {prefix}v2tauto on',
"list_unavailable": "チャットにアクセスできません",
"list_page": '{page}/{pages} ページ · {prefix}v2tlist <ページ>',
"list_usage": '💢 1 から {pages} までのページを指定してください: {prefix}v2tlist <ページ>',
"_cmd_doc_v2t": "(音声・ビデオメッセージへの返信) — 文字起こしする",
"_cmd_doc_v2tauto": "[on/off] — このチャットの受信音声の自動文字起こしを有効/無効にする",
"_cmd_doc_v2tlist": "[ページ] — 自動文字起こしが有効なチャットを表示する",
"_cmd_doc_v2tkey": " [api_key] — 予備プロバイダーの API キーを設定/削除する",
}
strings_neofit = {
"_cls_doc": "Speech-to-text pipeline: Google STT / Groq Whisper / Deepgram / Mistral",
"no_reply": '💢 InputError: reply to a voice or video message',
"not_voice": '💢 TypeError: expected a voice or video message',
"processing": '✨ Transcription job: running...',
"result": '✨ Transcription stdout:\n\n{text}
',
"empty_result": '🫥 EmptyResult: no speech recognized',
"all_failed": '💢 TranscriptionError: all providers failed\n{err}',
"ffmpeg_missing": '💢 FileNotFoundError: ffmpeg is not in PATH',
"auto_on": '✨ auto_transcription[this_chat] = True',
"auto_off": '🌙 auto_transcription[this_chat] = False',
"key_set": '✨ {provider}: API key persisted',
"key_removed": '🌙 {provider}: API key unset',
"key_usage": '💢 Usage: {prefix}v2tkey <groq|deepgram|mistral> [api_key]',
"groq_api_key_doc": "groq_api_key (console.groq.com): fallback for long or complex audio",
"deepgram_api_key_doc": "deepgram_api_key (console.deepgram.com): fallback provider",
"mistral_api_key_doc": "mistral_api_key (console.mistral.ai): Voxtral fallback provider",
"lang_doc": "Recognition locale: ru-RU format for Google/Deepgram; short code for Groq/Mistral",
"max_duration_doc": "Auto-transcription audio duration limit, in seconds",
"list_header": '✨ auto_transcription.enabled_chats: {count}',
"list_empty": '🫥 auto_transcription.enabled_chats = []\nRun in the target chat: {prefix}v2tauto on',
"list_unavailable": "EntityLookupError: chat unavailable",
"list_page": 'Page {page}/{pages} · {prefix}v2tlist <page>',
"list_usage": '💢 IndexError: page must be between 1 and {pages}: {prefix}v2tlist <page>',
"_cmd_doc_v2t": "(reply to voice/video) — run the speech-to-text pipeline",
"_cmd_doc_v2tauto": "[on/off] — set auto-transcription for incoming voice messages in this chat",
"_cmd_doc_v2tlist": "[page] — print auto_transcription.enabled_chats",
"_cmd_doc_v2tkey": " [api_key] — set/unset a fallback provider key",
}
strings_tiktok = {
"_cls_doc": "Переводит войсы и кружочки в текст через Google STT / Groq Whisper / Deepgram / Mistral",
"no_reply": '💢 Ответь на войс или кружочек, чел',
"not_voice": '💢 Это ни войс, ни кружочек',
"processing": '✨ Разбираю, что там наговорили...',
"result": '✨ Вот что в войсе:\n\n{text}
',
"empty_result": '🫥 Речь не разобрал, печаль',
"all_failed": '💢 Все провайдеры отвалились, войс не расшифрован\n{err}',
"ffmpeg_missing": '💢 ffmpeg на сервере нет, без него никак',
"auto_on": '✨ Авторасшифровку в этом чате врубил',
"auto_off": '🌙 Авторасшифровку в этом чате вырубил',
"key_set": '✨ Ключ {provider} сохранил, погнали',
"key_removed": '🌙 Ключ {provider} удалил',
"key_usage": '💢 Юзай так: {prefix}v2tkey <groq|deepgram|mistral> [api_key]',
"groq_api_key_doc": "Ключ Groq (console.groq.com) — подстраховка для длинных и сложных войсов",
"deepgram_api_key_doc": "Ключ Deepgram (console.deepgram.com) — запасной провайдер",
"mistral_api_key_doc": "Ключ Mistral (console.mistral.ai) — запасной провайдер Voxtral",
"lang_doc": "Язык войсов: Google/Deepgram — типа ru-RU, Groq/Mistral — короткий код",
"max_duration_doc": "Лимит длины войса в секундах для авторасшифровки",
"list_header": '✨ Авторасшифровка пашет тут: {count} чатов',
"list_empty": '🫥 Авторасшифровка нигде не врублена.\nВ нужном чате пиши: {prefix}v2tauto on',
"list_unavailable": "Чат недоступен, бывает",
"list_page": 'Страница {page}/{pages} · {prefix}v2tlist <страница>',
"list_usage": '💢 Дай страницу от 1 до {pages}: {prefix}v2tlist <страница>',
"_cmd_doc_v2t": "(ответ на войс/кружочек) — вытащить текст",
"_cmd_doc_v2tauto": "[on/off] — врубить/вырубить авторасшифровку входящих войсов в этом чате",
"_cmd_doc_v2tlist": "[страница] — глянуть, где пашет авторасшифровка",
"_cmd_doc_v2tkey": " [api_key] — закинуть/удалить ключ запасного провайдера",
}
strings_uwu = {
"_cls_doc": "Twanscwibes voice/video messages to text via fwee APIs (Google STT / Groq Whisper / Deepgram / Mistral)",
"no_reply": "💢 Wepwy to a voice ow video message",
"not_voice": "💢 This is not a voice ow video message",
"processing": "✨ Twanscwibing...",
"result": "✨ Twanscwiption:\n\n{text}
",
"empty_result": "🫥 Couwd not wecognize speech",
"all_failed": "💢 Aww pwovidews faiwed to twanscwibe the audio\n{err}",
"ffmpeg_missing": "💢 ffmpeg not found on the system",
"auto_on": "✨ Auto-twanscwiption enabwed in this chat",
"auto_off": "🌙 Auto-twanscwiption disabwed in this chat",
"key_set": "✨ {provider} API key saved",
"key_removed": "🌙 {provider} API key wemoved",
"key_usage": "💢 Usage: {prefix}v2tkey <groq|deepgram|mistral> [api_key]",
"groq_api_key_doc": "Groq API key (fwee, console.groq.com) — fawwback fow wong/compwex audio",
"deepgram_api_key_doc": "Deepgram API key (console.deepgram.com) — fawwback pwovidew",
"mistral_api_key_doc": "Mistral API key (console.mistral.ai) — fawwback pwovidew (Voxtral)",
"lang_doc": "Wecognition wanguage (Google/Deepgram — ru-RU fowmat, Groq/Mistral use the showt code)",
"max_duration_doc": "Maximum audio duwation in seconds fow auto-twanscwiption",
"list_header": "✨ Chats with auto-twanscwiption: {count}",
"list_empty": "🫥 Auto-twanscwiption is not enabwed in any chat.\nEnabwe it in the desiwed chat: {prefix}v2tauto on",
"list_unavailable": "Chat unavaiwabwe",
"list_page": "Page {page}/{pages} · {prefix}v2tlist <page>",
"list_usage": "💢 Entew a page fwom 1 to {pages}: {prefix}v2tlist <page>",
"_cmd_doc_v2t": "(wepwy to a voice/video message) — twanscwibe it to text",
"_cmd_doc_v2tauto": "[on/off] — enabwe/disabwe auto-twanscwiption of incoming voice messages in this chat",
"_cmd_doc_v2tlist": "[page] — wist chats with auto-twanscwiption enabwed",
"_cmd_doc_v2tkey": " [api_key] — set/wemove a pwovidew API key (fawwback)",
}
strings_leet = {
"_cls_doc": "7r4n5cr1b35 v01c3/v1d30 m3554g35 70 73x7 v14 fr33 4P15 (Google STT / Groq Whisper / Deepgram / Mistral)",
"no_reply": "💢 R3ply 70 4 v01c3 0r v1d30 m3554g3",
"not_voice": "💢 7h15 15 n07 4 v01c3 0r v1d30 m3554g3",
"processing": "✨ 7r4n5cr1b1ng...",
"result": "✨ 7r4n5cr1p710n:\n\n{text}
",
"empty_result": "🫥 C0uld n07 r3c0gn1z3 5p33ch",
"all_failed": "💢 4ll pr0v1d3r5 f41l3d 70 7r4n5cr1b3 7h3 4ud10\n{err}",
"ffmpeg_missing": "💢 ffmpeg n07 f0und 0n 7h3 5y573m",
"auto_on": "✨ 4u70-7r4n5cr1p710n 3n4bl3d 1n 7h15 ch47",
"auto_off": "🌙 4u70-7r4n5cr1p710n d154bl3d 1n 7h15 ch47",
"key_set": "✨ {provider} API k3y 54v3d",
"key_removed": "🌙 {provider} API k3y r3m0v3d",
"key_usage": "💢 U54g3: {prefix}v2tkey <groq|deepgram|mistral> [api_key]",
"groq_api_key_doc": "Groq API k3y (fr33, console.groq.com) — f4llb4ck f0r l0ng/c0mpl3x 4ud10",
"deepgram_api_key_doc": "Deepgram API k3y (console.deepgram.com) — f4llb4ck pr0v1d3r",
"mistral_api_key_doc": "Mistral API k3y (console.mistral.ai) — f4llb4ck pr0v1d3r (Voxtral)",
"lang_doc": "R3c0gn1710n l4ngu4g3 (Google/Deepgram — ru-RU f0rm47, Groq/Mistral u53 7h3 5h0r7 c0d3)",
"max_duration_doc": "M4x1mum 4ud10 dur4710n 1n 53c0nd5 f0r 4u70-7r4n5cr1p710n",
"list_header": "✨ Ch475 w17h 4u70-7r4n5cr1p710n: {count}",
"list_empty": "🫥 4u70-7r4n5cr1p710n 15 n07 3n4bl3d 1n 4ny ch47.\n3n4bl3 17 1n 7h3 d351r3d ch47: {prefix}v2tauto on",
"list_unavailable": "Ch47 un4v41l4bl3",
"list_page": "P4g3 {page}/{pages} · {prefix}v2tlist <page>",
"list_usage": "💢 3n73r 4 p4g3 fr0m 1 70 {pages}: {prefix}v2tlist <page>",
"_cmd_doc_v2t": "(r3ply 70 4 v01c3/v1d30 m3554g3) — 7r4n5cr1b3 17 70 73x7",
"_cmd_doc_v2tauto": "[on/off] — 3n4bl3/d154bl3 4u70-7r4n5cr1p710n 0f 1nc0m1ng v01c3 m3554g35 1n 7h15 ch47",
"_cmd_doc_v2tlist": "[p4g3] — l157 ch475 w17h 4u70-7r4n5cr1p710n 3n4bl3d",
"_cmd_doc_v2tkey": " [api_key] — 537/r3m0v3 4 pr0v1d3r API k3y (f4llb4ck)",
}
def __init__(self):
self.config = loader.ModuleConfig(
loader.ConfigValue(
"groq_api_key",
"",
lambda: self.strings("groq_api_key_doc"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"deepgram_api_key",
"",
lambda: self.strings("deepgram_api_key_doc"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"mistral_api_key",
"",
lambda: self.strings("mistral_api_key_doc"),
validator=loader.validators.Hidden(),
),
loader.ConfigValue(
"lang",
"ru-RU",
lambda: self.strings("lang_doc"),
validator=loader.validators.String(),
),
loader.ConfigValue(
"max_duration",
180,
lambda: self.strings("max_duration_doc"),
validator=loader.validators.Integer(minimum=1),
),
)
async def client_ready(self, client, db):
self.client = client
async def _run(self, *cmd: str) -> tuple[int, bytes, bytes]:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
return proc.returncode, stdout, stderr
async def _to_flac(self, src_path: str) -> str:
dst_path = src_path + ".flac"
rc, _, stderr = await self._run(
"ffmpeg", "-y", "-i", src_path,
"-ar", "16000", "-ac", "1", "-f", "flac", dst_path,
)
if rc != 0 or not os.path.exists(dst_path):
raise RuntimeError(f"ffmpeg error: {stderr.decode(errors='replace')[:300]}")
return dst_path
async def _probe_duration(self, path: str) -> float:
rc, stdout, _ = await self._run(
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path,
)
try:
return float(stdout.decode().strip())
except Exception:
return 0.0
async def _google_stt(self, flac_bytes: bytes, lang: str) -> str | None:
headers = {"Content-Type": "audio/x-flac; rate=16000"}
params = {
"output": "json",
"lang": lang,
"key": GOOGLE_SPEECH_KEY,
"pFilter": "0",
}
async with aiohttp.ClientSession() as session:
async with session.post(
GOOGLE_SPEECH_URL, params=params, headers=headers, data=flac_bytes, timeout=30
) as resp:
text = await resp.text()
if resp.status != 200:
raise RuntimeError(f"google http {resp.status}: {text[:200]}")
best = None
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
result = obj.get("result") or []
if result and result[0].get("alternative"):
best = result[0]["alternative"][0].get("transcript")
break
return best
async def _groq_stt(self, flac_bytes: bytes, key: str, lang: str) -> str | None:
form = aiohttp.FormData()
form.add_field("file", flac_bytes, filename="audio.flac", content_type="audio/flac")
form.add_field("model", GROQ_MODEL)
form.add_field("response_format", "json")
short_lang = (lang or "").split("-")[0]
if short_lang:
form.add_field("language", short_lang)
headers = {"Authorization": f"Bearer {key}"}
async with aiohttp.ClientSession() as session:
async with session.post(GROQ_URL, data=form, headers=headers, timeout=60) as resp:
body = await resp.text()
if resp.status != 200:
raise RuntimeError(f"groq http {resp.status}: {body[:300]}")
data = json.loads(body)
return data.get("text")
async def _deepgram_stt(self, flac_bytes: bytes, key: str, lang: str) -> str | None:
headers = {
"Authorization": f"Token {key}",
"Content-Type": "audio/flac",
}
params = {
"language": lang or "ru-RU",
"punctuate": "true",
"smart_format": "true",
}
async with aiohttp.ClientSession() as session:
async with session.post(
DEEPGRAM_URL, params=params, headers=headers, data=flac_bytes, timeout=60
) as resp:
body = await resp.text()
if resp.status != 200:
raise RuntimeError(f"deepgram http {resp.status}: {body[:300]}")
data = json.loads(body)
try:
return data["results"]["channels"][0]["alternatives"][0]["transcript"]
except (KeyError, IndexError):
return None
async def _mistral_stt(self, flac_bytes: bytes, key: str, lang: str) -> str | None:
form = aiohttp.FormData()
form.add_field("file", flac_bytes, filename="audio.flac", content_type="audio/flac")
form.add_field("model", MISTRAL_MODEL)
short_lang = (lang or "").split("-")[0]
if short_lang:
form.add_field("language", short_lang)
headers = {"Authorization": f"Bearer {key}"}
async with aiohttp.ClientSession() as session:
async with session.post(MISTRAL_URL, data=form, headers=headers, timeout=60) as resp:
body = await resp.text()
if resp.status != 200:
raise RuntimeError(f"mistral http {resp.status}: {body[:300]}")
data = json.loads(body)
return data.get("text")
async def _transcribe(self, flac_bytes: bytes) -> str:
lang = self.config["lang"] or "ru-RU"
errors = []
mistral_key = self.config["mistral_api_key"]
if mistral_key:
try:
text = await self._mistral_stt(flac_bytes, mistral_key, lang)
if text:
return text.strip()
except Exception as e:
errors.append(f"mistral: {e}")
deepgram_key = self.config["deepgram_api_key"]
if deepgram_key:
try:
text = await self._deepgram_stt(flac_bytes, deepgram_key, lang)
if text:
return text.strip()
except Exception as e:
errors.append(f"deepgram: {e}")
groq_key = self.config["groq_api_key"]
if groq_key:
try:
text = await self._groq_stt(flac_bytes, groq_key, lang)
if text:
return text.strip()
except Exception as e:
errors.append(f"groq: {e}")
try:
text = await self._google_stt(flac_bytes, lang)
if text:
return text.strip()
except Exception as e:
errors.append(f"google: {e}")
raise RuntimeError("\n".join(errors) if errors else "empty response")
async def _process_media(self, message: Message, target: Message) -> str | None:
with tempfile.TemporaryDirectory() as tmpdir:
src_path = os.path.join(tmpdir, "input.ogg")
await self.client.download_media(target, file=src_path)
try:
flac_path = await self._to_flac(src_path)
except FileNotFoundError:
await utils.answer(message, self.strings["ffmpeg_missing"])
return None
except RuntimeError as e:
await utils.answer(message, self.strings["all_failed"].format(err=str(e)))
return None
with open(flac_path, "rb") as f:
flac_bytes = f.read()
try:
text = await self._transcribe(flac_bytes)
except RuntimeError as e:
await utils.answer(message, self.strings["all_failed"].format(err=str(e)))
return None
if not text:
await utils.answer(message, self.strings["empty_result"])
return None
return text
def _is_voice_media(self, msg: Message) -> bool:
return bool(msg and (msg.voice or msg.video_note))
@loader.command()
async def v2t(self, message: Message):
"""(reply to a voice/video message) — transcribe it to text"""
reply = await message.get_reply_message()
if not reply:
await utils.answer(message, self.strings["no_reply"])
return
if not self._is_voice_media(reply):
await utils.answer(message, self.strings["not_voice"])
return
await utils.answer(message, self.strings["processing"])
text = await self._process_media(message, reply)
if text:
await utils.answer(message, self.strings["result"].format(text=utils.escape_html(text)))
@loader.command()
async def v2tauto(self, message: Message):
"""[on/off] — enable/disable auto-transcription of incoming voice messages in this chat"""
args = utils.get_args_raw(message).strip().lower()
chat_id = message.chat_id
if chat_id is None:
return
chats = set(self.get("auto_chats", []))
if args in ("on", "1", "true", "вкл"):
enabled = True
elif args in ("off", "0", "false", "выкл"):
enabled = False
else:
enabled = chat_id not in chats
if enabled:
chats.add(chat_id)
else:
chats.discard(chat_id)
self.set("auto_chats", sorted(chats))
await utils.answer(
message,
self.strings["auto_on"] if enabled else self.strings["auto_off"],
)
async def _auto_chat_name(self, chat_id, semaphore):
async with semaphore:
try:
entity = await asyncio.wait_for(self.client.get_entity(chat_id), timeout=3)
name = (
getattr(entity, "title", None)
or " ".join(
part for part in (
getattr(entity, "first_name", None),
getattr(entity, "last_name", None),
) if part
)
or getattr(entity, "username", None)
)
except Exception:
name = None
name = " ".join((name or "").split())
return utils.escape_html(name[:100] or self.strings["list_unavailable"])
@loader.command()
async def v2tlist(self, message: Message):
"""[page] — list chats with auto-transcription enabled"""
chats = sorted(set(self.get("auto_chats", [])))
prefix = utils.escape_html(self.get_prefix())
if not chats:
await utils.answer(message, self.strings["list_empty"].format(prefix=prefix))
return
pages = (len(chats) + 9) // 10
try:
page = int(utils.get_args_raw(message).strip() or "1")
except ValueError:
page = 0
if not 1 <= page <= pages:
await utils.answer(
message,
self.strings["list_usage"].format(prefix=prefix, pages=pages),
)
return
offset = (page - 1) * 10
selected = chats[offset:offset + 10]
semaphore = asyncio.Semaphore(4)
names = await asyncio.gather(
*(self._auto_chat_name(chat_id, semaphore) for chat_id in selected)
)
lines = [self.strings["list_header"].format(count=len(chats)), ""]
lines.extend(
f"{offset + index}. {name} — {chat_id}"
for index, (chat_id, name) in enumerate(zip(selected, names), start=1)
)
if pages > 1:
lines.extend((
"",
self.strings["list_page"].format(page=page, pages=pages, prefix=prefix),
))
await utils.answer(message, "\n".join(lines), parse_mode="HTML", link_preview=False)
@loader.command()
async def v2tkey(self, message: Message):
""" [api_key] — set/remove a provider API key (fallback)"""
args = utils.get_args(message)
if len(args) == 1 and args[0].lower() not in PROVIDER_KEYS:
provider = "groq"
key = args[0]
elif args and args[0].lower() in PROVIDER_KEYS:
provider = args[0].lower()
key = args[1] if len(args) > 1 else ""
else:
await utils.answer(
message,
self.strings["key_usage"].format(prefix=utils.escape_html(self.get_prefix())),
)
return
config_key = f"{provider}_api_key"
if not key:
self.config[config_key] = ""
await utils.answer(message, self.strings["key_removed"].format(provider=provider.capitalize()))
return
self.config[config_key] = key
await utils.answer(message, self.strings["key_set"].format(provider=provider.capitalize()))
@loader.watcher(only_messages=True)
async def watcher(self, message: Message):
if message.chat_id is None or message.chat_id not in self.get("auto_chats", []):
return
if message.out:
return
if not self._is_voice_media(message):
return
with tempfile.TemporaryDirectory() as tmpdir:
src_path = os.path.join(tmpdir, "probe.ogg")
await self.client.download_media(message, file=src_path)
duration = await self._probe_duration(src_path)
if duration and duration > self.config["max_duration"]:
return
try:
text = await self._process_media(message, message)
except Exception as e:
logger.error(f"VoiceToText watcher error: {e}")
return
if text:
await utils.answer(
message,
self.strings["result"].format(text=utils.escape_html(text)),
reply_to=message.id,
parse_mode="HTML",
)