import os import asyncio import logging from datetime import datetime, timezone from typing import Optional, List from telethon import TelegramClient 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.proxy import get_telegram_proxy from core.error_logger import log_exception logger = logging.getLogger(__name__) SESSION_DIR = os.getenv("SESSION_DIR", "/app/sessions" if os.path.exists("/app") else "/projects/telegram-bots/copykar/sessions") class PublisherService: def __init__( self, repo: Repository, queue: RedisQueue, client: Optional[TelegramClient] = None, api_id: Optional[int] = None, api_hash: Optional[str] = None, bot_token: Optional[str] = None, session_name: Optional[str] = None, ): self.repo = repo self.queue = queue self.client = client 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") self.session_name = session_name or os.path.join(SESSION_DIR, "publisher.session") os.makedirs(os.path.dirname(self.session_name), exist_ok=True) if not self.client: self.client = TelegramClient(self.session_name, self.api_id, self.api_hash, proxy=get_telegram_proxy()) self._running = False self._task: Optional[asyncio.Task] = None async def start(self): logger.info("Starting Paced Target Publisher Service...") if not self.client.is_connected(): 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.") self._running = True self._task = asyncio.create_task(self._publisher_loop()) def _is_in_sleep_window(self, target: TargetChannel, current_hour: int) -> bool: if not target.is_sleep_enabled: return False start = target.sleep_start_hour end = target.sleep_end_hour if start == end: return False if start < end: return start <= current_hour < end else: # Overnight sleep (e.g. 23:00 to 08:00) return current_hour >= start or current_hour < end async def _publisher_loop(self): while self._running: try: 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) 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 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: continue # 1. Check Sleep Window if self._is_in_sleep_window(target, current_hour_local): logger.debug(f"Target #{target.id} ({target.title}) in sleep window ({target.sleep_start_hour}:00-{target.sleep_end_hour}:00). Skipping.") continue # 2. Check Cooldown Interval if target.last_post_time: last_post = target.last_post_time if last_post.tzinfo is None: last_post = last_post.replace(tzinfo=timezone.utc) diff_minutes = (now - last_post).total_seconds() / 60.0 if diff_minutes < target.post_interval_min: continue # 3. Pop next post payload for this target payload = await self.queue.pop_target_post(target.id) if not payload: continue post_id = payload.get("post_id") text = payload.get("text", "") media_path = payload.get("media_path") try: if media_path and os.path.exists(media_path): await self.client.send_file( target.channel_id, file=media_path, caption=text, parse_mode="html" ) else: await self.client.send_message( target.channel_id, text, parse_mode="html" ) # Record publication in database await self.repo.record_post_published_to_target(post_id, target.id, target.title or "Target") await self.repo.update_target_last_post(target.id) 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}) REDIS_QUEUE_SIZE_GAUGE.set(total_queued) async def stop(self): self._running = False if self._task: self._task.cancel() logger.info("Publisher Service stopped.")