/poll_end {poll.poll_id}',
+ f'Опрос доступен в меню "Опросы"',
parse_mode='HTML'
)
await state.clear()
@@ -244,9 +255,10 @@ async def callback_poll_vote(callback: CallbackQuery):
await callback.answer(f'✅ Ваш голос учтён!', show_alert=False)
- # Отправляем результаты в чат (видимо только проголосовавшему)
- await callback.message.answer(
+ # Обновляем текущее сообщение результатами
+ await callback.message.edit_text(
results_text,
+ reply_markup=get_poll_view_keyboard(poll_id, user.is_admin if user else False),
parse_mode='HTML'
)
@@ -354,33 +366,88 @@ async def cmd_poll_end(message: Message):
# =============================================================================
-# СПИСОК ОПРОСОВ
+# СПИСОК ОПРОСОВ (INLINE)
# =============================================================================
@router.message(Command('polls'))
-async def cmd_polls(message: Message):
- """Список активных опросов"""
+@router.callback_query(F.data == 'polls')
+async def cmd_polls(event):
+ """Список активных опросов (Inline)"""
+ chat_id = event.chat.id if isinstance(event, Message) else event.message.chat.id
+
async with AsyncSessionLocal() as session:
stmt = select(Poll).where(
- Poll.chat_id == message.chat.id,
Poll.is_active == True
).order_by(Poll.created_at.desc())
result = await session.execute(stmt)
polls = list(result.scalars().all())
- if not polls:
- await message.answer('📊 Нет активных опросов')
+ text = '📊 Активные опросы дома\n\nВыберите опрос для голосования или просмотра результатов:'
+ if not polls:
+ text = '📊 Активных опросов пока нет'
+
+ keyboard = get_polls_list_keyboard(polls)
+
+ if isinstance(event, Message):
+ await event.answer(text, reply_markup=keyboard, parse_mode='HTML')
+ else:
+ await event.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML')
+
+
+@router.callback_query(F.data.startswith('poll_view_'))
+async def cb_poll_view(callback: CallbackQuery):
+ """Детальный просмотр опроса"""
+ poll_id = int(callback.data.split('_')[2])
+ user_id = callback.from_user.id
+
+ async with AsyncSessionLocal() as session:
+ stmt = select(Poll).where(Poll.poll_id == poll_id)
+ result = await session.execute(stmt)
+ poll = result.scalar_one_or_none()
+
+ if not poll:
+ await callback.answer('❌ Опрос не найден', show_alert=True)
return
- text = '📊 Активные опросы\n\n'
- for poll in polls:
- total = poll.total_votes
- status = '🔒 Закрыт' if not poll.is_active else '🟢 Активен'
- text += f'{poll.poll_id}. {poll.question[:50]}... ({total} голосов) {status}\n'
+ # Проверяем админа
+ stmt_user = select(User).where(User.user_id == user_id)
+ user = (await session.execute(stmt_user)).scalar_one_or_none()
+ is_admin = user.is_admin if user else False
- text += '\n💡 Для голосования нажмите на кнопку в сообщении с опросом'
+ status = '🟢 Активен' if poll.is_active else '🔒 Закрыт'
+ text = (
+ f'📊 Опрос: {poll.question}\n\n'
+ f'Статус: {status}\n'
+ f'Всего голосов: {poll.total_votes}\n'
+ )
- await message.answer(text, parse_mode='HTML')
+ await callback.message.edit_text(
+ text,
+ reply_markup=get_poll_view_keyboard(poll_id, is_admin),
+ parse_mode='HTML'
+ )
+
+
+@router.callback_query(F.data.startswith('poll_vote_menu_'))
+async def cb_poll_vote_menu(callback: CallbackQuery):
+ """Меню выбора варианта для голосования"""
+ poll_id = int(callback.data.split('_')[3])
+
+ async with AsyncSessionLocal() as session:
+ stmt = select(Poll).where(Poll.poll_id == poll_id)
+ result = await session.execute(stmt)
+ poll = result.scalar_one_or_none()
+
+ if not poll or not poll.is_active:
+ await callback.answer('❌ Опрос закрыт или не найден', show_alert=True)
+ return
+
+ options = json.loads(poll.options)
+ await callback.message.edit_text(
+ f'🗳️ {poll.question}\n\nВыберите ваш вариант:',
+ reply_markup=get_poll_keyboard(poll_id, options),
+ parse_mode='HTML'
+ )
# =============================================================================
diff --git a/handlers/smart_broadcast.py b/handlers/smart_broadcast.py
index e0821c1..a735a22 100644
--- a/handlers/smart_broadcast.py
+++ b/handlers/smart_broadcast.py
@@ -102,6 +102,7 @@ async def process_broadcast_text(message: Message, state: FSMContext):
@router.callback_query(F.data.startswith('broadcast_recipients:'))
async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContext):
"""Обработка выбора получателей"""
+ await callback.answer()
recipients = callback.data.split(':')[1]
await state.update_data(recipients=recipients)
@@ -126,6 +127,7 @@ async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContex
@router.callback_query(F.data == 'broadcast_send')
async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext):
"""Отправка рассылки"""
+ await callback.answer()
data = await state.get_data()
text = data.get('text', '')
recipients = data.get('recipients', 'all_verified')
@@ -145,6 +147,7 @@ async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == 'broadcast_cancel')
async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
"""Отмена рассылки"""
+ await callback.answer()
await callback.message.edit_text("❌ Рассылка отменена")
await state.clear()
@@ -153,76 +156,87 @@ async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
# ОСНОВНАЯ ФУНКЦИЯ ОТПРАВКИ
# ============================================================================
-async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None, staircase: str = 'all') -> bool:
+async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified', photo_file_id: str = None, document_file_id: str = None, staircase: str = 'all', user_ids: list = None, broadcast_id: int = None) -> bool:
"""
Отправить умную рассылку с фильтрацией по подъезду.
- Args:
- bot: Bot instance
- text: Текст
- recipients: 'all_and_chat', 'all_verified', 'ig_only'
- photo_file_id: ID фото
- staircase: 'all' или номер подъезда (строкой)
"""
import aiohttp
from config import BOT_TOKEN, get_proxy_url
try:
- reply_markup = {"inline_keyboard": [[{"text": "✅ Прочитал", "callback_data": "broadcast_read"}]]}
+ if user_ids is None:
+ async with AsyncSessionLocal() as session:
+ stmt = select(User).where(User.verified == True)
+
+ # Фильтр по группе
+ if recipients == 'ig_only':
+ from services.initiative_group import InitiativeGroupService
+ ig_user_ids = await InitiativeGroupService(session).get_member_ids(active_only=True)
+ stmt = stmt.where(User.user_id.in_(ig_user_ids))
+
+ # ФИЛЬТР ПО ПОДЪЕЗДУ
+ if staircase != 'all':
+ stmt = stmt.where(User.staircase == int(staircase))
- async with AsyncSessionLocal() as session:
- stmt = select(User).where(User.verified == True)
-
- # Фильтр по группе
- if recipients == 'ig_only':
- from services.initiative_group import InitiativeGroupService
- ig_user_ids = await InitiativeGroupService(session).get_member_ids(active_only=True)
- stmt = stmt.where(User.user_id.in_(ig_user_ids))
-
- # ФИЛЬТР ПО ПОДЪЕЗДУ (НОВОЕ!)
- if staircase != 'all':
- stmt = stmt.where(User.staircase == int(staircase))
+ result = await session.execute(stmt)
+ users = list(result.scalars().all())
+ user_ids = [u.user_id for u in users]
+
+ if not user_ids:
+ logger.warning("No users found for broadcast.")
+ return False
- result = await session.execute(stmt)
- users = list(result.scalars().all())
+ # Создаём запись в БД, если ещё не создана
+ if broadcast_id is None:
+ async with AsyncSessionLocal() as session:
+ broadcast = Broadcast(
+ message_id=None,
+ chat_id=None,
+ text=text,
+ photo_file_id=photo_file_id,
+ sent_at=datetime.utcnow(),
+ sent_by=ADMIN_USER_ID,
+ total_sent=len(user_ids),
+ has_read_button=True,
+ broadcast_type='smart',
+ )
+ session.add(broadcast)
+ await session.commit()
+ await session.refresh(broadcast)
+ broadcast_id = broadcast.id
+
+ # Формируем кнопку с ID рассылки
+ reply_markup = {"inline_keyboard": [[{"text": "✅ Прочитал", "callback_data": f"broadcast_read:{broadcast_id}"}]]}
# URL для Telegram API
bot_token = BOT_TOKEN
proxy_url = get_proxy_url()
- # НЕ отправляем в общий чат! Умная рассылка идёт только выбранным получателям
- # Для объявлений в общий чат используйте обычную рассылку
- chat_message_id = None
-
- # Создаём запись в БД
- async with AsyncSessionLocal() as session:
- broadcast = Broadcast(
- message_id=None, # Умная рассылка не создаёт сообщение в общем чате
- chat_id=None,
- text=text,
- photo_file_id=photo_file_id,
- sent_at=datetime.utcnow(),
- sent_by=ADMIN_USER_ID,
- total_sent=len(users),
- has_read_button=True,
- broadcast_type='smart',
- )
- session.add(broadcast)
- await session.commit()
- await session.refresh(broadcast)
- broadcast_id = broadcast.id
-
# Отправляем пользователям
success_count = 0
error_count = 0
# Используем один HTTP сеанс для всех запросов
async with aiohttp.ClientSession() as http_session:
- for user in users:
+ for uid in user_ids:
try:
- if photo_file_id:
+ if document_file_id:
+ url = f"https://api.telegram.org/bot{bot_token}/sendDocument"
+ data = aiohttp.FormData()
+ data.add_field('chat_id', str(uid))
+ data.add_field('document', document_file_id)
+ data.add_field('caption', f"📢 Объявление от администрации\n\n{text}")
+ data.add_field('reply_markup', json.dumps(reply_markup))
+ data.add_field('parse_mode', 'HTML')
+
+ async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ result_data = await resp.json()
+ if result_data.get('ok'): success_count += 1
+ else: error_count += 1
+ elif photo_file_id:
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
data = aiohttp.FormData()
- data.add_field('chat_id', str(user.user_id))
+ data.add_field('chat_id', str(uid))
data.add_field('photo', photo_file_id)
data.add_field('caption', f"📢 Объявление от администрации\n\n{text}")
data.add_field('reply_markup', json.dumps(reply_markup))
@@ -230,14 +244,12 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
result_data = await resp.json()
- if result_data.get('ok'):
- success_count += 1
- else:
- error_count += 1
+ if result_data.get('ok'): success_count += 1
+ else: error_count += 1
else:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
- 'chat_id': user.user_id,
+ 'chat_id': uid,
'text': f"📢 Объявление от администрации\n\n{text}",
'reply_markup': json.dumps(reply_markup),
'parse_mode': 'HTML'
@@ -245,12 +257,10 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
async with http_session.post(url, json=payload, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
result_data = await resp.json()
- if result_data.get('ok'):
- success_count += 1
- else:
- error_count += 1
+ if result_data.get('ok'): success_count += 1
+ else: error_count += 1
except Exception as e:
- logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
+ logger.error(f"Ошибка отправки пользователю {uid}: {e}")
error_count += 1
logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}")
@@ -264,7 +274,7 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
'text': f"📊 Результаты рассылки #{broadcast_id}\n\n"
f"✅ Доставлено: {success_count}\n"
f"❌ Ошибки: {error_count}\n"
- f"📈 Прочитали: 0/{len(users)}\n\n"
+ f"📈 Прочитали: 0/{len(user_ids)}\n\n"
f"Статистика обновляется в реальном времени в веб-панели",
'parse_mode': 'HTML'
}
@@ -284,17 +294,18 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
# ОБРАБОТКА НАЖАТИЯ КНОПКИ "ПРОЧИТАЛ"
# ============================================================================
-@router.callback_query(F.data == 'broadcast_read')
+@router.callback_query(F.data.startswith('broadcast_read:'))
async def handle_read_button(callback: CallbackQuery):
"""Обработка нажатия кнопки 'Прочитал'"""
try:
user_id = callback.from_user.id
+ broadcast_id = int(callback.data.split(':')[1])
async with AsyncSessionLocal() as session:
# Проверяем, не нажал ли уже
stmt = select(BroadcastRead).where(
BroadcastRead.user_id == user_id,
- BroadcastRead.broadcast_id == callback.message.message_id # Используем message_id как временный ID
+ BroadcastRead.broadcast_id == broadcast_id
)
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
@@ -303,12 +314,26 @@ async def handle_read_button(callback: CallbackQuery):
await callback.answer("✅ Вы уже отметили прочтение!", show_alert=False)
return
- # Пока не можем определить broadcast_id, сохраняем по callback.message
- # В идеале нужно хранить broadcast_id в callback_data
- # Для простоты используем message_id чата
+ # Сохраняем отметку
+ new_read = BroadcastRead(
+ broadcast_id=broadcast_id,
+ user_id=user_id,
+ read_at=datetime.utcnow()
+ )
+ session.add(new_read)
+ await session.commit()
- # TODO: Улучшить идентификацию рассылки
+ # Уведомляем админа о прочтении (опционально)
+ # await callback.bot.send_message(ADMIN_USER_ID, f"👁️ Пользователь {callback.from_user.full_name} прочитал рассылку #{broadcast_id}")
+
await callback.answer("✅ Спасибо за отметку!", show_alert=False)
+
+ # Убираем кнопку после нажатия
+ await callback.message.edit_reply_markup(reply_markup=None)
+
+ except Exception as e:
+ logger.error(f"Ошибка обработки кнопки прочтения: {e}")
+ await callback.answer("❌ Ошибка сохранения статуса")
except Exception as e:
logger.error(f"Ошибка обработки кнопки прочтения: {e}")
diff --git a/handlers/users.py b/handlers/users.py
index 7ee28ae..3987c84 100644
--- a/handlers/users.py
+++ b/handlers/users.py
@@ -4,17 +4,31 @@
"""
import logging
from aiogram import Router, F
-from aiogram.types import Message, CallbackQuery
-from aiogram.filters import Command, CommandStart
-from sqlalchemy import select
+from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+from aiogram.filters import Command, CommandStart, StateFilter
+from aiogram.fsm.context import FSMContext
+from sqlalchemy import select, update
from database.db import AsyncSessionLocal
from database.models import User, InitiativeGroup
-from keyboards.inline import get_main_menu, get_phones_menu
+from keyboards.inline import get_main_menu, get_phones_menu, get_profile_keyboard
+from services.ai_service import ai_service
import config
logger = logging.getLogger(__name__)
router = Router()
+@router.message(Command('cancel'), StateFilter('*'))
+@router.message(F.text.lower().in_(['отмена', 'отменить']), StateFilter('*'))
+async def global_cancel(message: Message, state: FSMContext):
+ """Глобальный выход из любого состояния FSM"""
+ current_state = await state.get_state()
+ if current_state:
+ await state.clear()
+ await message.answer("❌ Действие отменено.", reply_markup=get_main_menu())
+ else:
+ await message.answer("ℹ️ Нет активных действий для отмены.", reply_markup=get_main_menu())
+
@router.message(CommandStart())
async def cmd_start(message: Message):
"""Команда /start"""
@@ -48,12 +62,58 @@ async def cmd_start(message: Message):
await message.answer(welcome_text, reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)), parse_mode='HTML')
+@router.callback_query(F.data == 'main_menu')
+async def cb_main_menu(callback: CallbackQuery):
+ """Возврат в главное меню"""
+ user_id = callback.from_user.id
+ await callback.answer()
+
+ async with AsyncSessionLocal() as session:
+ stmt = select(User).where(User.user_id == user_id)
+ user = (await session.execute(stmt)).scalar_one_or_none()
+ ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == user_id, InitiativeGroup.is_active == True)
+ is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
+
+ await callback.message.edit_text(
+ "Главное меню:",
+ reply_markup=get_main_menu(verified=user.verified, is_ig=is_ig, is_admin=(user.is_admin or user_id == config.ADMIN_USER_ID), is_superadmin=(user_id == config.ADMIN_USER_ID)),
+ parse_mode='HTML'
+ )
+
@router.message(Command('phones'))
@router.callback_query(F.data == 'phones')
async def cb_phones(event):
- msg = event if isinstance(event, Message) else event.message
- if isinstance(event, CallbackQuery): await event.answer()
- await msg.answer('📞 ЭКСТРЕННЫЕ ТЕЛЕФОНЫ\n\nВыберите категорию ниже:', reply_markup=get_phones_menu())
+ text = '📞 ЭКСТРЕННЫЕ ТЕЛЕФОНЫ\n\nВыберите категорию ниже:'
+ kb = get_phones_menu()
+ if isinstance(event, CallbackQuery):
+ await event.answer()
+ await event.message.edit_text(text, reply_markup=kb, parse_mode='HTML')
+ else:
+ await event.answer(text, reply_markup=kb, parse_mode='HTML')
+
+@router.message(F.text & ~F.text.startswith('/'))
+async def chat_with_ai(message: Message):
+ """Обработка свободного общения с ИИ (только для верифицированных)"""
+ if message.chat.type != 'private': return
+
+ async with AsyncSessionLocal() as session:
+ stmt = select(User).where(User.user_id == message.from_user.id)
+ user = (await session.execute(stmt)).scalar_one_or_none()
+
+ ig_stmt = select(InitiativeGroup).where(InitiativeGroup.user_id == message.from_user.id, InitiativeGroup.is_active == True)
+ is_ig = (await session.execute(ig_stmt)).scalar_one_or_none() is not None
+
+ # Проверка прав: Админ ИЛИ ИГ ИЛИ персональный доступ
+ if not (user and (user.is_admin or is_ig or False)):
+ await message.answer("🔒 Доступ ограничен\n\nМой юридический ИИ-модуль доступен только участникам Инициативной группы и по персональному разрешению администратора.\n\nОбратитесь к @atyrin для получения доступа к базе знаний ГИС ЖКХ.", parse_mode='HTML')
+ return
+
+ user_info = {"apartment": user.apartment, "verified": user.verified, "role": "ИГ" if is_ig else "Жилец"}
+
+ # Отправляем индикатор печати
+ await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
+ answer = await ai_service.answer_question(message.text, user_info)
+ await message.answer(answer, parse_mode='Markdown')
@router.callback_query(F.data.startswith('phones_'))
async def cb_phone_category(callback: CallbackQuery):
@@ -77,55 +137,60 @@ async def cb_phone_category(callback: CallbackQuery):
services = list((await session.execute(stmt)).scalars().all())
if not services:
- return await callback.message.answer("👷 Данные для этой категории еще не заполнены.", reply_markup=get_main_menu())
+ return await callback.message.edit_text("👷 Данные для этой категории еще не заполнены.", reply_markup=get_main_menu())
- # Формируем текст
text = f"📞 СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}\n\n"
for s in services:
text += f"🔹 {s.name}\n └ 📱 {s.phone}\n"
if s.description: text += f" └ 📝 {s.description}\n"
text += "\n"
- # Ищем картинку (берем от первой службы в категории, если есть)
+ # Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото)
photo_path = services[0].image_path if services[0].image_path else None
- try:
- if photo_path and os.path.exists(photo_path):
- await callback.message.answer_photo(FSInputFile(photo_path), caption=text, parse_mode='HTML')
- else:
- await callback.message.answer(text, parse_mode='HTML')
- except Exception as e:
- logger.error(f"Error sending phones: {e}")
- await callback.message.answer(text, parse_mode='HTML')
+ if photo_path and os.path.exists(photo_path):
+ await callback.message.delete()
+ await callback.message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=get_main_menu(), parse_mode='HTML')
+ else:
+ await callback.message.edit_text(text, reply_markup=get_main_menu(), parse_mode='HTML')
await callback.answer()
@router.message(Command('rules'))
@router.callback_query(F.data == 'rules')
async def cb_rules(event):
- msg = event if isinstance(event, Message) else event.message
- if isinstance(event, CallbackQuery): await event.answer()
text = "📜 ПРАВИЛА ЧАТА ЛКМ37\n\n1. Уважение к соседям\n2. Никакого спама и рекламы\n3. Мат запрещен\n4. Обсуждаем только дела дома."
- await msg.answer(text, parse_mode='HTML')
+ kb = InlineKeyboardBuilder()
+ kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
+
+ if isinstance(event, Message):
+ await event.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
+ else:
+ await event.answer()
+ await event.message.edit_text(text, reply_markup=kb.as_markup(), parse_mode='HTML')
@router.message(Command('about'))
@router.callback_query(F.data == 'about_house')
async def cb_about(event):
- msg = event if isinstance(event, Message) else event.message
- if isinstance(event, CallbackQuery): await event.answer()
text = ("🏢 О НАШЕМ ДОМЕ (ЛКМ37)\n\n"
"📍 Адрес: пр-т Ленинского Комсомола, д. 37\n"
"🏗️ Тип дома: Многоквартирный жилой дом\n"
"🛠️ Управляющая компания: (укажите вашу УК)\n\n"
"Этот бот создан для объединения жильцов и быстрого решения проблем.")
- await msg.answer(text, parse_mode='HTML')
+ kb = InlineKeyboardBuilder()
+ kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
+
+ if isinstance(event, Message):
+ await event.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
+ else:
+ await event.answer()
+ await event.message.edit_text(text, reply_markup=kb.as_markup(), parse_mode='HTML')
+@router.message(Command('profile'))
@router.callback_query(F.data == 'my_profile')
async def cb_profile(event):
from utils.ui_styles import get_level_badge, get_user_level_key
user_id = event.from_user.id
- msg = event if isinstance(event, Message) else event.message
- if isinstance(event, CallbackQuery): await event.answer()
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
@@ -140,28 +205,47 @@ async def cb_profile(event):
f"Подъезд: {user.staircase or '—'} | Этаж: {user.floor or '—'}\n"
f"Рейтинг: {user.rating} 🏆")
- kb = InlineKeyboardBuilder()
- kb.row(InlineKeyboardButton(text="📍 Указать подъезд/этаж", callback_data="edit_location"))
- kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
+ kb = get_profile_keyboard()
- await msg.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
+ if isinstance(event, Message):
+ await event.answer(text, reply_markup=kb, parse_mode='HTML')
+ else:
+ await event.answer()
+ await event.message.edit_text(text, reply_markup=kb, parse_mode='HTML')
@router.callback_query(F.data == "edit_location")
async def edit_location_start(callback: CallbackQuery):
- await callback.message.answer("Введите ваш подъезд и этаж через пробел\nНапример: '3 5' (3 подъезд, 5 этаж)")
+ await callback.message.edit_text("Введите ваш подъезд и этаж через пробел\nНапример: '3 5' (3 подъезд, 5 этаж)", parse_mode='HTML')
await callback.answer()
@router.message(F.text.regexp(r"^\d+\s+\d+$"))
async def process_location(message: Message):
- staircase, floor = map(int, message.text.split())
- async with AsyncSessionLocal() as session:
- await session.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor))
- await session.commit()
- await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}")
+ try:
+ staircase, floor = map(int, message.text.split())
+ async with AsyncSessionLocal() as session:
+ await session.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor))
+ await session.commit()
+ await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}", reply_markup=get_profile_keyboard(), parse_mode='HTML')
+ except Exception as e:
+ await message.answer("❌ Ошибка формата. Введите два числа через пробел.")
@router.message(Command('help'))
@router.callback_query(F.data == 'help')
async def cb_help(event):
- msg = event if isinstance(event, Message) else event.message
- if isinstance(event, CallbackQuery): await event.answer()
- await msg.answer("❓ СПРАВКА\n\n/start — Главное меню\n/phones — Телефоны\n/about — О доме\n/profile — Твой профиль\n/rules — Правила")
+ text = ("❓ СПРАВКА\n\n"
+ "• Используйте кнопки в главном меню для навигации.\n"
+ "• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
+ "Список команд:\n"
+ "/start — Главное меню\n"
+ "/phones — Телефоны\n"
+ "/about — О доме\n"
+ "/profile — Твой профиль\n"
+ "/rules — Правила чата")
+ kb = InlineKeyboardBuilder()
+ kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
+
+ if isinstance(event, Message):
+ await event.answer(text, reply_markup=kb.as_markup(), parse_mode='HTML')
+ else:
+ await event.answer()
+ await event.message.edit_text(text, reply_markup=kb.as_markup(), parse_mode='HTML')
diff --git a/handlers/voice.py b/handlers/voice.py
index b0bea33..abd0170 100644
--- a/handlers/voice.py
+++ b/handlers/voice.py
@@ -1,70 +1,106 @@
import os
import logging
+import aiohttp
+import json
+import sqlite3
+from datetime import datetime
from aiogram import Router, F, Bot
from aiogram.types import Message
-from config import ADMIN_USER_ID
-from services.voice_service import VoiceService
+from config import ADMIN_USER_ID, DATABASE_PATH
from pathlib import Path
logger = logging.getLogger(__name__)
router = Router()
-_voice_service = None
+WHISPER_API_URL = "http://192.168.10.167:9001/transcribe"
-def get_voice_service():
- global _voice_service
- if _voice_service is None:
- DB_PATH = "/home/matrixhasyou/domovoy_bot/database/domovoy.db"
- _voice_service = VoiceService(db_path=DB_PATH, model_size="small")
- return _voice_service
+def log_voice_to_db(user_id, username, msg_type, duration, text):
+ """Фиксация статистики в основную базу Домового"""
+ try:
+ conn = sqlite3.connect(DATABASE_PATH)
+ cursor = conn.cursor()
+ cursor.execute('''
+ CREATE TABLE IF NOT EXISTS voice_recognition_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ username TEXT,
+ message_type TEXT,
+ duration INTEGER,
+ text TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ''')
+ cursor.execute('''
+ INSERT INTO voice_recognition_logs (user_id, username, message_type, duration, text)
+ VALUES (?, ?, ?, ?, ?)
+ ''', (user_id, username, msg_type, duration, text))
+ conn.commit()
+ conn.close()
+ except Exception as e:
+ logger.error(f"❌ DB Log error: {e}")
@router.message(F.voice | F.video_note)
async def handle_voice_message(message: Message, bot: Bot):
+ # ПЕРЕВОДИМ ТОЛЬКО ДЛЯ ТЕБЯ (СУПЕРАДМИНА)
if int(message.from_user.id) != int(ADMIN_USER_ID):
return
- status_msg = await message.reply("⏳ Расшифровываю...", parse_mode="HTML")
+ status_msg = await message.reply("⏳ Распознаю голос (Whisper AI)...", parse_mode="HTML")
await bot.send_chat_action(message.chat.id, "typing")
- # Абсолютный путь к временной папке
- temp_dir = Path("/home/matrixhasyou/domovoy_bot/temp_voice")
- temp_dir.mkdir(parents=True, exist_ok=True)
-
try:
if message.voice:
file_id = message.voice.file_id
- ext = "oga"
+ filename = f"{file_id}.oga"
msg_type = "VOICE"
else:
file_id = message.video_note.file_id
- ext = "mp4"
+ filename = f"{file_id}.mp4"
msg_type = "VIDEO_NOTE"
file = await bot.get_file(file_id)
- file_path = temp_dir / f"{file_id}.{ext}"
- # Скачиваем
- await bot.download_file(file.file_path, str(file_path))
-
- # Ждем секунду, чтобы ФС успела "переварить" файл
- import asyncio
- await asyncio.sleep(1)
+ # Используем aiohttp для отправки файла в микросервис
+ async with aiohttp.ClientSession() as session:
+ # Скачиваем файл во временный буфер
+ file_url = f"https://api.telegram.org/file/bot{bot.token}/{file.file_path}"
+
+ # Прокси если нужно (подтянется из конфига если настроено, но тут идем напрямую)
+ async with session.get(file_url) as file_resp:
+ if file_resp.status != 200:
+ raise Exception(f"Не удалось скачать файл из TG: {file_resp.status}")
+
+ file_content = await file_resp.read()
+
+ # Отправляем в наш Whisper-Service
+ data = aiohttp.FormData()
+ data.add_field('file', file_content, filename=filename)
+
+ async with session.post(WHISPER_API_URL, data=data) as whisper_resp:
+ if whisper_resp.status != 200:
+ err_text = await whisper_resp.text()
+ raise Exception(f"Ошибка Whisper API: {err_text}")
+
+ result = await whisper_resp.json()
+ text = result.get("text", "")
+ duration = result.get("duration", 0)
- if not file_path.exists():
- raise FileNotFoundError(f"Файл {file_path} не был скачан!")
-
- service = get_voice_service()
- text = service.transcribe(str(file_path), message.from_user.id, message.from_user.username or "Owner", msg_type)
-
- if file_path.exists(): file_path.unlink()
-
if text:
- await status_msg.edit_text(f"📝 РАСШИФРОВКА:\n\n{text}", parse_mode="HTML")
+ # Логируем в базу
+ log_voice_to_db(message.from_user.id, message.from_user.username, msg_type, int(duration), text)
+
+ # Формируем красивый ответ
+ response = f"📝 РАСШИФРОВКА ({msg_type})\n"
+ response += f"⏱ Длительность: {int(duration)}с\n"
+ response += f"━━━━━━━━━━━━━━\n"
+ response += f"{text}"
+
+ await status_msg.edit_text(response, parse_mode="HTML")
else:
- await status_msg.edit_text("🔇 Не удалось распознать текст.")
+ await status_msg.edit_text("🔇 Голос обнаружен, но слов не разобрал.")
except Exception as e:
- logger.error(f"❌ Voice Error: {e}")
- await status_msg.edit_text(f"❌ Ошибка: {e}")
+ logger.error(f"❌ Voice Integration Error: {e}")
+ await status_msg.edit_text(f"❌ Ошибка модуля Whisper: {e}")
voice_router = router
diff --git a/keyboards/inline.py b/keyboards/inline.py
index 7dee3aa..e7e10b0 100644
--- a/keyboards/inline.py
+++ b/keyboards/inline.py
@@ -349,3 +349,62 @@ def get_payment_type_menu() -> InlineKeyboardMarkup:
)
return builder.as_markup()
+
+def get_events_keyboard(events: list) -> InlineKeyboardMarkup:
+ """Список событий"""
+ builder = InlineKeyboardBuilder()
+ for event in events:
+ builder.row(InlineKeyboardButton(
+ text=f"📅 {event['title']}",
+ callback_data=f"event_view_{event['id']}"
+ ))
+ builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
+ return builder.as_markup()
+
+
+def get_event_view_keyboard(event_id: int, is_participant: bool, is_admin: bool = False) -> InlineKeyboardMarkup:
+ """Просмотр события"""
+ builder = InlineKeyboardBuilder()
+ if is_participant:
+ builder.row(InlineKeyboardButton(text='❌ Покинуть', callback_data=f"event_leave_{event_id}"))
+ else:
+ builder.row(InlineKeyboardButton(text='✅ Участвовать', callback_data=f"event_join_{event_id}"))
+
+ if is_admin:
+ builder.row(InlineKeyboardButton(text='🗑️ Удалить (Админ)', callback_data=f"event_delete_{event_id}"))
+
+ builder.row(InlineKeyboardButton(text='🔙 К списку', callback_data='events'))
+ return builder.as_markup()
+
+
+def get_polls_list_keyboard(polls: list) -> InlineKeyboardMarkup:
+ """Список активных опросов"""
+ builder = InlineKeyboardBuilder()
+ for poll in polls:
+ builder.row(InlineKeyboardButton(
+ text=f"📊 {poll.question[:40]}",
+ callback_data=f"poll_view_{poll.poll_id}"
+ ))
+ builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
+ return builder.as_markup()
+
+
+def get_poll_view_keyboard(poll_id: int, is_admin: bool = False) -> InlineKeyboardMarkup:
+ """Меню просмотра опроса"""
+ builder = InlineKeyboardBuilder()
+ builder.row(InlineKeyboardButton(text='🗳️ Голосовать', callback_data=f"poll_vote_menu_{poll_id}"))
+ builder.row(InlineKeyboardButton(text='📊 Результаты', callback_data=f"poll_results_{poll_id}"))
+
+ if is_admin:
+ builder.row(InlineKeyboardButton(text='🔒 Закрыть опрос', callback_data=f"poll_close_{poll_id}"))
+
+ builder.row(InlineKeyboardButton(text='🔙 К списку', callback_data='polls'))
+ return builder.as_markup()
+
+
+def get_profile_keyboard() -> InlineKeyboardMarkup:
+ """Клавиатура профиля"""
+ builder = InlineKeyboardBuilder()
+ builder.row(InlineKeyboardButton(text='🔄 Обновить', callback_data='my_profile'))
+ builder.row(InlineKeyboardButton(text='🔙 Назад', callback_data='main_menu'))
+ return builder.as_markup()
diff --git a/keyboards/reply.py b/keyboards/reply.py
index 6518252..59d24d4 100644
--- a/keyboards/reply.py
+++ b/keyboards/reply.py
@@ -11,14 +11,15 @@ def get_reply_menu() -> ReplyKeyboardMarkup:
Используется редко, в основном для важных действий
"""
builder = ReplyKeyboardBuilder()
-
+
builder.row(
KeyboardButton(text='📞 Телефоны'),
+ KeyboardButton(text='👷 Мастера'),
)
builder.row(
KeyboardButton(text='❓ Помощь'),
)
-
+
# Resize keyboard чтобы не занимал много места
return builder.as_markup(resize_keyboard=True, one_time_keyboard=False)
diff --git a/main.py b/main.py
index d09bfe5..23738de 100644
--- a/main.py
+++ b/main.py
@@ -9,6 +9,11 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BASE_DIR))
+# Добавляем путь к библиотекам роя
+LIBS_DIR = BASE_DIR.parent.parent / "libs"
+if LIBS_DIR.exists():
+ sys.path.insert(0, str(LIBS_DIR))
+
from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
diff --git a/manual_export_trigger.py b/manual_export_trigger.py
new file mode 100644
index 0000000..0417770
--- /dev/null
+++ b/manual_export_trigger.py
@@ -0,0 +1,26 @@
+import asyncio
+import sys
+import os
+
+# Добавляем путь к проекту в sys.path
+project_root = "/home/matrixhasyou/domovoy_bot"
+if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+os.chdir(project_root)
+
+from database.db import AsyncSessionLocal
+from services.chat_exporter import ChatExporter
+
+async def manual_export():
+ print("[*] Starting manual export to Forgejo...")
+ async with AsyncSessionLocal() as session:
+ exporter = ChatExporter(session)
+ result = await exporter.run_full_export()
+ print(f"[*] Result: {result}")
+ if result.get("success"):
+ print("✅ Export completed successfully!")
+ else:
+ print(f"❌ Export failed: {result.get('error')}")
+
+if __name__ == "__main__":
+ asyncio.run(manual_export())
diff --git a/requirements.txt b/requirements.txt
index b650776..c404393 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,5 +10,5 @@ fastapi>=0.109.0
uvicorn>=0.27.0
jinja2>=3.1.3
python-multipart>=0.0.6
-faster-whisper
+# faster-whisper # Moved to MacMini M4 API
pydub
diff --git a/scraper_gis/build_web_index.py b/scraper_gis/build_web_index.py
new file mode 100644
index 0000000..979f018
--- /dev/null
+++ b/scraper_gis/build_web_index.py
@@ -0,0 +1,66 @@
+import json, os, subprocess
+from datetime import datetime
+
+NAS_WEB_ROOT = "/volume1/web/legal/gis"
+HTML_FILE = "/app/web_index.html"
+
+def run():
+ # Получаем список папок на NAS через SSH
+ cmd = ["ssh", "matrixhasyou@192.168.10.105", f"ls -F {NAS_WEB_ROOT}/raw/"]
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ folders = [f.strip('/') for f in result.stdout.split('\n') if f.strip() and f.endswith('/')]
+
+ html = f"""
+
+
+
+
+ Скриншоты-улики и вложения в реальном времени
+Скриншоты-улики и вложения в реальном времени
+kZfEl4fsHaX:ZBRD;_-o[XgO5b%QQXC/0beBR>,9:`\2A(19GQ"+4Tu<'+:&qcG[S]\jkX`lenbUf71'_nX1DT_-Repd:c[9;%*TCP!nEOr#rA_?D@?.Vj nEnJq#3M0G^,(p1_%>baXh^:'U\aGZ_4E\+l4!7!;cS6L9:,mX.Pb&C!<(``kG^.M#8TM>]!?,Tp3>$LJo"/R2RUrZ.Bk4VrhO&.gnCq6.knQkn*bo3VfNY2Dom+[uMYNiLtWr2Vc#KNkaD-ia#`<64Yr+?2Wk /Im`$'(1Q,U.#^GltkQ8F+s#in0p1*fF\A#ROh$I1FrlRaGq-gH(Ia3HFJ+/pL"-oD\lVk=))m/SJ/d?n2p-+VGpJU]k)FE01ccR VQ)B&f;/:M+4pI26b5a']Ad2kI)3QG&<']EMtesZ%I"K3;(ugYDfF1Gh"7"V\tbfth8+O=r&l=,%=qd0-
"6DPX,Y8K/O6UJN"J(Rk6RT<r4<:R>3^P1(fgY_,QfhJnd4M!EL(G-*'NlZfFb?n(Ui7/e/ceEYS]C4X2HbmDbhsUH80BIB?^OeGl4NoY4'ofrG;eQYdQJU@VW""rDQmbC$r_%Or/?"0!hm2f[jJaKpV5Q*OLSdHd!MQ(/cMi`#,
0me==%H^jjDJX]=V$gYX05&E1?il6_0'a1oSXbuVTnc6#0pma'M!rZ;(r?h8M`mk9+`+Qd+\pJ:e%i*Q13pO?eB?^9JPl.>W9cfqS($*oBQq(L\ZO!e5qdHV<7lV(bJOBM%dZa,%3VkZNci)Mr1e)C).iOCs^\?MQ]Z2E.7-_3[Rh`,QsfHj=;b)5!7=\]MYs/02gV!hnNjK?,O#693"4%-1#n;P'86^:'?L(@07]9)_n3*$H^_2HO?imEFXEA/it*gli57%s.,-NiP,@3C/S`=^gXK/0YA6cMiB0"`:PNW:+R*uZr`q62P@k7dBYE1P"R,.(OkC;$quNRYK23c$nT%.M``'fN;Jf+;c2MgPDMMZdXMa>LZ\!_i8R^F1N)U9rMn33n?).c9qg6s%0)-25b:MLff["t4n5lR8%`#?[o"S>K?B(Ci`oI-dSoGUQs+