diff --git a/adapter/telegram/converter.py b/adapter/telegram/converter.py new file mode 100644 index 0000000..1c00927 --- /dev/null +++ b/adapter/telegram/converter.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from aiogram.types import Message + +from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI + + +def from_message(message: Message) -> IncomingMessage | None: + """Convert aiogram Message to IncomingMessage. Returns None for General topic.""" + thread_id = message.message_thread_id + if thread_id is None: + return None + return IncomingMessage( + user_id=str(message.from_user.id), + chat_id=str(thread_id), + text=message.text or message.caption or "", + attachments=_extract_attachments(message), + platform="telegram", + ) + + +def _extract_attachments(message: Message) -> list[Attachment]: + attachments: list[Attachment] = [] + if message.photo: + file = message.photo[-1] + attachments.append(Attachment( + type="image", + url=f"tg://file/{file.file_id}", + mime_type="image/jpeg", + )) + if message.document: + attachments.append(Attachment( + type="document", + url=f"tg://file/{message.document.file_id}", + mime_type=message.document.mime_type or "application/octet-stream", + filename=message.document.file_name, + )) + if message.voice: + attachments.append(Attachment( + type="audio", + url=f"tg://file/{message.voice.file_id}", + mime_type="audio/ogg", + )) + return attachments + + +def format_outgoing(event: OutgoingEvent) -> str: + """Extract text from an outgoing event for sending to Telegram.""" + if isinstance(event, (OutgoingMessage, OutgoingUI)): + return event.text + return str(event) diff --git a/adapter/telegram/db.py b/adapter/telegram/db.py new file mode 100644 index 0000000..d9c10aa --- /dev/null +++ b/adapter/telegram/db.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import os +import sqlite3 +from contextlib import contextmanager + +DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db") + + +@contextmanager +def _conn(): + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + try: + yield con + con.commit() + finally: + con.close() + + +def init_db() -> None: + with _conn() as con: + con.executescript(""" + CREATE TABLE IF NOT EXISTS chats ( + user_id INTEGER NOT NULL, + thread_id INTEGER NOT NULL, + chat_name TEXT NOT NULL DEFAULT 'Чат #1', + archived_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, thread_id) + ); + """) + + +def create_chat(user_id: int, thread_id: int, chat_name: str) -> None: + with _conn() as con: + con.execute( + "INSERT OR IGNORE INTO chats (user_id, thread_id, chat_name) VALUES (?, ?, ?)", + (user_id, thread_id, chat_name), + ) + + +def get_chat(user_id: int, thread_id: int) -> dict | None: + with _conn() as con: + row = con.execute( + "SELECT * FROM chats WHERE user_id = ? AND thread_id = ?", + (user_id, thread_id), + ).fetchone() + return dict(row) if row else None + + +def get_active_chats(user_id: int) -> list[dict]: + with _conn() as con: + rows = con.execute( + "SELECT * FROM chats WHERE user_id = ? AND archived_at IS NULL " + "ORDER BY created_at ASC", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def count_active_chats(user_id: int) -> int: + with _conn() as con: + row = con.execute( + "SELECT COUNT(*) FROM chats WHERE user_id = ? AND archived_at IS NULL", + (user_id,), + ).fetchone() + return row[0] + + +def archive_chat(user_id: int, thread_id: int) -> None: + with _conn() as con: + con.execute( + "UPDATE chats SET archived_at = CURRENT_TIMESTAMP " + "WHERE user_id = ? AND thread_id = ?", + (user_id, thread_id), + ) + + +def rename_chat(user_id: int, thread_id: int, new_name: str) -> None: + with _conn() as con: + con.execute( + "UPDATE chats SET chat_name = ? WHERE user_id = ? AND thread_id = ?", + (new_name, user_id, thread_id), + ) + + +def get_display_number(user_id: int, thread_id: int) -> int: + """Return 1-based display number for a chat (by creation order).""" + with _conn() as con: + row = con.execute( + """ + SELECT rn FROM ( + SELECT thread_id, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn + FROM chats + WHERE user_id = ? + ) WHERE thread_id = ? + """, + (user_id, thread_id), + ).fetchone() + return row[0] if row else 1 diff --git a/tests/adapter/telegram/__init__.py b/tests/adapter/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapter/telegram/test_converter.py b/tests/adapter/telegram/test_converter.py new file mode 100644 index 0000000..38fd70a --- /dev/null +++ b/tests/adapter/telegram/test_converter.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from adapter.telegram.converter import format_outgoing, from_message +from core.protocol import OutgoingMessage, OutgoingUI + + +def make_message(*, text="hello", thread_id=42, user_id=1): + m = SimpleNamespace() + m.text = text + m.caption = None + m.photo = None + m.document = None + m.voice = None + m.message_thread_id = thread_id + m.from_user = SimpleNamespace(id=user_id, full_name="Alice") + return m + + +def test_from_message_in_topic(): + msg = make_message(thread_id=42, user_id=7) + result = from_message(msg) + assert result is not None + assert result.user_id == "7" + assert result.chat_id == "42" + assert result.text == "hello" + assert result.platform == "telegram" + + +def test_from_message_in_general_returns_none(): + msg = make_message(thread_id=None) + assert from_message(msg) is None + + +def test_from_message_uses_caption_if_no_text(): + msg = make_message(text=None, thread_id=10) + msg.caption = "caption text" + result = from_message(msg) + assert result.text == "caption text" + + +def test_format_outgoing_message(): + event = OutgoingMessage(chat_id="42", text="response") + assert format_outgoing(event) == "response" + + +def test_format_outgoing_ui(): + event = OutgoingUI(chat_id="42", text="choose") + assert format_outgoing(event) == "choose" diff --git a/tests/adapter/test_forum_db.py b/tests/adapter/test_forum_db.py new file mode 100644 index 0000000..e69adc4 --- /dev/null +++ b/tests/adapter/test_forum_db.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import importlib +import pytest + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) + import adapter.telegram.db as db_mod + importlib.reload(db_mod) + db_mod.init_db() + return db_mod + + +def test_create_and_get_chat(fresh_db): + db = fresh_db + db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1") + chat = db.get_chat(user_id=1, thread_id=100) + assert chat is not None + assert chat["chat_name"] == "Чат #1" + assert chat["archived_at"] is None + + +def test_get_chat_missing(fresh_db): + assert fresh_db.get_chat(user_id=1, thread_id=999) is None + + +def test_archive_chat(fresh_db): + db = fresh_db + db.create_chat(1, 100, "Чат #1") + db.archive_chat(1, 100) + chat = db.get_chat(1, 100) + assert chat["archived_at"] is not None + + +def test_rename_chat(fresh_db): + db = fresh_db + db.create_chat(1, 100, "Чат #1") + db.rename_chat(1, 100, "Новое имя") + assert db.get_chat(1, 100)["chat_name"] == "Новое имя" + + +def test_get_active_chats(fresh_db): + db = fresh_db + db.create_chat(1, 100, "Чат #1") + db.create_chat(1, 200, "Чат #2") + db.archive_chat(1, 100) + chats = db.get_active_chats(1) + assert len(chats) == 1 + assert chats[0]["thread_id"] == 200 + + +def test_display_number(fresh_db): + db = fresh_db + db.create_chat(1, 100, "Чат #1") + db.create_chat(1, 200, "Чат #2") + db.create_chat(1, 300, "Чат #3") + assert db.get_display_number(1, 100) == 1 + assert db.get_display_number(1, 200) == 2 + assert db.get_display_number(1, 300) == 3 + + +def test_count_active_chats(fresh_db): + db = fresh_db + db.create_chat(1, 100, "Чат #1") + db.create_chat(1, 200, "Чат #2") + db.archive_chat(1, 100) + assert db.count_active_chats(1) == 1 + + +def test_different_users_isolated(fresh_db): + db = fresh_db + db.create_chat(1, 100, "Чат #1") + db.create_chat(2, 100, "Чат #1") # same thread_id, different user + assert db.get_chat(1, 100)["chat_name"] == "Чат #1" + assert db.get_chat(2, 100)["chat_name"] == "Чат #1" + db.archive_chat(1, 100) + assert db.get_chat(1, 100)["archived_at"] is not None + assert db.get_chat(2, 100)["archived_at"] is None