diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..ee4c4fe --- /dev/null +++ b/bot/keyboards.py @@ -0,0 +1,18 @@ +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/admin_bot.py b/services/admin_bot.py new file mode 100644 index 0000000..cac67f4 --- /dev/null +++ b/services/admin_bot.py @@ -0,0 +1,218 @@ +import os +import logging +from typing import Optional, List +from telethon import TelegramClient, events, Button +from db.models import Post, TargetChannel +from db.repository import Repository +from bot.keyboards import get_review_keyboard +from core.metrics import ADMIN_ACTIONS_TOTAL + +logger = logging.getLogger(__name__) + +class AdminBotService: + def __init__( + self, + repo: Repository, + bot_token: Optional[str] = None, + api_id: Optional[int] = None, + api_hash: Optional[str] = None, + review_channel_id: Optional[int] = None, + admin_user_ids: Optional[List[int]] = None, + session_name: str = "/projects/telegram-bots/copykar/sessions/admin_bot.session", + ): + self.repo = repo + self.bot_token = bot_token or os.getenv("BOT_TOKEN", "") + self.api_id = api_id or int(os.getenv("API_ID", "0")) + self.api_hash = api_hash or os.getenv("API_HASH", "") + self.review_channel_id = review_channel_id or int(os.getenv("REVIEW_CHANNEL_ID", "0")) + raw_admins = os.getenv("ADMIN_USER_IDS", "") + self.admin_user_ids = admin_user_ids or [int(x.strip()) for x in raw_admins.split(",") if x.strip()] + self.session_name = session_name + self.client = TelegramClient(self.session_name, self.api_id, self.api_hash) + + def is_admin(self, user_id: int) -> bool: + return not self.admin_user_ids or user_id in self.admin_user_ids + + async def start(self): + os.makedirs(os.path.dirname(self.session_name), exist_ok=True) + logger.info("Starting Admin Review Bot...") + await self.client.start(bot_token=self.bot_token) + logger.info("Admin Review Bot connected successfully.") + + self._register_handlers() + + def _register_handlers(self): + @self.client.on(events.CallbackQuery) + async def on_callback(event: events.CallbackQuery.Event): + if not self.is_admin(event.sender_id): + await event.answer("ā›” You are not authorized.", alert=True) + return + + data = event.data.decode("utf-8") + if data.startswith("appr:"): + _, post_id_str, target_id_str = data.split(":") + post_id = int(post_id_str) + target_id = int(target_id_str) + + target = await self.repo.get_target_by_id(target_id) + target_title = target.title if target else f"Target #{target_id}" + + await self.repo.approve_post(post_id, target_id) + ADMIN_ACTIONS_TOTAL.labels(action="approved").inc() + + await event.edit( + f"{event.text}\n\nāœ… Approved for {target_title} by admin.", + parse_mode="html", + buttons=None + ) + await event.answer(f"Approved for {target_title}!") + + elif data.startswith("rej:"): + _, post_id_str = data.split(":") + post_id = int(post_id_str) + + await self.repo.reject_post(post_id) + ADMIN_ACTIONS_TOTAL.labels(action="rejected").inc() + + await event.edit( + f"{event.text}\n\nāŒ Rejected by admin.", + parse_mode="html", + buttons=None + ) + await event.answer("Post rejected.") + + # --- Admin Commands --- + @self.client.on(events.NewMessage(pattern="/sources")) + async def cmd_sources(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + sources = await self.repo.get_active_sources() + if not sources: + await event.reply("No active source channels configured. Use /add_source [username]") + return + lines = ["<b>Active Monitored Sources:</b>"] + for s in sources: + lines.append(f"• ID: <code>{s.channel_id}</code> | Title: <b>{s.title or 'N/A'}</b> (@{s.username or 'none'})") + await event.reply("\n".join(lines), parse_mode="html") + + @self.client.on(events.NewMessage(pattern=r"/add_source\s+(-?\d+)\s+([^\s]+)(?:\s+([^\s]+))?")) + async def cmd_add_source(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + ch_id = int(event.pattern_match.group(1)) + title = event.pattern_match.group(2) + username = event.pattern_match.group(3) + await self.repo.add_source(channel_id=ch_id, title=title, username=username) + await event.reply(f"āœ… Added source channel <b>{title}</b> (<code>{ch_id}</code>).", parse_mode="html") + + @self.client.on(events.NewMessage(pattern="/targets")) + async def cmd_targets(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + targets = await self.repo.get_active_targets() + if not targets: + await event.reply("No target channels configured. Use /add_target <channel_id> <title> <interval_min> [username]") + return + lines = ["<b>Target Publishing Channels:</b>"] + for t in targets: + lines.append(f"• ID: <code>{t.id}</code> (Channel: <code>{t.channel_id}</code>) | <b>{t.title}</b> | Interval: <b>{t.post_interval_min}m</b>") + await event.reply("\n".join(lines), parse_mode="html") + + @self.client.on(events.NewMessage(pattern=r"/add_target\s+(-?\d+)\s+([^\s]+)\s+(\d+)(?:\s+([^\s]+))?")) + async def cmd_add_target(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + ch_id = int(event.pattern_match.group(1)) + title = event.pattern_match.group(2) + interval_min = int(event.pattern_match.group(3)) + username = event.pattern_match.group(4) + await self.repo.add_target(channel_id=ch_id, title=title, username=username, post_interval_min=interval_min) + await event.reply(f"āœ… Added target channel <b>{title}</b> with interval <b>{interval_min}m</b>.", parse_mode="html") + + @self.client.on(events.NewMessage(pattern=r"/set_interval\s+(\d+)\s+(\d+)")) + async def cmd_set_interval(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + target_id = int(event.pattern_match.group(1)) + new_interval = int(event.pattern_match.group(2)) + target = await self.repo.get_target_by_id(target_id) + if not target: + await event.reply("Target channel not found.") + return + await self.repo.add_target( + channel_id=target.channel_id, + title=target.title, + username=target.username, + post_interval_min=new_interval + ) + await event.reply(f"āœ… Updated interval for <b>{target.title}</b> to <b>{new_interval} minutes</b>.", parse_mode="html") + + @self.client.on(events.NewMessage(pattern="/stats")) + async def cmd_stats(event: events.NewMessage.Event): + if not self.is_admin(event.sender_id): + return + pending_ai = len(await self.repo.get_posts_by_status("pending_ai", limit=1000)) + pending_review = len(await self.repo.get_posts_by_status("pending_review", limit=1000)) + approved = len(await self.repo.get_posts_by_status("approved", limit=1000)) + published = len(await self.repo.get_posts_by_status("published", limit=1000)) + + text = ( + "šŸ“Š <b>Copykar Bot Statistics</b>\n\n" + f"• ā³ Pending AI: <b>{pending_ai}</b>\n" + f"• šŸ“‹ Pending Admin Review: <b>{pending_review}</b>\n" + f"• šŸš€ Approved (Queued): <b>{approved}</b>\n" + f"• āœ… Published: <b>{published}</b>\n" + ) + await event.reply(text, parse_mode="html") + + async def send_review_post(self, post_id: int): + post = await self.repo.get_post_by_id(post_id) + if not post or not self.review_channel_id: + return + + targets = await self.repo.get_active_targets() + keyboard = get_review_keyboard(post.id, targets) + + tags_str = ", ".join(post.tags) if post.tags else "None" + dup_warning = "" + if post.is_duplicate: + dup_warning = ( + f"āš ļø <b>[DUPLICATE DETECTED]</b>\n" + f"<b>Reason:</b> {post.similarity_reason or 'Similar story already published'}\n" + f"<b>Matched Post ID:</b> #{post.duplicate_of_id}\n\n" + ) + + caption = ( + f"šŸ“Œ <b>Subject:</b> {post.subject or 'N/A'}\n" + f"šŸ· <b>Tags:</b> <code>{tags_str}</code>\n\n" + f"{dup_warning}" + f"šŸ“ <b>Generated Post Draft:</b>\n" + f"{post.ai_text or post.raw_text}\n\n" + f"<i>Source: Channel <code>{post.source_channel_id}</code> | Msg #{post.source_message_id}</i>" + ) + + try: + if post.media_path and os.path.exists(post.media_path): + msg = await self.client.send_file( + self.review_channel_id, + file=post.media_path, + caption=caption, + parse_mode="html", + buttons=keyboard + ) + else: + msg = await self.client.send_message( + self.review_channel_id, + caption, + parse_mode="html", + buttons=keyboard + ) + + await self.repo.update_review_message_id(post.id, msg.id) + except Exception as e: + logger.error(f"Failed to send review post {post.id} to review channel: {e}", exc_info=True) + + async def stop(self): + if self.client.is_connected(): + await self.client.disconnect() + logger.info("Admin Review Bot disconnected.")