|
|
|
|
@ -3,6 +3,7 @@ import logging
|
|
|
|
|
import os
|
|
|
|
|
import tempfile
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
from time import perf_counter
|
|
|
|
|
from typing import Any, Dict, List
|
|
|
|
|
|
|
|
|
|
import aiohttp
|
|
|
|
|
@ -153,6 +154,12 @@ class TelegramSatelliteService:
|
|
|
|
|
self._chat_workers_lock = asyncio.Lock()
|
|
|
|
|
self._chat_processing_semaphore = asyncio.Semaphore(TELEGRAM_MAX_CONCURRENT_CHATS)
|
|
|
|
|
|
|
|
|
|
def _log_telegram_event(self, event: str, **payload) -> None:
|
|
|
|
|
logger.info("telegram_event=%s payload=%s", event, mask_sensitive_payload(payload))
|
|
|
|
|
|
|
|
|
|
def _elapsed_ms(self, started_at: float) -> float:
|
|
|
|
|
return round((perf_counter() - started_at) * 1000, 2)
|
|
|
|
|
|
|
|
|
|
async def run(self) -> None:
|
|
|
|
|
"""Inicia loop de long polling para consumir atualizacoes do bot."""
|
|
|
|
|
logger.info("Telegram satellite iniciado com long polling.")
|
|
|
|
|
@ -292,9 +299,19 @@ class TelegramSatelliteService:
|
|
|
|
|
if queue is None:
|
|
|
|
|
queue = asyncio.Queue()
|
|
|
|
|
self._chat_queues[chat_id] = queue
|
|
|
|
|
update["_orq_enqueued_at_perf"] = perf_counter()
|
|
|
|
|
queue.put_nowait(update)
|
|
|
|
|
queue_size = queue.qsize()
|
|
|
|
|
|
|
|
|
|
worker = self._chat_workers.get(chat_id)
|
|
|
|
|
worker_active = worker is not None and not worker.done()
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"chat_update_enqueued",
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
update_id=update.get("update_id"),
|
|
|
|
|
queue_size=queue_size,
|
|
|
|
|
worker_active=worker_active,
|
|
|
|
|
)
|
|
|
|
|
if worker is None or worker.done():
|
|
|
|
|
self._chat_workers[chat_id] = asyncio.create_task(
|
|
|
|
|
self._run_chat_worker(
|
|
|
|
|
@ -315,6 +332,19 @@ class TelegramSatelliteService:
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
update = await queue.get()
|
|
|
|
|
queued_at_perf = update.get("_orq_enqueued_at_perf")
|
|
|
|
|
queue_wait_ms = (
|
|
|
|
|
round((perf_counter() - queued_at_perf) * 1000, 2)
|
|
|
|
|
if isinstance(queued_at_perf, (int, float))
|
|
|
|
|
else None
|
|
|
|
|
)
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"chat_update_dequeued",
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
update_id=update.get("update_id"),
|
|
|
|
|
queue_wait_ms=queue_wait_ms,
|
|
|
|
|
queue_size=queue.qsize(),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
async with self._chat_processing_semaphore:
|
|
|
|
|
await self._handle_update(session=session, update=update)
|
|
|
|
|
@ -410,12 +440,28 @@ class TelegramSatelliteService:
|
|
|
|
|
if offset is not None:
|
|
|
|
|
payload["offset"] = offset
|
|
|
|
|
|
|
|
|
|
started_at = perf_counter()
|
|
|
|
|
async with session.post(f"{self.base_url}/getUpdates", json=payload) as response:
|
|
|
|
|
data = await response.json()
|
|
|
|
|
if not data.get("ok"):
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"get_updates_failed",
|
|
|
|
|
offset=offset,
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
response=data,
|
|
|
|
|
)
|
|
|
|
|
logger.warning("Falha em getUpdates: %s", data)
|
|
|
|
|
return []
|
|
|
|
|
return data.get("result", [])
|
|
|
|
|
updates = data.get("result", [])
|
|
|
|
|
|
|
|
|
|
if updates:
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"get_updates_completed",
|
|
|
|
|
offset=offset,
|
|
|
|
|
updates_count=len(updates),
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
)
|
|
|
|
|
return updates
|
|
|
|
|
|
|
|
|
|
async def _handle_update(
|
|
|
|
|
self,
|
|
|
|
|
@ -423,11 +469,13 @@ class TelegramSatelliteService:
|
|
|
|
|
update: Dict[str, Any],
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Processa uma atualizacao recebida e envia resposta ao chat."""
|
|
|
|
|
started_at = perf_counter()
|
|
|
|
|
message = update.get("message", {})
|
|
|
|
|
text = message.get("text") or message.get("caption")
|
|
|
|
|
chat = message.get("chat", {})
|
|
|
|
|
chat_id = chat.get("id")
|
|
|
|
|
sender = message.get("from", {})
|
|
|
|
|
update_id = update.get("update_id")
|
|
|
|
|
|
|
|
|
|
if not chat_id:
|
|
|
|
|
return
|
|
|
|
|
@ -441,11 +489,29 @@ class TelegramSatelliteService:
|
|
|
|
|
self._build_update_idempotency_key(update),
|
|
|
|
|
)
|
|
|
|
|
await self._deliver_message(session=session, chat_id=chat_id, text=cached_answer)
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"update_completed",
|
|
|
|
|
update_id=update_id,
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
cached_hit=True,
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
input_chars=len(str(text or "")),
|
|
|
|
|
answer_chars=len(cached_answer),
|
|
|
|
|
image_count=0,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
image_attachments = await self._extract_image_attachments(session=session, message=message)
|
|
|
|
|
image_count = len(image_attachments)
|
|
|
|
|
|
|
|
|
|
if not text and not image_attachments:
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"update_ignored",
|
|
|
|
|
update_id=update_id,
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
reason="empty_text_and_no_image",
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
@ -463,10 +529,19 @@ class TelegramSatelliteService:
|
|
|
|
|
answer = "Nao consegui processar sua solicitacao agora. Tente novamente em instantes."
|
|
|
|
|
|
|
|
|
|
self._store_processed_update(update=update, answer=answer)
|
|
|
|
|
update_id = update.get("update_id")
|
|
|
|
|
if isinstance(update_id, int):
|
|
|
|
|
self._persist_last_processed_update_id(update_id)
|
|
|
|
|
await self._deliver_message(session=session, chat_id=chat_id, text=answer)
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"update_completed",
|
|
|
|
|
update_id=update_id,
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
cached_hit=False,
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
input_chars=len(str(text or "")),
|
|
|
|
|
answer_chars=len(str(answer or "")),
|
|
|
|
|
image_count=image_count,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _deliver_message(
|
|
|
|
|
self,
|
|
|
|
|
@ -488,17 +563,24 @@ class TelegramSatelliteService:
|
|
|
|
|
text: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Envia mensagem de texto para o chat informado no Telegram."""
|
|
|
|
|
for chunk_index, chunk in enumerate(_split_telegram_text(text), start=1):
|
|
|
|
|
chunks = _split_telegram_text(text)
|
|
|
|
|
started_at = perf_counter()
|
|
|
|
|
total_attempts = 0
|
|
|
|
|
successful_chunks = 0
|
|
|
|
|
for chunk_index, chunk in enumerate(chunks, start=1):
|
|
|
|
|
payload = {
|
|
|
|
|
"chat_id": chat_id,
|
|
|
|
|
"text": chunk,
|
|
|
|
|
}
|
|
|
|
|
for attempt in range(1, TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS + 1):
|
|
|
|
|
total_attempts += 1
|
|
|
|
|
try:
|
|
|
|
|
async with session.post(f"{self.base_url}/sendMessage", json=payload) as response:
|
|
|
|
|
data = await response.json()
|
|
|
|
|
if not data.get("ok"):
|
|
|
|
|
logger.warning("Falha em sendMessage: %s", data)
|
|
|
|
|
else:
|
|
|
|
|
successful_chunks += 1
|
|
|
|
|
break
|
|
|
|
|
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
|
|
|
|
|
if attempt >= TELEGRAM_SEND_MESSAGE_MAX_ATTEMPTS:
|
|
|
|
|
@ -522,6 +604,16 @@ class TelegramSatelliteService:
|
|
|
|
|
)
|
|
|
|
|
await asyncio.sleep(delay_seconds)
|
|
|
|
|
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"send_message_completed",
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
chunk_count=len(chunks),
|
|
|
|
|
successful_chunks=successful_chunks,
|
|
|
|
|
total_attempts=total_attempts,
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
text_chars=len(str(text or "")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Processa uma mensagem do Telegram e injeta o texto extraido de imagens quando houver.
|
|
|
|
|
async def _process_message(
|
|
|
|
|
self,
|
|
|
|
|
@ -531,22 +623,49 @@ class TelegramSatelliteService:
|
|
|
|
|
image_attachments: List[Dict[str, Any]] | None = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Encaminha mensagem ao orquestrador com usuario identificado e retorna resposta."""
|
|
|
|
|
started_at = perf_counter()
|
|
|
|
|
message_text = text
|
|
|
|
|
image_processing_ms = None
|
|
|
|
|
if image_attachments:
|
|
|
|
|
image_started_at = perf_counter()
|
|
|
|
|
image_message = await self._build_orchestration_message_from_image(
|
|
|
|
|
caption=text,
|
|
|
|
|
image_attachments=image_attachments,
|
|
|
|
|
)
|
|
|
|
|
image_processing_ms = self._elapsed_ms(image_started_at)
|
|
|
|
|
if self._is_image_analysis_failure_message(image_message):
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"process_message_completed",
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
image_processing_ms=image_processing_ms,
|
|
|
|
|
orchestration_offloaded=False,
|
|
|
|
|
used_image_attachments=True,
|
|
|
|
|
input_chars=len(str(text or "")),
|
|
|
|
|
response_chars=len(image_message),
|
|
|
|
|
)
|
|
|
|
|
return image_message
|
|
|
|
|
message_text = image_message
|
|
|
|
|
|
|
|
|
|
return await asyncio.to_thread(
|
|
|
|
|
orchestration_started_at = perf_counter()
|
|
|
|
|
answer = await asyncio.to_thread(
|
|
|
|
|
self._run_blocking_orchestration_turn,
|
|
|
|
|
message_text=message_text,
|
|
|
|
|
sender=sender,
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
)
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"process_message_completed",
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
image_processing_ms=image_processing_ms,
|
|
|
|
|
orchestration_ms=self._elapsed_ms(orchestration_started_at),
|
|
|
|
|
orchestration_offloaded=True,
|
|
|
|
|
used_image_attachments=bool(image_attachments),
|
|
|
|
|
input_chars=len(message_text),
|
|
|
|
|
response_chars=len(str(answer or "")),
|
|
|
|
|
)
|
|
|
|
|
return answer
|
|
|
|
|
|
|
|
|
|
def _run_blocking_orchestration_turn(
|
|
|
|
|
self,
|
|
|
|
|
@ -559,9 +678,11 @@ class TelegramSatelliteService:
|
|
|
|
|
Executa o turno do orquestrador fora do loop async principal.
|
|
|
|
|
Isso isola sessoes SQLAlchemy sincronas e outras operacoes bloqueantes.
|
|
|
|
|
"""
|
|
|
|
|
started_at = perf_counter()
|
|
|
|
|
tools_db = SessionLocal()
|
|
|
|
|
mock_db = SessionMockLocal()
|
|
|
|
|
try:
|
|
|
|
|
user_resolution_started_at = perf_counter()
|
|
|
|
|
user_service = UserService(mock_db)
|
|
|
|
|
external_id = str(sender.get("id") or chat_id)
|
|
|
|
|
first_name = (sender.get("first_name") or "").strip()
|
|
|
|
|
@ -575,12 +696,30 @@ class TelegramSatelliteService:
|
|
|
|
|
name=display_name,
|
|
|
|
|
username=username,
|
|
|
|
|
)
|
|
|
|
|
user_resolution_ms = self._elapsed_ms(user_resolution_started_at)
|
|
|
|
|
|
|
|
|
|
service_init_started_at = perf_counter()
|
|
|
|
|
service = OrquestradorService(
|
|
|
|
|
tools_db,
|
|
|
|
|
state_repository=self.state,
|
|
|
|
|
)
|
|
|
|
|
return asyncio.run(service.handle_message(message=message_text, user_id=user.id))
|
|
|
|
|
service_init_ms = self._elapsed_ms(service_init_started_at)
|
|
|
|
|
|
|
|
|
|
orchestration_started_at = perf_counter()
|
|
|
|
|
response = asyncio.run(service.handle_message(message=message_text, user_id=user.id))
|
|
|
|
|
orchestration_ms = self._elapsed_ms(orchestration_started_at)
|
|
|
|
|
self._log_telegram_event(
|
|
|
|
|
"blocking_turn_completed",
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
elapsed_ms=self._elapsed_ms(started_at),
|
|
|
|
|
user_resolution_ms=user_resolution_ms,
|
|
|
|
|
service_init_ms=service_init_ms,
|
|
|
|
|
orchestration_ms=orchestration_ms,
|
|
|
|
|
input_chars=len(message_text),
|
|
|
|
|
response_chars=len(str(response or "")),
|
|
|
|
|
)
|
|
|
|
|
return response
|
|
|
|
|
finally:
|
|
|
|
|
tools_db.close()
|
|
|
|
|
mock_db.close()
|
|
|
|
|
|