Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db

# Flask stuff:
instance/
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions .idea/FastFetchBot.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/inspectionProfiles/profiles_settings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/telegram-bot/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
7 changes: 6 additions & 1 deletion apps/telegram-bot/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
83 changes: 83 additions & 0 deletions apps/telegram-bot/core/handlers/commands.py
Original file line number Diff line number Diff line change
@@ -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."
)
15 changes: 13 additions & 2 deletions apps/telegram-bot/core/handlers/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 35 additions & 0 deletions apps/telegram-bot/core/handlers/url_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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...",
)
Expand Down Expand Up @@ -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"])
Expand Down
18 changes: 18 additions & 0 deletions apps/telegram-bot/core/services/bot_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
mimetypes.init()

from telegram import (
BotCommand,
Update,
MessageEntity,
)
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
filters,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions apps/telegram-bot/core/services/user_settings.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +11 to +21
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Possible IntegrityError under concurrent requests for the same new user.

If two messages from the same (new) user arrive near-simultaneously, both coroutines can pass the cache check, both query and find no row, and both attempt to insert. The second commit will raise an IntegrityError on the primary key. While narrow, this is possible in asyncio due to await suspension points.

A simple fix is to catch IntegrityError and treat it as a no-op:

🛡️ Proposed fix
+from sqlalchemy.exc import IntegrityError
+
 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))
+    try:
+        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))
+    except IntegrityError:
+        pass  # Another coroutine already created the row
     _known_user_ids.add(user_id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/telegram-bot/core/services/user_settings.py` around lines 11 - 21,
ensure_user_settings can raise IntegrityError if two coroutines concurrently
insert the same new user: keep the existing cache check and DB query in
ensure_user_settings, but wrap the session add/commit of
UserSetting(telegram_user_id=user_id) in a try/except that catches
sqlalchemy.exc.IntegrityError, performs session.rollback() (or ensures the
transaction is aborted) and treats the error as a no-op; still add user_id to
_known_user_ids after the DB operation to avoid future races. Reference
ensure_user_settings, _known_user_ids, get_session, and UserSetting when making
the change.



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
Loading