diff --git a/assistant_bot/handlers.py b/assistant_bot/handlers.py index 3ba4b4b..4c24099 100644 --- a/assistant_bot/handlers.py +++ b/assistant_bot/handlers.py @@ -148,16 +148,18 @@ async def private_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N ) status_message = await update.message.reply_text("Распознаю голосовое сообщение...") temp_path: Path | None = None + transcript = "" try: telegram_file = await context.bot.get_file(voice.file_id) file_descriptor, raw_path = tempfile.mkstemp(suffix=".ogg") os.close(file_descriptor) - temp_path = Path(raw_path) - await telegram_file.download_to_drive(custom_path=temp_path) + voice_path = Path(raw_path) + temp_path = voice_path + await telegram_file.download_to_drive(custom_path=voice_path) transcript = await asyncio.to_thread( get_speech_recognizer(context).transcribe, - temp_path, + voice_path, ) except (SpeechRecognitionError, TelegramError): logger.exception("Failed to transcribe Telegram voice message") @@ -364,7 +366,7 @@ async def remind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> reminder_id = get_storage(context).add_reminder( user_id=user_id, - chat_id=int(update.effective_chat.id), + chat_id=int(update.message.chat_id), text=parsed.text, remind_at_utc=parsed.remind_at_utc, ) diff --git a/assistant_bot/speech.py b/assistant_bot/speech.py index 4a9e58a..15f59a9 100644 --- a/assistant_bot/speech.py +++ b/assistant_bot/speech.py @@ -2,7 +2,7 @@ import sys import threading import warnings from pathlib import Path -from typing import Any, Callable, Protocol +from typing import Any, Callable, Protocol, cast class SpeechRecognitionError(RuntimeError): @@ -21,9 +21,9 @@ def load_whisper_model_class() -> Any: missing = object() loaded_torch = sys.modules.get("torch", missing) if loaded_torch is missing: - sys.modules["torch"] = None + sys.modules["torch"] = cast(Any, None) try: - with warnings.catch_warnings(): + with warnings.catch_warnings(record=False): warnings.filterwarnings( "ignore", message="pkg_resources is deprecated as an API.*", diff --git a/assistant_bot/storage.py b/assistant_bot/storage.py index edb9232..43c5670 100644 --- a/assistant_bot/storage.py +++ b/assistant_bot/storage.py @@ -9,6 +9,17 @@ from .config import get_default_model from .time_utils import to_utc_iso, utc_now +def row_to_dict(row: sqlite3.Row) -> dict[str, Any]: + return {str(key): row[key] for key in row.keys()} + + +def required_lastrowid(cursor: sqlite3.Cursor) -> int: + lastrowid = cursor.lastrowid + if lastrowid is None: + raise RuntimeError("SQLite insert did not return a row ID") + return lastrowid + + class AssistantStorage: def __init__(self, path: Path) -> None: self.path = path @@ -162,7 +173,7 @@ class AssistantStorage: """, (user_id, chat_id, started_after_id, limit), ).fetchall() - return [dict(row) for row in reversed(rows)] + return [row_to_dict(row) for row in reversed(rows)] def start_new_conversation(self, user_id: int, chat_id: int) -> None: now = to_utc_iso(utc_now()) @@ -230,7 +241,7 @@ class AssistantStorage: age = max(0, newest_id - int(row["id"])) recency_weight = 1.0 / (1.0 + age / 50.0) score = (coverage + phrase_bonus) * (0.5 + 0.5 * recency_weight) - item = dict(row) + item = row_to_dict(row) item["score"] = round(score, 4) matches.append((score, item)) @@ -266,7 +277,7 @@ class AssistantStorage: (user_id, chat_id, message_id, radius), ).fetchall() rows = [*reversed(before), *after] - return [dict(row) for row in rows] + return [row_to_dict(row) for row in rows] def get_user_model(self, user_id: int, provider: str = "local") -> str: column = self._model_column(provider) @@ -315,7 +326,7 @@ class AssistantStorage: "INSERT INTO memories(user_id, text, created_at) VALUES (?, ?, ?)", (user_id, text, to_utc_iso(utc_now())), ) - return int(cursor.lastrowid) + return required_lastrowid(cursor) def list_memories(self, user_id: int, limit: int = 20) -> list[dict[str, Any]]: with self._connection() as connection: @@ -329,7 +340,7 @@ class AssistantStorage: """, (user_id, limit), ).fetchall() - return [dict(row) for row in rows] + return [row_to_dict(row) for row in rows] def delete_memory(self, user_id: int, memory_id: int) -> bool: with self._connection() as connection: @@ -345,7 +356,7 @@ class AssistantStorage: "INSERT INTO notes(user_id, text, created_at) VALUES (?, ?, ?)", (user_id, text, to_utc_iso(utc_now())), ) - return int(cursor.lastrowid) + return required_lastrowid(cursor) def list_notes(self, user_id: int, limit: int = 20) -> list[dict[str, Any]]: with self._connection() as connection: @@ -359,7 +370,7 @@ class AssistantStorage: """, (user_id, limit), ).fetchall() - return [dict(row) for row in rows] + return [row_to_dict(row) for row in rows] def delete_note(self, user_id: int, note_id: int) -> bool: with self._connection() as connection: @@ -390,7 +401,7 @@ class AssistantStorage: to_utc_iso(utc_now()), ), ) - return int(cursor.lastrowid) + return required_lastrowid(cursor) def list_reminders(self, user_id: int, limit: int = 20) -> list[dict[str, Any]]: with self._connection() as connection: @@ -404,7 +415,7 @@ class AssistantStorage: """, (user_id, limit), ).fetchall() - return [dict(row) for row in rows] + return [row_to_dict(row) for row in rows] def cancel_reminder(self, user_id: int, reminder_id: int) -> bool: with self._connection() as connection: @@ -430,7 +441,7 @@ class AssistantStorage: """, (to_utc_iso(now_utc), limit), ).fetchall() - return [dict(row) for row in rows] + return [row_to_dict(row) for row in rows] def mark_reminder_sent(self, reminder_id: int) -> None: with self._connection() as connection: @@ -453,7 +464,7 @@ class AssistantStorage: """, (user_id, title, status, now, now), ) - return int(cursor.lastrowid) + return required_lastrowid(cursor) def list_tracked_items(self, user_id: int, limit: int = 30) -> list[dict[str, Any]]: with self._connection() as connection: @@ -467,7 +478,7 @@ class AssistantStorage: """, (user_id, limit), ).fetchall() - return [dict(row) for row in rows] + return [row_to_dict(row) for row in rows] def set_tracked_status(self, user_id: int, item_id: int, status: str) -> bool: with self._connection() as connection: diff --git a/assistant_bot/telegram_utils.py b/assistant_bot/telegram_utils.py index 31e9351..6155a3d 100644 --- a/assistant_bot/telegram_utils.py +++ b/assistant_bot/telegram_utils.py @@ -57,12 +57,13 @@ def get_voice_max_duration(context: ContextTypes.DEFAULT_TYPE) -> int: def command_text(context: ContextTypes.DEFAULT_TYPE) -> str: - return " ".join(context.args).strip() + return " ".join(context.args or []).strip() def require_user_id(update: Update) -> int | None: - if update.effective_user: - return int(update.effective_user.id) + user = update.effective_user + if user: + return int(user.id) return None diff --git a/tests/test_core.py b/tests/test_core.py index 4c0d91e..05c6436 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -13,7 +13,10 @@ from assistant_bot.storage import AssistantStorage class ReminderParserTests(unittest.TestCase): def test_supported_relative_unit(self) -> None: - self.assertEqual(unit_to_timedelta(2, "часа").total_seconds(), 7200) + delta = unit_to_timedelta(2, "часа") + self.assertIsNotNone(delta) + assert delta is not None + self.assertEqual(delta.total_seconds(), 7200) def test_absolute_datetime(self) -> None: result = parse_reminder( diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 1fb766d..64d682c 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1,6 +1,7 @@ import asyncio import unittest from types import SimpleNamespace +from typing import Any from unittest.mock import patch from assistant_bot.jobs import post_init, post_shutdown @@ -10,11 +11,11 @@ class ReminderJobLifecycleTests(unittest.IsolatedAsyncioTestCase): async def test_reminder_task_is_cancelled_on_shutdown(self) -> None: started = asyncio.Event() - async def fake_reminder_loop(application) -> None: + async def fake_reminder_loop(_application) -> None: started.set() await asyncio.Event().wait() - application = SimpleNamespace(bot_data={}) + application: Any = SimpleNamespace(bot_data={}) with patch("assistant_bot.jobs.reminder_loop", fake_reminder_loop): await post_init(application) await started.wait() diff --git a/tests/test_voice_handler.py b/tests/test_voice_handler.py index 1b9d4e4..8e8bbee 100644 --- a/tests/test_voice_handler.py +++ b/tests/test_voice_handler.py @@ -1,6 +1,7 @@ import unittest from pathlib import Path from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from assistant_bot.handlers import private_voice @@ -15,7 +16,7 @@ class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase): voice=SimpleNamespace(duration=duration, file_id="voice-file-id"), reply_text=AsyncMock(return_value=status_message), ) - update = SimpleNamespace(message=message) + update: Any = SimpleNamespace(message=message) telegram_file = SimpleNamespace(download_to_drive=AsyncMock()) bot = SimpleNamespace( get_file=AsyncMock(return_value=telegram_file), @@ -23,7 +24,7 @@ class VoiceHandlerTests(unittest.IsolatedAsyncioTestCase): ) recognizer = MagicMock() recognizer.transcribe.return_value = transcript - context = SimpleNamespace( + context: Any = SimpleNamespace( bot=bot, application=SimpleNamespace( bot_data={ diff --git a/tests/test_yandex_ai.py b/tests/test_yandex_ai.py index e8d98b5..12877d1 100644 --- a/tests/test_yandex_ai.py +++ b/tests/test_yandex_ai.py @@ -38,6 +38,15 @@ class FakeChatCompletions: return self.completion +class FakeHTTPError(RuntimeError): + def __init__(self) -> None: + super().__init__("forbidden") + self.response = SimpleNamespace( + status_code=403, + text='{"error":{"message":"folder mismatch"}}', + ) + + class YandexAIClientTests(unittest.IsolatedAsyncioTestCase): def test_multiple_system_messages_are_merged_at_the_beginning(self) -> None: self.assertEqual( @@ -62,11 +71,7 @@ class YandexAIClientTests(unittest.IsolatedAsyncioTestCase): ) def test_http_error_includes_safe_response_body(self) -> None: - error = RuntimeError("forbidden") - error.response = SimpleNamespace( - status_code=403, - text='{"error":{"message":"folder mismatch"}}', - ) + error = FakeHTTPError() self.assertEqual( describe_yandex_error(error),