Final release: Multi-session comment bot with filtering
Features: - Multi-account support (session files) - AI comments via Ollama - Telegram bot moderation - Filter by sessions and groups - Docker support - Auto-join groups - Log notifications - DB migration script Bug fixes: - Fixed comment_to for proper post targeting - Fixed entity lookup with multiple ID formats - Fixed callback handlers for filtering - Added auto-join before entity lookup
This commit is contained in:
@@ -12,7 +12,8 @@ from bot.db import (
|
||||
increment_regeneration, get_all_sessions, get_active_sessions,
|
||||
toggle_session, delete_session, get_summary_stats, get_stats,
|
||||
update_stats, add_target_group, get_target_groups, get_all_target_groups,
|
||||
remove_target_group, toggle_target_group, is_target_group
|
||||
remove_target_group, toggle_target_group, is_target_group,
|
||||
get_pending_comments_by_session, get_pending_comments_by_group
|
||||
)
|
||||
from bot.ollama import generate_comment
|
||||
from bot.keyboard import (
|
||||
@@ -381,15 +382,27 @@ class CommentBot:
|
||||
if data == "stats":
|
||||
await self._callback_stats(callback)
|
||||
elif data == "sessions":
|
||||
await self._callback_sessions(callback)
|
||||
await self._callback_sessions_list(callback)
|
||||
elif data.startswith("session_select:"):
|
||||
session_file = data.split(":")[1]
|
||||
await self._callback_session_pending(callback, session_file)
|
||||
elif data == "pending":
|
||||
await self._callback_pending(callback)
|
||||
await self._callback_pending_groups(callback)
|
||||
elif data.startswith("group_pending:"):
|
||||
group_id = data.split(":")[1]
|
||||
await self._callback_group_pending(callback, group_id)
|
||||
elif data == "groups":
|
||||
await self._callback_groups(callback)
|
||||
elif data == "group_add":
|
||||
await self._callback_group_add(callback)
|
||||
elif data.startswith("group_"):
|
||||
await self._callback_group_action(callback)
|
||||
elif data.startswith("group_info:"):
|
||||
await self._callback_group_info(callback, data)
|
||||
elif data.startswith("group_pause:"):
|
||||
await self._callback_group_action(callback, "pause", data)
|
||||
elif data.startswith("group_resume:"):
|
||||
await self._callback_group_action(callback, "resume", data)
|
||||
elif data.startswith("group_delete:"):
|
||||
await self._callback_group_action(callback, "delete", data)
|
||||
elif data.startswith("approve:"):
|
||||
await self._callback_approve(callback)
|
||||
elif data.startswith("reject:"):
|
||||
@@ -400,8 +413,16 @@ class CommentBot:
|
||||
await self._callback_edit(callback)
|
||||
elif data.startswith("edit_cancel:"):
|
||||
await self._callback_edit_cancel(callback)
|
||||
elif data.startswith("session_"):
|
||||
await self._callback_session_action(callback)
|
||||
elif data.startswith("session_status:"):
|
||||
await self._callback_session_action(callback, "status", data)
|
||||
elif data.startswith("session_pause:"):
|
||||
await self._callback_session_action(callback, "pause", data)
|
||||
elif data.startswith("session_resume:"):
|
||||
await self._callback_session_action(callback, "resume", data)
|
||||
elif data.startswith("session_delete:"):
|
||||
await self._callback_session_action(callback, "delete", data)
|
||||
elif data == "main_menu":
|
||||
await self._callback_main_menu(callback)
|
||||
else:
|
||||
await callback.answer("Неизвестная команда")
|
||||
|
||||
@@ -462,6 +483,99 @@ class CommentBot:
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _callback_sessions_list(self, callback: CallbackQuery):
|
||||
"""Callback для списка сессий"""
|
||||
sessions = get_all_sessions()
|
||||
|
||||
if not sessions:
|
||||
text = "❌ Нет сессий\n\nДобавьте файлы сессий в `sessions/`"
|
||||
await callback.message.edit_text(text, reply_markup=create_back_keyboard())
|
||||
else:
|
||||
text = "👥 **Сессии**\n\nВыберите сессию для просмотра комментариев:\n\n"
|
||||
for session in sessions:
|
||||
status = "🟢" if session['is_active'] else "🔴"
|
||||
username = f"@{session['username']}" if session.get('username') else ""
|
||||
text += f"{status} `{session['session_file']}` {username}\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="Markdown",
|
||||
reply_markup=create_sessions_list_keyboard(sessions)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def _callback_session_pending(self, callback: CallbackQuery, session_file: str):
|
||||
"""Callback для просмотра pending комментариев сессии"""
|
||||
pending = get_pending_comments_by_session(session_file)
|
||||
|
||||
if not pending:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Нет ожидающих комментариев для `{session_file}`",
|
||||
reply_markup=create_back_keyboard()
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"📝 Ожидают модерации ({len(pending)}):\n\nСессия: `{session_file}`",
|
||||
reply_markup=create_back_keyboard()
|
||||
)
|
||||
for comment in pending[:10]:
|
||||
await self._send_moderation_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
comment=comment
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def _callback_pending_groups(self, callback: CallbackQuery):
|
||||
"""Callback для выбора группы из pending"""
|
||||
groups = get_all_target_groups()
|
||||
|
||||
if not groups:
|
||||
text = "📋 **Нет групп**\n\nДобавьте группу через /add_group"
|
||||
await callback.message.edit_text(text, parse_mode="Markdown", reply_markup=create_back_keyboard())
|
||||
else:
|
||||
text = "📋 **Группы**\n\nВыберите группу для просмотра комментариев:\n\n"
|
||||
for group in groups:
|
||||
name = group['group_name'] or f"Группа {group['group_id']}"
|
||||
text += f"📢 `{name}`\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="Markdown",
|
||||
reply_markup=create_groups_list_for_pending_keyboard(groups)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def _callback_group_pending(self, callback: CallbackQuery, group_id: str):
|
||||
"""Callback для просмотра pending комментариев группы"""
|
||||
pending = get_pending_comments_by_group(group_id)
|
||||
|
||||
if not pending:
|
||||
await callback.message.edit_text(
|
||||
f"✅ Нет ожидающих комментариев для этой группы",
|
||||
reply_markup=create_back_keyboard()
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"📝 Ожидают модерации ({len(pending)}):\n\nГруппа: `{group_id}`",
|
||||
reply_markup=create_back_keyboard()
|
||||
)
|
||||
for comment in pending[:10]:
|
||||
await self._send_moderation_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
comment=comment
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async def _callback_main_menu(self, callback: CallbackQuery):
|
||||
"""Callback для возврата в главное меню"""
|
||||
await self.cmd_start(callback.message)
|
||||
await callback.answer()
|
||||
|
||||
async def _callback_settings(self, callback: CallbackQuery):
|
||||
"""Callback для настроек"""
|
||||
text = (
|
||||
@@ -622,16 +736,38 @@ class CommentBot:
|
||||
channel_id = comment.get('channel_id') or comment['chat_id']
|
||||
post_url = f"https://t.me/c/{channel_id}/{comment['message_id']}"
|
||||
|
||||
# Получаем название группы/канала из БД
|
||||
# Ищем по chat_id (группа комментариев) или по channel_id (канал)
|
||||
groups = get_all_target_groups()
|
||||
group = None
|
||||
|
||||
# Сначала ищем по chat_id (группа комментариев)
|
||||
for g in groups:
|
||||
if str(g['group_id']) == str(comment['chat_id']):
|
||||
group = g
|
||||
break
|
||||
# Или по channel_id (канал)
|
||||
if str(g.get('group_id')) == str(channel_id):
|
||||
group = g
|
||||
break
|
||||
# Или по comments_group_id
|
||||
if str(g.get('comments_group_id')) == str(comment['chat_id']):
|
||||
group = g
|
||||
break
|
||||
|
||||
group_name = group['group_name'] if group and group.get('group_name') else f"Канал {channel_id}"
|
||||
|
||||
session_info = ""
|
||||
if comment.get('session_file'):
|
||||
session_info = f"👤 Сессия: `{comment['session_file']}`\n\n"
|
||||
|
||||
|
||||
post_text = comment.get('post_text', 'Текст поста не сохранён')
|
||||
if len(post_text) > 300:
|
||||
post_text = post_text[:300] + "..."
|
||||
|
||||
|
||||
text = (
|
||||
f"📝 **Новый комментарий**\n\n"
|
||||
f"📝 Новый комментарий\n\n"
|
||||
f"📢 Канал: **{group_name}**\n"
|
||||
f"🔗 Пост: {post_url}\n"
|
||||
f"{session_info}"
|
||||
f"📄 Текст поста:\n"
|
||||
@@ -640,13 +776,13 @@ class CommentBot:
|
||||
f"_{comment['comment_text'][:500]}_{'...' if len(comment['comment_text']) > 500 else ''}\n\n"
|
||||
f"🔄 Регенераций: {comment.get('regenerations', 0)}"
|
||||
)
|
||||
|
||||
|
||||
keyboard = create_moderation_keyboard(
|
||||
comment['id'],
|
||||
comment['message_id'],
|
||||
comment['chat_id']
|
||||
)
|
||||
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
|
||||
42
bot/db.py
42
bot/db.py
@@ -264,6 +264,48 @@ def get_pending_comments() -> list:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_pending_comments_by_session(session_file: str) -> list:
|
||||
"""Получение ожидающих комментариев для конкретной сессии"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM comments
|
||||
WHERE status = 'pending' AND session_file = ?
|
||||
ORDER BY created_at DESC
|
||||
''', (session_file,))
|
||||
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ожидающих комментариев для сессии: {e}")
|
||||
return []
|
||||
finally:
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_pending_comments_by_group(group_id: str) -> list:
|
||||
"""Получение ожидающих комментариев для конкретной группы"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM comments
|
||||
WHERE status = 'pending' AND chat_id = ?
|
||||
ORDER BY created_at DESC
|
||||
''', (group_id,))
|
||||
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении ожидающих комментариев для группы: {e}")
|
||||
return []
|
||||
finally:
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
# === Сессии ===
|
||||
|
||||
def save_session(session_file: str, user_info: dict) -> bool:
|
||||
|
||||
@@ -111,10 +111,37 @@ def create_sessions_list_keyboard(sessions: list) -> InlineKeyboardMarkup:
|
||||
for session in sessions:
|
||||
session_file = session['session_file']
|
||||
status = "🟢" if session['is_active'] else "🔴"
|
||||
username = f"@{session['username']}" if session.get('username') else ""
|
||||
builder.button(
|
||||
text=f"{status} {session_file}",
|
||||
callback_data=f"session_info:{session_file}"
|
||||
text=f"{status} {session_file} {username}",
|
||||
callback_data=f"session_select:{session_file}"
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
builder.button(text="🔙 Назад", callback_data="main_menu")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def create_groups_list_for_pending_keyboard(groups: list) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура со списком групп для выбора pending комментариев"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for group in groups:
|
||||
group_id = group['group_id']
|
||||
name = group['group_name'] or f"Группа {group_id}"
|
||||
builder.button(
|
||||
text=f"📢 {name}",
|
||||
callback_data=f"group_pending:{group_id}"
|
||||
)
|
||||
|
||||
builder.adjust(1)
|
||||
builder.button(text="🔙 Назад", callback_data="main_menu")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def create_back_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Клавиатура с кнопкой Назад"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔙 Назад", callback_data="main_menu")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
120
bot/worker.py
120
bot/worker.py
@@ -26,9 +26,10 @@ logger = logging.getLogger('worker')
|
||||
|
||||
class CommentWorker:
|
||||
"""Воркер для отправки комментариев от имени пользователей"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.clients: dict[str, TelegramClient] = {}
|
||||
self.log_client: TelegramClient = None # Клиент для отправки логов
|
||||
self.running = False
|
||||
|
||||
async def start(self):
|
||||
@@ -46,14 +47,37 @@ class CommentWorker:
|
||||
logger.warning("Нет активных сессий. Добавьте файлы в sessions/")
|
||||
return
|
||||
|
||||
# Создаём клиента для отправки логов (используем первую сессию)
|
||||
if self.clients:
|
||||
first_session = list(self.clients.keys())[0]
|
||||
self.log_client = self.clients[first_session]
|
||||
logger.info(f"Лог-клиент: {first_session}")
|
||||
|
||||
self.running = True
|
||||
|
||||
# Отправляем уведомление о запуске
|
||||
await self.send_log_message("🚀 Worker запущен")
|
||||
|
||||
# Запускаем задачи параллельно
|
||||
await asyncio.gather(
|
||||
self.listen_for_new_posts(),
|
||||
self.monitor_approved_comments()
|
||||
)
|
||||
|
||||
async def send_log_message(self, message: str):
|
||||
"""Отправка сообщения в лог-группу"""
|
||||
if not self.log_client or not LOG_GROUP_ID:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.log_client.send_message(
|
||||
int(LOG_GROUP_ID),
|
||||
message,
|
||||
parse_mode='HTML'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось отправить лог: {e}")
|
||||
|
||||
async def stop(self):
|
||||
"""Остановка воркера"""
|
||||
logger.info("Остановка Comment Worker...")
|
||||
@@ -164,45 +188,59 @@ class CommentWorker:
|
||||
|
||||
while self.running:
|
||||
current_time = datetime.now().timestamp()
|
||||
|
||||
# Проверяем новые группы каждые 10 секунд
|
||||
if current_time - last_groups_check > 10:
|
||||
target_groups = get_target_groups()
|
||||
new_group_ids = [str(g['group_id']) for g in target_groups]
|
||||
|
||||
# Проверяем, есть ли группы которые были удалены и добавлены заново
|
||||
# Добавляем обработчики для новых групп
|
||||
for group_id in new_group_ids:
|
||||
if group_id not in group_handlers:
|
||||
logger.info(f"Добавлена группа для мониторинга: {group_id}")
|
||||
logger.info(f"➕ Добавлена группа для мониторинга: {group_id}")
|
||||
|
||||
# Сканируем последние 10 сообщений новой группы
|
||||
# Всегда сканируем при добавлении, даже если уже было
|
||||
await self.scan_group(group_id, limit=10)
|
||||
scanned_groups.add(group_id)
|
||||
|
||||
# Регистрируем обработчик для каждой сессии
|
||||
for session_file, client in self.clients.items():
|
||||
# Проверяем что клиент подключён
|
||||
if not client.is_connected():
|
||||
logger.warning(f"Клиент {session_file} не подключён")
|
||||
continue
|
||||
|
||||
# Регистрируем обработчик
|
||||
@client.on(events.NewMessage(chats=group_id))
|
||||
async def handle_new_post(event):
|
||||
message = event.message
|
||||
logger.info(f"Новый пост в группе {group_id}: {message.id}")
|
||||
logger.info(f"📬 Новый пост в группе {group_id}: {message.id}")
|
||||
|
||||
# Проверяем что это не бот
|
||||
if message.from_id and message.from_id.user_id == (await client.get_me()).id:
|
||||
return
|
||||
|
||||
# Отправляем уведомление в лог-группу
|
||||
post_preview = message.text[:100] + "..." if len(message.text or "") > 100 else (message.text or "Без текста")
|
||||
log_message = (
|
||||
f"📬 <b>Новый пост</b>\n\n"
|
||||
f"📢 Группа: <code>{group_id}</code>\n"
|
||||
f"🔗 Пост: <a href='https://t.me/c/{str(group_id).lstrip('-')}/{message.id}'>{message.id}</a>\n"
|
||||
f"📄 Текст: <i>{post_preview}</i>"
|
||||
)
|
||||
await self.send_log_message(log_message)
|
||||
|
||||
await self.process_message(message, session_file, client, group_id)
|
||||
|
||||
group_handlers[group_id] = True
|
||||
else:
|
||||
# Группа уже обрабатывается, но проверяем не была ли она удалена и добавлена снова
|
||||
# Если была - очищаем scanned_groups для этой группы
|
||||
if group_id in scanned_groups:
|
||||
# Проверяем что группа всё ещё в БД
|
||||
group_exists = any(str(g['group_id']) == group_id for g in target_groups)
|
||||
if not group_exists:
|
||||
scanned_groups.discard(group_id)
|
||||
del group_handlers[group_id]
|
||||
logger.info(f"Группа {group_id} удалена, сброс кэша")
|
||||
logger.info(f"✅ Обработчик зарегистрирован для {group_id}")
|
||||
|
||||
# Удаляем обработчики для удалённых групп
|
||||
for group_id in list(group_handlers.keys()):
|
||||
if group_id not in new_group_ids:
|
||||
logger.info(f"❌ Группа {group_id} удалена из мониторинга")
|
||||
del group_handlers[group_id]
|
||||
scanned_groups.discard(group_id)
|
||||
logger.info(f"Группа {group_id} удалена из мониторинга")
|
||||
|
||||
last_groups_check = current_time
|
||||
|
||||
@@ -332,8 +370,27 @@ class CommentWorker:
|
||||
|
||||
logger.info(f"Отправка комментария {comment['id']}: message_id={message_id_in_channel}, comments_group={comments_group_id}, channel={channel_id}")
|
||||
|
||||
comments_group = await client.get_entity(PeerChannel(comments_group_id))
|
||||
logger.info(f"Группа комментариев: {comments_group.title} (ID: {comments_group_id})")
|
||||
# Вступаем в группу комментариев ПЕРЕД тем как получать сущность
|
||||
try:
|
||||
await session_manager.join_channel(session_file, comments_group_id)
|
||||
logger.info(f"✅ Вступил в группу комментариев: {comments_group_id}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Уже в группе: {e}")
|
||||
|
||||
# Теперь получаем сущность группы комментариев
|
||||
comments_group = None
|
||||
for try_id in [comments_group_id, abs(int(comments_group_id)), -abs(int(comments_group_id))]:
|
||||
try:
|
||||
comments_group = await client.get_entity(PeerChannel(abs(int(try_id))))
|
||||
logger.info(f"Группа комментариев: {comments_group.title} (ID: {try_id})")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось получить сущность {try_id}: {e}")
|
||||
continue
|
||||
|
||||
if not comments_group:
|
||||
logger.error(f"Не удалось найти группу комментариев {comments_group_id}")
|
||||
return
|
||||
|
||||
from telethon.tl.functions.channels import GetFullChannelRequest
|
||||
channel_full = await client(GetFullChannelRequest(comments_group))
|
||||
@@ -346,25 +403,24 @@ class CommentWorker:
|
||||
logger.info(f"Linked chat ID: {linked_chat_id}")
|
||||
|
||||
# Получаем сообщение в КАНАЛЕ
|
||||
target_message_in_channel = await client.get_messages(
|
||||
PeerChannel(abs(int(linked_chat_id))),
|
||||
ids=message_id_in_channel
|
||||
)
|
||||
target_message_in_channel = None
|
||||
for try_id in [linked_chat_id, abs(int(linked_chat_id)), -abs(int(linked_chat_id))]:
|
||||
try:
|
||||
target_message_in_channel = await client.get_messages(
|
||||
PeerChannel(abs(int(try_id))),
|
||||
ids=message_id_in_channel
|
||||
)
|
||||
if target_message_in_channel:
|
||||
logger.info(f"Найдено сообщение в канале: {target_message_in_channel.id}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось получить сообщение {try_id}/{message_id_in_channel}: {e}")
|
||||
continue
|
||||
|
||||
if not target_message_in_channel:
|
||||
logger.error(f"Сообщение {message_id_in_channel} не найдено в канале {linked_chat_id}")
|
||||
return
|
||||
|
||||
logger.info(f"Найдено сообщение в канале: {target_message_in_channel.id}")
|
||||
|
||||
# Вступаем в группу комментариев (если ещё не вступили)
|
||||
# Это требуется для некоторых каналов
|
||||
try:
|
||||
await session_manager.join_channel(session_file, comments_group_id)
|
||||
logger.info(f"Вступил в группу комментариев: {comments_group_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось вступить в группу комментариев: {e}")
|
||||
|
||||
# ОТПРАВЛЯЕМ В КАНАЛ с comment_to= (как в старом проекте)
|
||||
# Telegram автоматически направит комментарий в группу комментариев
|
||||
delay = random.uniform(COMMENT_DELAY_MIN, COMMENT_DELAY_MAX)
|
||||
|
||||
Reference in New Issue
Block a user