بهبود هندلر شروع، انعطاف‌پذیری دستورات و افزایش پایداری دیتابیس

This commit is contained in:
Antigravity Bot
2026-08-30 17:44:38 +03:30
parent 1e127580d4
commit d059c84f82
3 changed files with 229 additions and 92 deletions
+74 -29
View File
@@ -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()
+87 -63
View File
@@ -654,24 +654,31 @@ class AdminBotService:
tag_line = f"🏷 <b>موضوع:</b> #{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"🌐 وبسایت <b>{site.name}</b>"
elif site and site.url:
source_label = f"🌐 وبسایت <b>{site.url}</b>"
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"🌐 وبسایت <b>{site.name}</b>"
elif site and site.url:
source_label = f"🌐 وبسایت <b>{site.url}</b>"
else:
source_label = f"🌐 وبسایت <code>#{site_id}</code>"
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"📢 کانال <b>{src.title}</b>{user_part}"
elif src and src.username:
source_label = f"📢 کانال @<b>{src.username}</b>"
else:
source_label = f"کانال <code>{post.source_channel_id}</code>"
else:
source_label = f"🌐 وبسایت <code>#{site_id}</code>"
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"📢 کانال <b>{src.title}</b>{user_part}"
elif src and src.username:
source_label = f"📢 کانال @<b>{src.username}</b>"
else:
source_label = f"کانال <code>{post.source_channel_id}</code>"
source_label = "کانال نامشخص"
except Exception as e:
logger.debug(f"Error formatting source label for post {post.id}: {e}")
source_label = f"شناسه <code>{post.source_channel_id}</code>"
caption = (
f"📥 <b>پست جدید از {source_label}:</b>\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"⛔ دسترسی غیرمجاز. شناسه عددی شما: <code>{event.sender_id}</code>", 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"<b>{def_adm.title}</b> (<code>{def_adm.channel_id}</code>)" if (def_adm and def_adm.title) else (f"<code>{self.review_channel_id}</code>" if self.review_channel_id else "تعریف‌نشده")
def_adm = await self.repo.get_default_admin_channel()
adm_label = f"<b>{def_adm.title}</b> (<code>{def_adm.channel_id}</code>)" if (def_adm and def_adm.title) else (f"<code>{self.review_channel_id}</code>" if self.review_channel_id else "تعریف‌نشده")
welcome_text = (
"👋 <b>به پنل مدیریت سیستم هوشمند کپی‌کار خوش آمدید!</b>\n\n"
f"• 🤖 <b>وضعیت ربات جمع‌آوری‌کننده:</b> {userbot_status}\n"
f"• 📋 <b>کانال نظارت ادمین:</b> {adm_label}\n\n"
"برای مدیریت کانال‌ها از دکمه‌های زیر استفاده کنید:"
)
menu = await self.get_menu()
await event.reply(welcome_text, parse_mode="html", buttons=menu)
welcome_text = (
"👋 <b>به پنل مدیریت سیستم هوشمند کپی‌کار خوش آمدید!</b>\n\n"
f"• 🤖 <b>وضعیت ربات جمع‌آوری‌کننده:</b> {userbot_status}\n"
f"• 📋 <b>کانال نظارت ادمین:</b> {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("👋 <b>به پنل مدیریت سیستم هوشمند کپی‌کار خوش آمدید!</b>", 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
+68
View File
@@ -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 "شناسه <code>-900000001</code>" in caption