diff --git a/.gitignore b/.gitignore index e9d466b..09cf776 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ cover/ local_settings.py db.sqlite3 db.sqlite3-journal +*.db # Flask stuff: instance/ @@ -149,7 +150,19 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ +.idea/* +!.idea/runConfigurations/ +.idea/runConfigurations/* +!.idea/runConfigurations/*.xml +!.idea/codeStyles/ +.idea/codeStyles/* +!.idea/codeStyles/*.xml +!.idea/inspectionProfiles/ +.idea/inspectionProfiles/* +!.idea/inspectionProfiles/*.xml +!.idea/vcs.xml +!.idea/modules.xml +!.idea/*.iml # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/.idea/FastFetchBot.iml b/.idea/FastFetchBot.iml new file mode 100644 index 0000000..d9186b6 --- /dev/null +++ b/.idea/FastFetchBot.iml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..28e9740 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apps/telegram-bot/Dockerfile b/apps/telegram-bot/Dockerfile index 821fa64..2414abf 100644 --- a/apps/telegram-bot/Dockerfile +++ b/apps/telegram-bot/Dockerfile @@ -47,4 +47,5 @@ COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH COPY packages/ /app/packages/ COPY apps/telegram-bot/ /app/apps/telegram-bot/ WORKDIR /app/apps/telegram-bot +RUN mkdir -p /app/apps/telegram-bot/data CMD ["python", "-m", "core.main"] diff --git a/apps/telegram-bot/core/config.py b/apps/telegram-bot/core/config.py index 00d68a0..1a009b2 100644 --- a/apps/telegram-bot/core/config.py +++ b/apps/telegram-bot/core/config.py @@ -122,11 +122,16 @@ def ban_list_resolver(ban_list_string: str) -> list: GENERAL_SCRAPING_ON = get_env_bool(env, "GENERAL_SCRAPING_ON", False) # Database configuration -DATABASE_ON = get_env_bool(env, "DATABASE_ON", False) +ITEM_DATABASE_ON = get_env_bool(env, "ITEM_DATABASE_ON", False) MONGODB_PORT = int(env.get("MONGODB_PORT", 27017)) or 27017 MONGODB_HOST = env.get("MONGODB_HOST", "localhost") MONGODB_URL = env.get("MONGODB_URL", f"mongodb://{MONGODB_HOST}:{MONGODB_PORT}") +# User settings database (SQLAlchemy async) +SETTINGS_DATABASE_URL = env.get( + "SETTINGS_DATABASE_URL", "sqlite+aiosqlite:///data/fastfetchbot.db" +) + # Jinja2 template configuration templates_directory = os.path.join(current_directory, "templates") JINJA2_ENV = Environment( diff --git a/apps/telegram-bot/core/handlers/commands.py b/apps/telegram-bot/core/handlers/commands.py new file mode 100644 index 0000000..fa6ec01 --- /dev/null +++ b/apps/telegram-bot/core/handlers/commands.py @@ -0,0 +1,83 @@ +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import CallbackContext + +from core.services.user_settings import ( + ensure_user_settings, + get_auto_fetch_in_dm, + toggle_auto_fetch_in_dm, +) + + +async def start_command(update: Update, context: CallbackContext) -> None: + """Handle /start command: greet user and ensure settings row exists.""" + user_id = update.effective_user.id + await ensure_user_settings(user_id) + + await update.message.reply_text( + "Welcome to FastFetchBot!\n\n" + "Send me a URL in this chat and I'll fetch the content for you.\n\n" + "Available commands:\n" + "/settings — Customize bot behavior\n" + ) + + +async def settings_command(update: Update, context: CallbackContext) -> None: + """Handle /settings command: show current user settings with toggle buttons.""" + user_id = update.effective_user.id + await ensure_user_settings(user_id) + auto_fetch = await get_auto_fetch_in_dm(user_id) + + keyboard = _build_settings_keyboard(auto_fetch) + await update.message.reply_text( + text=_build_settings_text(auto_fetch), + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +async def settings_callback(update: Update, context: CallbackContext) -> None: + """Handle settings toggle button presses.""" + query = update.callback_query + await query.answer() + + data = query.data + + if data == "settings:close": + await query.message.delete() + return + + if data != "settings:toggle_auto_fetch": + return + + user_id = update.effective_user.id + new_value = await toggle_auto_fetch_in_dm(user_id) + + keyboard = _build_settings_keyboard(new_value) + await query.edit_message_text( + text=_build_settings_text(new_value), + reply_markup=InlineKeyboardMarkup(keyboard), + ) + + +def _build_settings_keyboard(auto_fetch: bool) -> list[list[InlineKeyboardButton]]: + status = "ON" if auto_fetch else "OFF" + return [ + [ + InlineKeyboardButton( + f"Auto-fetch in DM: {status}", + callback_data="settings:toggle_auto_fetch", + ) + ], + [ + InlineKeyboardButton("Close", callback_data="settings:close"), + ], + ] + + +def _build_settings_text(auto_fetch: bool) -> str: + status = "enabled" if auto_fetch else "disabled" + return ( + f"Your Settings\n\n" + f"Auto-fetch in DM: {status}\n" + f"When enabled, URLs sent in private chat will be automatically processed.\n" + f"When disabled, you will see action buttons to choose how to process each URL." + ) diff --git a/apps/telegram-bot/core/handlers/messages.py b/apps/telegram-bot/core/handlers/messages.py index 83f725f..b828ef2 100644 --- a/apps/telegram-bot/core/handlers/messages.py +++ b/apps/telegram-bot/core/handlers/messages.py @@ -13,17 +13,28 @@ from core.database import save_instances from core.models.telegram_chat import TelegramMessage, TelegramUser, TelegramChat +from core.services.user_settings import ensure_user_settings from fastfetchbot_shared.utils.logger import logger from core.config import ( TELEBOT_DEBUG_CHANNEL, - DATABASE_ON, + ITEM_DATABASE_ON, ) async def all_messages_process(update: Update, context: CallbackContext) -> None: message = update.message logger.debug(message) - if message and DATABASE_ON: + + # Ensure every private-chat user has a settings row from their first interaction. + if message and message.chat.type == "private" and message.from_user: + try: + await ensure_user_settings(message.from_user.id) + except Exception: + logger.warning( + "Failed to ensure user settings for user {}", message.from_user.id + ) + + if message and ITEM_DATABASE_ON: telegram_chat = TelegramChat.construct(**message.chat.to_dict()) telegram_user = TelegramUser.construct(**message.from_user.to_dict()) telegram_message = TelegramMessage( diff --git a/apps/telegram-bot/core/handlers/url_process.py b/apps/telegram-bot/core/handlers/url_process.py index 2497891..1f33db2 100644 --- a/apps/telegram-bot/core/handlers/url_process.py +++ b/apps/telegram-bot/core/handlers/url_process.py @@ -9,6 +9,7 @@ from core import api_client from core.services.message_sender import send_item_message +from core.services.user_settings import get_auto_fetch_in_dm from fastfetchbot_shared.utils.config import SOCIAL_MEDIA_WEBSITE_PATTERNS, VIDEO_WEBSITE_PATTERNS from fastfetchbot_shared.utils.logger import logger from core.config import ( @@ -24,6 +25,13 @@ async def https_url_process(update: Update, context: CallbackContext) -> None: message = update.message + + # Check user's auto-fetch preference + auto_fetch = await get_auto_fetch_in_dm(message.from_user.id) + if auto_fetch: + await _auto_fetch_urls(message) + return + welcome_message = await message.reply_text( text="Processing...", ) @@ -198,6 +206,33 @@ async def https_url_process(update: Update, context: CallbackContext) -> None: await process_message.delete() +async def _auto_fetch_urls(message) -> None: + """Auto-fetch all URLs in a DM message without showing action buttons.""" + url_dict = message.parse_entities(types=["url"]) + for i, url in enumerate(url_dict.values()): + url_metadata = await api_client.get_url_metadata( + url, ban_list=TELEGRAM_BOT_MESSAGE_BAN_LIST + ) + if url_metadata["source"] == "unknown" and GENERAL_SCRAPING_ON: + metadata_item = await api_client.get_item(url=url_metadata["url"]) + await send_item_message( + metadata_item, chat_id=message.chat_id + ) + elif url_metadata["source"] == "unknown" or url_metadata["source"] == "banned": + logger.debug(f"for the {i + 1}th url {url}, no supported url found.") + continue + if url_metadata.get("source") in SOCIAL_MEDIA_WEBSITE_PATTERNS.keys(): + metadata_item = await api_client.get_item(url=url_metadata["url"]) + await send_item_message( + metadata_item, chat_id=message.chat_id + ) + if url_metadata.get("source") in VIDEO_WEBSITE_PATTERNS.keys(): + metadata_item = await api_client.get_item(url=url_metadata["url"]) + await send_item_message( + metadata_item, chat_id=message.chat_id + ) + + async def https_url_auto_process(update: Update, context: CallbackContext) -> None: message = update.message url_dict = message.parse_entities(types=["url"]) diff --git a/apps/telegram-bot/core/services/bot_app.py b/apps/telegram-bot/core/services/bot_app.py index 6ecee75..5ef9624 100644 --- a/apps/telegram-bot/core/services/bot_app.py +++ b/apps/telegram-bot/core/services/bot_app.py @@ -3,11 +3,13 @@ mimetypes.init() from telegram import ( + BotCommand, Update, MessageEntity, ) from telegram.ext import ( Application, + CommandHandler, MessageHandler, CallbackQueryHandler, filters, @@ -32,6 +34,7 @@ from core.handlers.url_process import https_url_process, https_url_auto_process from core.handlers.buttons import buttons_process, invalid_buttons +from core.handlers.commands import start_command, settings_command, settings_callback from core.handlers.messages import all_messages_process, error_process # Re-export for external consumers @@ -94,6 +97,11 @@ async def startup() -> None: & filters.USER, callback=https_url_auto_process, ) + start_command_handler = CommandHandler("start", start_command) + settings_command_handler = CommandHandler("settings", settings_command) + settings_callback_handler = CallbackQueryHandler( + callback=settings_callback, pattern=r"^settings:" + ) invalid_buttons_handler = CallbackQueryHandler( callback=invalid_buttons, pattern=InvalidCallbackData, @@ -104,14 +112,24 @@ async def startup() -> None: # add handlers application.add_handlers( [ + start_command_handler, + settings_command_handler, https_url_process_handler, https_url_auto_process_handler, all_messages_handler, + settings_callback_handler, invalid_buttons_handler, buttons_process_handler, ] ) application.add_error_handler(error_process) + # Register bot menu commands + await application.bot.set_my_commands( + [ + BotCommand("start", "Start the bot"), + BotCommand("settings", "Customize bot behavior"), + ] + ) if application.post_init: await application.post_init() await application.start() diff --git a/apps/telegram-bot/core/services/user_settings.py b/apps/telegram-bot/core/services/user_settings.py new file mode 100644 index 0000000..9d17c0b --- /dev/null +++ b/apps/telegram-bot/core/services/user_settings.py @@ -0,0 +1,52 @@ +from sqlalchemy import select + +from fastfetchbot_shared.database.session import get_session +from fastfetchbot_shared.database.models.user_setting import UserSetting + +# In-memory cache of user IDs known to have a settings row. +# Resets on process restart — cheap way to avoid a DB query on every message. +_known_user_ids: set[int] = set() + + +async def ensure_user_settings(user_id: int) -> None: + """Create a UserSetting row with defaults if one doesn't exist yet.""" + if user_id in _known_user_ids: + return + async with get_session() as session: + result = await session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == user_id) + ) + if result.scalar_one_or_none() is None: + session.add(UserSetting(telegram_user_id=user_id)) + _known_user_ids.add(user_id) + + +async def get_auto_fetch_in_dm(user_id: int) -> bool: + """Return the user's auto_fetch_in_dm preference. Defaults to True.""" + async with get_session() as session: + result = await session.execute( + select(UserSetting.auto_fetch_in_dm).where( + UserSetting.telegram_user_id == user_id + ) + ) + value = result.scalar_one_or_none() + return value if value is not None else True + + +async def toggle_auto_fetch_in_dm(user_id: int) -> bool: + """Toggle auto_fetch_in_dm for the given user. Returns the new value.""" + async with get_session() as session: + result = await session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == user_id) + ) + user_setting = result.scalar_one_or_none() + if user_setting is None: + # Safety fallback — ensure_user_settings should have been called, + # but handle gracefully. + user_setting = UserSetting( + telegram_user_id=user_id, auto_fetch_in_dm=False + ) + session.add(user_setting) + else: + user_setting.auto_fetch_in_dm = not user_setting.auto_fetch_in_dm + return user_setting.auto_fetch_in_dm diff --git a/apps/telegram-bot/core/webhook/server.py b/apps/telegram-bot/core/webhook/server.py index 22189a0..b3c9700 100644 --- a/apps/telegram-bot/core/webhook/server.py +++ b/apps/telegram-bot/core/webhook/server.py @@ -21,12 +21,14 @@ async def lifespan(app): update_queue all share one event loop. """ from core.services.bot_app import startup, shutdown, set_webhook, start_polling, show_bot_info - from core.config import TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_MODE, DATABASE_ON + from core.config import TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_MODE, ITEM_DATABASE_ON + from fastfetchbot_shared.database import init_db, close_db # -- startup -- - if DATABASE_ON: + if ITEM_DATABASE_ON: from core import database await database.startup() + await init_db() if TELEGRAM_BOT_TOKEN: await startup() if TELEGRAM_BOT_MODE == "webhook": @@ -44,7 +46,8 @@ async def lifespan(app): # -- shutdown -- if TELEGRAM_BOT_TOKEN: await shutdown() - if DATABASE_ON: + await close_db() + if ITEM_DATABASE_ON: from core import database await database.shutdown() diff --git a/apps/telegram-bot/pyproject.toml b/apps/telegram-bot/pyproject.toml index 0ffbb01..8032fbd 100644 --- a/apps/telegram-bot/pyproject.toml +++ b/apps/telegram-bot/pyproject.toml @@ -3,7 +3,7 @@ name = "fastfetchbot-telegram-bot" version = "0.1.0" requires-python = ">=3.12,<3.13" dependencies = [ - "fastfetchbot-shared", + "fastfetchbot-shared[postgres]", "python-telegram-bot[callback-data,rate-limiter]>=21.11", "starlette>=0.45.0", "uvicorn>=0.34.2", diff --git a/docker-compose.template.yml b/docker-compose.template.yml index 24aff9c..05837c2 100644 --- a/docker-compose.template.yml +++ b/docker-compose.template.yml @@ -27,6 +27,8 @@ services: # dockerfile: apps/telegram-bot/Dockerfile container_name: fastfetchbot-telegram-bot # restart: always + volumes: + - telegram_bot_data:/app/apps/telegram-bot/data env_file: - .env environment: @@ -36,6 +38,7 @@ services: depends_on: - api - telegram-bot-api + # - postgres # Uncomment when using PostgreSQL for user settings telegram-bot-api: image: aiogram/telegram-bot-api:latest @@ -54,6 +57,22 @@ services: - TELEGRAM_STAT=1 - TELEBOT_API_SERVER_PORT=8081 + # Uncomment to use PostgreSQL for user settings instead of SQLite. + # Set SETTINGS_DATABASE_URL=postgresql+asyncpg://fastfetchbot:fastfetchbot@postgres:5432/fastfetchbot + # in your .env file, and uncomment the depends_on entry in the telegram-bot service. + # postgres: + # image: postgres:16-alpine + # container_name: fastfetchbot-postgres + # restart: always + # volumes: + # - postgres_data:/var/lib/postgresql/data + # environment: + # - POSTGRES_USER=fastfetchbot + # - POSTGRES_PASSWORD=fastfetchbot + # - POSTGRES_DB=fastfetchbot + # ports: + # - 5432:5432 + redis: image: redis:7-alpine container_name: fastfetchbot-redis @@ -85,5 +104,7 @@ services: volumes: telegram-bot-api-data-cache: + telegram_bot_data: + # postgres_data: # Uncomment when using PostgreSQL redis_data: shared_files: diff --git a/packages/shared/alembic.ini b/packages/shared/alembic.ini new file mode 100644 index 0000000..5b6c85f --- /dev/null +++ b/packages/shared/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = %(here)s/alembic +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/packages/shared/alembic/env.py b/packages/shared/alembic/env.py new file mode 100644 index 0000000..88143be --- /dev/null +++ b/packages/shared/alembic/env.py @@ -0,0 +1,58 @@ +import asyncio +import os +import sys +from pathlib import Path + +# Ensure the shared package is importable when running alembic from any directory +_shared_root = Path(__file__).resolve().parent.parent +if str(_shared_root) not in sys.path: + sys.path.insert(0, str(_shared_root)) + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from fastfetchbot_shared.database.base import Base + +import fastfetchbot_shared.database.models # noqa: F401 + +target_metadata = Base.metadata + +_DEFAULT_DATABASE_URL = "sqlite+aiosqlite:///data/fastfetchbot.db" + + +def get_url() -> str: + return os.environ.get("SETTINGS_DATABASE_URL", _DEFAULT_DATABASE_URL) + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + engine = create_async_engine(get_url()) + async with engine.connect() as connection: + await connection.run_sync(do_run_migrations) + await engine.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/packages/shared/alembic/script.py.mako b/packages/shared/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/packages/shared/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/packages/shared/alembic/versions/0001_add_user_settings_table.py b/packages/shared/alembic/versions/0001_add_user_settings_table.py new file mode 100644 index 0000000..74c7ce7 --- /dev/null +++ b/packages/shared/alembic/versions/0001_add_user_settings_table.py @@ -0,0 +1,33 @@ +"""add user_settings table + +Revision ID: 0001 +Revises: +Create Date: 2025-02-22 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_settings", + sa.Column("telegram_user_id", sa.BigInteger(), nullable=False), + sa.Column( + "auto_fetch_in_dm", sa.Boolean(), server_default="1", nullable=False + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("telegram_user_id"), + ) + + +def downgrade() -> None: + op.drop_table("user_settings") diff --git a/packages/shared/fastfetchbot_shared/database/__init__.py b/packages/shared/fastfetchbot_shared/database/__init__.py new file mode 100644 index 0000000..6178f33 --- /dev/null +++ b/packages/shared/fastfetchbot_shared/database/__init__.py @@ -0,0 +1,17 @@ +from fastfetchbot_shared.database.base import Base +from fastfetchbot_shared.database.engine import ( + close_db, + get_engine, + get_session_factory, + init_db, +) +from fastfetchbot_shared.database.session import get_session + +__all__ = [ + "Base", + "get_engine", + "get_session_factory", + "get_session", + "init_db", + "close_db", +] diff --git a/packages/shared/fastfetchbot_shared/database/base.py b/packages/shared/fastfetchbot_shared/database/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/packages/shared/fastfetchbot_shared/database/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/packages/shared/fastfetchbot_shared/database/engine.py b/packages/shared/fastfetchbot_shared/database/engine.py new file mode 100644 index 0000000..5a19047 --- /dev/null +++ b/packages/shared/fastfetchbot_shared/database/engine.py @@ -0,0 +1,88 @@ +import os +from pathlib import Path +from typing import Optional + +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +_DEFAULT_DATABASE_URL = "sqlite+aiosqlite:///data/fastfetchbot.db" + +_engine: Optional[AsyncEngine] = None +_session_factory: Optional[async_sessionmaker[AsyncSession]] = None + + +def _get_database_url() -> str: + return os.environ.get("SETTINGS_DATABASE_URL", _DEFAULT_DATABASE_URL) + + +def _ensure_sqlite_dir(url: str) -> None: + """Create parent directories for SQLite database files.""" + if not url.startswith("sqlite"): + return + # sqlite+aiosqlite:///path/to/db → path/to/db + # sqlite+aiosqlite:////absolute/path → /absolute/path + db_path = url.split("///", 1)[-1] + if db_path: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + +def get_engine() -> AsyncEngine: + """Return the async engine, creating it lazily on first call.""" + global _engine + if _engine is None: + _engine = create_async_engine(_get_database_url(), echo=False) + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + """Return the session factory, creating it lazily on first call.""" + global _session_factory + if _session_factory is None: + _session_factory = async_sessionmaker(get_engine(), expire_on_commit=False) + return _session_factory + + +async def init_db() -> None: + """Initialize database: auto-create tables for SQLite, verify schema for PostgreSQL.""" + from fastfetchbot_shared.database.base import Base + + import fastfetchbot_shared.database.models # noqa: F401 + + database_url = _get_database_url() + engine = get_engine() + + if database_url.startswith("sqlite"): + _ensure_sqlite_dir(database_url) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + elif database_url.startswith("postgresql"): + from sqlalchemy import inspect as sa_inspect + + async with engine.connect() as conn: + table_names = await conn.run_sync( + lambda sync_conn: sa_inspect(sync_conn).get_table_names() + ) + required_tables = set(Base.metadata.tables.keys()) + missing = required_tables - set(table_names) + if missing: + from fastfetchbot_shared.utils.logger import logger + + logger.error( + f"Missing database tables: {missing}. " + f"Run 'alembic upgrade head' to create them." + ) + raise SystemExit(1) + else: + raise ValueError(f"Unsupported database URL scheme: {database_url}") + + +async def close_db() -> None: + global _engine, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _session_factory = None diff --git a/packages/shared/fastfetchbot_shared/database/models/__init__.py b/packages/shared/fastfetchbot_shared/database/models/__init__.py new file mode 100644 index 0000000..9134376 --- /dev/null +++ b/packages/shared/fastfetchbot_shared/database/models/__init__.py @@ -0,0 +1 @@ +from fastfetchbot_shared.database.models.user_setting import UserSetting # noqa: F401 diff --git a/packages/shared/fastfetchbot_shared/database/models/user_setting.py b/packages/shared/fastfetchbot_shared/database/models/user_setting.py new file mode 100644 index 0000000..2c2d1d3 --- /dev/null +++ b/packages/shared/fastfetchbot_shared/database/models/user_setting.py @@ -0,0 +1,26 @@ +from datetime import datetime, timezone + +from sqlalchemy import BigInteger, Boolean, DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from fastfetchbot_shared.database.base import Base + + +class UserSetting(Base): + __tablename__ = "user_settings" + + telegram_user_id: Mapped[int] = mapped_column( + BigInteger, primary_key=True, autoincrement=False + ) + auto_fetch_in_dm: Mapped[bool] = mapped_column( + Boolean, default=True, server_default="1" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/packages/shared/fastfetchbot_shared/database/session.py b/packages/shared/fastfetchbot_shared/database/session.py new file mode 100644 index 0000000..9ffc613 --- /dev/null +++ b/packages/shared/fastfetchbot_shared/database/session.py @@ -0,0 +1,17 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession + +from fastfetchbot_shared.database.engine import get_session_factory + + +@asynccontextmanager +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with get_session_factory()() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/packages/shared/pyproject.toml b/packages/shared/pyproject.toml index 5a7a82e..5532b6c 100644 --- a/packages/shared/pyproject.toml +++ b/packages/shared/pyproject.toml @@ -13,8 +13,14 @@ dependencies = [ "aiofiles>=24.1.0", "fake-useragent>=1.5.1", "playwright>=1.52.0", + "sqlalchemy[asyncio]>=2.0.0", + "aiosqlite>=0.17.0", ] +[project.optional-dependencies] +postgres = ["asyncpg>=0.30.0"] +migrate = ["alembic>=1.15.0"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/packages/shared/tests/__init__.py b/packages/shared/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/shared/tests/test_user_setting.py b/packages/shared/tests/test_user_setting.py new file mode 100644 index 0000000..4acebcd --- /dev/null +++ b/packages/shared/tests/test_user_setting.py @@ -0,0 +1,115 @@ +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from fastfetchbot_shared.database.base import Base +from fastfetchbot_shared.database.models.user_setting import UserSetting + + +@pytest_asyncio.fixture +async def db_session(): + """In-memory SQLite session for testing.""" + engine = create_async_engine("sqlite+aiosqlite://", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + yield session + await engine.dispose() + + +@pytest.mark.asyncio +async def test_create_user_setting(db_session): + setting = UserSetting(telegram_user_id=123456789, auto_fetch_in_dm=True) + db_session.add(setting) + await db_session.commit() + + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == 123456789) + ) + fetched = result.scalar_one() + assert fetched.auto_fetch_in_dm is True + assert fetched.created_at is not None + assert fetched.updated_at is not None + + +@pytest.mark.asyncio +async def test_toggle_user_setting(db_session): + setting = UserSetting(telegram_user_id=123456789, auto_fetch_in_dm=True) + db_session.add(setting) + await db_session.commit() + + setting.auto_fetch_in_dm = False + await db_session.commit() + + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == 123456789) + ) + fetched = result.scalar_one() + assert fetched.auto_fetch_in_dm is False + + +@pytest.mark.asyncio +async def test_default_auto_fetch_is_true(db_session): + setting = UserSetting(telegram_user_id=999999) + db_session.add(setting) + await db_session.commit() + + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == 999999) + ) + fetched = result.scalar_one() + assert fetched.auto_fetch_in_dm is True + + +@pytest.mark.asyncio +async def test_no_record_returns_none(db_session): + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == 888888) + ) + assert result.scalar_one_or_none() is None + + +@pytest.mark.asyncio +async def test_ensure_user_settings_creates_row(db_session): + """ensure pattern: first call creates row with defaults, second is a no-op.""" + user_id = 777777 + + # No row yet + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == user_id) + ) + assert result.scalar_one_or_none() is None + + # Simulate ensure: create if missing + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == user_id) + ) + if result.scalar_one_or_none() is None: + db_session.add(UserSetting(telegram_user_id=user_id)) + await db_session.commit() + + # Row exists with defaults + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == user_id) + ) + setting = result.scalar_one() + assert setting.auto_fetch_in_dm is True + assert setting.created_at is not None + + # Second ensure is a no-op — row unchanged + original_created_at = setting.created_at + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == user_id) + ) + if result.scalar_one_or_none() is None: + db_session.add(UserSetting(telegram_user_id=user_id)) + await db_session.commit() + + result = await db_session.execute( + select(UserSetting).where(UserSetting.telegram_user_id == user_id) + ) + setting = result.scalar_one() + assert setting.auto_fetch_in_dm is True + assert setting.created_at == original_created_at diff --git a/pyproject.toml b/pyproject.toml index 993e102..43c35c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "firecrawl-py>=4.13.0,<5.0.0", "zyte-api>=0.8.1,<0.9.0", "celery[redis]>=5.4.0,<6.0.0", - "fastfetchbot-shared", + "fastfetchbot-shared[postgres]", "fastfetchbot-file-export", ] diff --git a/template.env b/template.env index 2cc7512..429a75a 100644 --- a/template.env +++ b/template.env @@ -137,6 +137,12 @@ FIRECRAWL_WAIT_FOR=3000 # The API key for Zyte. Default: `None` ZYTE_API_KEY= +# User Settings Database +# SQLAlchemy async database URL for user settings. +# SQLite (default): sqlite+aiosqlite:///data/fastfetchbot.db +# PostgreSQL: postgresql+asyncpg://user:password@host:5432/dbname +SETTINGS_DATABASE_URL=sqlite+aiosqlite:///data/fastfetchbot.db + # Celery Worker # Redis URL for Celery message broker. Default: `redis://localhost:6379/0` CELERY_BROKER_URL=redis://redis:6379/0 diff --git a/uv.lock b/uv.lock index 1eec7af..e6696ed 100644 --- a/uv.lock +++ b/uv.lock @@ -144,6 +144,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/48/77c0092f716c4bf9460dca44f5120f70b8f71f14a12f40d22551a7152719/aiosqlite-0.17.0-py3-none-any.whl", hash = "sha256:6c49dc6d3405929b1d08eeccc72306d3677503cc5e5e43771efc1e00232e8231", size = 15433, upload-time = "2021-02-22T01:01:07.698Z" }, ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "amqp" version = "5.3.1" @@ -196,6 +210,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + [[package]] name = "asyncpraw" version = "7.8.1" @@ -650,7 +680,7 @@ dependencies = [ { name = "fake-useragent" }, { name = "fastapi" }, { name = "fastfetchbot-file-export" }, - { name = "fastfetchbot-shared" }, + { name = "fastfetchbot-shared", extra = ["postgres"] }, { name = "firecrawl-py" }, { name = "gunicorn" }, { name = "html-telegraph-poster-v2" }, @@ -700,7 +730,7 @@ requires-dist = [ { name = "fake-useragent", specifier = ">=1.5.1,<2.0.0" }, { name = "fastapi", specifier = ">=0.115.12,<0.116.0" }, { name = "fastfetchbot-file-export", editable = "packages/file-export" }, - { name = "fastfetchbot-shared", editable = "packages/shared" }, + { name = "fastfetchbot-shared", extras = ["postgres"], editable = "packages/shared" }, { name = "firecrawl-py", specifier = ">=4.13.0,<5.0.0" }, { name = "gunicorn", specifier = ">=23.0.0,<24.0.0" }, { name = "html-telegraph-poster-v2", specifier = ">=0.2.5,<0.3.0" }, @@ -818,6 +848,7 @@ version = "0.1.0" source = { editable = "packages/shared" } dependencies = [ { name = "aiofiles" }, + { name = "aiosqlite" }, { name = "beautifulsoup4" }, { name = "fake-useragent" }, { name = "httpx" }, @@ -827,11 +858,23 @@ dependencies = [ { name = "playwright" }, { name = "pydantic" }, { name = "python-magic" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] + +[package.optional-dependencies] +migrate = [ + { name = "alembic" }, +] +postgres = [ + { name = "asyncpg" }, ] [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, + { name = "aiosqlite", specifier = ">=0.17.0" }, + { name = "alembic", marker = "extra == 'migrate'", specifier = ">=1.15.0" }, + { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.30.0" }, { name = "beautifulsoup4", specifier = ">=4.13.4" }, { name = "fake-useragent", specifier = ">=1.5.1" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -841,7 +884,9 @@ requires-dist = [ { name = "playwright", specifier = ">=1.52.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "python-magic", specifier = ">=0.4.27" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, ] +provides-extras = ["postgres", "migrate"] [[package]] name = "fastfetchbot-telegram-bot" @@ -850,7 +895,7 @@ source = { virtual = "apps/telegram-bot" } dependencies = [ { name = "aiofiles" }, { name = "beanie" }, - { name = "fastfetchbot-shared" }, + { name = "fastfetchbot-shared", extra = ["postgres"] }, { name = "httpx" }, { name = "jinja2" }, { name = "python-telegram-bot", extra = ["callback-data", "rate-limiter"] }, @@ -862,7 +907,7 @@ dependencies = [ requires-dist = [ { name = "aiofiles", specifier = ">=24.1.0" }, { name = "beanie", specifier = ">=1.29.0" }, - { name = "fastfetchbot-shared", editable = "packages/shared" }, + { name = "fastfetchbot-shared", extras = ["postgres"], editable = "packages/shared" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "python-telegram-bot", extras = ["callback-data", "rate-limiter"], specifier = ">=21.11" }, @@ -1232,6 +1277,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -1928,6 +1985,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, + { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "starlette" version = "0.46.2"