feat(tg): db schema (user_id,thread_id) PK + converter context_key
This commit is contained in:
parent
5def360f8d
commit
82dc840544
5 changed files with 283 additions and 0 deletions
51
adapter/telegram/converter.py
Normal file
51
adapter/telegram/converter.py
Normal file
|
|
@ -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)
|
||||||
102
adapter/telegram/db.py
Normal file
102
adapter/telegram/db.py
Normal file
|
|
@ -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
|
||||||
0
tests/adapter/telegram/__init__.py
Normal file
0
tests/adapter/telegram/__init__.py
Normal file
50
tests/adapter/telegram/test_converter.py
Normal file
50
tests/adapter/telegram/test_converter.py
Normal file
|
|
@ -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"
|
||||||
80
tests/adapter/test_forum_db.py
Normal file
80
tests/adapter/test_forum_db.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue