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"