Files
copykar/services/admin_bot.py
T

392 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
from core.proxy import get_telegram_proxy
logger = logging.getLogger(__name__)
SESSION_DIR = os.getenv("SESSION_DIR", "/app/sessions" if os.path.exists("/app") else "/projects/telegram-bots/copykar/sessions")
def get_main_menu_keyboard():
return [
[Button.text("🔑 Request Login Code", resize=True), Button.text("📊 Fleet Statistics", resize=True)],
[Button.text("📡 Monitored Sources", resize=True), Button.text("🎯 Target Channels", resize=True)],
[Button.text(" Add Source Guide", resize=True), Button.text(" Add Target Guide", resize=True)],
[Button.text("📥 Scrape History Guide", resize=True), Button.text("❓ Help & Documentation", resize=True)]
]
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: Optional[str] = None,
):
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 or os.path.join(SESSION_DIR, "admin_bot.session")
os.makedirs(os.path.dirname(self.session_name), exist_ok=True)
self.client = TelegramClient(self.session_name, self.api_id, self.api_hash, proxy=get_telegram_proxy())
self.collector = None
def set_collector(self, collector):
self.collector = collector
def is_admin(self, user_id: int) -> bool:
return not self.admin_user_ids or user_id in self.admin_user_ids
async def notify_admins(self, text: str):
"""Broadcast message to review channel and all admin DMs."""
if self.review_channel_id:
try:
await self.client.send_message(self.review_channel_id, text, parse_mode="html")
except Exception as e:
logger.error(f"Failed to notify review channel: {e}")
for admin_id in self.admin_user_ids:
try:
await self.client.send_message(admin_id, text, parse_mode="html", buttons=get_main_menu_keyboard())
except Exception as e:
logger.debug(f"Could not send DM to admin {admin_id}: {e}")
async def start(self):
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):
# --- /start and Main Menu ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/start|/menu|menu)$"))
async def cmd_start(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
await event.reply(f"⛔ Unauthorized user ID: <code>{event.sender_id}</code>. Please add this ID to <code>ADMIN_USER_IDS</code> in .env.", parse_mode="html")
return
userbot_status = "🔴 Not Authorized"
if self.collector and self.collector.client.is_connected() and await self.collector.client.is_user_authorized():
me = await self.collector.client.get_me()
userbot_status = f"🟢 Online ({me.first_name})"
welcome_text = (
"👋 <b>Welcome to Copykar Admin Console!</b>\n\n"
f"• 🤖 <b>Userbot Status:</b> {userbot_status}\n"
f"• 📋 <b>Review Channel:</b> <code>{self.review_channel_id}</code>\n\n"
"Use the interactive menu buttons below to manage the fleet:"
)
await event.reply(welcome_text, parse_mode="html", buttons=get_main_menu_keyboard())
# --- Interactive Userbot Authentication Commands ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/request_code|🔑 Request Login Code)$"))
async def cmd_request_code(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
if not self.collector:
await event.reply("Collector service not linked.")
return
msg = await event.reply("⏳ Contacting Telegram to request login code...")
try:
sent = await self.collector.client.send_code_request(self.collector.phone)
self.collector.phone_code_hash = sent.phone_code_hash
await msg.edit(
f"📩 <b>Code Sent!</b>\n\n"
f"Telegram sent a verification code to <code>{self.collector.phone}</code>.\n\n"
f"Please reply with:\n"
f"<code>/code &lt;your_code&gt;</code>\n\n"
f"<i>Example:</i> <code>/code 12345</code>",
parse_mode="html"
)
except Exception as e:
await msg.edit(f"❌ Could not request code: {e}")
@self.client.on(events.NewMessage(pattern=r"^/code\s+(\S+)"))
async def cmd_code(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
if not self.collector:
await event.reply("Collector service not linked.")
return
code = event.pattern_match.group(1).strip()
status_msg = await event.reply("⏳ Submitting code to Telegram...")
result = await self.collector.submit_code(code)
await status_msg.edit(result, parse_mode="html", buttons=get_main_menu_keyboard())
@self.client.on(events.NewMessage(pattern=r"^/password\s+(.+)"))
async def cmd_password(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
if not self.collector:
await event.reply("Collector service not linked.")
return
pwd = event.pattern_match.group(1).strip()
status_msg = await event.reply("⏳ Verifying 2FA password...")
result = await self.collector.submit_password(pwd)
await status_msg.edit(result, parse_mode="html", buttons=get_main_menu_keyboard())
# --- History Scraper Commands ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(📥 Scrape History Guide)$"))
async def cmd_scrape_guide(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
guide = (
"📥 <b>Historical Channel Scraper:</b>\n\n"
"Import previous/past posts from any channel:\n"
"<code>/scrape_history &lt;channel_id&gt; [number_of_posts]</code>\n\n"
"<i>Example (Scrape last 30 posts):</i>\n"
"<code>/scrape_history -1001234567890 30</code>\n\n"
"<i>(Default is 20 posts if count is omitted).</i>"
)
await event.reply(guide, parse_mode="html")
@self.client.on(events.NewMessage(pattern=r"^/scrape_history\s+(-?\d+)(?:\s+(\d+))?"))
async def cmd_scrape_history(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
if not self.collector:
await event.reply("Collector service not linked.")
return
ch_id = int(event.pattern_match.group(1))
limit = int(event.pattern_match.group(2)) if event.pattern_match.group(2) else 20
status_msg = await event.reply(f"⏳ Scraping the last <b>{limit}</b> posts from <code>{ch_id}</code> in the background...", parse_mode="html")
async def progress_notify(txt: str):
await status_msg.edit(txt, parse_mode="html", buttons=get_main_menu_keyboard())
await self.collector.scrape_channel_history(channel_id=ch_id, limit=limit, progress_callback=progress_notify)
# --- Statistics ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/stats|📊 Fleet Statistics)$"))
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))
rejected = len(await self.repo.get_posts_by_status("rejected", limit=1000))
text = (
"📊 <b>Copykar Fleet Metrics</b>\n\n"
f"• ⏳ <b>Pending AI:</b> {pending_ai}\n"
f"• 📋 <b>Pending Review:</b> {pending_review}\n"
f"• 🚀 <b>Approved (In Queue):</b> {approved}\n"
f"• ✅ <b>Published:</b> {published}\n"
f"• ❌ <b>Rejected:</b> {rejected}\n\n"
"📈 <i>Grafana Dashboard:</i> http://localhost:3000"
)
await event.reply(text, parse_mode="html", buttons=get_main_menu_keyboard())
# --- Sources Management ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/sources|📡 Monitored 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.\nUse <code> Add Source Guide</code> to add one.", parse_mode="html")
return
lines = ["<b>📡 Active Monitored Sources:</b>\n"]
for s in sources:
lines.append(f"• ID: <code>{s.channel_id}</code> | <b>{s.title or 'N/A'}</b> (@{s.username or 'none'})")
await event.reply("\n".join(lines), parse_mode="html", buttons=get_main_menu_keyboard())
@self.client.on(events.NewMessage(pattern=r"(?i)^( Add Source Guide)$"))
async def cmd_add_source_guide(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
guide = (
" <b>How to Add a Source Channel:</b>\n\n"
"Send the command in this format:\n"
"<code>/add_source &lt;channel_id&gt; &lt;title&gt; [username]</code>\n\n"
"<i>Example:</i>\n"
"<code>/add_source -1001234567890 TechNews technews_chan</code>"
)
await event.reply(guide, 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>) to monitoring.", parse_mode="html", buttons=get_main_menu_keyboard())
# --- Targets Management ---
@self.client.on(events.NewMessage(pattern=r"(?i)^(/targets|🎯 Target Channels)$"))
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.\nUse <code> Add Target Guide</code> to add one.", parse_mode="html")
return
lines = ["<b>🎯 Target Publishing Channels:</b>\n"]
for t in targets:
lines.append(f"• ID: <code>{t.id}</code> (Channel: <code>{t.channel_id}</code>)\n Title: <b>{t.title}</b> | Interval: <b>{t.post_interval_min}m</b>")
await event.reply("\n".join(lines), parse_mode="html", buttons=get_main_menu_keyboard())
@self.client.on(events.NewMessage(pattern=r"(?i)^( Add Target Guide)$"))
async def cmd_add_target_guide(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
guide = (
"🎯 <b>How to Add a Target Channel:</b>\n\n"
"Send the command in this format:\n"
"<code>/add_target &lt;channel_id&gt; &lt;title&gt; &lt;interval_minutes&gt; [username]</code>\n\n"
"<i>Example (posts every 30 minutes):</i>\n"
"<code>/add_target -1009876543210 MyMainChannel 30 my_main_chan</code>"
)
await event.reply(guide, 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", buttons=get_main_menu_keyboard())
@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", buttons=get_main_menu_keyboard())
@self.client.on(events.NewMessage(pattern=r"(?i)^(/help|❓ Help & Documentation)$"))
async def cmd_help(event: events.NewMessage.Event):
if not self.is_admin(event.sender_id):
return
help_text = (
"📖 <b>Copykar Bot Quick Help</b>\n\n"
"1. <b>Authentication:</b> Click <code>🔑 Request Login Code</code> and submit via <code>/code &lt;12345&gt;</code>.\n"
"2. <b>Monitored Sources:</b> Add channels via <code>/add_source</code>.\n"
"3. <b>Historical Posts:</b> Backfill existing posts using <code>/scrape_history &lt;channel_id&gt; [limit]</code>.\n"
"4. <b>Review Flow:</b> AI scans posts, checks duplicates, and sends drafts to the review channel with inline approval buttons.\n"
"5. <b>Publishing:</b> Approved posts are published to your target channels strictly according to their interval minutes."
)
await event.reply(help_text, parse_mode="html", buttons=get_main_menu_keyboard())
# --- Review Keyboard Callbacks ---
@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.")
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.")