@@ -14,11 +14,20 @@ import subprocess
import tempfile
import threading
import time
import traceback
import uuid
from pathlib import Path
from typing import Any
from urllib import error , request
DEFAULT_ALLOWED_PLAYERS = " , " . join ( [
" malvviiina:Милана " ,
" zodiaktechnika32:Сергей " ,
" oidasyda:Иван " ,
" blackbyrd1:Ворон " ,
" dimasol1:Дима " ,
] )
def now_iso ( ) - > str :
return dt . datetime . now ( dt . timezone . utc ) . isoformat ( )
@@ -33,6 +42,21 @@ def normalize_username(value: str | None) -> str:
return value . lower ( )
def parse_allowed_players ( raw : str ) - > dict [ str , str ] :
players : dict [ str , str ] = { }
for item in ( raw or " " ) . split ( " , " ) :
part = item . strip ( )
if not part :
continue
username_part , sep , name_part = part . partition ( " : " )
username = normalize_username ( username_part )
if not username :
continue
player_name = ( name_part if sep else username_part ) . strip ( ) or username
players [ username ] = player_name
return players
def split_long_text ( text : str , chunk_size : int = 3500 ) - > list [ str ] :
text = ( text or " " ) . strip ( )
if not text :
@@ -55,6 +79,27 @@ def read_env_file(path: Path) -> dict[str, str]:
return result
class VoiceTranscriptionError ( RuntimeError ) :
def __init__ (
self ,
user_message : str ,
* ,
stage : str ,
retryable : bool = True ,
detail : str = " " ,
) :
super ( ) . __init__ ( user_message )
self . user_message = user_message
self . stage = stage
self . retryable = retryable
self . detail = detail
def log_text ( self ) - > str :
if self . detail and self . detail != self . user_message :
return f " { self . user_message } stage= { self . stage } retryable= { self . retryable } detail= { self . detail } "
return f " { self . user_message } stage= { self . stage } retryable= { self . retryable } "
class JsonLineStore :
@staticmethod
def load ( path : Path ) - > list [ dict [ str , Any ] ] :
@@ -116,11 +161,39 @@ class TelegramApi:
result = self . call ( " getUpdates " , payload = payload , timeout = timeout_sec + 15 )
return result . get ( " result " , [ ] )
def send_message ( self , chat_id : int , text : str , reply_to_message_id : int | None = None ) - > None :
def send_message ( self , chat_id : int | str , text : str , reply_to_message_id : int | None = None ) - > dict [ str , Any ] :
payload : dict [ str , Any ] = { " chat_id " : chat_id , " text " : text }
if reply_to_message_id is not None :
payload [ " reply_to_message_id " ] = reply_to_message_id
self . call ( " sendMessage " , payload = payload , timeout = 30 )
return self . call ( " sendMessage " , payload = payload , timeout = 30 )
def send_voice (
self ,
chat_id : int | str ,
voice : str ,
caption : str = " " ,
reply_to_message_id : int | None = None ,
) - > dict [ str , Any ] :
payload : dict [ str , Any ] = { " chat_id " : chat_id , " voice " : voice }
if caption :
payload [ " caption " ] = caption
if reply_to_message_id is not None :
payload [ " reply_to_message_id " ] = reply_to_message_id
return self . call ( " sendVoice " , payload = payload , timeout = 60 )
def send_audio (
self ,
chat_id : int | str ,
audio : str ,
caption : str = " " ,
reply_to_message_id : int | None = None ,
) - > dict [ str , Any ] :
payload : dict [ str , Any ] = { " chat_id " : chat_id , " audio " : audio }
if caption :
payload [ " caption " ] = caption
if reply_to_message_id is not None :
payload [ " reply_to_message_id " ] = reply_to_message_id
return self . call ( " sendAudio " , payload = payload , timeout = 60 )
def delete_webhook ( self ) - > None :
self . call ( " deleteWebhook " , payload = { " drop_pending_updates " : False } , timeout = 30 )
@@ -134,10 +207,13 @@ class BotConfig:
self . root_dir = root_dir
self . telegram_bot_token = self . _required ( env , " TELEGRAM_BOT_TOKEN " )
self . allowed_username = normalize_username ( env . get ( " ALLOWED_TELEGRAM_USERNAME " , " AidarKC " ) )
self . allowed_players = parse_allowed_players ( env . get ( " ALLOWED_TELEGRAM_PLAYERS " , DEFAULT_ALLOWED_PLAYERS ) )
self . allowed_channel_username = normalize_username ( env . get ( " ALLOWED_TELEGRAM_CHANNEL_USERNAME " , " shine_writing " ) )
self . bot_username = env . get ( " BOT_USERNAME " , " aidar_su_bot " )
self . openai_api_key = env . get ( " OPENAI_API_KEY " , " " ) . strip ( )
self . openai_transcribe_model = env . get ( " OPENAI_TRANSCRIBE_MODEL " , " gpt-4o-mini-transcribe " )
self . telegram_file_download_timeout_seconds = int ( env . get ( " TELEGRAM_FILE_DOWNLOAD_TIMEOUT_SECONDS " , " 300 " ) )
self . openai_transcribe_timeout_seconds = int ( env . get ( " OPENAI_TRANSCRIBE_TIMEOUT_SECONDS " , " 900 " ) )
self . codex_bin = Path ( env . get (
" CODEX_BIN " ,
" /home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl "
@@ -185,6 +261,20 @@ class ShinePyBotService:
self . last_heartbeat_at : float = 0.0
self . restart_requested = False
def _is_owner ( self , username : str ) - > bool :
return normalize_username ( username ) == self . cfg . allowed_username
def _is_allowed_player ( self , username : str ) - > bool :
return normalize_username ( username ) in self . cfg . allowed_players
def _is_allowed_user ( self , username : str ) - > bool :
uname = normalize_username ( username )
return uname == self . cfg . allowed_username or uname in self . cfg . allowed_players
def _player_name ( self , username : str ) - > str :
uname = normalize_username ( username )
return self . cfg . allowed_players . get ( uname , uname )
def run ( self ) - > None :
self . _ensure_dirs ( )
self . _acquire_single_instance_lock ( )
@@ -250,9 +340,16 @@ class ShinePyBotService:
self . state = json . loads ( self . state_file . read_text ( encoding = " utf-8 " ) )
else :
self . state = { }
sessions = self . state . get ( " user_sessions " )
if not isinstance ( sessions , dict ) :
sessions = { }
self . state [ " user_sessions " ] = sessions
if not self . state . get ( " current_history_file " ) :
history_file = self . _create_new_history_file ( " initial " )
history_file = self . _create_new_history_file ( " initial " , self . cfg . allowed_username )
self . state [ " current_history_file " ] = str ( history_file )
sessions [ self . cfg . allowed_username ] = { " current_history_file " : str ( history_file ) }
elif self . cfg . allowed_username not in sessions :
sessions [ self . cfg . allowed_username ] = { " current_history_file " : str ( self . state [ " current_history_file " ] ) }
if not isinstance ( self . state . get ( " next_job_number " ) , int ) :
self . state [ " next_job_number " ] = 1
self . state [ " updated_at " ] = now_iso ( )
@@ -320,26 +417,70 @@ class ShinePyBotService:
self . _persist_state ( )
def _current_history_file ( self ) - > Path :
return Path ( self . state [ " current_history_file " ] )
return self . _current_history_file_for_user ( self . cfg . allowed_username )
def _create_new_history_file ( self , reason : str ) - > Path :
def _history_dirs_for_user ( self , username : str ) - > tuple [ Path , Path ] :
uname = normalize_username ( username ) or " unknown "
history_dir = self . history_dir / uname
archive_dir = history_dir / " archive "
history_dir . mkdir ( parents = True , exist_ok = True )
archive_dir . mkdir ( parents = True , exist_ok = True )
return history_dir , archive_dir
def _ensure_user_session ( self , username : str ) - > None :
uname = normalize_username ( username ) or self . cfg . allowed_username
sessions = self . state . setdefault ( " user_sessions " , { } )
if not isinstance ( sessions , dict ) :
sessions = { }
self . state [ " user_sessions " ] = sessions
session = sessions . get ( uname )
if isinstance ( session , dict ) and session . get ( " current_history_file " ) :
return
history_file = self . _create_new_history_file ( " initial " , uname )
sessions [ uname ] = { " current_history_file " : str ( history_file ) }
if uname == self . cfg . allowed_username :
self . state [ " current_history_file " ] = str ( history_file )
self . _persist_state ( )
def _current_history_file_for_user ( self , username : str ) - > Path :
uname = normalize_username ( username ) or self . cfg . allowed_username
self . _ensure_user_session ( uname )
sessions = self . state . get ( " user_sessions " ) or { }
session = sessions . get ( uname ) or { }
return Path ( session [ " current_history_file " ] )
def _create_new_history_file ( self , reason : str , username : str ) - > Path :
ts = dt . datetime . now ( ) . strftime ( " % Y- % m- %d _ % H % M % S " )
rnd = " " . join ( random . choices ( string . hexdigits . lower ( ) , k = 8 ) )
path = self . history_dir / f " { ts } _ { rnd } .jsonl "
JsonLineStore . append ( path , { " ts " : now_iso ( ) , " type " : " history_created " , " reason " : reason } )
history_dir , _ = self . _history_dirs_for_user ( username )
path = history_dir / f " { ts } _ { rnd } .jsonl "
JsonLineStore . append ( path , {
" ts " : now_iso ( ) ,
" type " : " history_created " ,
" reason " : reason ,
" username " : normalize_username ( username ) ,
} )
return path
def _rotate_history ( self , reason : str , username : str ) - > Path :
current = self . _current_history_file ( )
uname = normalize_username ( username ) or self . cfg . allowed_username
current = self . _current_history_file_for_user ( uname )
_ , archive_dir = self . _history_dirs_for_user ( uname )
if current . exists ( ) :
archived = self . history_archive_dir / current . name
archived = archive_dir / current . name
current . replace ( archived )
else :
archived = self . history_archive_dir / " (empty) "
new_file = self . _create_new_history_file ( reason )
self . state [ " current_history_file " ] = str ( new_file )
archived = archive_dir / " (empty) "
new_file = self . _create_new_history_file ( reason , uname )
sessions = self . state . setdefault ( " user_sessions " , { } )
if not isinstance ( sessions , dict ) :
sessions = { }
self . state [ " user_sessions " ] = sessions
sessions [ uname ] = { " current_history_file " : str ( new_file ) }
if uname == self . cfg . allowed_username :
self . state [ " current_history_file " ] = str ( new_file )
self . _persist_state ( )
self . _append_history_event ( " history_rotated " , { " reason " : reason , " username " : username , " archived " : str ( archived ) } )
self . _append_history_event ( " history_rotated " , { " reason " : reason , " username " : uname , " archived " : str ( archived ) } , username = uname )
return archived
def _append_history ( self , history_path : Path , event_type : str , payload : dict [ str , Any ] ) - > None :
@@ -347,10 +488,28 @@ class ShinePyBotService:
row . update ( payload )
JsonLineStore . append ( history_path , row )
def _append_history_event ( self , event_type : str , payload : dict [ str , Any ] ) - > None :
history_path = self . _current_history_file ( )
def _append_history_event ( self , event_type : str , payload : dict [ str , Any ] , username : str | None = None ) - > None :
history_path = self . _current_history_file_for_user ( username or self . cfg . allowed_username )
self . _append_history ( history_path , " system_event " , { " event " : event_type , * * payload } )
def _send_player_welcome_once ( self , chat_id : int , message_id : int , username : str ) - > None :
uname = normalize_username ( username )
sent = self . state . get ( " player_welcome_sent " )
if not isinstance ( sent , dict ) :
sent = { }
self . state [ " player_welcome_sent " ] = sent
if sent . get ( uname ) :
return
player_name = self . _player_name ( uname )
text = (
f " Привет, { player_name } . \n "
" Можно задавать вопросы по проекту, просить анализ, идеи и подготовку готового ТЗ. \n "
" Команда /new начинает новую сессию и архивирует текущую историю. "
)
self . _safe_send ( chat_id , text , reply_to = message_id )
sent [ uname ] = now_iso ( )
self . _persist_state ( )
def _resolve_chat_id ( self , chat_id : int ) - > int :
migrations = self . state . get ( " chat_id_migrations " )
if not isinstance ( migrations , dict ) :
@@ -440,35 +599,39 @@ class ShinePyBotService:
)
if is_channel_post and not is_allowed_channel :
return
if chat_username and chat_username == self . cfg . allowed_channel_username :
self . _remember_public_report_chat ( chat_id )
# Игнорируем системные сообщения о входе/выходе и смене заголовка/фото.
if message . get ( " new_chat_members " ) or message . get ( " left_chat_member " ) :
return
if message . get ( " group_chat_created " ) or message . get ( " supergroup_chat_created " ) or message . get ( " channel_chat_created " ) :
return
text = ( message . get ( " text " ) or message . get ( " caption " ) or " " ) . strip ( )
history_path = self . _current_history_file ( )
if author_username ! = self . cfg . allowed_username :
if is_channel_post or is_group_message :
self . _append_history ( history_path , " chat_context_message " , {
" chatId " : chat_id ,
" messageId " : message_id ,
" updateType " : update_type ,
" chatType " : chat_type ,
" chatUsername " : chat_username ,
" chatTitle " : chat_title ,
" username " : username ,
" authorSignature " : author_signature ,
" text " : text ,
" hasVoice " : bool ( message . get ( " voice " ) ) ,
" hasAudio " : bool ( message . get ( " audio " ) ) ,
} )
if is_group_message :
self . _safe_send ( chat_id , " Получил сообщение. " , reply_to = message_id )
actor_username = normalize_username ( author_username )
is_allowed = self . _is_allowed_user ( actor_username )
is_private = chat_type == " private "
if not is_allowed :
if is_private :
self . _safe_send ( chat_id , " Извините, доступ к этому агенту пока не выдан. Обратитесь к Айдару. " , reply_to = message_id )
return
if self . _is_allowed_player ( actor_username ) and not is_private :
return
self . _ensure_user_session ( actor_username )
history_path = self . _current_history_file_for_user ( actor_username )
if self . _is_allowed_player ( actor_username ) :
self . _send_player_welcome_once ( chat_id , message_id , actor_username )
if not text :
if message . get ( " voice " ) :
self . _enqueue_voice_job (
chat_id ,
message_id ,
author_username ,
actor_username ,
message [ " voice " ] . get ( " file_id " ) ,
media_type = " voice " ,
update_type = update_type ,
chat_username = chat_username ,
chat_title = chat_title ,
@@ -480,8 +643,9 @@ class ShinePyBotService:
self . _enqueue_voice_job (
chat_id ,
message_id ,
author_username ,
actor_username ,
message [ " audio " ] . get ( " file_id " ) ,
media_type = " audio " ,
update_type = update_type ,
chat_username = chat_username ,
chat_title = chat_title ,
@@ -493,7 +657,7 @@ class ShinePyBotService:
return
if text . startswith ( " / " ) :
self . _handle_command ( chat_id , message_id , author_username , text )
self . _handle_command ( chat_id , message_id , actor_username , text )
return
self . _append_history ( history_path , " incoming_text " , {
@@ -503,11 +667,11 @@ class ShinePyBotService:
" chatType " : chat_type ,
" chatUsername " : chat_username ,
" chatTitle " : chat_title ,
" username " : author_username ,
" username " : actor_username ,
" authorSignature " : author_signature ,
" text " : text ,
} )
job = self . _build_job_base ( chat_id , message_id , author_username , str ( history_path ) )
job = self . _build_job_base ( chat_id , message_id , actor_username , str ( history_path ) )
job [ " type " ] = " text "
job [ " text " ] = text
job [ " update_type " ] = update_type
@@ -515,6 +679,8 @@ class ShinePyBotService:
job [ " chat_username " ] = chat_username
job [ " chat_title " ] = chat_title
job [ " author_signature " ] = author_signature
job [ " role " ] = " owner " if self . _is_owner ( actor_username ) else " player "
job [ " player_name " ] = self . _player_name ( actor_username ) if job [ " role " ] == " player " else " "
with self . queue_lock :
self . queue . append ( job )
self . _persist_queue ( )
@@ -527,6 +693,7 @@ class ShinePyBotService:
username : str ,
file_id : str | None ,
* ,
media_type : str = " voice " ,
update_type : str = " message " ,
chat_username : str = " " ,
chat_title : str = " " ,
@@ -536,7 +703,7 @@ class ShinePyBotService:
if not file_id :
self . _safe_send ( chat_id , " Не удалось прочитать file_id голосового. " , reply_to = message_id )
return
history_path = self . _current_history_file ( )
history_path = self . _current_history_file_for_user ( username )
self . _append_history ( history_path , " incoming_voice " , {
" chatId " : chat_id ,
" messageId " : message_id ,
@@ -547,15 +714,19 @@ class ShinePyBotService:
" username " : username ,
" authorSignature " : author_signature ,
" fileId " : file_id ,
" mediaType " : media_type ,
} )
job = self . _build_job_base ( chat_id , message_id , username , str ( history_path ) )
job [ " type " ] = " voice "
job [ " telegram_file_id " ] = file_id
job [ " telegram_media_type " ] = media_type
job [ " update_type " ] = update_type
job [ " chat_type " ] = chat_type
job [ " chat_username " ] = chat_username
job [ " chat_title " ] = chat_title
job [ " author_signature " ] = author_signature
job [ " role " ] = " owner " if self . _is_owner ( username ) else " player "
job [ " player_name " ] = self . _player_name ( username ) if job [ " role " ] == " player " else " "
with self . queue_lock :
self . queue . append ( job )
self . _persist_queue ( )
@@ -579,8 +750,11 @@ class ShinePyBotService:
" chat_username " : " " ,
" chat_title " : " " ,
" author_signature " : " " ,
" role " : " owner " ,
" player_name " : " " ,
" text " : " " ,
" telegram_file_id " : " " ,
" telegram_media_type " : " " ,
" history_file " : history_file ,
" attempts " : 0 ,
" retry_reason " : " " ,
@@ -592,8 +766,9 @@ class ShinePyBotService:
def _handle_command ( self , chat_id : int , message_id : int , username : str , text : str ) - > None :
lower = text . lower ( )
is_owner = self . _is_owner ( username )
if lower in ( " /start " , " /help " ) :
self . _safe_send ( chat_id , self . _help_text ( ) , reply_to = message_id )
self . _safe_send ( chat_id , self . _help_text ( is_owner = is_owner ) , reply_to = message_id )
return
if lower == " /status " :
self . _safe_send ( chat_id , self . _status_text ( ) , reply_to = message_id )
@@ -606,11 +781,14 @@ class ShinePyBotService:
self . _safe_send ( chat_id , f " История очищена. Новый диалог начат. \n Архив: { archived . name } " , reply_to = message_id )
return
if lower in ( " /restart_service " , " /restart " ) :
if not is_owner :
self . _safe_send ( chat_id , " Команда недоступна. " , reply_to = message_id )
return
self . _append_history_event ( " restart_service_requested " , {
" chatId " : chat_id ,
" messageId " : message_id ,
" username " : username ,
} )
} , username = username )
self . _safe_send (
chat_id ,
" Перезапускаю сервис. Если задача была активна, после старта она вернётся в очередь и продолжится. " ,
@@ -644,17 +822,19 @@ class ShinePyBotService:
self . _safe_send ( chat_id , f " Задача удалена: { arg } " if cancelled else f " Задача не найдена: { arg } " , reply_to = message_id )
return
def _help_text ( self ) - > str :
return (
" Доступные команды: \n "
" /status — активная задача и размер очереди \n "
" /queue — список задач в очереди \n "
" /stop — остановить текущую задачу \n "
" /cancel <id|all> — удалить задачу по id (префикс) или все \n "
" /new — архивировать историю и начать новую \n "
" /restart_service — перезапустить сервис через systemd \n "
" /help — эта справка "
)
def _help_text ( self , * , is_owner : bool ) - > str :
lines = [
" Доступные команды: " ,
" /status — активная задача и размер очереди " ,
" /queue — список задач в очереди " ,
" /stop — остановить текущую задачу " ,
" /cancel <id|all> — удалить задачу по id (префикс) или все " ,
" /new — архивировать историю и начать новую " ,
" /help — эта справка " ,
]
if is_owner :
lines . insert ( - 1 , " /restart_service — перезапустить сервис через systemd " )
return " \n " . join ( lines )
def _status_text ( self ) - > str :
with self . queue_lock :
@@ -768,6 +948,7 @@ class ShinePyBotService:
self . _safe_send ( chat_id , f " Готово # { job_num } . " , reply_to = message_id )
self . _append_history ( history_path , " codex_response " , { " jobId " : job_id , " text " : answer } )
self . _mark_job_done ( job_id )
self . _send_private_job_public_report ( job , answer )
except Exception as e :
if self . stop_current_job :
self . _append_history ( history_path , " job_stopped " , { " jobId " : job_id , " reason " : str ( e ) } )
@@ -775,6 +956,15 @@ class ShinePyBotService:
self . _mark_job_removed ( job_id )
self . stop_current_job = False
return
if isinstance ( e , VoiceTranscriptionError ) :
self . _append_history ( history_path , " voice_transcription_failed " , {
" jobId " : job_id ,
" jobNum " : job_num ,
" stage " : e . stage ,
" retryable " : e . retryable ,
" error " : e . user_message ,
" detail " : e . detail ,
} )
self . _handle_job_failure ( job , e )
def _build_prompt ( self , job : dict [ str , Any ] ) - > str :
@@ -782,6 +972,19 @@ class ShinePyBotService:
retry_reason = ( job . get ( " retry_reason " ) or " " ) . strip ( )
if retry_reason :
retry_block = f " \n \n Пометка retry: { retry_reason } "
role = ( job . get ( " role " ) or " owner " ) . strip ( ) . lower ( )
player_block = " "
if role == " player " :
player_name = ( job . get ( " player_name " ) or " " ) . strip ( )
player_dir = self . cfg . codex_workdir / " Players " / ( job . get ( " username " ) or " " )
player_block = (
" \n \n Режим игрока (обязательно): \n "
f " - Пользователь: { player_name } (@ { job . get ( ' username ' ) } ). \n "
f " - Рабочая папка игрока: { player_dir } \n "
" - Код проекта не изменять. \n "
" - Можно отвечать на вопросы по проекту, предлагать идеи и готовить ТЗ. \n "
" - Если нужны правки кода, описывать предложение текстом и сохранять материалы только в папке игрока. "
)
return (
" Пришло сообщение в Telegram. \n "
f " Тип: { job . get ( ' type ' ) } \n "
@@ -794,7 +997,7 @@ class ShinePyBotService:
f " { job . get ( ' text ' ) } \n \n "
f " История диалога (JSONL): { job . get ( ' history_file ' ) } \n "
f " Инструкции агента: { self . cfg . agent_instructions_file } \n "
f " Работай в рабочем проекте аккуратно и верни только текст ответа пользователю. { retry_block } "
f " Работай в рабочем проекте аккуратно и верни только текст ответа пользователю. { player_block } { retry_block } "
)
def _run_codex ( self , prompt : str , chat_id : int , message_id : int , job_id : str , job_num : Any ) - > str :
@@ -921,7 +1124,11 @@ class ShinePyBotService:
chat_id = int ( job [ " chat_id " ] )
message_id = int ( job [ " message_id " ] )
error_text = str ( err ) . strip ( ) or err . __class__ . __name__
print ( f " [py-bot] Ошибка job= { job_id [ : 8 ] } : { error_text } " , flush = True )
user_error_text = self . _user_error_text ( err )
retryable = not isinstance ( err , VoiceTranscriptionError ) or err . retryable
log_error_text = err . log_text ( ) if isinstance ( err , VoiceTranscriptionError ) else error_text
print ( f " [py-bot] Ошибка job= { job_id [ : 8 ] } : { log_error_text } " , flush = True )
print ( traceback . format_exc ( ) , flush = True )
with self . queue_lock :
target = next ( ( j for j in self . queue if j . get ( " id " ) == job_id ) , None )
@@ -931,7 +1138,7 @@ class ShinePyBotService:
target [ " attempts " ] = attempts
target [ " last_error " ] = error_text [ : 1000 ]
target [ " updated_at " ] = now_iso ( )
if attempts < self . cfg . max_retries :
if retryable and attempts < self . cfg . max_retries :
target [ " status " ] = " pending "
target [ " retry_reason " ] = error_text [ : 200 ]
self . _persist_queue ( )
@@ -942,9 +1149,19 @@ class ShinePyBotService:
will_retry = False
if will_retry :
self . _safe_send ( chat_id , f " Ошибка задачи # { job_num } , повтор: { attempts } / { self . cfg . max_retries } " , reply_to = message_id )
self . _safe_send (
chat_id ,
f " { user_error_text } \n Повторю задачу # { job_num } : попытка { attempts + 1 } / { self . cfg . max_retries } . " ,
reply_to = message_id ,
)
else :
self . _safe_send ( chat_id , f " Ошибка з адачи #{ job_num } . Лимит попыток исчерпан ." , reply_to = message_id )
self . _safe_send ( chat_id , f " { user_error_text } \n З адача #{ job_num } остановлена ." , reply_to = message_id )
def _user_error_text ( self , err : Exception ) - > str :
if isinstance ( err , VoiceTranscriptionError ) :
return f " Не удалось распознать голосовое: { err . user_message } "
error_text = str ( err ) . strip ( ) or err . __class__ . __name__
return f " Ошибка выполнения задачи: { error_text } "
def _mark_job_done ( self , job_id : str ) - > None :
with self . queue_lock :
@@ -956,27 +1173,145 @@ class ShinePyBotService:
self . queue = [ j for j in self . queue if j . get ( " id " ) != job_id ]
self . _persist_queue ( )
def _safe_send ( self , chat_id : int , text : str , reply_to : int | None = None ) - > None :
text = ( text or " " ) . strip ( )
if not text :
def _remember_public_report_chat ( self , chat_id : int ) - > None :
if self . state . get ( " public_report_chat_id " ) == chat_id :
return
if len ( text ) > 3900 :
text = text [ : 3900 ] + " \n ...[обрезано] "
resolved_chat_id = self . _resolve_chat_id ( chat_id )
resolved_reply_to = reply_to if resolved_chat_id == chat_id else None
self . state [ " public_report_chat_id " ] = chat_id
self . state [ " updated_at " ] = now_iso ( )
self . _persist_state ( )
def _public_report_chat_id ( self ) - > int | str | None :
chat_id = self . state . get ( " public_report_chat_id " )
if isinstance ( chat_id , int ) :
return self . _resolve_chat_id ( chat_id )
if self . cfg . allowed_channel_username :
return f " @ { self . cfg . allowed_channel_username } "
return None
def _send_private_job_public_report ( self , job : dict [ str , Any ] , answer : str ) - > None :
if job . get ( " chat_type " ) != " private " :
return
report_chat_id = self . _public_report_chat_id ( )
if report_chat_id is None :
return
job_num = job . get ( " num " , " ? " )
source_text = ( job . get ( " text " ) or " " ) . strip ( )
if not source_text :
source_text = " (пустой текст запроса) "
role = ( job . get ( " role " ) or " owner " ) . strip ( ) . lower ( )
author_label = " Айдар "
if role == " player " :
player_name = ( job . get ( " player_name " ) or " " ) . strip ( ) or job . get ( " username " ) or " Игрок "
author_label = f " { player_name } (@ { job . get ( ' username ' ) } ) "
if job . get ( " type " ) == " voice " :
voice_file_id = ( job . get ( " telegram_file_id " ) or " " ) . strip ( )
media_type = ( job . get ( " telegram_media_type " ) or " voice " ) . strip ( )
request_caption = self . _trim_telegram_caption (
f " { author_label } сделал { media_type } -запрос, задача # { job_num } . \n \n "
f " Распознанный текст: \n { source_text } "
)
request_message_id = None
if voice_file_id :
request_message_id = self . _safe_send_telegram_file (
report_chat_id ,
voice_file_id ,
media_type = media_type ,
caption = request_caption ,
)
if request_message_id is None :
request_message_id = self . _safe_send ( report_chat_id , request_caption )
else :
request_report = (
f " { author_label } сделал запрос, задача # { job_num } . \n \n "
f " { source_text } "
)
request_message_id = self . _safe_send ( report_chat_id , request_report )
if request_message_id is None :
return
answer_text = ( answer or " " ) . strip ( ) or " (пустой ответ) "
answer_chunks = split_long_text ( f " Ответ на задачу # { job_num } : \n \n { answer_text } " )
for chunk in answer_chunks :
self . _safe_send ( report_chat_id , chunk , reply_to = request_message_id )
@staticmethod
def _trim_telegram_caption ( text : str , limit : int = 1000 ) - > str :
text = ( text or " " ) . strip ( )
if len ( text ) < = limit :
return text
return text [ : limit ] . rstrip ( ) + " \n ...[обрезано] "
def _safe_send_telegram_file (
self ,
chat_id : int | str ,
file_id : str ,
* ,
media_type : str = " voice " ,
caption : str = " " ,
reply_to : int | None = None ,
) - > int | None :
file_id = ( file_id or " " ) . strip ( )
if not file_id :
return None
caption = self . _trim_telegram_caption ( caption )
resolved_chat_id : int | str = self . _resolve_chat_id ( chat_id ) if isinstance ( chat_id , int ) else chat_id
resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance ( chat_id , str ) else None
def send ( target_chat_id : int | str , target_reply_to : int | None ) - > dict [ str , Any ] :
if media_type == " audio " :
return self . telegram . send_audio ( target_chat_id , file_id , caption = caption , reply_to_message_id = target_reply_to )
return self . telegram . send_voice ( target_chat_id , file_id , caption = caption , reply_to_message_id = target_reply_to )
try :
self . telegram . send_message ( resolved_chat_id , text , reply_to_message_id = resolved_reply_to )
sent = send ( resolved_chat_id , resolved_reply_to )
result = sent . get ( " result " ) or { }
message_id = result . get ( " message_id " )
return message_id if isinstance ( message_id , int ) else None
except Exception as e :
migrate_to_chat_id = self . _extract_migrate_to_chat_id ( str ( e ) )
if migrate_to_chat_id is not None :
self . _remember_chat_migration ( resolved_chat_id , migrate_to_chat_id , " send_message_error " )
if isinstance ( resolved_chat_id , int ) :
self . _remember_chat_migration ( resolved_chat_id , migrate_to_chat_id , " send_file_error " )
try :
self . telegram . send_message ( migrate_to_chat_id , text , reply_to_message_id = None )
return
sent = send ( migrate_to_chat_id , None )
result = sent . get ( " result " ) or { }
message_id = result . get ( " message_id " )
return message_id if isinstance ( message_id , int ) else None
except Exception as retry_error :
print ( f " [py-bot] sendFile retry after migration error: { retry_error } " , flush = True )
return None
print ( f " [py-bot] sendFile error: { e } " , flush = True )
return None
def _safe_send ( self , chat_id : int | str , text : str , reply_to : int | None = None ) - > int | None :
text = ( text or " " ) . strip ( )
if not text :
return None
if len ( text ) > 3900 :
text = text [ : 3900 ] + " \n ...[обрезано] "
resolved_chat_id : int | str = self . _resolve_chat_id ( chat_id ) if isinstance ( chat_id , int ) else chat_id
resolved_reply_to = reply_to if resolved_chat_id == chat_id or isinstance ( chat_id , str ) else None
try :
sent = self . telegram . send_message ( resolved_chat_id , text , reply_to_message_id = resolved_reply_to )
result = sent . get ( " result " ) or { }
message_id = result . get ( " message_id " )
return message_id if isinstance ( message_id , int ) else None
except Exception as e :
migrate_to_chat_id = self . _extract_migrate_to_chat_id ( str ( e ) )
if migrate_to_chat_id is not None :
if isinstance ( resolved_chat_id , int ) :
self . _remember_chat_migration ( resolved_chat_id , migrate_to_chat_id , " send_message_error " )
try :
sent = self . telegram . send_message ( migrate_to_chat_id , text , reply_to_message_id = None )
result = sent . get ( " result " ) or { }
message_id = result . get ( " message_id " )
return message_id if isinstance ( message_id , int ) else None
except Exception as retry_error :
print ( f " [py-bot] sendMessage retry after migration error: { retry_error } " , flush = True )
return
return None
print ( f " [py-bot] sendMessage error: { e } " , flush = True )
return None
def _schedule_self_restart ( self ) - > None :
if self . restart_requested :
@@ -992,26 +1327,94 @@ class ShinePyBotService:
def _transcribe_voice_job ( self , job : dict [ str , Any ] ) - > str :
if not self . cfg . openai_api_key :
raise RuntimeError ( " Не задан OPENAI_API_KEY для распознавания voice " )
raise VoiceTranscriptionError (
" не настроен ключ OpenAI для распознавания. " ,
stage = " config " ,
retryable = False ,
)
file_id = ( job . get ( " telegram_file_id " ) or " " ) . strip ( )
if not file_id :
raise RuntimeError ( " Пустой telegram_file_id " )
raise VoiceTranscriptionError (
" Telegram не передал идентификатор файла. " ,
stage = " telegram_file_id " ,
retryable = False ,
)
job_id = str ( job . get ( " id " ) or " " ) [ : 8 ]
job_num = job . get ( " num " , " ? " )
media_type = ( job . get ( " telegram_media_type " ) or " voice " ) . strip ( )
started_at = time . time ( )
print ( f " [py-bot] transcribe start job= { job_id } num= { job_num } media= { media_type } " , flush = True )
file_bytes , filename = self . _download_telegram_file ( file_id )
print (
f " [py-bot] transcribe downloaded job= { job_id } filename= { filename } size= { len ( file_bytes ) } bytes " ,
flush = True ,
)
text = self . _openai_transcribe ( file_bytes , filename ) . strip ( )
if not text :
raise RuntimeError ( " Распознавание вернуло пустой текст " )
raise VoiceTranscriptionError (
" сервис распознавания вернул пустой текст. Возможно, в записи нет слышимой речи или качество звука слишком низкое. " ,
stage = " empty_text " ,
retryable = False ,
)
elapsed = self . _format_duration ( int ( time . time ( ) - started_at ) )
print ( f " [py-bot] transcribe done job= { job_id } chars= { len ( text ) } elapsed= { elapsed } " , flush = True )
return text
def _download_telegram_file ( self , file_id : str ) - > tuple [ bytes , str ] :
result = self . telegram . call ( " getFile " , { " file_id " : file_id } , timeout = 60 )
try :
result = self . telegram . call ( " getFile " , { " file_id " : file_id } , timeout = 120 )
except TimeoutError as e :
raise VoiceTranscriptionError (
" Telegram долго не отдавал информацию о файле. " ,
stage = " telegram_get_file_timeout " ,
detail = str ( e ) ,
) from e
except Exception as e :
raise VoiceTranscriptionError (
" не удалось получить информацию о файле из Telegram. " ,
stage = " telegram_get_file " ,
detail = str ( e ) ,
) from e
info = result . get ( " result " ) or { }
file_path = info . get ( " file_path " )
if not file_path :
raise RuntimeError ( " Telegram getFile не вернул file_path " )
raise VoiceTranscriptionError (
" Telegram не вернул путь к файлу. " ,
stage = " telegram_file_path " ,
retryable = True ,
detail = json . dumps ( info , ensure_ascii = False ) [ : 1000 ] ,
)
file_url = f " https://api.telegram.org/file/bot { self . cfg . telegram_bot_token } / { file_path } "
req = request . Request ( file_url , method = " GET " )
with request . urlopen ( req , timeout = 120 ) as resp :
data = resp . read ( )
try :
with request . urlopen ( req , timeout = self . cfg . telegram_file_download_timeout_seconds ) as resp :
data = resp . read ( )
except TimeoutError as e :
raise VoiceTranscriptionError (
f " Telegram не успел отдать аудиофайл за { self . cfg . telegram_file_download_timeout_seconds } секунд. " ,
stage = " telegram_download_timeout " ,
detail = str ( e ) ,
) from e
except error . HTTPError as e :
detail = e . read ( ) . decode ( " utf-8 " , errors = " replace " )
raise VoiceTranscriptionError (
f " Telegram вернул ошибку HTTP { e . code } при скачивании аудио. " ,
stage = " telegram_download_http " ,
retryable = e . code > = 500 or e . code == 429 ,
detail = detail [ : 1000 ] ,
) from e
except error . URLError as e :
raise VoiceTranscriptionError (
" не удалось скачать аудиофайл из Telegram из-за сетевой ошибки. " ,
stage = " telegram_download_network " ,
detail = str ( e . reason ) ,
) from e
if not data :
raise VoiceTranscriptionError (
" Telegram отдал пустой аудиофайл. " ,
stage = " telegram_download_empty " ,
retryable = True ,
)
original_name = Path ( file_path ) . name or " audio.ogg "
lower = original_name . lower ( )
# OpenAI transcription может не принимать расширение .oga, нормализуем в .ogg.
@@ -1051,11 +1454,40 @@ class ShinePyBotService:
req . add_header ( " Authorization " , f " Bearer { self . cfg . openai_api_key } " )
req . add_header ( " Content-Type " , f " multipart/form-data; boundary= { boundary } " )
try :
with request . urlopen ( req , timeout = 240 ) as resp :
with request . urlopen ( req , timeout = self . cfg . openai_transcribe_timeout_seconds ) as resp :
return resp . read ( ) . decode ( " utf-8 " , errors = " replace " )
except TimeoutError as e :
raise VoiceTranscriptionError (
f " OpenAI не успел распознать аудио за { self . cfg . openai_transcribe_timeout_seconds } секунд. " ,
stage = " openai_transcribe_timeout " ,
detail = str ( e ) ,
) from e
except error . HTTPError as e :
detail = e . read ( ) . decode ( " utf-8 " , errors = " replace " )
raise RuntimeError ( f " OpenAI transcribe HTTP { e . code } : { detail } " ) from e
if e . code == 400 :
user_message = " OpenAI не принял аудиофайл для распознавания. "
elif e . code == 401 :
user_message = " OpenAI отклонил ключ API для распознавания. "
elif e . code == 413 :
user_message = " аудиофайл слишком большой для распознавания OpenAI. "
elif e . code == 429 :
user_message = " OpenAI временно ограничил распознавание из-за лимита запросов. "
elif e . code > = 500 :
user_message = " OpenAI временно не смог обработать распознавание. "
else :
user_message = f " OpenAI вернул ошибку HTTP { e . code } при распознавании. "
raise VoiceTranscriptionError (
user_message ,
stage = " openai_transcribe_http " ,
retryable = e . code == 429 or e . code > = 500 ,
detail = detail [ : 1500 ] ,
) from e
except error . URLError as e :
raise VoiceTranscriptionError (
" не удалось отправить аудио в OpenAI из-за сетевой ошибки. " ,
stage = " openai_transcribe_network " ,
detail = str ( e . reason ) ,
) from e
@staticmethod
def _extract_codex_user_note ( line : str ) - > str | None :