From d059c84f826515bcfd682ccad955fb9aa397ecb4 Mon Sep 17 00:00:00 2001 From: Antigravity Bot Date: Sun, 30 Aug 2026 17:44:38 +0330 Subject: [PATCH] =?UTF-8?q?=D8=A8=D9=87=D8=A8=D9=88=D8=AF=20=D9=87=D9=86?= =?UTF-8?q?=D8=AF=D9=84=D8=B1=20=D8=B4=D8=B1=D9=88=D8=B9=D8=8C=20=D8=A7?= =?UTF-8?q?=D9=86=D8=B9=D8=B7=D8=A7=D9=81=E2=80=8C=D9=BE=D8=B0=DB=8C=D8=B1?= =?UTF-8?q?=DB=8C=20=D8=AF=D8=B3=D8=AA=D9=88=D8=B1=D8=A7=D8=AA=20=D9=88=20?= =?UTF-8?q?=D8=A7=D9=81=D8=B2=D8=A7=DB=8C=D8=B4=20=D9=BE=D8=A7=DB=8C=D8=AF?= =?UTF-8?q?=D8=A7=D8=B1=DB=8C=20=D8=AF=DB=8C=D8=AA=D8=A7=D8=A8=DB=8C=D8=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/repository.py | 103 ++++++++++++----- services/admin_bot.py | 150 ++++++++++++++----------- tests/test_start_command_resilience.py | 68 +++++++++++ 3 files changed, 229 insertions(+), 92 deletions(-) create mode 100644 tests/test_start_command_resilience.py 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