diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2e4a464..baefd70 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,21 +2,17 @@ 👤 ویرایش‌کننده: mamad -🔹 وبسایت‌های مبدا و کشف خودکار API با هوش مصنوعی (Source Websites & AI Discovery): -• امکان تعریف انواع وبسایت‌ها و خبرگزاری‌ها به عنوان مبدا ورودی محتوا در کنار کانال‌های تلگرام. -• تحلیل خودکار ساختار سایت، کشف اندپوینت‌های REST API و فیدهای RSS توسط هوش مصنوعی و ذخیره در تنظیمات وبسایت. -• استخراج دوره‌ای اخبار جدید، تشخیص تکراری بودن با هوش مصنوعی و ارسال به صف انتشار کانال‌های مقصد. -• اعلام هوشمند خطاهای دریافت در کانال ادمین همراه با کلید «🤖 تحلیل مجدد هوشمند AI و تولید مجدد API». -📁 فایل‌های تغییریافته: services/website_analyzer.py, services/website_collector.py, services/admin_bot.py, db/database.py, db/models.py, db/repository.py, main.py +🔹 انتقال پیام‌های زمینه (Context History) به کانال‌های مقصد: +• انتقال و اعمال تنظیمات پیام‌های زمینه به کانال‌های مقصد: ارسال تاریخچه آخرین پست‌های منتشرشده در همان کانال مقصد به عنوان کانتکست به هوش مصنوعی جهت حفظ پیوستگی محتوایی، لحن و استایل کانال. +• افزودن کلید «📜 پیام‌های زمینه» در تنظیمات هر کانال مقصد با امکان انتخاب سریع تعداد پست‌های کانتکست (۱ تا ۱۰ پیام). +📁 فایل‌های تغییریافته: db/database.py, db/models.py, db/repository.py, services/admin_bot.py -🔹 تنظیم شیوه ارسال پست‌ها از صف (ترتیبی FIFO یا تصادفی): -• افزودن گزینه «🔀 شیوه ارسال» در تنظیمات هر کانال مقصد با قابلیت سوییچ بین حالت‌های «به ترتیب ورود» و «تصادفی (Random)». -📁 فایل‌های تغییریافته: core/queue.py, services/publisher.py, services/admin_bot.py, db/models.py, db/repository.py +🔹 تعیین نیازمندی‌های استخراج وبسایت‌ها و تفکیک دکمه تحلیل AI: +• امکان تعریف نیازمندی‌ها، موضوعات و فیلترهای اختصاصی برای هر وبسایت مبدا با کلید «📝 نیازمندی‌های استخراج». +• حذف تحلیل خودکار بلافاصله پس از ثبت سایت و تعبیه کلید مستقل «🤖 شروع / تکرار تحلیل با AI» برای استخراج آگاهانه ساختار داده مطابق با نیازمندی‌های تعیین‌شده. +📁 فایل‌های تغییریافته: services/website_analyzer.py, services/website_collector.py, services/admin_bot.py, db/database.py, db/models.py, db/repository.py -🔹 پشتیبانی کامل از استایل‌های Markdown در ارسال پست‌ها به تلگرام: -• پشتیبانی از فرمت‌بندی غنی Markdown (بولد، ایتالیک، لینک‌ها و کدهای برنامه‌نویسی) خروجی هوش مصنوعی در ارسال به کانال‌های مقصد. -📁 فایل‌های تغییریافته: services/publisher.py - -🔹 مدیریت هوشمند ارسال گزارش انتشار (Release Notes Broadcast Hash Tracking): -• ردیابی هش گزارش تغییرات جهت جلوگیری از ارسال پیام‌های تکراری هنگام ری‌استارت سیستم. +🔹 بازطراحی، دسته‌بندی و بهینه‌سازی کیبورد دکمه‌های عملیاتی ربات: +• افزودن دکمه «🏠 منوی اصلی» و دسته‌بندی استاندارد دکمه‌های عملیاتی ربات در ۵ ردیف منظم و ساختاریافته. +• تجمیع عملیات ثبت در دکمه هاب یکپارچه «➕ افزودن مبدا / مقصد» جهت دسترسی سریع به کانال مبدا، کانال مقصد و وبسایت مبدا. 📁 فایل‌های تغییریافته: services/admin_bot.py diff --git a/db/database.py b/db/database.py index c1b926a..89bb41a 100644 --- a/db/database.py +++ b/db/database.py @@ -170,8 +170,9 @@ ALTER TABLE targets ADD COLUMN IF NOT EXISTS sleep_end_hour INT DEFAULT 0; ALTER TABLE targets ADD COLUMN IF NOT EXISTS is_sleep_enabled BOOLEAN DEFAULT FALSE; ALTER TABLE targets ADD COLUMN IF NOT EXISTS auto_source_ids BIGINT[] DEFAULT '{}'; ALTER TABLE targets ADD COLUMN IF NOT EXISTS language VARCHAR(32) DEFAULT 'fa'; -ALTER TABLE targets ADD COLUMN IF NOT EXISTS custom_prompt TEXT DEFAULT ''; ALTER TABLE targets ADD COLUMN IF NOT EXISTS dispatch_order VARCHAR(32) DEFAULT 'order'; +ALTER TABLE targets ADD COLUMN IF NOT EXISTS context_message_count INT DEFAULT 0; +ALTER TABLE source_websites ADD COLUMN IF NOT EXISTS custom_instructions TEXT DEFAULT ''; ALTER TABLE sources ADD COLUMN IF NOT EXISTS context_message_count INT DEFAULT 0; ALTER TABLE posts ADD COLUMN IF NOT EXISTS source_created_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_posts_source_created ON posts(source_channel_id, source_created_at DESC); diff --git a/db/models.py b/db/models.py index c6e09d9..f998933 100644 --- a/db/models.py +++ b/db/models.py @@ -31,6 +31,7 @@ class SourceWebsite: last_reanalyzed_at: Optional[str] = None last_fetched_at: Optional[str] = None api_config: Dict[str, Any] = field(default_factory=dict) + custom_instructions: str = "" last_error: Optional[str] = None last_error_at: Optional[str] = None is_active: bool = True @@ -54,6 +55,7 @@ class TargetChannel: auto_source_ids: List[int] = field(default_factory=list) language: str = "fa" dispatch_order: str = "order" # "order" (FIFO) or "random" + context_message_count: int = 0 last_post_time: Optional[str] = None is_active: bool = True created_at: Optional[str] = None diff --git a/db/repository.py b/db/repository.py index 1b775e3..b6cfb50 100644 --- a/db/repository.py +++ b/db/repository.py @@ -92,22 +92,24 @@ class Repository: category_id: Optional[int] = None, check_interval_min: int = 30, auto_reanalyze_hours: int = 24, - api_config: Optional[Dict[str, Any]] = None + api_config: Optional[Dict[str, Any]] = None, + custom_instructions: str = "" ) -> int: pool = await self._get_pool() async with pool.acquire() as conn: cfg_json = json.dumps(api_config or {}) row = await conn.fetchrow( """ - INSERT INTO source_websites (name, url, category_id, check_interval_min, auto_reanalyze_hours, api_config) - VALUES ($1, $2, $3, $4, $5, $6::jsonb) + INSERT INTO source_websites (name, url, category_id, check_interval_min, auto_reanalyze_hours, api_config, custom_instructions) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7) ON CONFLICT(url) DO UPDATE SET name = EXCLUDED.name, category_id = COALESCE(EXCLUDED.category_id, source_websites.category_id), + custom_instructions = COALESCE(NULLIF(EXCLUDED.custom_instructions, ''), source_websites.custom_instructions), is_active = TRUE RETURNING id; """, - name, url, category_id, check_interval_min, auto_reanalyze_hours, cfg_json + name, url, category_id, check_interval_min, auto_reanalyze_hours, cfg_json, custom_instructions ) return row["id"] @@ -190,11 +192,50 @@ class Repository: async with pool.acquire() as conn: await conn.execute("UPDATE source_websites SET auto_reanalyze_hours = $1 WHERE id = $2;", max(0, hours), site_id) + async def update_source_website_custom_instructions(self, site_id: int, instructions: str) -> None: + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute("UPDATE source_websites SET custom_instructions = $1 WHERE id = $2;", instructions.strip(), site_id) + async def delete_source_website(self, site_id: int) -> None: pool = await self._get_pool() async with pool.acquire() as conn: await conn.execute("UPDATE source_websites SET is_active = FALSE WHERE id = $1;", site_id) + async def update_target_context_count(self, target_id: int, count: int) -> None: + pool = await self._get_pool() + async with pool.acquire() as conn: + await conn.execute("UPDATE targets SET context_message_count = $1 WHERE id = $2;", max(0, count), target_id) + + async def get_recent_target_posts( + self, + target_id: int, + limit: int = 10, + exclude_post_id: Optional[int] = None + ) -> List[Post]: + if limit <= 0: + return [] + pool = await self._get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT * FROM posts + WHERE ( + status = 'published' + OR target_channel_id = $1 + OR published_to::text LIKE '%"target_id": ' || $1 || '%' + ) + AND ($2::BIGINT IS NULL OR id != $2) + AND is_deleted = FALSE + ORDER BY COALESCE(published_at, created_at) DESC, id DESC + LIMIT $3; + """, + target_id, exclude_post_id, limit + ) + posts = [_parse_post_row(r) for r in rows] + posts.reverse() + return posts + async def delete_target(self, target_id: int) -> None: pool = await self._get_pool() async with pool.acquire() as conn: diff --git a/services/admin_bot.py b/services/admin_bot.py index a5fddf9..739e0a0 100644 --- a/services/admin_bot.py +++ b/services/admin_bot.py @@ -44,12 +44,10 @@ def clamp_for_telegram(text: str, has_media: bool) -> str: def get_persian_main_menu(is_paused: bool = False): pause_btn = "▶️ راه‌اندازی و ادامه سیستم" if is_paused else "🛑 توقف اضطراری سیستم" return [ - [Button.text("📊 آمار و وضعیت ناوگان", resize=True), Button.text("🔑 درخواست کد لاگین", resize=True)], - [Button.text("📡 کانال‌های مبدا", resize=True), Button.text("🎯 کانال‌های مقصد", resize=True)], - [Button.text("🌐 وبسایت‌های مبدا", resize=True), Button.text("📂 دسته‌بندی کانال‌ها", resize=True)], - [Button.text("🧠 تنظیمات و لاگ‌های AI", resize=True), Button.text("⚠️ خطاهای سیستم", resize=True)], - [Button.text("➕ افزودن کانال مبدا", resize=True), Button.text("➕ افزودن کانال مقصد", resize=True)], - [Button.text("📨 ارسال پست‌های بررسی‌نشده", resize=True), Button.text(pause_btn, resize=True)], + [Button.text("🏠 منوی اصلی", resize=True), Button.text("📊 آمار و وضعیت ناوگان", resize=True), Button.text("🔑 کد لاگین", resize=True)], + [Button.text("📡 کانال‌های مبدا", resize=True), Button.text("🎯 کانال‌های مقصد", resize=True), Button.text("🌐 وبسایت‌های مبدا", resize=True)], + [Button.text("📂 دسته‌بندی‌ها", resize=True), Button.text("🧠 تنظیمات و لاگ‌های AI", resize=True), Button.text("⚠️ خطاهای سیستم", resize=True)], + [Button.text("➕ افزودن مبدا / مقصد", resize=True), Button.text("📨 پست‌های بررسی‌نشده", resize=True), Button.text(pause_btn, resize=True)], [Button.text("❓ راهنمای سیستم", resize=True)] ] @@ -270,6 +268,8 @@ class AdminBotService: custom_p = getattr(target, "custom_prompt", "") or "" order_mode = getattr(target, "dispatch_order", "order") or "order" order_label = "🟢 به ترتیب ورود (FIFO)" if order_mode == "order" else "🎲 تصادفی (Random)" + ctx_count = getattr(target, "context_message_count", 0) or 0 + ctx_label = f"🟢 {ctx_count} پیام اخیر کانال مقصد" if ctx_count > 0 else "⚪️ غیرفعال" card_text = ( f"🎯 تنظیمات کانال مقصد: {target.title}\n\n" @@ -278,6 +278,7 @@ class AdminBotService: f"• 📁 دسته‌بندی: {cat_name}\n" f"• 🌐 زبان کانال: {lang_label}\n" f"• 🔀 ترتیب ارسال از صف: {order_label}\n" + f"• 📜 پیام‌های زمینه (Context): {ctx_label}\n" f"• ⏱ فاصله ارسال پست‌ها: هر {target.post_interval_min} دقیقه\n" f"• 🌙 وضعیت ساعت خواب: {sleep_st}\n" f"• 📥 پست‌های منتظر در صف: {qsize} پست\n" @@ -296,19 +297,20 @@ class AdminBotService: Button.inline("🎭 تغییر لحن و استایل", data=f"st_pers:{target.id}"), Button.inline("📝 پرامپت و فرامین اختصاصی", data=f"st_cprompt:{target.id}") ], + [ + Button.inline(f"📜 پیام‌های زمینه ({ctx_count})", data=f"trg_ctx_menu:{target.id}"), + Button.inline(f"🔀 شیوه ارسال ({'ترتیبی' if order_mode == 'order' else 'تصادفی'})", data=f"trg_order:{target.id}") + ], [ Button.inline("📁 تعیین دسته‌بندی", data=f"trg_cat:{target.id}"), Button.inline("🏷 تغییر فوتر و تگ‌ها", data=f"st_foot:{target.id}") ], [ Button.inline("🌐 تنظیم زبان", data=f"st_lang:{target.id}"), - Button.inline(f"🔀 شیوه ارسال ({'ترتیبی' if order_mode == 'order' else 'تصادفی'})", data=f"trg_order:{target.id}") - ], - [ - Button.inline("⏱ تغییر فاصله ارسال", data=f"st_intv:{target.id}"), - Button.inline("🌙 تنظیم ساعت خواب", data=f"st_slp:{target.id}") + Button.inline("⏱ تغییر فاصله ارسال", data=f"st_intv:{target.id}") ], [ + Button.inline("🌙 تنظیم ساعت خواب", data=f"st_slp:{target.id}"), Button.inline("🤖 ارسال خودکار از مبدا", data=f"auto_src:{target.id}") ] ] @@ -321,6 +323,39 @@ class AdminBotService: ]) return card_text, buttons + async def _render_target_context_menu(self, target_id: int): + target = await self.repo.get_target_by_id(target_id) + if not target: + return "❌ کانال مقصد یافت نشد.", [] + + current_cnt = getattr(target, "context_message_count", 0) or 0 + status_text = f"{current_cnt} پیام اخیر" if current_cnt > 0 else "غیرفعال (۰ پیام)" + + text = ( + f"📜 تنظیم تعداد پیام‌های زمینه (Context History) برای کانال مقصد\n" + f"🎯 کانال مقصد: {target.title or target.channel_id}\n\n" + f"با فعال‌سازی این قابلیت، هنگام بازنویسی هر پست برای این کانال، تعداد مشخصی از آخرین پست‌های منتشرشده در همین کانال مقصد به هوش مصنوعی داده می‌شود تا خط داستانی، لحن نگارش و اخبار مرتبط اخیر رعایت شود.\n\n" + f"• وضعیت کنونی: {status_text}\n\n" + f"تعداد پیام مورد نظر را انتخاب کنید:" + ) + + buttons = [ + [ + Button.inline("0️⃣ خاموش (۰)", data=f"trg_ctx_set:{target.id}:0"), + Button.inline("1️⃣ ۱ پیام", data=f"trg_ctx_set:{target.id}:1"), + Button.inline("2️⃣ ۲ پیام", data=f"trg_ctx_set:{target.id}:2"), + ], + [ + Button.inline("3️⃣ ۳ پیام", data=f"trg_ctx_set:{target.id}:3"), + Button.inline("5️⃣ ۵ پیام", data=f"trg_ctx_set:{target.id}:5"), + Button.inline("🔟 ۱۰ پیام", data=f"trg_ctx_set:{target.id}:10"), + ], + [ + Button.inline("🔙 بازگشت به کانال مقصد", data=f"trg_view:{target.id}") + ] + ] + return text, buttons + async def _render_target_language_menu(self, target_id: int) -> Tuple[str, List[List[Button]]]: target = await self.repo.get_target_by_id(target_id) if not target: @@ -965,11 +1000,13 @@ class AdminBotService: cat_name = cat.name api_cfg = site.api_config or {} - parser_type = api_cfg.get("parser_type", "نامشخص") - endpoint = api_cfg.get("endpoint_url", site.url) + has_api = bool(api_cfg.get("endpoint_url")) + parser_type = api_cfg.get("parser_type", "در انتظار تحلیل AI") if has_api else "⚠️ در انتظار تحلیل اولیه" + endpoint = api_cfg.get("endpoint_url", "تنظیم نشده (نیازمند تحلیل)") err_block = f"⚠️ خطای اخیر: {site.last_error}\n\n" if site.last_error else "" last_fe = site.last_fetched_at.strftime("%Y-%m-%d %H:%M") if site.last_fetched_at else "هنوز دریافت نشده" last_re = site.last_reanalyzed_at.strftime("%Y-%m-%d %H:%M") if site.last_reanalyzed_at else "انجام نشده" + needs = getattr(site, "custom_instructions", "") or "" text = ( f"🌐 وبسایت مبدا: {site.name}\n\n" @@ -977,6 +1014,8 @@ class AdminBotService: f"• 🔌 اندپوینت دریافت: {endpoint}\n" f"• 🏷 نوع ساختار: {parser_type}\n" f"• 📁 دسته‌بندی: {cat_name}\n" + f"• 📝 نیازمندی‌ها و فیلترهای استخراج:\n" + f"{needs or 'ثبت نشده (استخراج تمام اخبار و پست‌ها)'}\n\n" f"• ⏱ فاصله بررسی: هر {site.check_interval_min} دقیقه\n" f"• 🔄 دوره تحلیل مجدد AI: هر {site.auto_reanalyze_hours} ساعت\n" f"• 📥 آخرین دریافت: {last_fe}\n" @@ -985,19 +1024,20 @@ class AdminBotService: f"👇 عملیات مورد نظر را انتخاب کنید:" ) buttons = [ + [ + Button.inline("🤖 شروع / تکرار تحلیل با AI", data=f"web_reanalyze:{site.id}"), + Button.inline("📝 نیازمندی‌های استخراج", data=f"web_needs:{site.id}") + ], [ Button.inline("🔄 دریافت دستی (Fetch Now)", data=f"web_fetch:{site.id}"), - Button.inline("🤖 تحلیل مجدد هوشمند AI", data=f"web_reanalyze:{site.id}") + Button.inline("⏱ تغییر فاصله بررسی", data=f"web_intv:{site.id}") ], [ - Button.inline("⏱ تغییر فاصله بررسی", data=f"web_intv:{site.id}"), - Button.inline("🔄 دوره تحلیل مجدد AI", data=f"web_reintv:{site.id}") - ], - [ - Button.inline("📁 تعیین دسته‌بندی", data=f"web_cat:{site.id}"), - Button.inline("🗑 حذف این وبسایت", data=f"del_web:{site.id}") + Button.inline("🔄 دوره تحلیل مجدد AI", data=f"web_reintv:{site.id}"), + Button.inline("📁 تعیین دسته‌بندی", data=f"web_cat:{site.id}") ], [ + Button.inline("🗑 حذف این وبسایت", data=f"del_web:{site.id}"), Button.inline("🔙 بازگشت به لیست وبسایت‌ها", data="list_web") ] ] @@ -1163,19 +1203,18 @@ class AdminBotService: if not targets: return 0 - source = await self.repo.get_source_by_channel_id(post.source_channel_id) - context_posts = [] - if source and getattr(source, "context_message_count", 0) > 0: - context_posts = await self.repo.get_recent_source_posts( - source_channel_id=post.source_channel_id, - limit=source.context_message_count, - exclude_post_id=post.id - ) - routed = 0 ai_rejected_reasons = [] for target in targets: try: + context_posts = [] + if target and getattr(target, "context_message_count", 0) > 0: + context_posts = await self.repo.get_recent_target_posts( + target_id=target.id, + limit=target.context_message_count, + exclude_post_id=post.id + ) + text = post.raw_text or "" if self.ai_processor: rewrite_res = await self.ai_processor.rewrite_for_target( @@ -1345,6 +1384,19 @@ class AdminBotService: text, buttons = await self._render_categories_menu() await event.reply(text, parse_mode="html", buttons=buttons or None) + # --- Unified Add Hub --- + @self.client.on(events.NewMessage(pattern=r"(?i)^(/add|➕ افزودن مبدا / مقصد)$")) + async def cmd_add_hub(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + text = "➕ افزودن منبع یا کانال جدید:\n\nلطفاً نوع مورد نظر را انتخاب کنید:" + buttons = [ + [Button.inline("📡 افزودن کانال تلگرام مبدا", data="hub_add_src")], + [Button.inline("🎯 افزودن کانال تلگرام مقصد", data="hub_add_trg")], + [Button.inline("🌐 افزودن وبسایت مبدا (Web)", data="add_web")], + ] + await event.reply(text, parse_mode="html", buttons=buttons) + @self.client.on(events.NewMessage(pattern=r"(?i)^(➕ افزودن کانال مقصد)$")) async def cmd_add_target_prompt(event: events.NewMessage.Event): if not self.is_admin(event.sender_id): @@ -1736,7 +1788,6 @@ class AdminBotService: await event.reply("❌ فرمت نامعتبر است. لطفاً نام و آدرس را با فاصله ارسال کنید (مثال: دیجیاتو https://digiato.com).", parse_mode="html", buttons=menu) return - # If last part is URL: if parts[-1].startswith(("http://", "https://", "www.")) or "." in parts[-1]: url = parts[-1] name = " ".join(parts[:-1]) @@ -1747,25 +1798,29 @@ class AdminBotService: if not url.startswith("http://") and not url.startswith("https://"): url = "https://" + url - loading_msg = await event.reply( - f"🤖 در حال تحلیل وبسایت «{name}» ({url}) توسط هوش مصنوعی...\n(کشف خودکار REST APIها، فیدهای RSS و ساختار داده)", - parse_mode="html" - ) - - api_cfg = {} - if self.website_analyzer: - ok, cfg, summary = await self.website_analyzer.analyze_website(url) - if ok and cfg: - api_cfg = cfg - - site_id = await self.repo.add_source_website(name=name, url=url, api_config=api_cfg) + site_id = await self.repo.add_source_website(name=name, url=url, api_config={}) card, buttons = await self._render_website_config(site_id) - await loading_msg.edit( - f"✅ وبسایت «{name}» با موفقیت افزوده و پیکربندی شد!\n\n{card}", + await event.reply( + f"✅ وبسایت «{name}» افزوده شد!\n\n" + f"اکنون می‌توانید با دکمه 📝 نیازمندی‌های استخراج موضوعات مدنظرتان را تعیین کنید یا با دکمه 🤖 شروع / تکرار تحلیل با AI استخراج خودکار را آغاز کنید.\n\n" + f"{card}", parse_mode="html", buttons=buttons ) + # 8.2. Waiting for Website Custom Needs / Extraction Instructions + elif action == "wait_website_needs": + site_id = state.get("site_id") + self.user_states.pop(event.sender_id, None) + instr = "" if text.strip() in ("/clear", "clear", "حذف", "پاک") else text.strip() + await self.repo.update_source_website_custom_instructions(site_id, instr) + card, buttons = await self._render_website_config(site_id) + if instr: + await event.reply("✅ نیازمندی‌ها و فیلترهای استخراج وبسایت با موفقیت ذخیره شد!", parse_mode="html") + else: + await event.reply("✅ فیلتر اختصاصی حذف شد (استخراج عمومی فعال شد).", parse_mode="html") + await event.reply(card, parse_mode="html", buttons=buttons) + # 8.2. Waiting for Website Fetch Interval elif action == "wait_website_intv": site_id = state.get("site_id") @@ -2579,6 +2634,55 @@ class AdminBotService: await event.edit(card, parse_mode="html", buttons=buttons) await event.answer("🎲 شیوه ارسال روی تصادفی تنظیم شد." if new_order == "random" else "🟢 شیوه ارسال روی به ترتیب تنظیم شد.") + elif data.startswith("trg_ctx_menu:"): + target_id = int(data.split(":")[1]) + text, buttons = await self._render_target_context_menu(target_id) + await event.edit(text, parse_mode="html", buttons=buttons) + await event.answer() + + elif data.startswith("trg_ctx_set:"): + _, target_id_str, cnt_str = data.split(":") + target_id = int(target_id_str) + cnt = int(cnt_str) + await self.repo.update_target_context_count(target_id, cnt) + card, buttons = await self._render_target_config(target_id) + await event.edit(card, parse_mode="html", buttons=buttons or None) + await event.answer(f"✅ پیام‌های زمینه کانال مقصد روی {cnt} تنظیم شد.") + + elif data.startswith("web_needs:"): + site_id = int(data.split(":")[1]) + site = await self.repo.get_source_website_by_id(site_id) + if not site: + await event.answer("وبسایت یافت نشد.", alert=True) + return + self.user_states[event.sender_id] = {"action": "wait_website_needs", "site_id": site_id} + await event.reply( + f"📝 تعیین نیازمندی‌ها و فیلترهای استخراج از «{site.name}»:\n\n" + f"چه موضوعات، بخش‌ها یا کلیدواژه‌هایی مد نظر شماست؟ این متن به عنوان دستورالعمل در تحلیل AI قرار می‌گیرد:\n" + f"(مثال: فقط مقالات مربوط به هوش مصنوعی، فناوری و سخت‌افزار یا /clear برای حذف فیلتر)", + parse_mode="html", + buttons=get_cancel_button() + ) + await event.answer() + + elif data == "hub_add_src": + self.user_states[event.sender_id] = {"action": "wait_source_fwd"} + guide = ( + "📡 افزودن کانال مبدا جدید:\n\n" + "یک پیام از کانال مورد نظر را فوروارد کنید یا آیدی/یوزرنیم آن را بفرستید." + ) + await event.reply(guide, parse_mode="html", buttons=get_cancel_button()) + await event.answer() + + elif data == "hub_add_trg": + self.user_states[event.sender_id] = {"action": "wait_target_fwd"} + guide = ( + "🎯 افزودن کانال مقصد جدید:\n\n" + "یک پیام از کانال مقصد را فوروارد کنید یا آیدی/یوزرنیم آن را بفرستید." + ) + await event.reply(guide, parse_mode="html", buttons=get_cancel_button()) + await event.answer() + elif data.startswith("auto_src:"): target_id = int(data.split(":")[1]) text, buttons = await self._render_auto_sources(target_id) @@ -2758,12 +2862,11 @@ class AdminBotService: except Exception: pass - source = await self.repo.get_source_by_channel_id(post.source_channel_id) context_posts = [] - if source and getattr(source, "context_message_count", 0) > 0: - context_posts = await self.repo.get_recent_source_posts( - source_channel_id=post.source_channel_id, - limit=source.context_message_count, + if target and getattr(target, "context_message_count", 0) > 0: + context_posts = await self.repo.get_recent_target_posts( + target_id=target.id, + limit=target.context_message_count, exclude_post_id=post.id ) diff --git a/services/website_analyzer.py b/services/website_analyzer.py index 3312912..76e2bb0 100644 --- a/services/website_analyzer.py +++ b/services/website_analyzer.py @@ -38,7 +38,7 @@ class WebsiteAnalyzer: def __init__(self, llm: LLMClient): self.llm = llm - async def analyze_website(self, url: str) -> Tuple[bool, Dict[str, Any], str]: + async def analyze_website(self, url: str, custom_instructions: str = "") -> Tuple[bool, Dict[str, Any], str]: """Discover endpoints and prompt AI to build the extractor schema.""" clean_url = url.strip() if not clean_url.startswith("http://") and not clean_url.startswith("https://"): @@ -109,11 +109,14 @@ class WebsiteAnalyzer: except Exception as e: return False, {}, f"خطا در برقراری ارتباط با وبسایت: {e}" + custom_block = f"\nUser Extraction Needs / Filtering Instructions:\n{custom_instructions.strip()}\n" if custom_instructions and custom_instructions.strip() else "" + prompt = ( f"Website Target URL: {clean_url}\n" + f"{custom_block}" f"Discovery Notes:\n" + "\n".join(discovered_info) + "\n\n" f"Data / Feed Sample:\n{sample_data}\n\n" - f"Determine the best endpoint_url, parser_type, and field mappings to regularly extract the latest news/articles from this site." + f"Determine the best endpoint_url, parser_type, and field mappings to regularly extract the latest news/articles from this site matching the user requirements." ) try: diff --git a/services/website_collector.py b/services/website_collector.py index 2fd9ae7..b1f11c4 100644 --- a/services/website_collector.py +++ b/services/website_collector.py @@ -110,7 +110,8 @@ class WebsiteCollectorService: if not site: return False, "وبسایت یافت نشد." - ok, api_cfg, summary = await self.analyzer.analyze_website(site.url) + custom_inst = getattr(site, "custom_instructions", "") or "" + ok, api_cfg, summary = await self.analyzer.analyze_website(site.url, custom_instructions=custom_inst) if ok and api_cfg: await self.repo.update_source_website_api_config(site_id, api_cfg) return True, f"✅ وبسایت «{site.name}» با موفقیت تحلیل شد:\n{summary}" @@ -133,7 +134,8 @@ class WebsiteCollectorService: endpoint_url = api_cfg.get("endpoint_url") if not endpoint_url: # Trigger initial analysis - ok, new_cfg, _ = await self.analyzer.analyze_website(site.url) + custom_inst = getattr(site, "custom_instructions", "") or "" + ok, new_cfg, _ = await self.analyzer.analyze_website(site.url, custom_instructions=custom_inst) if ok and new_cfg: await self.repo.update_source_website_api_config(site_id, new_cfg) api_cfg = new_cfg diff --git a/tests/test_target_context_and_website_custom_needs.py b/tests/test_target_context_and_website_custom_needs.py new file mode 100644 index 0000000..073bdae --- /dev/null +++ b/tests/test_target_context_and_website_custom_needs.py @@ -0,0 +1,129 @@ +import asyncio +import time +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch +from db.database import init_db +from db.repository import Repository +from db.models import TargetChannel, SourceWebsite, Post +from services.website_analyzer import WebsiteAnalyzer +from services.admin_bot import get_persian_main_menu + + +async def test_target_context_message_count_and_history(): + await init_db() + repo = Repository() + + # 1. Create target + unique_channel_id = -10077665544 - int(time.time() % 100000) + target_id = await repo.add_target(channel_id=unique_channel_id, title="Target Context Test", username="trg_ctx_test") + assert target_id is not None + + # Check default context count is 0 + t = await repo.get_target_by_id(target_id) + assert t.context_message_count == 0 + + # Update context count to 5 + await repo.update_target_context_count(target_id, 5) + t_updated = await repo.get_target_by_id(target_id) + assert t_updated.context_message_count == 5 + + # Insert published posts for this target + t0 = datetime(2026, 8, 28, 14, 0, 0, tzinfo=timezone.utc) + t1 = datetime(2026, 8, 28, 15, 0, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 8, 28, 16, 0, 0, tzinfo=timezone.utc) + + p0_id = await repo.create_raw_post( + source_channel_id=-1001, + source_message_id=501, + raw_text="پست منتشرشده ۱ در تارگت", + source_created_at=t0 + ) + await repo.record_post_published_to_target(p0_id, target_id, "Target Context Test") + + p1_id = await repo.create_raw_post( + source_channel_id=-1001, + source_message_id=502, + raw_text="پست منتشرشده ۲ در تارگت", + source_created_at=t1 + ) + await repo.record_post_published_to_target(p1_id, target_id, "Target Context Test") + + p2_id = await repo.create_raw_post( + source_channel_id=-1001, + source_message_id=503, + raw_text="پست در حال بازنویسی ۳", + source_created_at=t2 + ) + + # Fetch recent target posts for target_id excluding p2_id -> should return p0 and p1 in chronological order + recent_target_posts = await repo.get_recent_target_posts(target_id, limit=5, exclude_post_id=p2_id) + assert len(recent_target_posts) == 2 + assert recent_target_posts[0].id == p0_id + assert recent_target_posts[1].id == p1_id + + # Clean up + await repo.delete_target(target_id) + + +async def test_website_custom_needs_and_prompt_injection(): + await init_db() + repo = Repository() + + test_url = f"https://techblog.example.com/site-{int(time.time())}" + site_id = await repo.add_source_website( + name="Tech Blog Custom Needs", + url=test_url, + custom_instructions="فقط اخبار مربوط به پردازنده‌های گرافیکی و تراشه‌ها" + ) + assert site_id is not None + + site = await repo.get_source_website_by_id(site_id) + assert site.custom_instructions == "فقط اخبار مربوط به پردازنده‌های گرافیکی و تراشه‌ها" + assert site.api_config == {} # Not analyzed automatically + + # Test WebsiteAnalyzer prompt receives custom_instructions + captured_payload = {} + llm_mock = MagicMock() + async def fake_generate_json(prompt, system_prompt, action_name): + captured_payload["prompt"] = prompt + return { + "status": "success", + "endpoint_url": f"{test_url}/feed", + "parser_type": "rss", + "field_mappings": {"title": "title", "content": "description", "link": "link"} + } + llm_mock.generate_json = AsyncMock(side_effect=fake_generate_json) + + analyzer = WebsiteAnalyzer(llm=llm_mock) + with patch("httpx.AsyncClient.get") as mock_get: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = '' + mock_get.return_value = mock_resp + + ok, cfg, summary = await analyzer.analyze_website(test_url, custom_instructions=site.custom_instructions) + assert ok is True + assert "فقط اخبار مربوط به پردازنده‌های گرافیکی و تراشه‌ها" in captured_payload["prompt"] + + # Clean up + await repo.delete_source_website(site_id) + + +async def test_menu_layout(): + menu = get_persian_main_menu(is_paused=False) + assert len(menu) == 5 + # Row 1 has Home/Start + assert any("🏠 منوی اصلی" in btn.text for btn in menu[0]) + # Row 4 has Unified Add + assert any("➕ افزودن مبدا / مقصد" in btn.text for btn in menu[3]) + + +async def main(): + await test_target_context_message_count_and_history() + await test_website_custom_needs_and_prompt_injection() + await test_menu_layout() + print("All Target Context, Website Custom Needs, and Reorganized Menu tests passed successfully!") + + +if __name__ == "__main__": + asyncio.run(main())