From 2137a1158d6304d58b6526d3f6a9da9cd4251f2c Mon Sep 17 00:00:00 2001 From: mamad Date: Fri, 28 Aug 2026 19:33:29 +0330 Subject: [PATCH] services: update ingestion collector, paced publisher, and deduplication --- bot/keyboards.py | 18 ----- services/collector.py | 91 +++++++++++++++++----- services/publisher.py | 93 ++++++++++++++++++++--- services/queue_consumer.py | 50 ------------ tests/test_auto_routing.py | 99 ++++++++++++++++++++++++ tests/test_db.py | 63 ---------------- tests/test_dedup.py | 4 + tests/test_publisher_failures.py | 111 +++++++++++++++++++++++++++ tests/test_scrape_history.py | 126 +++++++++++++++++++++++++++++++ 9 files changed, 494 insertions(+), 161 deletions(-) delete mode 100644 bot/keyboards.py delete mode 100644 services/queue_consumer.py create mode 100644 tests/test_auto_routing.py delete mode 100644 tests/test_db.py create mode 100644 tests/test_publisher_failures.py create mode 100644 tests/test_scrape_history.py diff --git a/bot/keyboards.py b/bot/keyboards.py deleted file mode 100644 index ee4c4fe..0000000 --- a/bot/keyboards.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import List -from telethon import Button -from db.models import TargetChannel - -def get_review_keyboard(post_id: int, targets: List[TargetChannel]) -> List[List[Button]]: - keyboard = [] - # Add a button for each target channel - for target in targets: - title = target.title or f"Target #{target.id}" - keyboard.append([ - Button.inline(f"🚀 Send to {title}", data=f"appr:{post_id}:{target.id}") - ]) - - # Add Reject button - keyboard.append([ - Button.inline("❌ Reject Post", data=f"rej:{post_id}") - ]) - return keyboard diff --git a/services/collector.py b/services/collector.py index 7b6e141..836b485 100644 --- a/services/collector.py +++ b/services/collector.py @@ -1,5 +1,6 @@ import os import logging +from dataclasses import dataclass from typing import Optional, Callable, Awaitable from telethon import TelegramClient, events from telethon.errors import SessionPasswordNeededError @@ -7,7 +8,7 @@ from telethon.tl.types import MessageMediaPhoto, MessageMediaDocument from db.repository import Repository from core.dedup import compute_content_hash, compute_file_hash from core.queue import RedisQueue -from core.metrics import COLLECTED_POSTS_TOTAL, SOURCE_ACTIVITY_TOTAL +from core.metrics import SOURCE_ACTIVITY_TOTAL, DUPLICATES_DETECTED_TOTAL from core.proxy import get_telegram_proxy from core.error_logger import log_exception @@ -16,6 +17,40 @@ logger = logging.getLogger(__name__) SESSION_DIR = os.getenv("SESSION_DIR", "/app/sessions" if os.path.exists("/app") else "/projects/telegram-bots/copykar/sessions") MEDIA_DIR = os.getenv("MEDIA_DIR", "/app/data/media" if os.path.exists("/app") else "/projects/telegram-bots/copykar/data/media") +# Bounds for the "custom count" prompt in the admin bot. +MIN_FETCH_LIMIT = 1 +MAX_FETCH_LIMIT = 500 + + +@dataclass +class ScrapeResult: + """Outcome of a history scrape. + + 'collected' alone is misleading: a repeat scrape of the same window legitimately + adds nothing, which reads as a broken button unless the other counters are shown. + """ + scanned: int = 0 + collected: int = 0 + already_stored: int = 0 + duplicates: int = 0 + error: Optional[str] = None + + def summary_fa(self, channel_id: int) -> str: + if self.error: + return f"❌ خطا در دریافت پست‌های کانال {channel_id}: {self.error}" + header = ( + f"✅ {self.collected} پست جدید از {channel_id} دریافت و به کانال ادمین ارسال شد." + if self.collected + else f"ℹ️ هیچ پست جدیدی در {channel_id} پیدا نشد." + ) + return ( + f"{header}\n\n" + f"• 🔍 پیام بررسی‌شده: {self.scanned}\n" + f"• 🆕 پست جدید: {self.collected}\n" + f"• 🗂 قبلا ذخیره شده: {self.already_stored}\n" + f"• ♻️ محتوای تکراری: {self.duplicates}" + ) + class CollectorService: def __init__( self, @@ -136,6 +171,10 @@ class CollectorService: async def _handle_message(self, event: events.NewMessage.Event): try: + if await self.repo.is_system_paused(): + logger.info(f"[collector] System is paused by admin. Ignoring incoming post from chat {event.chat_id}") + return + chat_id = event.chat_id source = await self.repo.get_source_by_channel_id(chat_id) if not source or not source.is_active: @@ -162,6 +201,7 @@ class CollectorService: media_hash = compute_file_hash(downloaded_file) content_hash = compute_content_hash(raw_text, media_hash) + duplicate_of = await self.repo.find_duplicate_post(content_hash) post_id = await self.repo.create_raw_post( source_channel_id=chat_id, @@ -170,10 +210,15 @@ class CollectorService: media_path=media_path, media_type=media_type, content_hash=content_hash, + is_duplicate=duplicate_of is not None, + duplicate_of_id=duplicate_of.id if duplicate_of else None, + similarity_reason=f"content hash matches post #{duplicate_of.id}" if duplicate_of else None, ) + if post_id and duplicate_of: + DUPLICATES_DETECTED_TOTAL.labels(method="content_hash").inc() + if post_id: - COLLECTED_POSTS_TOTAL.labels(source_channel_id=str(chat_id)).inc() SOURCE_ACTIVITY_TOTAL.labels(channel_id=str(chat_id), title=source.title or 'Unknown').inc() logger.info(f"Collected raw post ID {post_id} from source channel {chat_id}") @@ -187,20 +232,21 @@ class CollectorService: channel_id: int, limit: int = 20, progress_callback: Optional[Callable[[str], Awaitable[None]]] = None - ) -> int: + ) -> ScrapeResult: """Scrape historical messages from a source channel.""" + result = ScrapeResult() + if not self.client.is_connected() or not await self.client.is_user_authorized(): if progress_callback: await progress_callback("❌ ربات متصل نیست. لطفا ابتدا لاگین کنید.") - return 0 - - source = await self.repo.get_source_by_channel_id(channel_id) - source_title = source.title if source else str(channel_id) - username = source.username if source else None - collected_count = 0 - skipped_count = 0 + result.error = "not connected" + return result try: + source = await self.repo.get_source_by_channel_id(channel_id) + source_title = source.title if source else str(channel_id) + username = source.username if source else None + entity = await self._resolve_channel_entity(channel_id, username) messages = [] async for msg in self.client.iter_messages(entity, limit=limit): @@ -208,6 +254,8 @@ class CollectorService: messages.reverse() + result.scanned = len(messages) + for message in messages: raw_text = message.raw_text or "" if not raw_text and not message.media: @@ -233,6 +281,10 @@ class CollectorService: media_hash = compute_file_hash(downloaded_file) content_hash = compute_content_hash(raw_text, media_hash) + duplicate_of = await self.repo.find_duplicate_post(content_hash) + if duplicate_of: + result.duplicates += 1 + DUPLICATES_DETECTED_TOTAL.labels(method="content_hash").inc() post_id = await self.repo.create_raw_post( source_channel_id=channel_id, @@ -241,29 +293,30 @@ class CollectorService: media_path=media_path, media_type=media_type, content_hash=content_hash, + is_duplicate=duplicate_of is not None, + duplicate_of_id=duplicate_of.id if duplicate_of else None, + similarity_reason=f"content hash matches post #{duplicate_of.id}" if duplicate_of else None, ) if post_id: - collected_count += 1 - COLLECTED_POSTS_TOTAL.labels(source_channel_id=str(channel_id)).inc() + result.collected += 1 SOURCE_ACTIVITY_TOTAL.labels(channel_id=str(channel_id), title=source_title).inc() logger.info(f"Backfilled raw post ID {post_id} from {channel_id}") if self.on_post_received: await self.on_post_received(post_id) else: - skipped_count += 1 + result.already_stored += 1 if progress_callback: - await progress_callback( - f"✅ تعداد {collected_count} پست جدید از {channel_id} دریافت و در کانال ادمین قرار گرفت! (رد شده تکراری: {skipped_count})." - ) - return collected_count + await progress_callback(result.summary_fa(channel_id)) + return result except Exception as e: await log_exception("collector.scrape_history", e, {"channel_id": channel_id, "limit": limit}) + result.error = str(e) if progress_callback: - await progress_callback(f"❌ خطا در دریافت پست‌های کانال {channel_id}: {e}") - return collected_count + await progress_callback(result.summary_fa(channel_id)) + return result async def stop(self): if self.client.is_connected(): diff --git a/services/publisher.py b/services/publisher.py index 502d66e..a79fc93 100644 --- a/services/publisher.py +++ b/services/publisher.py @@ -1,13 +1,20 @@ import os import asyncio import logging -from datetime import datetime, timezone -from typing import Optional, List +from datetime import datetime, timedelta, timezone +from typing import Optional, List, Awaitable, Callable, Set, Tuple from telethon import TelegramClient +from telethon.errors import ( + ChannelPrivateError, + ChatAdminRequiredError, + ChatWriteForbiddenError, + PeerIdInvalidError, + UserBannedInChannelError, +) from db.models import TargetChannel from db.repository import Repository from core.queue import RedisQueue -from core.metrics import TARGET_ACTIVITY_TOTAL, QUEUE_POSTS_GAUGE, REDIS_QUEUE_SIZE_GAUGE +from core.metrics import TARGET_ACTIVITY_TOTAL, QUEUE_POSTS_GAUGE from core.proxy import get_telegram_proxy from core.error_logger import log_exception @@ -15,6 +22,19 @@ logger = logging.getLogger(__name__) SESSION_DIR = os.getenv("SESSION_DIR", "/app/sessions" if os.path.exists("/app") else "/projects/telegram-bots/copykar/sessions") +# Sleep windows are configured in the operator's wall-clock time, not UTC. +TIMEZONE_OFFSET_HOURS = float(os.getenv("TIMEZONE_OFFSET_HOURS", "3.5")) + +# Failures that will never resolve by retrying: the account simply cannot post there. +# Re-queueing these would spin the same post through the loop forever. +PERMANENT_DELIVERY_ERRORS = ( + ChatAdminRequiredError, + ChatWriteForbiddenError, + ChannelPrivateError, + UserBannedInChannelError, + PeerIdInvalidError, +) + class PublisherService: def __init__( self, @@ -25,10 +45,15 @@ class PublisherService: api_hash: Optional[str] = None, bot_token: Optional[str] = None, session_name: Optional[str] = None, + notify_fn: Optional[Callable[[str], Awaitable[None]]] = None, ): self.repo = repo self.queue = queue self.client = client + self.notify_fn = notify_fn + # (target_id, error type) pairs already reported, so a broken target is + # announced once instead of every polling cycle. + self._reported_failures: Set[Tuple[int, str]] = set() self.api_id = api_id or int(os.getenv("API_ID", "0")) self.api_hash = api_hash or os.getenv("API_HASH", "") self.bot_token = bot_token or os.getenv("BOT_TOKEN") @@ -42,11 +67,13 @@ class PublisherService: async def start(self): logger.info("Starting Paced Target Publisher Service...") if not self.client.is_connected(): + # Only owns the connection when it built its own client; a shared client + # (the admin bot's) is already connected by its owner. if self.bot_token: await self.client.start(bot_token=self.bot_token) else: await self.client.start() - logger.info("Paced Target Publisher Service connected.") + logger.info("Paced Target Publisher Service connected (delivering as the bot account).") self._running = True self._task = asyncio.create_task(self._publisher_loop()) @@ -66,7 +93,10 @@ class PublisherService: async def _publisher_loop(self): while self._running: try: - await self._process_all_target_queues() + if await self.repo.is_system_paused(): + logger.debug("[publisher] System is paused by admin. Skipping queue processing.") + else: + await self._process_all_target_queues() except Exception as e: logger.error(f"Error in target publisher loop: {e}", exc_info=True) await asyncio.sleep(15) @@ -74,13 +104,11 @@ class PublisherService: async def _process_all_target_queues(self): targets = await self.repo.get_active_targets() now = datetime.now(timezone.utc) - current_hour_local = (now.hour + 3) % 24 # UTC+3:30 approx hour - - total_queued = 0 + local_now = now + timedelta(hours=TIMEZONE_OFFSET_HOURS) + current_hour_local = local_now.hour for target in targets: qsize = await self.queue.get_target_queue_size(target.id) - total_queued += qsize QUEUE_POSTS_GAUGE.labels(status=f"target_{target.id}").set(qsize) if qsize == 0: @@ -130,9 +158,52 @@ class PublisherService: TARGET_ACTIVITY_TOTAL.labels(channel_id=str(target.channel_id), title=target.title or '').inc() logger.info(f"Published post ID {post_id} to Target {target.title} ({target.channel_id})") except Exception as e: - await log_exception("publisher.publish", e, {"post_id": post_id, "target_id": target.id, "channel_id": target.channel_id}) + permanent = isinstance(e, PERMANENT_DELIVERY_ERRORS) + if permanent: + # Dropping the payload is deliberate: the post stays 'pending_review' + # so an admin can re-send it once the permission problem is resolved. + await self._report_broken_target(target, e) + else: + # The payload was already popped; putting it back keeps the post from + # being silently lost on a transient Telegram failure. + try: + await self.queue.push_target_post(target.id, payload) + except Exception as requeue_err: + logger.critical(f"Failed to requeue post {post_id} for target {target.id}: {requeue_err}") - REDIS_QUEUE_SIZE_GAUGE.set(total_queued) + await log_exception("publisher.publish", e, { + "post_id": post_id, + "target_id": target.id, + "channel_id": target.channel_id, + "permanent": permanent, + }) + + async def _report_broken_target(self, target: TargetChannel, error: Exception) -> None: + """Tell the admins once that a target channel is unreachable for this account.""" + key = (target.id, type(error).__name__) + if key in self._reported_failures: + return + self._reported_failures.add(key) + + logger.error( + f"Target #{target.id} ({target.title}) rejected delivery permanently: " + f"{type(error).__name__}. Queue drained for this target until it is fixed." + ) + if not self.notify_fn: + return + + try: + await self.notify_fn( + f"⛔ ارسال به کانال مقصد «{target.title}» ممکن نیست!\n\n" + f"• 🆔 شناسه کانال: {target.channel_id}\n" + f"• ❗️ خطا: {type(error).__name__}\n\n" + "ربات در این کانال عضو یا ادمین با دسترسی ارسال پیام نیست.\n" + "لطفا ربات را در کانال ادمین کنید و دسترسی ارسال پیام (Post Messages) بدهید، " + "سپس پست را دوباره به صف بفرستید.\n\n" + "تا رفع این مشکل، پست‌های این کانال ارسال نمی‌شوند و در وضعیت بررسی باقی می‌مانند." + ) + except Exception as notify_err: + logger.error(f"Could not notify admins about broken target {target.id}: {notify_err}") async def stop(self): self._running = False diff --git a/services/queue_consumer.py b/services/queue_consumer.py deleted file mode 100644 index 1a9d0f0..0000000 --- a/services/queue_consumer.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -import asyncio -import logging -from typing import Optional -from core.queue import RedisQueue -from core.metrics import QUEUE_POSTS_GAUGE, REDIS_QUEUE_SIZE_GAUGE - -logger = logging.getLogger(__name__) - -FETCH_INTERVAL_SECONDS = int(os.getenv("AI_PROCESSING_INTERVAL_SECONDS", "120")) - -class QueueConsumerService: - def __init__(self, queue: RedisQueue, on_post_popped = None, fetch_interval: int = FETCH_INTERVAL_SECONDS): - self.queue = queue - self.on_post_popped = on_post_popped - self.fetch_interval = fetch_interval - self._running = False - self._task: Optional[asyncio.Task] = None - - async def start(self): - logger.info(f"Starting Redis Queue Consumer Service (interval: {self.fetch_interval}s / {self.fetch_interval // 60}m)...") - await self.queue.connect() - self._running = True - self._task = asyncio.create_task(self._consumer_loop()) - - async def _consumer_loop(self): - while self._running: - try: - qsize = await self.queue.qsize() - QUEUE_POSTS_GAUGE.labels(status="redis_incoming").set(qsize) - REDIS_QUEUE_SIZE_GAUGE.set(qsize) - - if qsize > 0: - post_id = await self.queue.pop() - if post_id and self.on_post_popped: - logger.info(f"Dispatching raw post ID {post_id} to Admin Review channel (remaining in Redis: {qsize - 1})") - await self.on_post_popped(post_id) - new_qsize = await self.queue.qsize() - QUEUE_POSTS_GAUGE.labels(status="redis_incoming").set(new_qsize) - REDIS_QUEUE_SIZE_GAUGE.set(new_qsize) - except Exception as e: - logger.error(f"Error in queue consumer loop: {e}", exc_info=True) - - await asyncio.sleep(self.fetch_interval) - - async def stop(self): - self._running = False - if self._task: - self._task.cancel() - logger.info("Redis Queue Consumer Service stopped.") diff --git a/tests/test_auto_routing.py b/tests/test_auto_routing.py new file mode 100644 index 0000000..d5d42a2 --- /dev/null +++ b/tests/test_auto_routing.py @@ -0,0 +1,99 @@ +"""Auto-routing must queue to subscribed targets WITHOUT replacing the admin review card.""" +import asyncio +import sys + +sys.path.insert(0, "/app") + +from db.database import init_db, close_db_pool +from db.repository import Repository +from services.admin_bot import AdminBotService + +SRC_A = -1009999000020 +SRC_B = -1009999000021 +TRG = -1009999000022 + + +class FakeQueue: + def __init__(self): + self.pushed = [] + + async def push_target_post(self, target_id, payload): + self.pushed.append((target_id, payload)) + + async def get_target_queue_size(self, target_id): + return 0 + + +class FakeAI: + async def rewrite_for_target(self, raw_text, target, has_media=False, *args, **kwargs): + return f"[{target.title}] {raw_text}" + + + +async def _cleanup(repo): + pool = await repo._get_pool() + async with pool.acquire() as conn: + for cid in (SRC_A, SRC_B): + await conn.execute("DELETE FROM posts WHERE source_channel_id = $1;", cid) + await conn.execute("DELETE FROM sources WHERE channel_id = $1;", cid) + await conn.execute("DELETE FROM targets WHERE channel_id = $1;", TRG) + + +async def run_tests(): + await init_db() + repo = Repository() + await _cleanup(repo) + + await repo.add_source(SRC_A, "Source A", None) + await repo.add_source(SRC_B, "Source B", None) + target_id = await repo.add_target(TRG, "Auto Target", None) + + # A brand new target routes nothing. + assert await repo.get_targets_auto_routed_from(SRC_A) == [] + + # Toggle is a switch, not a one-way door. + assert await repo.toggle_target_auto_source(target_id, SRC_A) is True + routed = await repo.get_targets_auto_routed_from(SRC_A) + assert len(routed) == 1 and routed[0].id == target_id + assert routed[0].auto_source_ids == [SRC_A] + assert await repo.toggle_target_auto_source(target_id, SRC_A) is False + assert await repo.get_targets_auto_routed_from(SRC_A) == [] + + # Subscribe to A only; B must stay manual. + await repo.set_target_auto_sources(target_id, [SRC_A]) + + queue = FakeQueue() + bot = AdminBotService(repo=repo, ai_processor=FakeAI(), queue=queue, + review_channel_id=0, admin_user_ids=[1]) + + post_a = await repo.create_raw_post(SRC_A, 1, "post from A", content_hash="auto_a") + post_b = await repo.create_raw_post(SRC_B, 2, "post from B", content_hash="auto_b") + + assert await bot.auto_route_post(post_a) == 1, "subscribed source must route" + assert await bot.auto_route_post(post_b) == 0, "unsubscribed source must not route" + + assert len(queue.pushed) == 1, f"expected 1 enqueue, got {queue.pushed}" + tid, payload = queue.pushed[0] + assert tid == target_id + assert payload["text"] == "[Auto Target] post from A", f"AI rewrite not applied: {payload['text']}" + assert payload["post_id"] == post_a + + # The post is recorded against the target but must NOT be marked published yet. + stored = await repo.get_post_by_id(post_a) + assert stored.status == "pending_review", f"auto-routing must not publish, got {stored.status}" + assert len(stored.published_to) == 1 + assert stored.published_to[0]["target_id"] == target_id + assert stored.published_to[0]["published_at"] is None + + # A soft-deleted post is never auto-routed. + await repo.soft_delete_post(post_b) + await repo.set_target_auto_sources(target_id, [SRC_A, SRC_B]) + assert await bot.auto_route_post(post_b) == 0, "deleted posts must not route" + + await _cleanup(repo) + await close_db_pool() + print("All auto-routing tests passed successfully!") + + +if __name__ == "__main__": + asyncio.run(run_tests()) diff --git a/tests/test_db.py b/tests/test_db.py deleted file mode 100644 index 3b510e7..0000000 --- a/tests/test_db.py +++ /dev/null @@ -1,63 +0,0 @@ -import asyncio -import os -import tempfile -from db.database import init_db -from db.repository import Repository - -async def run_tests(): - with tempfile.NamedTemporaryFile(suffix=".db") as tmp: - db_path = tmp.name - print(f"Testing DB on {db_path}...") - await init_db(db_path) - repo = Repository(db_path) - - # 1. Test Sources - s_id = await repo.add_source(-1001234567890, "Source Tech", "source_tech") - sources = await repo.get_active_sources() - assert len(sources) == 1 - assert sources[0].channel_id == -1001234567890 - - # 2. Test Targets - t_id = await repo.add_target(-1009876543210, "Target Channel", "target_chan", post_interval_min=15) - targets = await repo.get_active_targets() - assert len(targets) == 1 - assert targets[0].post_interval_min == 15 - - # 3. Test Raw Post & Deduplication - post_id = await repo.create_raw_post( - source_channel_id=-1001234567890, - source_message_id=101, - raw_text="Breaking news: AI update released!", - content_hash="hash_12345", - is_duplicate=False - ) - assert post_id is not None - - # Check duplicate lookup - dup_match = await repo.find_duplicate_post("hash_12345") - assert dup_match is not None - assert dup_match.id == post_id - - # 4. Test AI update & Approval flow - await repo.update_ai_result(post_id, subject="AI/Tech", ai_text="Rewritten: AI update is now live!", suggested_target_id=t_id) - pending_review = await repo.get_posts_by_status("pending_review") - assert len(pending_review) == 1 - assert pending_review[0].subject == "AI/Tech" - - await repo.approve_post(post_id, target_channel_id=t_id) - approved_post = await repo.get_next_approved_post_for_target(t_id) - assert approved_post is not None - assert approved_post.id == post_id - - await repo.mark_post_published(post_id) - assert await repo.get_next_approved_post_for_target(t_id) is None - - # 5. Settings - await repo.set_setting("system_prompt", "You are a professional editor.") - val = await repo.get_setting("system_prompt") - assert val == "You are a professional editor." - - print("All database tests passed successfully!") - -if __name__ == "__main__": - asyncio.run(run_tests()) diff --git a/tests/test_dedup.py b/tests/test_dedup.py index ee9c47b..9365290 100644 --- a/tests/test_dedup.py +++ b/tests/test_dedup.py @@ -1,3 +1,7 @@ +import sys + +sys.path.insert(0, "/app") + from core.dedup import normalize_text, compute_content_hash def test_normalize_text(): diff --git a/tests/test_publisher_failures.py b/tests/test_publisher_failures.py new file mode 100644 index 0000000..07cfc8e --- /dev/null +++ b/tests/test_publisher_failures.py @@ -0,0 +1,111 @@ +"""A target the account cannot post to must not spin the same post through the queue forever.""" +import asyncio +import sys + +sys.path.insert(0, "/app") + +from telethon.errors import ChatAdminRequiredError +from db.models import TargetChannel +from services.publisher import PublisherService + + +class FakeQueue: + def __init__(self, payloads): + self.items = list(payloads) + self.pushes = 0 + + async def get_target_queue_size(self, target_id): + return len(self.items) + + async def pop_target_post(self, target_id): + return self.items.pop(0) if self.items else None + + async def push_target_post(self, target_id, payload): + self.pushes += 1 + self.items.append(payload) + + +class FakeRepo: + def __init__(self, targets): + self._targets = targets + self.published = [] + + async def get_active_targets(self): + return self._targets + + async def record_post_published_to_target(self, *a): + self.published.append(a) + + async def update_target_last_post(self, *a): + pass + + +def make_target(): + return TargetChannel(id=2, channel_id=-1003848540849, title="newsjoker", username=None, + post_interval_min=1, last_post_time=None) + + +class FailingClient: + def __init__(self, error): + self.error = error + self.attempts = 0 + + def is_connected(self): + return True + + async def send_message(self, *a, **kw): + self.attempts += 1 + raise self.error + + async def send_file(self, *a, **kw): + self.attempts += 1 + raise self.error + + +async def drive(error, cycles=3): + queue = FakeQueue([{"post_id": 117, "text": "hello", "media_path": None}]) + repo = FakeRepo([make_target()]) + notes = [] + + async def notify(text): + notes.append(text) + + pub = PublisherService(repo=repo, queue=queue, client=FailingClient(error), notify_fn=notify) + for _ in range(cycles): + await pub._process_all_target_queues() + return queue, notes + + +async def _cleanup_error_rows(): + """log_exception writes to the real table even from fakes; remove those rows.""" + from db.database import init_db, get_db_pool + await init_db() + pool = await get_db_pool() + async with pool.acquire() as conn: + await conn.execute( + "DELETE FROM error_logs WHERE service_name = 'publisher.publish' " + "AND context->>'target_id' = '2' AND context->>'post_id' = '117';") + + +async def run_tests(): + # Permanent: drained after the first attempt, admins told exactly once. + queue, notes = await drive(ChatAdminRequiredError(request=None)) + assert queue.pushes == 0, f"permanent failure must not requeue, got {queue.pushes} pushes" + assert len(queue.items) == 0, f"queue should be drained, still holds {queue.items}" + assert len(notes) == 1, f"admins should be warned exactly once, got {len(notes)}" + assert "newsjoker" in notes[0] and "-1003848540849" in notes[0] + + # Transient: the post survives every failed cycle. + queue, notes = await drive(ConnectionError("network blip")) + assert queue.pushes == 3, f"transient failure must requeue each time, got {queue.pushes}" + assert len(queue.items) == 1, f"post must still be queued, got {queue.items}" + assert notes == [], "a transient blip must not raise a permanent-failure alarm" + + await _cleanup_error_rows() + from db.database import close_db_pool + await close_db_pool() + print("All publisher failure-handling tests passed successfully!") + + +if __name__ == "__main__": + asyncio.run(run_tests()) diff --git a/tests/test_scrape_history.py b/tests/test_scrape_history.py new file mode 100644 index 0000000..d9d75b9 --- /dev/null +++ b/tests/test_scrape_history.py @@ -0,0 +1,126 @@ +"""Covers the flow behind the '📥 استخراج ۲۰ پست' button, which used to abort silently.""" +import asyncio +import sys +import types + +sys.path.insert(0, "/app") + +from db.database import init_db, close_db_pool +from db.repository import Repository +from services.collector import CollectorService + +SOURCE_ID = -1009999000010 + + +class FakeMessage: + def __init__(self, msg_id, text): + self.id = msg_id + self.raw_text = text + self.media = None + + async def download_media(self, file=None): + return None + + +class FakeClient: + """Minimal stand-in for the Telethon client used by the collector.""" + + def __init__(self, messages): + self._messages = messages + + def is_connected(self): + return True + + async def is_user_authorized(self): + return True + + async def get_entity(self, ident): + return types.SimpleNamespace(id=abs(SOURCE_ID), title="Scrape Source") + + def iter_messages(self, entity, limit=20): + async def gen(): + for m in self._messages[:limit]: + yield m + return gen() + + +async def _cleanup(repo): + pool = await repo._get_pool() + async with pool.acquire() as conn: + await conn.execute("DELETE FROM posts WHERE source_channel_id = $1;", SOURCE_ID) + await conn.execute("DELETE FROM sources WHERE channel_id = $1;", SOURCE_ID) + # The failure-path assertion logs a real error row; don't leave it behind. + await conn.execute( + "DELETE FROM error_logs WHERE context->>'channel_id' = $1;", str(SOURCE_ID)) + + +async def run_tests(): + await init_db() + repo = Repository() + await _cleanup(repo) + await repo.add_source(SOURCE_ID, "Scrape Source", None) + + reviewed = [] + collector = CollectorService(repo=repo, on_post_received=lambda pid: _record(reviewed, pid)) + # 20 distinct messages plus one exact repeat of the first, to exercise dedup. + messages = [FakeMessage(1000 + i, f"historical post number {i}") for i in range(20)] + messages.append(FakeMessage(1099, "historical post number 0")) + collector.client = FakeClient(messages) + + progress = [] + + async def on_progress(text): + progress.append(text) + + res = await collector.scrape_channel_history(SOURCE_ID, limit=21, progress_callback=on_progress) + + assert res.collected == 21, f"expected 21 posts collected, got {res.collected}" + assert res.scanned == 21, f"expected 21 messages scanned, got {res.scanned}" + assert res.duplicates == 1, f"expected 1 duplicate, got {res.duplicates}" + assert res.already_stored == 0, f"expected 0 already-stored, got {res.already_stored}" + assert len(reviewed) == 21, f"expected 21 review cards dispatched, got {len(reviewed)}" + assert progress and progress[-1].startswith("✅"), f"admin was not told the result: {progress}" + + # Re-running the same window adds nothing new, and the report must say WHY + # rather than looking like a dead button. + progress.clear() + again = await collector.scrape_channel_history(SOURCE_ID, limit=21, progress_callback=on_progress) + assert again.collected == 0, f"repeat scrape should add nothing, got {again.collected}" + assert again.already_stored == 21, f"expected 21 already-stored, got {again.already_stored}" + assert progress[-1].startswith("ℹ️"), f"repeat scrape must be explained: {progress[-1]}" + assert "قبلا ذخیره شده" in progress[-1] + + stored = await repo.get_posts_by_status("pending_review", limit=100) + mine = [p for p in stored if p.source_channel_id == SOURCE_ID] + assert len(mine) == 21, f"expected 21 stored posts, got {len(mine)}" + + # The repeated text must be flagged against the original rather than stored blind. + dupes = [p for p in mine if p.is_duplicate] + assert len(dupes) == 1, f"expected exactly 1 duplicate flagged, got {len(dupes)}" + assert dupes[0].duplicate_of_id is not None + + # A channel that cannot be resolved reports the failure instead of vanishing. + class BrokenClient(FakeClient): + async def get_entity(self, ident): + raise RuntimeError("channel not reachable") + + async def get_dialogs(self, limit=50): + return [] + + collector.client = BrokenClient([]) + progress.clear() + result = await collector.scrape_channel_history(SOURCE_ID, limit=5, progress_callback=on_progress) + assert result.collected == 0 and result.error + assert progress and progress[-1].startswith("❌"), f"failure was not reported: {progress}" + + await _cleanup(repo) + await close_db_pool() + print("All scrape-history tests passed successfully!") + + +async def _record(bucket, post_id): + bucket.append(post_id) + + +if __name__ == "__main__": + asyncio.run(run_tests())