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 = '