diff --git a/db/repository.py b/db/repository.py
index 6edcbc1..57fdba6 100644
--- a/db/repository.py
+++ b/db/repository.py
@@ -70,16 +70,26 @@ class Repository:
return [SourceChannel(**dict(r)) for r in rows]
async def get_source_by_channel_id(self, channel_id: int) -> Optional[SourceChannel]:
+ if not isinstance(channel_id, int) or not (-9223372036854775808 <= channel_id <= 9223372036854775807):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM sources WHERE channel_id = $1;", channel_id)
- return SourceChannel(**dict(row)) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM sources WHERE channel_id = $1;", channel_id)
+ return SourceChannel(**dict(row)) if row else None
+ except Exception:
+ return None
async def get_source_by_id(self, source_id: int) -> Optional[SourceChannel]:
+ if not isinstance(source_id, int) or not (1 <= source_id <= 2147483647):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM sources WHERE id = $1;", source_id)
- return SourceChannel(**dict(row)) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM sources WHERE id = $1;", source_id)
+ return SourceChannel(**dict(row)) if row else None
+ except Exception:
+ return None
async def update_source_context_count(self, source_id: int, count: int) -> None:
pool = await self._get_pool()
@@ -133,10 +143,15 @@ class Repository:
return [_parse_source_website_row(r) for r in rows]
async def get_source_website_by_id(self, site_id: int) -> Optional[SourceWebsite]:
+ if not isinstance(site_id, int) or not (1 <= site_id <= 2147483647):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM source_websites WHERE id = $1;", site_id)
- return _parse_source_website_row(row) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM source_websites WHERE id = $1;", site_id)
+ return _parse_source_website_row(row) if row else None
+ except Exception:
+ return None
async def get_source_websites_by_category(self, category_id: Optional[int]) -> List[SourceWebsite]:
pool = await self._get_pool()
@@ -363,10 +378,15 @@ class Repository:
return [TargetChannel(**dict(r)) for r in rows]
async def get_target_by_id(self, target_id: int) -> Optional[TargetChannel]:
+ if not isinstance(target_id, int) or not (1 <= target_id <= 2147483647):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM targets WHERE id = $1;", target_id)
- return TargetChannel(**dict(row)) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM targets WHERE id = $1;", target_id)
+ return TargetChannel(**dict(row)) if row else None
+ except Exception:
+ return None
async def set_target_auto_sources(self, target_id: int, source_channel_ids: List[int]) -> None:
"""Replace the set of source channels auto-routed into this target."""
@@ -459,10 +479,15 @@ class Repository:
return None
async def get_post_by_id(self, post_id: int) -> Optional[Post]:
+ if not isinstance(post_id, int) or not (1 <= post_id <= 9223372036854775807):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM posts WHERE id = $1;", post_id)
- return _parse_post_row(row) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM posts WHERE id = $1;", post_id)
+ return _parse_post_row(row) if row else None
+ except Exception:
+ return None
async def get_recent_source_posts(
self,
@@ -862,21 +887,26 @@ class Repository:
return [AIProviderProfile(**dict(r)) for r in rows]
async def get_provider_profile_by_id(self, profile_id: int) -> Optional[AIProviderProfile]:
+ if not isinstance(profile_id, int) or not (1 <= profile_id <= 2147483647):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow(
- """
- SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
- p.fallback_provider_id, p.supports_vision,
- fb.name as fallback_provider_name,
- to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
- FROM ai_providers p
- LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
- WHERE p.id = $1;
- """,
- profile_id
- )
- return AIProviderProfile(**dict(row)) if row else None
+ try:
+ row = await conn.fetchrow(
+ """
+ SELECT p.id, p.name, p.provider_type, p.model, p.base_url, p.api_key, p.reasoning_effort, p.is_active,
+ p.fallback_provider_id, p.supports_vision,
+ fb.name as fallback_provider_name,
+ to_char(p.created_at, 'YYYY-MM-DD HH24:MI:SS') as created_at
+ FROM ai_providers p
+ LEFT JOIN ai_providers fb ON p.fallback_provider_id = fb.id
+ WHERE p.id = $1;
+ """,
+ profile_id
+ )
+ return AIProviderProfile(**dict(row)) if row else None
+ except Exception:
+ return None
async def get_active_provider_profile(self) -> Optional[AIProviderProfile]:
pool = await self._get_pool()
@@ -983,10 +1013,15 @@ class Repository:
return [ChannelCategory(**dict(r)) for r in rows]
async def get_category_by_id(self, cat_id: int) -> Optional[ChannelCategory]:
+ if not isinstance(cat_id, int) or not (1 <= cat_id <= 2147483647):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM channel_categories WHERE id = $1;", cat_id)
- return ChannelCategory(**dict(row)) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM channel_categories WHERE id = $1;", cat_id)
+ return ChannelCategory(**dict(row)) if row else None
+ except Exception:
+ return None
async def update_category(self, cat_id: int, name: Optional[str] = None, description: Optional[str] = None, cat_type: Optional[str] = None) -> None:
pool = await self._get_pool()
@@ -1092,16 +1127,26 @@ class Repository:
return [_parse_admin_channel_row(r) for r in rows]
async def get_admin_channel_by_id(self, admin_id: int) -> Optional[AdminReviewChannel]:
+ if not isinstance(admin_id, int) or not (1 <= admin_id <= 2147483647):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM admin_channels WHERE id = $1;", admin_id)
- return _parse_admin_channel_row(row) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM admin_channels WHERE id = $1;", admin_id)
+ return _parse_admin_channel_row(row) if row else None
+ except Exception:
+ return None
async def get_admin_channel_by_telegram_id(self, channel_id: int) -> Optional[AdminReviewChannel]:
+ if not isinstance(channel_id, int) or not (-9223372036854775808 <= channel_id <= 9223372036854775807):
+ return None
pool = await self._get_pool()
async with pool.acquire() as conn:
- row = await conn.fetchrow("SELECT * FROM admin_channels WHERE channel_id = $1;", channel_id)
- return _parse_admin_channel_row(row) if row else None
+ try:
+ row = await conn.fetchrow("SELECT * FROM admin_channels WHERE channel_id = $1;", channel_id)
+ return _parse_admin_channel_row(row) if row else None
+ except Exception:
+ return None
async def get_default_admin_channel(self) -> Optional[AdminReviewChannel]:
pool = await self._get_pool()
diff --git a/services/admin_bot.py b/services/admin_bot.py
index 7bb639b..b13cd1c 100644
--- a/services/admin_bot.py
+++ b/services/admin_bot.py
@@ -654,24 +654,31 @@ class AdminBotService:
tag_line = f"🏷 موضوع: #{post.subject}\n\n" if getattr(post, "subject", None) else ""
- if -999999999 <= post.source_channel_id <= -900000000:
- site_id = abs(post.source_channel_id + 900000000)
- site = await self.repo.get_source_website_by_id(site_id)
- if site and site.name:
- source_label = f"🌐 وبسایت {site.name}"
- elif site and site.url:
- source_label = f"🌐 وبسایت {site.url}"
+ source_label = "کانال"
+ try:
+ if post.source_channel_id is not None and -999999999 <= post.source_channel_id <= -900000000:
+ site_id = abs(post.source_channel_id + 900000000)
+ site = await self.repo.get_source_website_by_id(site_id)
+ if site and site.name:
+ source_label = f"🌐 وبسایت {site.name}"
+ elif site and site.url:
+ source_label = f"🌐 وبسایت {site.url}"
+ else:
+ source_label = f"🌐 وبسایت #{site_id}"
+ elif post.source_channel_id is not None:
+ src = await self.repo.get_source_by_channel_id(post.source_channel_id)
+ if src and src.title:
+ user_part = f" (@{src.username})" if src.username else ""
+ source_label = f"📢 کانال {src.title}{user_part}"
+ elif src and src.username:
+ source_label = f"📢 کانال @{src.username}"
+ else:
+ source_label = f"کانال {post.source_channel_id}"
else:
- source_label = f"🌐 وبسایت #{site_id}"
- else:
- src = await self.repo.get_source_by_channel_id(post.source_channel_id)
- if src and src.title:
- user_part = f" (@{src.username})" if src.username else ""
- source_label = f"📢 کانال {src.title}{user_part}"
- elif src and src.username:
- source_label = f"📢 کانال @{src.username}"
- else:
- source_label = f"کانال {post.source_channel_id}"
+ source_label = "کانال نامشخص"
+ except Exception as e:
+ logger.debug(f"Error formatting source label for post {post.id}: {e}")
+ source_label = f"شناسه {post.source_channel_id}"
caption = (
f"📥 پست جدید از {source_label}:\n\n"
@@ -685,19 +692,22 @@ class AdminBotService:
async def get_effective_review_channel_id(self, post: Post) -> Optional[int]:
"""Determine which Admin Review Channel this post should be sent to."""
- if -999999999 <= post.source_channel_id <= -900000000:
- site_id = abs(post.source_channel_id + 900000000)
- site = await self.repo.get_source_website_by_id(site_id)
- if site and site.admin_channel_id:
- return site.admin_channel_id
- else:
- src = await self.repo.get_source_by_channel_id(post.source_channel_id)
- if src and src.admin_channel_id:
- return src.admin_channel_id
+ try:
+ if post.source_channel_id is not None and -999999999 <= post.source_channel_id <= -900000000:
+ site_id = abs(post.source_channel_id + 900000000)
+ site = await self.repo.get_source_website_by_id(site_id)
+ if site and site.admin_channel_id:
+ return site.admin_channel_id
+ elif post.source_channel_id is not None:
+ src = await self.repo.get_source_by_channel_id(post.source_channel_id)
+ if src and src.admin_channel_id:
+ return src.admin_channel_id
- default_adm = await self.repo.get_default_admin_channel()
- if default_adm and default_adm.channel_id:
- return default_adm.channel_id
+ default_adm = await self.repo.get_default_admin_channel()
+ if default_adm and default_adm.channel_id:
+ return default_adm.channel_id
+ except Exception as e:
+ logger.error(f"Error resolving review channel for post {post.id}: {e}")
return self.review_channel_id or None
@@ -1707,30 +1717,38 @@ class AdminBotService:
def _register_handlers(self):
# --- Start / Menu ---
- @self.client.on(events.NewMessage(pattern=r"(?i)^(/start|/menu|منو|منوی اصلی|🏠 منوی اصلی)$"))
+ @self.client.on(events.NewMessage(pattern=r"(?i)^(/start(@\w+)?|/srart(@\w+)?|/strt(@\w+)?|/starrt(@\w+)?|/menu(@\w+)?|شروع|منو|منوی اصلی|🏠 منوی اصلی)(\s.*)?$"))
async def cmd_start(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
await event.reply(f"⛔ دسترسی غیرمجاز. شناسه عددی شما: {event.sender_id}", parse_mode="html")
return
- self.user_states.pop(event.sender_id, None)
+ try:
+ self.user_states.pop(event.sender_id, None)
- userbot_status = "🔴 قطع / نیاز به لاگین"
- if self.collector and self.collector.client.is_connected() and await self.collector.client.is_user_authorized():
- me = await self.collector.client.get_me()
- userbot_status = f"🟢 آنلاین ({me.first_name})"
+ userbot_status = "🔴 قطع / نیاز به لاگین"
+ if self.collector and self.collector.client.is_connected() and await self.collector.client.is_user_authorized():
+ me = await self.collector.client.get_me()
+ userbot_status = f"🟢 آنلاین ({me.first_name})"
- def_adm = await self.repo.get_default_admin_channel()
- adm_label = f"{def_adm.title} ({def_adm.channel_id})" if (def_adm and def_adm.title) else (f"{self.review_channel_id}" if self.review_channel_id else "تعریفنشده")
+ def_adm = await self.repo.get_default_admin_channel()
+ adm_label = f"{def_adm.title} ({def_adm.channel_id})" if (def_adm and def_adm.title) else (f"{self.review_channel_id}" if self.review_channel_id else "تعریفنشده")
- welcome_text = (
- "👋 به پنل مدیریت سیستم هوشمند کپیکار خوش آمدید!\n\n"
- f"• 🤖 وضعیت ربات جمعآوریکننده: {userbot_status}\n"
- f"• 📋 کانال نظارت ادمین: {adm_label}\n\n"
- "برای مدیریت کانالها از دکمههای زیر استفاده کنید:"
- )
- menu = await self.get_menu()
- await event.reply(welcome_text, parse_mode="html", buttons=menu)
+ welcome_text = (
+ "👋 به پنل مدیریت سیستم هوشمند کپیکار خوش آمدید!\n\n"
+ f"• 🤖 وضعیت ربات جمعآوریکننده: {userbot_status}\n"
+ f"• 📋 کانال نظارت ادمین: {adm_label}\n\n"
+ "برای مدیریت کانالها از دکمههای زیر استفاده کنید:"
+ )
+ menu = await self.get_menu()
+ await event.reply(welcome_text, parse_mode="html", buttons=menu)
+ except Exception as e:
+ logger.error(f"Error executing cmd_start: {e}", exc_info=True)
+ try:
+ menu = await self.get_menu()
+ await event.reply("👋 به پنل مدیریت سیستم هوشمند کپیکار خوش آمدید!", parse_mode="html", buttons=menu)
+ except Exception:
+ pass
# --- 5 Primary Category Hubs: Copy, AI, Bots, System, Monitor ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/copy|📋.*|.*copy.*|.*محتوا.*)$"))
@@ -2032,25 +2050,31 @@ class AdminBotService:
return
text = (event.raw_text or "").strip()
+ first_word = text.split()[0].lower() if text else ""
- # Ignore main menu commands
- if text in ["/start", "/menu", "منو", "منوی اصلی", "🏠 منوی اصلی",
- "/copy", "📋 مدیریت محتوا (Copy)", "📋 مدیریت محتوا", "📋 copy (محتوا)", "📋 Copy (محتوا)", "📋 کپی", "Copy",
- "/ai", "🧠 هوش مصنوعی (AI)", "🧠 هوش مصنوعی", "🧠 ai (هوش مصنوعی)", "🧠 AI (هوش مصنوعی)", "🧠 تنظیمات AI", "🧠 تنظیمات و لاگهای AI", "AI",
- "/bots", "🤖 کانالها و وب (Bots)", "🤖 کانالها و وب", "🤖 bots (کانالها و رباتها)", "🤖 Bots (کانالها و رباتها)", "🤖 کانالها و رباتها", "Bots",
- "/system", "⚙️ مدیریت سیستم (System)", "⚙️ مدیریت سیستم", "⚙️ system (سیستم)", "⚙️ System (سیستم)", "System",
- "/monitor", "📊 مانیتورینگ (Monitor)", "📊 مانیتورینگ", "📊 monitor (مانیتورینگ)", "📊 Monitor (مانیتورینگ)", "Monitor",
- "/stats", "/report", "📊 آمار ناوگان", "📊 آمار و وضعیت ناوگان", "📊 گزارش و آمار سیستم",
- "/sources", "📡 کانالهای مبدا", "/targets", "🎯 کانالهای مقصد",
- "/websites", "/sites", "🌐 وبسایتهای مبدا",
- "/request_code", "🔑 کد لاگین", "🔑 درخواست کد لاگین",
- "/pending", "📨 پستهای بررسینشده", "📨 ارسال پستهای بررسینشده",
- "/errors", "/err", "⚠️ خطاهای سیستم",
- "/categories", "/cats", "📂 دستهبندیها", "📂 دستهبندی کانالها",
- "/add", "➕ افزودن مبدا / مقصد", "➕ افزودن کانال مبدا", "➕ افزودن کانال مقصد",
- "/pause", "/stop", "🛑 توقف سیستم", "🛑 توقف اضطراری سیستم",
- "/resume", "/start_fleet", "▶️ راهاندازی سیستم", "▶️ راهاندازی و ادامه سیستم",
- "/help", "❓ راهنمای سیستم"]:
+ # Ignore main menu commands and start variants
+ if (
+ re.match(r"(?i)^(/start(@\w+)?|/srart(@\w+)?|/strt(@\w+)?|/starrt(@\w+)?|/menu(@\w+)?|شروع|منو|منوی اصلی|🏠 منوی اصلی)$", first_word)
+ or text in [
+ "/start", "/menu", "منو", "منوی اصلی", "🏠 منوی اصلی", "شروع",
+ "/copy", "📋 مدیریت محتوا (Copy)", "📋 مدیریت محتوا", "📋 copy (محتوا)", "📋 Copy (محتوا)", "📋 کپی", "Copy",
+ "/ai", "🧠 هوش مصنوعی (AI)", "🧠 هوش مصنوعی", "🧠 ai (هوش مصنوعی)", "🧠 AI (هوش مصنوعی)", "🧠 تنظیمات AI", "🧠 تنظیمات و لاگهای AI", "AI",
+ "/bots", "🤖 کانالها و وب (Bots)", "🤖 کانالها و وب", "🤖 bots (کانالها و رباتها)", "🤖 Bots (کانالها و رباتها)", "🤖 کانالها و رباتها", "Bots",
+ "/system", "⚙️ مدیریت سیستم (System)", "⚙️ مدیریت سیستم", "⚙️ تنظیمات و سیستم", "⚙️ system (سیستم)", "⚙️ System (سیستم)", "System",
+ "/monitor", "📊 مانیتورینگ (Monitor)", "📊 مانیتورینگ", "📊 monitor (مانیتورینگ)", "📊 Monitor (مانیتورینگ)", "Monitor",
+ "/stats", "/report", "📊 آمار ناوگان", "📊 آمار و وضعیت ناوگان", "📊 گزارش و آمار سیستم",
+ "/sources", "📡 کانالهای مبدا", "📡 مبداها", "/targets", "🎯 کانالهای مقصد", "🎯 مقصدها",
+ "/websites", "/sites", "🌐 وبسایتهای مبدا", "🌐 وبسایتها",
+ "/request_code", "🔑 کد لاگین", "🔑 درخواست کد لاگین", "🔑 لاگین یوزربات",
+ "/pending", "📨 پستهای بررسینشده", "📨 ارسال پستهای بررسینشده", "📨 بررسینشده",
+ "/errors", "/err", "⚠️ خطاهای سیستم",
+ "/categories", "/cats", "📂 دستهبندیها", "📂 دستهبندی کانالها",
+ "/add", "➕ افزودن مبدا / مقصد", "➕ افزودن کانال مبدا", "➕ افزودن کانال مقصد", "➕ افزودن مبدا", "➕ افزودن مقصد", "➕ افزودن وبسایت",
+ "/pause", "/stop", "🛑 توقف سیستم", "🛑 توقف اضطراری سیستم",
+ "/resume", "/start_fleet", "▶️ راهاندازی سیستم", "▶️ راهاندازی و ادامه سیستم",
+ "/help", "❓ راهنمای سیستم", "❓ راهنما"
+ ]
+ ):
return
# Cancel command
diff --git a/tests/test_start_command_resilience.py b/tests/test_start_command_resilience.py
new file mode 100644
index 0000000..c43d08b
--- /dev/null
+++ b/tests/test_start_command_resilience.py
@@ -0,0 +1,68 @@
+import re
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+from db.repository import Repository
+from db.models import Post, SourceWebsite, SourceChannel
+from services.admin_bot import AdminBotService
+
+def test_start_command_regex():
+ pattern = r"(?i)^(/start(@\w+)?|/srart(@\w+)?|/strt(@\w+)?|/starrt(@\w+)?|/menu(@\w+)?|شروع|منو|منوی اصلی|🏠 منوی اصلی)(\s.*)?$"
+
+ valid_inputs = [
+ "/start",
+ "/START",
+ "/start@copykar_2026_bot",
+ "/start ",
+ "/srart",
+ "/srart@copykar_2026_bot",
+ "/strt",
+ "/starrt",
+ "/menu",
+ "/menu@copykar_2026_bot",
+ "منو",
+ "منوی اصلی",
+ "🏠 منوی اصلی",
+ "شروع",
+ "/start something",
+ ]
+ for inp in valid_inputs:
+ assert re.match(pattern, inp), f"Failed to match: {inp}"
+
+ invalid_inputs = [
+ "/start123",
+ "سلام",
+ "/settings",
+ "test",
+ ]
+ for inp in invalid_inputs:
+ assert not re.match(pattern, inp), f"Should not match: {inp}"
+
+@pytest.mark.asyncio
+async def test_repository_safe_bounds_without_db():
+ repo = Repository()
+ # Out of range integer bounds should return None without error
+ assert await repo.get_source_website_by_id(-1) is None
+ assert await repo.get_source_website_by_id(0) is None
+ assert await repo.get_source_website_by_id(99999999999999) is None
+ assert await repo.get_source_by_id(-5) is None
+ assert await repo.get_target_by_id(-10) is None
+ assert await repo.get_category_by_id(-1) is None
+ assert await repo.get_admin_channel_by_id(0) is None
+
+@pytest.mark.asyncio
+async def test_admin_bot_safe_caption_formatting():
+ repo = MagicMock()
+ repo.get_source_website_by_id = AsyncMock(side_effect=Exception("Database connection down"))
+ repo.get_source_by_channel_id = AsyncMock(side_effect=Exception("Database connection down"))
+ bot = AdminBotService(repo=repo)
+
+ post = Post(
+ id=1,
+ source_channel_id=-900000001,
+ source_message_id=10,
+ raw_text="متن تستی",
+ status="raw"
+ )
+ caption = await bot._format_raw_post_caption(post)
+ assert "متن تستی" in caption
+ assert "شناسه -900000001" in caption