Files
copykar/services/admin_bot.py
T

219 lines
9.9 KiB
Python

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✅ <b>Approved for {target_title}</b> 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❌ <b>Rejected</b> 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 <channel_id> <title> [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.")