Files
copykar/services/publisher.py
T

142 lines
5.6 KiB
Python

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.")