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 = ["Active Monitored Sources:"]
+ for s in sources:
+ lines.append(f"⢠ID: {s.channel_id} | Title: {s.title or 'N/A'} (@{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 {title} ({ch_id}).", 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 [username]")
+ return
+ lines = ["Target Publishing Channels:"]
+ for t in targets:
+ lines.append(f"⢠ID: {t.id} (Channel: {t.channel_id}) | {t.title} | Interval: {t.post_interval_min}m")
+ 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 {title} with interval {interval_min}m.", 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 {target.title} to {new_interval} minutes.", 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 = (
+ "š Copykar Bot Statistics\n\n"
+ f"⢠ⳠPending AI: {pending_ai}\n"
+ f"⢠š Pending Admin Review: {pending_review}\n"
+ f"⢠š Approved (Queued): {approved}\n"
+ f"⢠ā
Published: {published}\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"ā ļø [DUPLICATE DETECTED]\n"
+ f"Reason: {post.similarity_reason or 'Similar story already published'}\n"
+ f"Matched Post ID: #{post.duplicate_of_id}\n\n"
+ )
+
+ caption = (
+ f"š Subject: {post.subject or 'N/A'}\n"
+ f"š· Tags: {tags_str}\n\n"
+ f"{dup_warning}"
+ f"š Generated Post Draft:\n"
+ f"{post.ai_text or post.raw_text}\n\n"
+ f"Source: Channel {post.source_channel_id} | Msg #{post.source_message_id}"
+ )
+
+ 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.")