diff --git a/modules/channels/telegram/models.py b/modules/channels/telegram/models.py index 2b2dbf1..117e3bd 100644 --- a/modules/channels/telegram/models.py +++ b/modules/channels/telegram/models.py @@ -27,9 +27,10 @@ class TelegramSession(Base): # Composite primary key: bot_id + user_id bot_id: Mapped[str] = mapped_column(String(50), primary_key=True, default="default") user_id: Mapped[int] = mapped_column(Integer, primary_key=True) - chat_session_id: Mapped[str] = mapped_column( + chat_session_id: Mapped[Optional[str]] = mapped_column( String(50), - ForeignKey("chat_sessions.id", ondelete="CASCADE"), + ForeignKey("chat_sessions.id", ondelete="SET NULL"), + nullable=True, ) username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) first_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) diff --git a/modules/channels/telegram/router.py b/modules/channels/telegram/router.py index 9744943..b576f92 100644 --- a/modules/channels/telegram/router.py +++ b/modules/channels/telegram/router.py @@ -649,6 +649,24 @@ async def admin_get_bot_instance_sessions( return {"sessions": sessions} +@router.post("/instances/{instance_id}/register-user") +async def register_bot_user(instance_id: str, request: dict): + """Register a Telegram user for a bot instance (called by bot middleware, no auth).""" + user_id = request.get("user_id") + if not user_id: + raise HTTPException(status_code=400, detail="user_id required") + + await telegram_session_service.register_user( + user_id=user_id, + bot_id=instance_id, + username=request.get("username"), + first_name=request.get("first_name"), + last_name=request.get("last_name"), + ) + + return {"status": "ok"} + + @router.post("/instances/{instance_id}/sessions") async def admin_create_bot_instance_session(instance_id: str, request: dict): """Create/register a session for a bot instance (used by telegram_bot_service)""" diff --git a/modules/channels/telegram/service.py b/modules/channels/telegram/service.py index fc56baa..fdcb3ed 100644 --- a/modules/channels/telegram/service.py +++ b/modules/channels/telegram/service.py @@ -1,11 +1,13 @@ """Telegram channel services.""" import logging +from datetime import datetime from typing import Any, Dict, List, Optional from db.database import AsyncSessionLocal from db.repositories import BotInstanceRepository, TelegramRepository, UserIdentityRepository from db.retry import retry_on_busy +from modules.channels.telegram.models import TelegramSession logger = logging.getLogger(__name__) @@ -171,6 +173,64 @@ async def set_session( await session.commit() + async def register_user( + self, + user_id: int, + bot_id: str, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + ) -> None: + """Register a Telegram user for a bot (without chat session).""" + async with AsyncSessionLocal() as session: + repo = TelegramRepository(session, bot_id=bot_id) + existing = await repo.get_session(user_id, bot_id=bot_id) + if existing: + # Update user info + from sqlalchemy import select + + result = await session.execute( + select(TelegramSession).where( + TelegramSession.bot_id == bot_id, + TelegramSession.user_id == user_id, + ) + ) + ts = result.scalar_one_or_none() + if ts: + ts.username = username + ts.first_name = first_name + ts.last_name = last_name + ts.updated = datetime.utcnow() + else: + ts = TelegramSession( + bot_id=bot_id, + user_id=user_id, + chat_session_id=None, + username=username, + first_name=first_name, + last_name=last_name, + ) + session.add(ts) + + # Track identity + try: + identity_repo = UserIdentityRepository(session) + display = first_name or username or str(user_id) + await identity_repo.find_or_create( + provider="telegram", + provider_uid=str(user_id), + display_name=display, + metadata_dict={ + "username": username, + "first_name": first_name, + "last_name": last_name, + }, + ) + except Exception: + logger.debug("Failed to track Telegram identity for %s", user_id, exc_info=True) + + await session.commit() + async def get_all_sessions(self, bot_id: Optional[str] = None) -> List[dict]: """Get all sessions (optionally for specific bot).""" async with AsyncSessionLocal() as session: diff --git a/scripts/migrate_telegram_sessions_nullable.py b/scripts/migrate_telegram_sessions_nullable.py new file mode 100644 index 0000000..523e5b0 --- /dev/null +++ b/scripts/migrate_telegram_sessions_nullable.py @@ -0,0 +1,58 @@ +"""Make telegram_sessions.chat_session_id nullable. + +Allows registering users before they have a chat session. +""" + +import sqlite3 +import sys + + +DB_PATH = sys.argv[1] if len(sys.argv) > 1 else "data/secretary.db" + + +def migrate(db_path: str) -> None: + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=OFF") + + # Check current schema + schema = conn.execute("SELECT sql FROM sqlite_master WHERE name='telegram_sessions'").fetchone() + if not schema: + print("telegram_sessions table not found — nothing to do") + return + + # SQLite doesn't support ALTER COLUMN, so we recreate the table + conn.executescript(""" + BEGIN; + + CREATE TABLE telegram_sessions_new ( + bot_id VARCHAR(50) NOT NULL DEFAULT 'default', + user_id INTEGER NOT NULL, + chat_session_id VARCHAR(50) REFERENCES chat_sessions(id) ON DELETE SET NULL, + username VARCHAR(100), + first_name VARCHAR(100), + last_name VARCHAR(100), + created DATETIME DEFAULT CURRENT_TIMESTAMP, + updated DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (bot_id, user_id) + ); + + INSERT INTO telegram_sessions_new + SELECT bot_id, user_id, chat_session_id, username, first_name, last_name, created, updated + FROM telegram_sessions; + + DROP TABLE telegram_sessions; + + ALTER TABLE telegram_sessions_new RENAME TO telegram_sessions; + + CREATE INDEX ix_telegram_sessions_bot_user ON telegram_sessions(bot_id, user_id); + + COMMIT; + """) + conn.execute("PRAGMA foreign_keys=ON") + conn.close() + print(f"Migration done: chat_session_id is now nullable in {db_path}") + + +if __name__ == "__main__": + migrate(DB_PATH)