FIX: Restore reply.py, add new mail domains, fix sync pipeline, update to v2.2.0

This commit is contained in:
Admin 2026-06-10 15:38:29 +04:00
parent 0386a86fcf
commit 455c3dbd5b
245 changed files with 120217 additions and 275 deletions

22
.gitignore vendored
View file

@ -37,3 +37,25 @@ backups/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Scraper GIS
scraper_gis/browser_context/
scraper_gis/data/
scraper_gis/downloads/
scraper_gis/logs/
scraper_gis/screenshots/
# Logs
logs/
*.log
# Screen captures
scraper_gis/*.png
scraper_gis/dump.html
# Virtual Env
venv/
venv-google/
# DBs
database/*.db
database/*.sqlite3
# Other
__pycache__/
*.pyc

14
SPECKIT_CONSTITUTION.md Normal file
View file

@ -0,0 +1,14 @@
# 📜 Domovoy Bot Reborn: Конституция
## 1. СТАНДАРТЫ ИНТЕРФЕЙСА
- **Inline Only**: Все новые меню должны использовать `InlineKeyboardMarkup`.
- **Edit instead of Post**: При навигации по меню использовать `edit_text` или `edit_reply_markup` вместо отправки новых сообщений (где это логично).
- **Emojification**: Использовать стандартизированные эмодзи для разделов.
## 2. СТАНДАРТЫ КОДА
- Асинхронность везде. Никаких блокирующих `time.sleep` или `subprocess.run`.
- SQLAlchemy ORM для всех операций с БД.
- Логирование через `logger.info/error`.
## 3. БЭКАПЫ
- База данных бэкапится ежедневно в 00:00 на Synology.

9
SPECKIT_TASKS.md Normal file
View file

@ -0,0 +1,9 @@
# ✅ Domovoy Bot Reborn: Tasks
- [x] **Stage 1 (Foundation):** Включить WAL режим для SQLite.
- [x] **Stage 1 (Foundation):** Добавить индексы в таблицу User (ID, Username, Phone, Admin).
- [x] **Stage 1 (Foundation):** Исправить баги полей staircase/floor в User.
- [x] **Stage 1 (Foundation):** Настроить ежедневный бэкап (00:00).
- [ ] **Stage 2 (UX/UI):** Заменить Reply-меню на Inline в `handlers/users.py`.
- [ ] **Stage 2 (UX/UI):** Обновить меню админа на Inline.
- [ ] **Stage 3 (AI):** Внедрить FAQ на базе NotebookLM.

1
VERSION Normal file
View file

@ -0,0 +1 @@
2.2.0

345
data/knowledge_base.txt Normal file
View file

@ -0,0 +1,345 @@
# 🏛️ Глубокая карта Личного Кабинета ГИС ЖКХ
## 📁 Регламенты и инструкции
- **URL**: `https://dom.gosuslugi.ru//#!/regulations`
- **Скриншот**: `screenshots/deep_Регламенты_и_инструкции.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Все пользователи
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=1`
- **Скриншот**: `screenshots/deep_Все_пользователи.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Граждане
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=2`
- **Скриншот**: `screenshots/deep_Граждане.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Кредитные организации и банковские агенты
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=9`
- **Скриншот**: `screenshots/deep_Кредитные_организации_и_банковские_агенты.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Органы государственного жилищного надзора
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=8`
- **Скриншот**: `screenshots/deep_Органы_государственного_жилищногоадзора.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Органы государственной власти
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=3`
- **Скриншот**: `screenshots/deep_Органы_государственной_власти.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Органы местного самоуправления
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=4`
- **Скриншот**: `screenshots/deep_Органы_местного_самоуправления.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Ответственные за реализацию приоритетного проекта "Формирование комфортной городской среды"
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=14`
- **Скриншот**: `screenshots/deep_Ответственныеа_реализацию_приоритетного_проектаормированиеомфортной_городской_среды.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Региональный оператор капитального ремонта
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=10`
- **Скриншот**: `screenshots/deep_Региональный_операторапитального_ремонта.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Региональный оператор по обращению с твердыми коммунальными отходами
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=12`
- **Скриншот**: `screenshots/deep_Региональный_оператор_по_обращению_с_твердыми_коммунальными_отходами.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Ресурсоснабжающие организации
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=7`
- **Скриншот**: `screenshots/deep_Ресурсоснабжающие_организации.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 РКЦ, ЕИРЦ и платежные агенты
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=13`
- **Скриншот**: `screenshots/deep_РКЦ_ЕИРЦ_и_платежные_агенты.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Управляющие организации, ТСЖ, ЖК, ЖСК
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=6`
- **Скриншот**: `screenshots/deep_Управляющие_организации_ТСЖ_ЖКСК.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Федеральные органы власти
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=5`
- **Скриншот**: `screenshots/deep_Федеральные_органы_власти.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Фонд содействия реформированию ЖКХ
- **URL**: `https://dom.gosuslugi.ru//#!/regulations?userCtgrCode=11`
- **Скриншот**: `screenshots/deep_Фонд_содействия_реформированию_ЖКХ.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Найти`
- [Button] `Очистить`
- [Button] `Выбрать все`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
- [Button] `Скачать`
---
## 📁 Карта сайта
- **URL**: `https://dom.gosuslugi.ru//#!/site-map`
- **Скриншот**: `screenshots/deep_Карта_сайта.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Отменить`
---
## 📁 ГИС ЖКХ
- **URL**: `https://dom.gosuslugi.ru//#!/main`
- **Скриншот**: `screenshots/deep_ГИСКХ.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Все сервисы`
- [Button] `Законодательство по ГИС ЖКХ`
- [Button] `Законодательство РФ`
- [Button] `Все мероприятия`
- [Button] `Все ссылки`
- [Button] `Отменить`
---
## 📁 Обращение
в службу поддержки
- **URL**: `https://dom.gosuslugi.ru/#!/support`
- **Скриншот**: `screenshots/deep_Обращениев_службу_поддержки.png`
- **Найденные элементы управления:**
- [Button] `Перейти на новый сайт`
- [Button] `Личный кабинет`
- [Button] `Отменить`
---
# 🗺️ Карта Личного Кабинета ГИС ЖКХ
Дата сканирования: 1297340.377546797
- **Часто задаваемые вопросы**: `/#!/faq`
- **Регламенты и инструкции**: `/#!/regulations`
- **Органы государственной власти**: `/#!/regulations?userCtgrCode=3`
- **Версия для слабовидящих**: `/special/`
- **Органы местного самоуправления**: `/#!/regulations?userCtgrCode=4`
- **ГИС ЖКХ**: `/#!/main`
- **Карта сайта**: `/#!/site-map`
- **Государственная информационная система жилищно-коммунального хозяйства**: `/#!/main`
- **Региональный оператор по обращению с твердыми коммунальными отходами**: `/#!/regulations?userCtgrCode=12`
- **Ответственные за реализацию приоритетного проекта "Формирование комфортной городской среды"**: `/#!/regulations?userCtgrCode=14`
- **Кредитные организации и банковские агенты**: `/#!/regulations?userCtgrCode=9`
- **Все пользователи**: `/#!/regulations?userCtgrCode=1`
- **Граждане**: `/#!/regulations?userCtgrCode=2`
- **Региональный оператор капитального ремонта**: `/#!/regulations?userCtgrCode=10`
- **Ресурсоснабжающие организации**: `/#!/regulations?userCtgrCode=7`
- **Управляющие организации, ТСЖ, ЖК, ЖСК**: `/#!/regulations?userCtgrCode=6`
- **Органы государственного жилищного надзора**: `/#!/regulations?userCtgrCode=8`
- **Федеральные органы власти**: `/#!/regulations?userCtgrCode=5`
- **РКЦ, ЕИРЦ и платежные агенты**: `/#!/regulations?userCtgrCode=13`
- **Фонд содействия реформированию ЖКХ**: `/#!/regulations?userCtgrCode=11`73-2020-7
73-2025-28074
73-2025-28128
73-2026-10010
73-2026-10011
73-2026-14011
73-2026-14014
73-2026-14016
73-2026-14123
73-2026-14124
73-2026-14126
73-2026-1580
73-2026-2441
73-2026-26
73-2026-2619
73-2026-2645
73-2026-2766
73-2026-2898
73-2026-2900
73-2026-3108
73-2026-3294
73-2026-3295
73-2026-4093
73-2026-4385
73-2026-5028
73-2026-5029
73-2026-5075
73-2026-5818
73-2026-6151
73-2026-681

View file

@ -0,0 +1,21 @@
import sqlite3
DB_PATH = "/home/matrixhasyou/legal-swarm-core/services/domovoy_bot/database/domovoy.db"
def migrate():
print(f"[*] Adding has_ai_access column to users...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
cursor.execute("ALTER TABLE users ADD COLUMN has_ai_access BOOLEAN DEFAULT FALSE;")
print("[+] Column has_ai_access added.")
# Даем доступ админу (тебе) сразу
cursor.execute("UPDATE users SET has_ai_access = TRUE WHERE is_admin = TRUE;")
print("[+] AI access granted to all admins.")
except Exception as e:
print(f"[!] Error or already exists: {e}")
conn.commit()
conn.close()
if __name__ == "__main__":
migrate()

View file

@ -0,0 +1,30 @@
import sqlite3
import os
import sys
# Путь к БД
DB_PATH = "/home/matrixhasyou/legal-swarm-core/services/domovoy_bot/database/domovoy.db"
def migrate():
print(f"[*] Starting migration: adding staircase/floor to users...")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
# Добавляем колонки если их нет
cursor.execute("ALTER TABLE users ADD COLUMN staircase INTEGER;")
cursor.execute("ALTER TABLE users ADD COLUMN floor INTEGER;")
print("[+] Columns added successfully.")
except sqlite3.OperationalError as e:
print(f"[!] Warning: {e} (Columns might already exist)")
# Включаем WAL режим
cursor.execute("PRAGMA journal_mode=WAL;")
print(f"[+] WAL mode enabled: {cursor.fetchone()[0]}")
conn.commit()
conn.close()
print("[*] Migration finished.")
if __name__ == "__main__":
migrate()

View file

@ -27,18 +27,18 @@ class User(Base):
"""Пользователи (жильцы)""" """Пользователи (жильцы)"""
__tablename__ = 'users' __tablename__ = 'users'
user_id = Column(BigInteger, primary_key=True) user_id = Column(BigInteger, primary_key=True, index=True)
username = Column(String(255), nullable=True) username = Column(String(255), nullable=True, index=True)
first_name = Column(String(255), nullable=True) first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True) last_name = Column(String(255), nullable=True)
apartment = Column(String(10), nullable=True) # Номер квартиры apartment = Column(String(10), nullable=True) # Номер квартиры
phone = Column(String(20), nullable=True) # Телефон жильца phone = Column(String(20), nullable=True, index=True) # Телефон жильца
verified = Column(Boolean, default=False) # Прошёл верификацию verified = Column(Boolean, default=False) # Прошёл верификацию
verification_date = Column(DateTime, nullable=True) verification_date = Column(DateTime, nullable=True)
join_date = Column(DateTime, default=datetime.utcnow) join_date = Column(DateTime, default=datetime.utcnow)
message_count = Column(Integer, default=0) message_count = Column(Integer, default=0)
is_banned = Column(Boolean, default=False) is_banned = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False) is_admin = Column(Boolean, default=False, index=True)
last_seen = Column(DateTime, nullable=True) last_seen = Column(DateTime, nullable=True)
# Рейтинг активности # Рейтинг активности
@ -49,6 +49,11 @@ class User(Base):
spy_score = Column(Integer, default=0) # 0-100 spy_score = Column(Integer, default=0) # 0-100
spy_flags = Column(Text, nullable=True) # JSON список флагов spy_flags = Column(Text, nullable=True) # JSON список флагов
# Дополнительные данные (LKM37 Reborn)
staircase = Column(Integer, nullable=True) # Подъезд
floor = Column(Integer, nullable=True) # Этаж
has_ai_access = Column(Boolean, default=False) # Доступ к ИИ-консультанту
# Для анти-спама # Для анти-спама
spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам spam_warning_count = Column(Integer, default=0) # Количество предупреждений за спам

View file

@ -4,9 +4,10 @@
""" """
import logging import logging
import json import json
from aiogram import Router, F from aiogram import Router, F, types
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup
from aiogram.filters import Command, StateFilter from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, String from sqlalchemy import select, func, String
@ -18,7 +19,7 @@ from utils.formatters import format_stats, format_user_info, get_spy_status
from services.analytics import ChatAnalytics from services.analytics import ChatAnalytics
from services.spy_detector import SpyDetector from services.spy_detector import SpyDetector
from keyboards.inline import get_admin_menu, get_spy_detail_menu from keyboards.inline import get_admin_menu, get_spy_detail_menu
from keyboards.admin_menu import get_admin_main_menu, get_admin_reply_menu, get_admin_help_menu, get_admin_rating_menu from keyboards.admin_menu import get_admin_main_menu, get_admin_help_menu, get_admin_rating_menu
import config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,6 +35,56 @@ class BroadcastState(StatesGroup):
waiting_for_message = State() waiting_for_message = State()
waiting_for_photo = State() # Состояние для ожидания картинки waiting_for_photo = State() # Состояние для ожидания картинки
class SwarmTaskState(StatesGroup):
waiting_for_task = State()
# =============================================================================
# SWARM COMMAND CENTER
# =============================================================================
@router.message(Command('task'))
@admin_only
async def cmd_swarm_task(message: Message, state: FSMContext):
"""Постановка задачи Рою"""
await message.answer("🦾 <b>SWARM COMMAND CENTER</b>\n\nОпишите задачу для Роя (она будет добавлена в SQL-очередь):", parse_mode='HTML')
await state.set_state(SwarmTaskState.waiting_for_task)
@router.message(SwarmTaskState.waiting_for_task)
@admin_only
async def process_swarm_task(message: Message, state: FSMContext):
# ... (код остается прежним)
pass
@router.message(F.text & ~F.text.startswith('/') & F.chat.type == 'private')
@admin_only
async def neural_proxy_handler(message: Message):
"""Neural Proxy: любой текст от админа -> задача для Роя"""
import psycopg2
db_params = {
"host": "192.168.10.105",
"port": 5433,
"database": "swarm_memory",
"user": "gemini_admin",
"password": "secure_swarm_pass_2026"
}
try:
conn = psycopg2.connect(**db_params)
cur = conn.cursor()
cur.execute(
"INSERT INTO swarm_tasks (title, description, status, creator) VALUES (%s, %s, %s, %s)",
("Neural Proxy Input", message.text, "pending", "Telegram_Admin")
)
conn.commit()
cur.close()
conn.close()
# Добавляем реакцию, чтобы было понятно, что Рой услышал
await message.react([types.ReactionTypeEmoji(emoji="🧠")])
except Exception as e:
await message.answer(f"❌ <b>Neural Proxy Error:</b>\n{str(e)}", parse_mode='HTML')
# ============================================================================= # =============================================================================
# АДМИН МЕНЮ # АДМИН МЕНЮ
@ -199,45 +250,47 @@ async def msg_help_button(message: Message):
await message.answer(help_text, parse_mode='HTML') await message.answer(help_text, parse_mode='HTML')
@router.callback_query(F.data == 'admin_menu')
@admin_callback_only
async def cb_admin_menu(callback: CallbackQuery):
"""Главное админ-меню (редактирование)"""
await callback.answer()
await callback.message.edit_text(
'🛡️ <b>Панель администратора</b>\n\nВыберите раздел:',
reply_markup=get_admin_main_menu(),
parse_mode='HTML'
)
@router.callback_query(F.data == 'admin_stats') @router.callback_query(F.data == 'admin_stats')
@admin_callback_only @admin_callback_only
async def cb_admin_stats(callback: CallbackQuery): async def cb_admin_stats(callback: CallbackQuery):
"""Статистика чата""" """Статистика чата (редактирование)"""
await callback.answer() await callback.answer()
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
analytics = ChatAnalytics(session) analytics = ChatAnalytics(session)
stats = await analytics.get_general_stats() stats = await analytics.get_general_stats()
text = format_stats(stats) text = format_stats(stats)
await callback.message.answer(text, parse_mode='HTML') await callback.message.edit_text(text, reply_markup=get_admin_help_menu(), parse_mode='HTML')
@router.callback_query(F.data == 'admin_spies') @router.callback_query(F.data == 'admin_spies')
@admin_callback_only @admin_callback_only
async def cb_admin_spies(callback: CallbackQuery): async def cb_admin_spies(callback: CallbackQuery):
"""Список подозрительных""" """Список подозрительных (редактирование)"""
await callback.answer() await callback.answer()
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
spy_detector = SpyDetector(session) spy_detector = SpyDetector(session)
suspicious = await spy_detector.get_suspicious_users(min_score=30) suspicious = await spy_detector.get_suspicious_users(min_score=30)
if not suspicious: if not suspicious:
await callback.message.answer('🟢 Подозрительных пользователей нет') await callback.message.edit_text('🟢 Подозрительных пользователей нет', reply_markup=get_admin_help_menu())
return return
text = f'🕵️ <b>Подозрительные пользователи</b> ({len(suspicious)}):\n\n' text = f'🕵️ <b>Подозрительные пользователи</b> ({len(suspicious)}):\n\n'
for user in suspicious: for user in suspicious:
status_emoji = '🔴' if user.spy_score > 60 else '🟡' status_emoji = '🔴' if user.spy_score > 60 else '🟡'
name = user.full_name or f'user{user.user_id}' text += f'{status_emoji} {user.full_name or user.user_id} (кв. {user.apartment or "?"}) — Score: {user.spy_score}\n'
apt = user.apartment or '?'
text += f'{status_emoji} {name} (кв. {apt}) — Score: {user.spy_score}\n'
text += '\nНажмите на пользователя для деталей:' await callback.message.edit_text(text, reply_markup=get_admin_help_menu(), parse_mode='HTML')
await callback.message.answer(text, parse_mode='HTML')
@router.callback_query(F.data == 'admin_users') @router.callback_query(F.data == 'admin_users')

View file

@ -73,6 +73,7 @@ async def cmd_digest(message: Message):
@router.callback_query(F.data.startswith('digest_approve:')) @router.callback_query(F.data.startswith('digest_approve:'))
async def approve_digest(callback: CallbackQuery): async def approve_digest(callback: CallbackQuery):
"""Утвердить и отправить дайджест""" """Утвердить и отправить дайджест"""
await callback.answer()
if callback.from_user.id != ADMIN_USER_ID: if callback.from_user.id != ADMIN_USER_ID:
return return
@ -156,6 +157,7 @@ async def approve_digest(callback: CallbackQuery):
@router.callback_query(F.data.startswith('digest_reject:')) @router.callback_query(F.data.startswith('digest_reject:'))
async def reject_digest(callback: CallbackQuery, state: FSMContext): async def reject_digest(callback: CallbackQuery, state: FSMContext):
"""Отклонить дайджест""" """Отклонить дайджест"""
await callback.answer()
if callback.from_user.id != ADMIN_USER_ID: if callback.from_user.id != ADMIN_USER_ID:
return return
@ -213,6 +215,7 @@ async def process_reject_reason(message: Message, state: FSMContext):
@router.callback_query(F.data.startswith('digest_edit:')) @router.callback_query(F.data.startswith('digest_edit:'))
async def edit_digest(callback: CallbackQuery, state: FSMContext): async def edit_digest(callback: CallbackQuery, state: FSMContext):
"""Редактировать текст дайджеста""" """Редактировать текст дайджеста"""
await callback.answer()
if callback.from_user.id != ADMIN_USER_ID: if callback.from_user.id != ADMIN_USER_ID:
return return

View file

@ -10,8 +10,12 @@ from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database.db import AsyncSessionLocal from database.db import AsyncSessionLocal
from database.models import User
from services.events import EventsService from services.events import EventsService
from utils.decorators import admin_only from utils.decorators import admin_only
from keyboards.inline import get_events_keyboard, get_event_view_keyboard, get_main_menu
from swarmlib.notifier import send_telegram
from sqlalchemy import select
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -128,54 +132,148 @@ async def process_event_description(message: Message, state: FSMContext):
created_by=message.from_user.id created_by=message.from_user.id
) )
# Уведомляем админа через swarmlib
send_telegram(f"📅 *Новое событие создано!*\n\n"
f"Название: {data['title']}\n"
f"Дата: {data['event_date'].strftime('%d.%m.%Y %H:%M')}\n"
f"Автор: {message.from_user.full_name} (ID: {message.from_user.id})",
parse_mode="markdown")
await message.answer( await message.answer(
f'✅ <b>Событие создано!</b>\n\n' f'✅ <b>Событие создано!</b>\n\n'
f'📅 {data["title"]}\n' f'📅 {data["title"]}\n'
f'🗓️ {data["event_date"].strftime("%d.%m.%Y %H:%M")}\n' f'🗓️ {data["event_date"].strftime("%d.%m.%Y %H:%M")}\n'
f'📍 {data.get("location") or "Место не указано"}\n' f'📍 {data.get("location") or "Место не указано"}\n'
f'🔖 ID: {event_id}\n\n' f'🔖 ID: {event_id}\n\n'
f'Для просмотра: /events', f'Событие доступно в меню "События"',
parse_mode='HTML' parse_mode='HTML'
) )
await state.clear() await state.clear()
# ============================================================================= # =============================================================================
# ПРОСМОТР СОБЫТИЙ # ПРОСМОТР СОБЫТИЙ (INLINE)
# ============================================================================= # =============================================================================
@router.message(Command('events')) @router.message(Command('events'))
@router.message(Command('события')) @router.message(Command('события'))
async def cmd_events(message: Message): @router.callback_query(F.data == 'events')
"""Показать предстоящие события""" async def cmd_events(event):
"""Показать предстоящие события (Inline)"""
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
events_service = EventsService(session) events_service = EventsService(session)
events = await events_service.get_upcoming_events(10) events = await events_service.get_upcoming_events(10)
text = '📅 <b>Предстоящие события</b>\n\n'
if not events: if not events:
await message.answer('📅 <b>Предстоящих событий нет</b>\n\n' text += 'Событий пока нет. Следите за обновлениями!'
'Добавить событие: /event_add', else:
parse_mode='HTML') for e in events:
text += f'• <b>{e["title"]}</b> ({e["event_date"].strftime("%d.%m.%Y %H:%M")})\n'
keyboard = get_events_keyboard(events)
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('event_view_'))
async def cb_event_view(callback: CallbackQuery):
"""Детальный просмотр события"""
event_id = int(callback.data.split('_')[2])
user_id = callback.from_user.id
async with AsyncSessionLocal() as session:
events_service = EventsService(session)
event = await events_service.get_event(event_id)
if not event:
await callback.answer('❌ Событие не найдено', show_alert=True)
return return
text = '📅 <b>Предстоящие события</b>\n\n' participants = await events_service.get_participants(event_id)
is_participant = any(p['user_id'] == user_id for p in participants)
# Проверяем админа для кнопки удаления
stmt = select(User).where(User.user_id == user_id)
user = (await session.execute(stmt)).scalar_one_or_none()
is_admin = user.is_admin if user else False
for event in events:
emoji = events_service.get_event_type_emoji(event['event_type']) emoji = events_service.get_event_type_emoji(event['event_type'])
date_str = event['event_date'].strftime('%d.%m.%Y %H:%M') text = (
f'{emoji} <b>{event["title"]}</b>\n\n'
f'🗓️ <b>Дата:</b> {event["event_date"].strftime("%d.%m.%Y %H:%M")}\n'
f'📍 <b>Место:</b> {event["location"] or "Не указано"}\n'
f'👥 <b>Участников:</b> {event["participants_count"]}\n\n'
f'📝 <b>Описание:</b>\n{event["description"] or "Нет описания"}'
)
text += f'{emoji} <b>{event["title"]}</b>\n' if participants:
text += f' └─ {date_str}\n' text += '\n\n<b>Участники:</b>\n'
text += f' └─ 📍 {event["location"] or "Место не указано"}\n' for p in participants[:10]:
text += f' └─ 🔖 ID: {event["id"]}\n\n' text += f'{p["name"]}\n'
if len(participants) > 10:
text += f'<i>...и еще {len(participants)-10}</i>'
text += '💡 Для участия: /join <ID>' await callback.message.edit_text(
text,
reply_markup=get_event_view_keyboard(event_id, is_participant, is_admin),
parse_mode='HTML'
)
await message.answer(text, parse_mode='HTML')
@router.callback_query(F.data.startswith('event_join_'))
async def cb_event_join(callback: CallbackQuery):
"""Записаться на событие через кнопку"""
event_id = int(callback.data.split('_')[2])
user_id = callback.from_user.id
async with AsyncSessionLocal() as session:
events_service = EventsService(session)
success = await events_service.join_event(event_id, user_id)
if success:
await callback.answer('✅ Вы записались!', show_alert=False)
# Обновляем сообщение
await cb_event_view(callback)
else:
await callback.answer(' Вы уже участвуете', show_alert=True)
@router.callback_query(F.data.startswith('event_leave_'))
async def cb_event_leave(callback: CallbackQuery):
"""Отписаться от события через кнопку"""
event_id = int(callback.data.split('_')[2])
user_id = callback.from_user.id
async with AsyncSessionLocal() as session:
events_service = EventsService(session)
await events_service.leave_event(event_id, user_id)
await callback.answer('✅ Вы отписались', show_alert=False)
await cb_event_view(callback)
@router.callback_query(F.data.startswith('event_delete_'))
@admin_only
async def cb_event_delete(callback: CallbackQuery):
"""Удалить событие (Админ)"""
event_id = int(callback.data.split('_')[2])
async with AsyncSessionLocal() as session:
events_service = EventsService(session)
event = await events_service.get_event(event_id)
await events_service.cancel_event(event_id)
send_telegram(f"🗑️ *Событие удалено!*\n\nID: {event_id}\nНазвание: {event['title'] if event else '???'}\nАдмин: {callback.from_user.full_name}", parse_mode="markdown")
await callback.answer('🗑️ Событие удалено', show_alert=True)
await cmd_events(callback)
# ============================================================================= # =============================================================================
# УЧАСТИЕ В СОБЫТИИ # УЧАСТИЕ В СОБЫТИИ (СТАРЫЕ КОМАНДЫ ДЛЯ СОВМЕСТИМОСТИ)
# ============================================================================= # =============================================================================
@router.message(Command('join')) @router.message(Command('join'))

View file

@ -15,7 +15,11 @@ from sqlalchemy import select
from database.db import AsyncSessionLocal from database.db import AsyncSessionLocal
from database.models import User, Poll from database.models import User, Poll
from utils.decorators import admin_only from utils.decorators import admin_only
from keyboards.inline import get_poll_keyboard, get_poll_results_keyboard, get_poll_close_keyboard from keyboards.inline import (
get_poll_keyboard, get_poll_results_keyboard, get_poll_close_keyboard,
get_polls_list_keyboard, get_poll_view_keyboard
)
from swarmlib.notifier import send_telegram
import config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -132,11 +136,18 @@ async def create_poll_from_text(message: Message, text: str, state: FSMContext):
poll.message_id = sent_message.message_id poll.message_id = sent_message.message_id
await session.commit() await session.commit()
# Уведомляем админа через swarmlib
send_telegram(f"📊 *Новый опрос создан!*\n\n"
f"Вопрос: {question}\n"
f"Вариантов: {len(options)}\n"
f"Автор: {message.from_user.full_name}",
parse_mode="markdown")
await message.answer( await message.answer(
f'✅ Опрос создан!\n\n' f'✅ Опрос создан!\n\n'
f'Вопрос: {question}\n' f'Вопрос: {question}\n'
f'Вариантов: {len(options)}\n\n' f'Вариантов: {len(options)}\n\n'
f'Для закрытия: <code>/poll_end {poll.poll_id}</code>', f'Опрос доступен в меню "Опросы"',
parse_mode='HTML' parse_mode='HTML'
) )
await state.clear() await state.clear()
@ -244,9 +255,10 @@ async def callback_poll_vote(callback: CallbackQuery):
await callback.answer(f'✅ Ваш голос учтён!', show_alert=False) await callback.answer(f'✅ Ваш голос учтён!', show_alert=False)
# Отправляем результаты в чат (видимо только проголосовавшему) # Обновляем текущее сообщение результатами
await callback.message.answer( await callback.message.edit_text(
results_text, results_text,
reply_markup=get_poll_view_keyboard(poll_id, user.is_admin if user else False),
parse_mode='HTML' parse_mode='HTML'
) )
@ -354,33 +366,88 @@ async def cmd_poll_end(message: Message):
# ============================================================================= # =============================================================================
# СПИСОК ОПРОСОВ # СПИСОК ОПРОСОВ (INLINE)
# ============================================================================= # =============================================================================
@router.message(Command('polls')) @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: async with AsyncSessionLocal() as session:
stmt = select(Poll).where( stmt = select(Poll).where(
Poll.chat_id == message.chat.id,
Poll.is_active == True Poll.is_active == True
).order_by(Poll.created_at.desc()) ).order_by(Poll.created_at.desc())
result = await session.execute(stmt) result = await session.execute(stmt)
polls = list(result.scalars().all()) polls = list(result.scalars().all())
text = '📊 <b>Активные опросы дома</b>\n\nВыберите опрос для голосования или просмотра результатов:'
if not polls: if not polls:
await message.answer('📊 Нет активных опросов') text = '📊 <b>Активных опросов пока нет</b>'
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 return
text = '📊 <b>Активные опросы</b>\n\n' # Проверяем админа
for poll in polls: stmt_user = select(User).where(User.user_id == user_id)
total = poll.total_votes user = (await session.execute(stmt_user)).scalar_one_or_none()
status = '🔒 Закрыт' if not poll.is_active else '🟢 Активен' is_admin = user.is_admin if user else False
text += f'{poll.poll_id}. {poll.question[:50]}... ({total} голосов) {status}\n'
text += '\n💡 Для голосования нажмите на кнопку в сообщении с опросом' status = '🟢 Активен' if poll.is_active else '🔒 Закрыт'
text = (
f'📊 <b>Опрос: {poll.question}</b>\n\n'
f'<b>Статус:</b> {status}\n'
f'<b>Всего голосов:</b> {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'🗳️ <b>{poll.question}</b>\n\nВыберите ваш вариант:',
reply_markup=get_poll_keyboard(poll_id, options),
parse_mode='HTML'
)
# ============================================================================= # =============================================================================

View file

@ -102,6 +102,7 @@ async def process_broadcast_text(message: Message, state: FSMContext):
@router.callback_query(F.data.startswith('broadcast_recipients:')) @router.callback_query(F.data.startswith('broadcast_recipients:'))
async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContext): async def process_broadcast_recipients(callback: CallbackQuery, state: FSMContext):
"""Обработка выбора получателей""" """Обработка выбора получателей"""
await callback.answer()
recipients = callback.data.split(':')[1] recipients = callback.data.split(':')[1]
await state.update_data(recipients=recipients) 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') @router.callback_query(F.data == 'broadcast_send')
async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext): async def send_broadcast_callback(callback: CallbackQuery, state: FSMContext):
"""Отправка рассылки""" """Отправка рассылки"""
await callback.answer()
data = await state.get_data() data = await state.get_data()
text = data.get('text', '') text = data.get('text', '')
recipients = data.get('recipients', 'all_verified') 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') @router.callback_query(F.data == 'broadcast_cancel')
async def cancel_broadcast(callback: CallbackQuery, state: FSMContext): async def cancel_broadcast(callback: CallbackQuery, state: FSMContext):
"""Отмена рассылки""" """Отмена рассылки"""
await callback.answer()
await callback.message.edit_text("❌ Рассылка отменена") await callback.message.edit_text("❌ Рассылка отменена")
await state.clear() await state.clear()
@ -153,22 +156,15 @@ 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 import aiohttp
from config import BOT_TOKEN, get_proxy_url from config import BOT_TOKEN, get_proxy_url
try: try:
reply_markup = {"inline_keyboard": [[{"text": "✅ Прочитал", "callback_data": "broadcast_read"}]]} if user_ids is None:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
stmt = select(User).where(User.verified == True) stmt = select(User).where(User.verified == True)
@ -178,31 +174,29 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
ig_user_ids = await InitiativeGroupService(session).get_member_ids(active_only=True) ig_user_ids = await InitiativeGroupService(session).get_member_ids(active_only=True)
stmt = stmt.where(User.user_id.in_(ig_user_ids)) stmt = stmt.where(User.user_id.in_(ig_user_ids))
# ФИЛЬТР ПО ПОДЪЕЗДУ (НОВОЕ!) # ФИЛЬТР ПО ПОДЪЕЗДУ
if staircase != 'all': if staircase != 'all':
stmt = stmt.where(User.staircase == int(staircase)) stmt = stmt.where(User.staircase == int(staircase))
result = await session.execute(stmt) result = await session.execute(stmt)
users = list(result.scalars().all()) users = list(result.scalars().all())
user_ids = [u.user_id for u in users]
# URL для Telegram API if not user_ids:
bot_token = BOT_TOKEN logger.warning("No users found for broadcast.")
proxy_url = get_proxy_url() return False
# НЕ отправляем в общий чат! Умная рассылка идёт только выбранным получателям # Создаём запись в БД, если ещё не создана
# Для объявлений в общий чат используйте обычную рассылку if broadcast_id is None:
chat_message_id = None
# Создаём запись в БД
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
broadcast = Broadcast( broadcast = Broadcast(
message_id=None, # Умная рассылка не создаёт сообщение в общем чате message_id=None,
chat_id=None, chat_id=None,
text=text, text=text,
photo_file_id=photo_file_id, photo_file_id=photo_file_id,
sent_at=datetime.utcnow(), sent_at=datetime.utcnow(),
sent_by=ADMIN_USER_ID, sent_by=ADMIN_USER_ID,
total_sent=len(users), total_sent=len(user_ids),
has_read_button=True, has_read_button=True,
broadcast_type='smart', broadcast_type='smart',
) )
@ -211,18 +205,38 @@ async def send_smart_broadcast(bot, text: str, recipients: str = 'all_verified',
await session.refresh(broadcast) await session.refresh(broadcast)
broadcast_id = broadcast.id 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()
# Отправляем пользователям # Отправляем пользователям
success_count = 0 success_count = 0
error_count = 0 error_count = 0
# Используем один HTTP сеанс для всех запросов # Используем один HTTP сеанс для всех запросов
async with aiohttp.ClientSession() as http_session: async with aiohttp.ClientSession() as http_session:
for user in users: for uid in user_ids:
try: 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"📢 <b>Объявление от администрации</b>\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" url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
data = aiohttp.FormData() 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('photo', photo_file_id)
data.add_field('caption', f"📢 <b>Объявление от администрации</b>\n\n{text}") data.add_field('caption', f"📢 <b>Объявление от администрации</b>\n\n{text}")
data.add_field('reply_markup', json.dumps(reply_markup)) 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: async with http_session.post(url, data=data, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
result_data = await resp.json() result_data = await resp.json()
if result_data.get('ok'): if result_data.get('ok'): success_count += 1
success_count += 1 else: error_count += 1
else:
error_count += 1
else: else:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage" url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = { payload = {
'chat_id': user.user_id, 'chat_id': uid,
'text': f"📢 <b>Объявление от администрации</b>\n\n{text}", 'text': f"📢 <b>Объявление от администрации</b>\n\n{text}",
'reply_markup': json.dumps(reply_markup), 'reply_markup': json.dumps(reply_markup),
'parse_mode': 'HTML' '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: async with http_session.post(url, json=payload, proxy=proxy_url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
result_data = await resp.json() result_data = await resp.json()
if result_data.get('ok'): if result_data.get('ok'): success_count += 1
success_count += 1 else: error_count += 1
else:
error_count += 1
except Exception as e: except Exception as e:
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}") logger.error(f"Ошибка отправки пользователю {uid}: {e}")
error_count += 1 error_count += 1
logger.info(f"📬 Рассылка #{broadcast_id}: отправлено={success_count}, ошибок={error_count}") 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"📊 <b>Результаты рассылки #{broadcast_id}</b>\n\n" 'text': f"📊 <b>Результаты рассылки #{broadcast_id}</b>\n\n"
f"✅ Доставлено: {success_count}\n" f"✅ Доставлено: {success_count}\n"
f"❌ Ошибки: {error_count}\n" f"❌ Ошибки: {error_count}\n"
f"📈 Прочитали: 0/{len(users)}\n\n" f"📈 Прочитали: 0/{len(user_ids)}\n\n"
f"Статистика обновляется в реальном времени в веб-панели", f"Статистика обновляется в реальном времени в веб-панели",
'parse_mode': 'HTML' '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): async def handle_read_button(callback: CallbackQuery):
"""Обработка нажатия кнопки 'Прочитал'""" """Обработка нажатия кнопки 'Прочитал'"""
try: try:
user_id = callback.from_user.id user_id = callback.from_user.id
broadcast_id = int(callback.data.split(':')[1])
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# Проверяем, не нажал ли уже # Проверяем, не нажал ли уже
stmt = select(BroadcastRead).where( stmt = select(BroadcastRead).where(
BroadcastRead.user_id == user_id, 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) result = await session.execute(stmt)
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
@ -303,13 +314,27 @@ async def handle_read_button(callback: CallbackQuery):
await callback.answer("✅ Вы уже отметили прочтение!", show_alert=False) await callback.answer("✅ Вы уже отметили прочтение!", show_alert=False)
return return
# Пока не можем определить broadcast_id, сохраняем по callback.message # Сохраняем отметку
# В идеале нужно хранить broadcast_id в callback_data new_read = BroadcastRead(
# Для простоты используем message_id чата broadcast_id=broadcast_id,
user_id=user_id,
read_at=datetime.utcnow()
)
session.add(new_read)
await session.commit()
# Уведомляем админа о прочтении (опционально)
# await callback.bot.send_message(ADMIN_USER_ID, f"👁️ Пользователь {callback.from_user.full_name} прочитал рассылку #{broadcast_id}")
# TODO: Улучшить идентификацию рассылки
await callback.answer("✅ Спасибо за отметку!", show_alert=False) 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: except Exception as e:
logger.error(f"Ошибка обработки кнопки прочтения: {e}") logger.error(f"Ошибка обработки кнопки прочтения: {e}")

View file

@ -4,17 +4,31 @@
""" """
import logging import logging
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.filters import Command, CommandStart from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select 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.db import AsyncSessionLocal
from database.models import User, InitiativeGroup 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 import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router() 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()) @router.message(CommandStart())
async def cmd_start(message: Message): async def cmd_start(message: Message):
"""Команда /start""" """Команда /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') 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.message(Command('phones'))
@router.callback_query(F.data == 'phones') @router.callback_query(F.data == 'phones')
async def cb_phones(event): async def cb_phones(event):
msg = event if isinstance(event, Message) else event.message text = '📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:'
if isinstance(event, CallbackQuery): await event.answer() kb = get_phones_menu()
await msg.answer('📞 <b>ЭКСТРЕННЫЕ ТЕЛЕФОНЫ</b>\n\nВыберите категорию ниже:', reply_markup=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("🔒 <b>Доступ ограничен</b>\n\nМой юридический ИИ-модуль доступен только участникам <b>Инициативной группы</b> и по персональному разрешению администратора.\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_')) @router.callback_query(F.data.startswith('phones_'))
async def cb_phone_category(callback: CallbackQuery): 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()) services = list((await session.execute(stmt)).scalars().all())
if not services: 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"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n" text = f"📞 <b>СПИСОК ТЕЛЕФОНОВ: {cat_code.upper()}</b>\n\n"
for s in services: for s in services:
text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n" text += f"🔹 <b>{s.name}</b>\n └ 📱 <code>{s.phone}</code>\n"
if s.description: text += f" └ 📝 <i>{s.description}</i>\n" if s.description: text += f" └ 📝 <i>{s.description}</i>\n"
text += "\n" text += "\n"
# Ищем картинку (берем от первой службы в категории, если есть) # Если есть фото, придется прислать новое сообщение (edit_text не умеет менять текст на фото)
photo_path = services[0].image_path if services[0].image_path else None photo_path = services[0].image_path if services[0].image_path else None
try:
if photo_path and os.path.exists(photo_path): if photo_path and os.path.exists(photo_path):
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, parse_mode='HTML') await callback.message.delete()
await callback.message.answer_photo(FSInputFile(photo_path), caption=text, reply_markup=get_main_menu(), parse_mode='HTML')
else: else:
await callback.message.answer(text, parse_mode='HTML') await callback.message.edit_text(text, reply_markup=get_main_menu(), parse_mode='HTML')
except Exception as e:
logger.error(f"Error sending phones: {e}")
await callback.message.answer(text, parse_mode='HTML')
await callback.answer() await callback.answer()
@router.message(Command('rules')) @router.message(Command('rules'))
@router.callback_query(F.data == 'rules') @router.callback_query(F.data == 'rules')
async def cb_rules(event): async def cb_rules(event):
msg = event if isinstance(event, Message) else event.message
if isinstance(event, CallbackQuery): await event.answer()
text = "<b>📜 ПРАВИЛА ЧАТА ЛКМ37</b>\n\n1. Уважение к соседям\n2. Никакого спама и рекламы\n3. Мат запрещен\n4. Обсуждаем только дела дома." text = "<b>📜 ПРАВИЛА ЧАТА ЛКМ37</b>\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.message(Command('about'))
@router.callback_query(F.data == 'about_house') @router.callback_query(F.data == 'about_house')
async def cb_about(event): async def cb_about(event):
msg = event if isinstance(event, Message) else event.message
if isinstance(event, CallbackQuery): await event.answer()
text = ("<b>🏢 О НАШЕМ ДОМЕ (ЛКМ37)</b>\n\n" text = ("<b>🏢 О НАШЕМ ДОМЕ (ЛКМ37)</b>\n\n"
"📍 Адрес: пр-т Ленинского Комсомола, д. 37\n" "📍 Адрес: пр-т Ленинского Комсомола, д. 37\n"
"🏗️ Тип дома: Многоквартирный жилой дом\n" "🏗️ Тип дома: Многоквартирный жилой дом\n"
"🛠️ Управляющая компания: (укажите вашу УК)\n\n" "🛠️ Управляющая компания: (укажите вашу УК)\n\n"
"<i>Этот бот создан для объединения жильцов и быстрого решения проблем.</i>") "<i>Этот бот создан для объединения жильцов и быстрого решения проблем.</i>")
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') @router.callback_query(F.data == 'my_profile')
async def cb_profile(event): async def cb_profile(event):
from utils.ui_styles import get_level_badge, get_user_level_key from utils.ui_styles import get_level_badge, get_user_level_key
user_id = event.from_user.id 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: async with AsyncSessionLocal() as session:
user = await session.get(User, user_id) 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.staircase or ''} | Этаж: {user.floor or ''}\n"
f"Рейтинг: {user.rating} 🏆") f"Рейтинг: {user.rating} 🏆")
kb = InlineKeyboardBuilder() kb = get_profile_keyboard()
kb.row(InlineKeyboardButton(text="📍 Указать подъезд/этаж", callback_data="edit_location"))
kb.row(InlineKeyboardButton(text="🏠 В меню", callback_data="main_menu"))
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") @router.callback_query(F.data == "edit_location")
async def edit_location_start(callback: CallbackQuery): async def edit_location_start(callback: CallbackQuery):
await callback.message.answer("Введите ваш подъезд и этаж через пробел\n<i>Например: '3 5' (3 подъезд, 5 этаж)</i>") await callback.message.edit_text("Введите ваш подъезд и этаж через пробел\n<i>Например: '3 5' (3 подъезд, 5 этаж)</i>", parse_mode='HTML')
await callback.answer() await callback.answer()
@router.message(F.text.regexp(r"^\d+\s+\d+$")) @router.message(F.text.regexp(r"^\d+\s+\d+$"))
async def process_location(message: Message): async def process_location(message: Message):
try:
staircase, floor = map(int, message.text.split()) staircase, floor = map(int, message.text.split())
async with AsyncSessionLocal() as session: 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.execute(update(User).where(User.user_id == message.from_user.id).values(staircase=staircase, floor=floor))
await session.commit() await session.commit()
await message.answer(f"✅ Данные обновлены: Подъезд {staircase}, Этаж {floor}") 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.message(Command('help'))
@router.callback_query(F.data == 'help') @router.callback_query(F.data == 'help')
async def cb_help(event): async def cb_help(event):
msg = event if isinstance(event, Message) else event.message text = ("<b>❓ СПРАВКА</b>\n\n"
if isinstance(event, CallbackQuery): await event.answer() "• Используйте кнопки в главном меню для навигации.\n"
await msg.answer("<b>❓ СПРАВКА</b>\n\n/start — Главное меню\n/phones — Телефоны\n/about — О доме\n/profile — Твой профиль\n/rules — Правила") "• Если бот не понимает команду, просто напишите вопрос текстом — наш ИИ постарается помочь.\n\n"
"<b>Список команд:</b>\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')

View file

@ -1,70 +1,106 @@
import os import os
import logging import logging
import aiohttp
import json
import sqlite3
from datetime import datetime
from aiogram import Router, F, Bot from aiogram import Router, F, Bot
from aiogram.types import Message from aiogram.types import Message
from config import ADMIN_USER_ID from config import ADMIN_USER_ID, DATABASE_PATH
from services.voice_service import VoiceService
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = Router() router = Router()
_voice_service = None WHISPER_API_URL = "http://192.168.10.167:9001/transcribe"
def get_voice_service(): def log_voice_to_db(user_id, username, msg_type, duration, text):
global _voice_service """Фиксация статистики в основную базу Домового"""
if _voice_service is None: try:
DB_PATH = "/home/matrixhasyou/domovoy_bot/database/domovoy.db" conn = sqlite3.connect(DATABASE_PATH)
_voice_service = VoiceService(db_path=DB_PATH, model_size="small") cursor = conn.cursor()
return _voice_service 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) @router.message(F.voice | F.video_note)
async def handle_voice_message(message: Message, bot: Bot): async def handle_voice_message(message: Message, bot: Bot):
# ПЕРЕВОДИМ ТОЛЬКО ДЛЯ ТЕБЯ (СУПЕРАДМИНА)
if int(message.from_user.id) != int(ADMIN_USER_ID): if int(message.from_user.id) != int(ADMIN_USER_ID):
return return
status_msg = await message.reply("⏳ <i>Расшифровываю...</i>", parse_mode="HTML") status_msg = await message.reply("⏳ <i>Распознаю голос (Whisper AI)...</i>", parse_mode="HTML")
await bot.send_chat_action(message.chat.id, "typing") 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: try:
if message.voice: if message.voice:
file_id = message.voice.file_id file_id = message.voice.file_id
ext = "oga" filename = f"{file_id}.oga"
msg_type = "VOICE" msg_type = "VOICE"
else: else:
file_id = message.video_note.file_id file_id = message.video_note.file_id
ext = "mp4" filename = f"{file_id}.mp4"
msg_type = "VIDEO_NOTE" msg_type = "VIDEO_NOTE"
file = await bot.get_file(file_id) file = await bot.get_file(file_id)
file_path = temp_dir / f"{file_id}.{ext}"
# Скачиваем # Используем aiohttp для отправки файла в микросервис
await bot.download_file(file.file_path, str(file_path)) async with aiohttp.ClientSession() as session:
# Скачиваем файл во временный буфер
file_url = f"https://api.telegram.org/file/bot{bot.token}/{file.file_path}"
# Ждем секунду, чтобы ФС успела "переварить" файл # Прокси если нужно (подтянется из конфига если настроено, но тут идем напрямую)
import asyncio async with session.get(file_url) as file_resp:
await asyncio.sleep(1) if file_resp.status != 200:
raise Exception(f"Не удалось скачать файл из TG: {file_resp.status}")
if not file_path.exists(): file_content = await file_resp.read()
raise FileNotFoundError(f"Файл {file_path} не был скачан!")
service = get_voice_service() # Отправляем в наш Whisper-Service
text = service.transcribe(str(file_path), message.from_user.id, message.from_user.username or "Owner", msg_type) data = aiohttp.FormData()
data.add_field('file', file_content, filename=filename)
if file_path.exists(): file_path.unlink() 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 text: if text:
await status_msg.edit_text(f"📝 <b>РАСШИФРОВКА:</b>\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"📝 <b>РАСШИФРОВКА</b> ({msg_type})\n"
response += f"⏱ Длительность: {int(duration)}с\n"
response += f"━━━━━━━━━━━━━━\n"
response += f"<i>{text}</i>"
await status_msg.edit_text(response, parse_mode="HTML")
else: else:
await status_msg.edit_text("🔇 Не удалось распознать текст.") await status_msg.edit_text("🔇 Голос обнаружен, но слов не разобрал.")
except Exception as e: except Exception as e:
logger.error(f"❌ Voice Error: {e}") logger.error(f"❌ Voice Integration Error: {e}")
await status_msg.edit_text(f"❌ Ошибка: {e}") await status_msg.edit_text(f"❌ Ошибка модуля Whisper: {e}")
voice_router = router voice_router = router

View file

@ -349,3 +349,62 @@ def get_payment_type_menu() -> InlineKeyboardMarkup:
) )
return builder.as_markup() 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()

View file

@ -14,6 +14,7 @@ def get_reply_menu() -> ReplyKeyboardMarkup:
builder.row( builder.row(
KeyboardButton(text='📞 Телефоны'), KeyboardButton(text='📞 Телефоны'),
KeyboardButton(text='👷 Мастера'),
) )
builder.row( builder.row(
KeyboardButton(text='❓ Помощь'), KeyboardButton(text='❓ Помощь'),

View file

@ -9,6 +9,11 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BASE_DIR)) 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 import Bot, Dispatcher
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage

26
manual_export_trigger.py Normal file
View file

@ -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())

View file

@ -10,5 +10,5 @@ fastapi>=0.109.0
uvicorn>=0.27.0 uvicorn>=0.27.0
jinja2>=3.1.3 jinja2>=3.1.3
python-multipart>=0.0.6 python-multipart>=0.0.6
faster-whisper # faster-whisper # Moved to MacMini M4 API
pydub pydub

View file

@ -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"""<!DOCTYPE html>
<html lang='ru'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Жатва ГИС ЖКХ: LIVE</title>
<script src='https://cdn.tailwindcss.com'></script>
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css'>
</head>
<body class='bg-slate-100 text-slate-900 pb-10'>
<header class='bg-blue-600 text-white py-12 px-4 shadow-xl mb-8'>
<div class='container mx-auto max-w-4xl text-center'>
<h1 class='text-4xl font-black mb-2 tracking-tighter'>🚀 ВЕЛИКАЯ ЖАТВА ГИС ЖКХ</h1>
<p class='opacity-80 italic font-semibold text-sm uppercase tracking-widest'>Скриншоты-улики и вложения в реальном времени</p>
</div>
</header>
<main class='container mx-auto max-w-4xl px-4'>
<div class='grid grid-cols-1 md:grid-cols-2 gap-6'>
"""
for f in folders:
html += f"""
<div class='bg-white rounded-3xl p-6 shadow-sm border border-slate-200 hover:shadow-md transition-all'>
<div class='flex items-center justify-between mb-4'>
<span class='bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest'>Обращение</span>
<span class='text-slate-400 font-mono text-xs'>{f}</span>
</div>
<div class='space-y-3'>
<a href='archive_new/{f}/evidence.png' target='_blank' class='flex items-center gap-2 bg-slate-900 text-white p-3 rounded-2xl text-xs font-bold hover:bg-slate-800 shadow-lg shadow-slate-200'>
<i class='bi bi-camera-fill'></i> Скриншот-улика (Дата/Время)
</a>
<a href='archive_new/{f}/attachments.zip' target='_blank' class='flex items-center gap-2 bg-blue-600 text-white p-3 rounded-2xl text-xs font-bold hover:bg-blue-500 shadow-lg shadow-blue-100'>
<i class='bi bi-file-zip-fill'></i> Скачать все документы (ZIP)
</a>
</div>
</div>
"""
html += """
</div>
</main>
</body>
</html>"""
with open(HTML_FILE, 'w', encoding='utf-8') as f:
f.write(html)
# Пушим на Synology
with open(HTML_FILE, 'rb') as f:
subprocess.run(["ssh", "matrixhasyou@192.168.10.105", f"cat > {NAS_WEB_ROOT}/index.html"], stdin=f)
print("✅ Сайт /harvest/ успешно обновлен с 10 новыми папками!")
if __name__ == '__main__':
run()

View file

@ -0,0 +1,35 @@
from playwright.sync_api import sync_playwright
import json
def run():
with sync_playwright() as p:
try:
# Пробуем 127.0.0.1 вместо localhost для надежности
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
data = []
# Используем query_selector_all для простоты
rows = page.query_selector_all('tr')
print(f'DOM Rows: {len(rows)}')
for row in rows:
text = row.inner_text()
if '73-20' in text:
# Ищем ссылку внутри строки
link = row.query_selector('a[href*="appeals/view/"]')
if link:
number = link.inner_text().strip()
href = link.get_attribute('href')
data.append({'number': number, 'href': href})
with open('/app/queue.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f'Captured: {len(data)}')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
run()

View file

@ -0,0 +1,35 @@
from playwright.sync_api import sync_playwright
import json, time
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
print(f'Attached to: {page.url}')
# Ждем прогрузки таблицы
page.wait_for_selector('tr', timeout=30000)
time.sleep(5)
data = []
rows = page.query_selector_all('tr')
for row in rows:
text = row.inner_text()
if '73-20' in text:
link = row.query_selector('a[href*="appeals/view/"]')
if link:
number = link.inner_text().strip()
href = link.get_attribute('href')
data.append({'number': number, 'href': href})
with open('/app/appeals_queue.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f'SUCCESS: Captured {len(data)} items.')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
run()

View file

@ -0,0 +1,37 @@
from playwright.sync_api import sync_playwright
import json, time
def run():
try:
with sync_playwright() as p:
# Пытаемся зайти в тот же профиль.
# Если браузер УЖЕ открыт, Playwright может выдать ошибку или открыть второе окно.
browser = p.chromium.launch_persistent_context(
user_data_dir='/app/browser_context',
headless=True
)
page = browser.pages[0]
# Ждем прогрузки, если вдруг открылась новая пустая страница
if 'about:blank' in page.url:
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/Pafo/Home')
page.wait_for_load_state('networkidle')
print(f'STATUS: URL is {page.url}')
page.screenshot(path='/app/current_state.png', full_page=True)
# Ищем конкретные ссылки
links = []
for a in page.query_selector_all('a'):
text = a.inner_text().strip()
if text: links.append(text)
with open('/app/links.json', 'w') as f:
json.dump(links, f)
browser.close()
except Exception as e:
print(f'ERROR: {str(e)}')
if __name__ == '__main__':
run()

View file

@ -0,0 +1,36 @@
from playwright.sync_api import sync_playwright
import time
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
# Находим ссылку по тексту номера
# Но в таблице текст - это тема. Используем текст темы.
target_text = 'ТРЕБОВАНИЕ о недопустимости предоставления недостоверных сведений'
print(f'Looking for link with text: {target_text}')
# Пытаемся кликнуть по первой ссылке в таблице
page.locator('tr a[href*="appeals/view/"]').first.click()
print('Clicked! Waiting for load...')
time.sleep(10)
print(f'New URL: {page.url}')
page.screenshot(path='/app/appeal_page_success.png', full_page=True)
# Пробуем найти кнопку Скачать все
btn = page.get_by_text('Скачать все')
if btn.is_visible():
print('Download button visible!')
btn.highlight()
page.screenshot(path='/app/download_btn_found.png')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
run()

View file

@ -0,0 +1,34 @@
from playwright.sync_api import sync_playwright
import time
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
print(f'Currently at: {page.url}')
# Скроллим вниз, чтобы кнопка стала видимой/активной
page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
time.sleep(2)
# Ищем кнопку по тексту
btn = page.get_by_text('Скачать все').first
if btn.is_visible():
print('Download button found! Clicking...')
with page.expect_download(timeout=120000) as download_info:
btn.click()
download = download_info.value
path = f'/app/downloads/harvested_appeal_73-2026-14124.zip'
download.save_as(path)
print(f'SUCCESS: Saved to {path}')
else:
print('Download button NOT VISIBLE. Dumping text...')
print(page.content()[-2000:]) # Дампим конец страницы
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
run()

View file

@ -4,6 +4,7 @@ services:
gis-scraper: gis-scraper:
build: . build: .
container_name: lkm37-gis-scraper container_name: lkm37-gis-scraper
shm_size: 2gb
ports: ports:
- "6080:6080" - "6080:6080"
volumes: volumes:

View file

@ -0,0 +1,58 @@
from playwright.sync_api import sync_playwright
import json, time
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
print(f'Attached. URL: {page.url}')
# Скроллим вниз, чтобы подгрузились элементы
page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
time.sleep(3)
data = []
# Ищем все строки таблицы
rows = page.query_selector_all('tr')
print(f'Found {len(rows)} potential rows.')
for row in rows:
inner_text = row.inner_text()
if '73-20' in inner_text:
# Нашли строку с обращением
link = row.query_selector('a[href*="appeals/view/"]')
if link:
num = link.inner_text().strip()
href = link.get_attribute('href')
# Достаем статус
status = 'Unknown'
if 'Исполнено' in inner_text: status = 'Исполнено'
data.append({
'number': num,
'href': href,
'status': status
})
with open('/app/found_appeals.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f'CAPTURED: {len(data)} appeals.')
# Если нашли хотя бы одно, попробуем зайти
if data:
target = data[0]
print(f'Attempting to enter: {target["number"]}')
page.goto('https://my.dom.gosuslugi.ru' + target['href'])
page.wait_for_load_state('networkidle')
time.sleep(10)
page.screenshot(path='/app/appeal_detail_view.png', full_page=True)
print(f'Detail screenshot saved for {target["number"]}')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
run()

View file

@ -0,0 +1,58 @@
from playwright.sync_api import sync_playwright
import json, time, re
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
# 1. Идем в список
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(10)
# 2. Пытаемся выставить 'По 100'
try:
# Ищем селект пагинации
page.locator('select.page-size-selector, .ui-select-container').click()
time.sleep(2)
page.get_by_text('100').click()
time.sleep(10)
except:
print('Не удалось выставить "по 100", будем собирать что есть.')
full_queue = []
# 3. Собираем со всех страниц (если их несколько)
while True:
rows = page.locator('tr').all()
for row in rows:
text = row.inner_text()
if '73-20' in text:
link = row.locator('a[href*="appeals/view/"]').first
num_match = re.search(r'73-202\d-\d+', text)
num = num_match.group(0) if num_match else "unknown"
href = link.get_attribute('href')
if href and num not in [x['number'] for x in full_queue]:
full_queue.append({'number': num, 'href': href})
# Ищем кнопку Следующая
next_btn = page.locator('a:has-text("следующая")')
if next_btn.is_visible() and next_btn.is_enabled():
print(f'Переход на следующую страницу... Уже собрано: {len(full_queue)}')
next_btn.click()
time.sleep(10)
else:
break
with open('/app/services/gis_harvester/data/full_queue.json', 'w', encoding='utf-8') as f:
json.dump(full_queue, f, ensure_ascii=False, indent=2)
print(f'ИТОГО СОБРАНО: {len(full_queue)} целей.')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == "__main__":
run()

View file

@ -0,0 +1,73 @@
from playwright.sync_api import sync_playwright
import json, time, re
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(10)
# Нажимаем 'Найти' для сброса фильтров
print('Нажимаем Найти...')
page.locator('button:has-text("Найти")').click()
time.sleep(10)
# Пытаемся выставить 'По 100'
try:
page.evaluate('''() => {
const el = Array.from(document.querySelectorAll('span, div')).find(e => e.innerText === '10');
if (el) el.click();
}''')
time.sleep(2)
page.locator('div.ui-select-choices-row:has-text("100")').click()
print('Выбрано по 100.')
time.sleep(10)
except: pass
full_queue = []
page_num = 1
while True:
# Очистка оверлеев
page.evaluate('document.querySelectorAll(".p-dialog-mask, .modal-backdrop").forEach(el => el.remove())')
rows = page.locator('tr').all()
added_on_page = 0
for row in rows:
text = row.inner_text()
if '73-20' in text:
link = row.locator('a[href*="appeals/view/"]').first
num_match = re.search(r'73-202\d-\d+', text)
num = num_match.group(0) if num_match else f"unknown_{time.time()}"
href = link.get_attribute('href')
if href and num not in [x['number'] for x in full_queue]:
full_queue.append({'number': num, 'href': href})
added_on_page += 1
print(f'Страница {page_num}: собрано {added_on_page} новых.')
# Ищем стрелку вправо или слово 'следующая'
next_btn = page.locator('a:has-text("следующая"), .pagination-next a').first
if next_btn.is_visible() and next_btn.is_enabled():
page.evaluate('el => el.click()', next_btn.element_handle())
page.wait_for_load_state('networkidle')
time.sleep(10)
page_num += 1
else:
break
output_path = '/app/services/gis_harvester/data/full_queue.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(full_queue, f, ensure_ascii=False, indent=2)
print(f'ИТОГО СОБРАНО: {len(full_queue)} целей.')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == "__main__":
run()

View file

@ -0,0 +1,79 @@
from playwright.sync_api import sync_playwright
import json, time, re
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(10)
# Закрываем мешающие диалоги (Restore session и т.д.)
try:
page.keyboard.press('Escape')
time.sleep(2)
except: pass
# Пытаемся выставить 'По 100' через JavaScript (самый надежный способ в SPA)
try:
# В ГИС ЖКХ это обычно Angular-компонент. Попробуем найти селект по значению.
page.evaluate('''() => {
const selectors = document.querySelectorAll('select, .ui-select-container');
for (const s of selectors) {
if (s.innerText.includes('10')) {
s.click();
}
}
}''')
time.sleep(2)
page.get_by_text('100').click()
print('Выставлено по 100 на странице.')
time.sleep(10)
except Exception as e:
print(f'Не удалось выставить "по 100" программно: {e}')
full_queue = []
while True:
# Убираем оверлеи если они есть
page.evaluate('''() => {
document.querySelectorAll('.p-dialog-mask, .modal-backdrop').forEach(el => el.remove());
document.body.classList.remove('p-overflow-hidden');
}''')
rows = page.locator('tr').all()
for row in rows:
text = row.inner_text()
if '73-20' in text:
link = row.locator('a[href*="appeals/view/"]').first
num_match = re.search(r'73-202\d-\d+', text)
num = num_match.group(0) if num_match else f"unknown_{time.time()}"
href = link.get_attribute('href')
if href and num not in [x['number'] for x in full_queue]:
full_queue.append({'number': num, 'href': href})
next_btn = page.locator('a:has-text("следующая")').first
if next_btn.is_visible() and next_btn.is_enabled():
print(f'Переход на следующую страницу... Уже собрано: {len(full_queue)}')
# Кликаем принудительно через JS если обычный клик перехвачен
page.evaluate('el => el.click()', next_btn.element_handle())
page.wait_for_load_state('networkidle')
time.sleep(10)
else:
break
# Сохраняем в официальную папку сервиса
output_path = '/app/services/gis_harvester/data/full_queue.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(full_queue, f, ensure_ascii=False, indent=2)
print(f'ИТОГО СОБРАНО: {len(full_queue)} целей.')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == "__main__":
run()

View file

@ -0,0 +1,10 @@
from playwright.sync_api import sync_playwright
def run():
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp('http://localhost:9222')
page = browser.contexts[0].pages[0]
page.screenshot(path='/app/browser_only.png')
print(f'Browser URL: {page.url}')
browser.close()
if __name__ == '__main__':
run()

51
scraper_gis/harvest.py Normal file
View file

@ -0,0 +1,51 @@
from playwright.sync_api import sync_playwright
import time, os
def run():
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
context = browser.contexts[0]
page = context.pages[0]
print(f'Start Harvest. Current URL: {page.url}')
# 1. Screenshot of the list
page.screenshot(path='/app/appeals_list_final.png', full_page=True)
print('List screenshot saved.')
# 2. Click first appeal link
# The number is 73-2026-5028
first_appeal = page.get_by_text('73-2026-5028').first
if first_appeal.is_visible():
print('Entering appeal 73-2026-5028...')
first_appeal.click()
page.wait_for_load_state('networkidle')
time.sleep(5)
# 3. Screenshot inside
page.screenshot(path='/app/appeal_inside.png', full_page=True)
print('Inside screenshot saved.')
# 4. Try to download ALL
try:
download_btn = page.get_by_text('Скачать все').first
if download_btn.is_visible():
print('Clicking Download All...')
with page.expect_download(timeout=60000) as download_info:
download_btn.click()
download = download_info.value
path = f'/app/downloads/73-2026-5028_archive.zip'
download.save_as(path)
print(f'Archive saved to {path}')
else:
print('Download All button NOT found.')
except Exception as e:
print(f'Download failed: {e}')
else:
print('Appeal 73-2026-5028 NOT found in list.')
browser.close()
if __name__ == '__main__':
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
run()

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

View file

@ -0,0 +1,58 @@
from playwright.sync_api import sync_playwright
import time, os
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
# Собираем видимые ссылки
links = page.query_selector_all('a[href*="appeals/view/"]')
print(f'Visible links: {len(links)}')
if not links:
print('No links found. Scrolling...')
page.evaluate('window.scrollTo(0, 500)')
time.sleep(2)
links = page.query_selector_all('a[href*="appeals/view/"]')
for i in range(min(3, len(links))):
# Находим ссылку заново после навигации назад
links = page.query_selector_all('a[href*="appeals/view/"]')
target = links[i]
num = target.inner_text().strip()
print(f'Step {i+1}: Entering {num}')
target.click()
page.wait_for_load_state('networkidle')
time.sleep(10)
# Скриншот внутри
page.screenshot(path=f'/app/downloads/screen_{num}.png', full_page=True)
# Download All
btn = page.get_by_text('Скачать все').first
if btn.is_visible():
print(f'Downloading all files for {num}...')
with page.expect_download(timeout=60000) as download_info:
btn.click()
download = download_info.value
download.save_as(f'/app/downloads/archive_{num}.zip')
print(f'SUCCESS: Saved archive_{num}.zip')
else:
print(f'Download button NOT found for {num}')
# Go back
print('Going back...')
page.go_back()
page.wait_for_load_state('networkidle')
time.sleep(5)
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
run()

View file

@ -0,0 +1,38 @@
from playwright.sync_api import sync_playwright
import time, os
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
# Находим первую ссылку и кликаем
link = page.query_selector('a[href*="appeals/view/"]')
if link:
num = link.inner_text().strip()
print(f'Clicking appeal: {num}')
link.click()
# Ждем появления кнопки Скачать все
print('Waiting for Download All button...')
page.wait_for_selector('button:has-text("Скачать все")', timeout=60000)
# Кликаем и ждем загрузку
with page.expect_download(timeout=120000) as download_info:
page.get_by_text('Скачать все').first.click()
download = download_info.value
path = f'/app/downloads/harvested_file.zip'
download.save_as(path)
print(f'SUCCESS: Downloaded to {path}')
else:
print('No appeal links found on page!')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
run()

43
scraper_gis/harvest_v2.py Normal file
View file

@ -0,0 +1,43 @@
from playwright.sync_api import sync_playwright
import time, os
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
print(f'URL: {page.url}')
# 1. Close popup if exists
# We see a Restore popup in screenshots
try:
page.keyboard.press('Escape')
except: pass
# 2. Click the appeal
print('Clicking 73-2026-5028...')
page.get_by_text('73-2026-5028').first.click()
time.sleep(10)
print(f'New URL: {page.url}')
# 3. Download
download_btn = page.get_by_text('Скачать все')
if download_btn.is_visible():
print('Found Download button. Clicking...')
with page.expect_download(timeout=120000) as download_info:
download_btn.click()
download = download_info.value
path = f'/app/downloads/test_appeal.zip'
download.save_as(path)
print(f'SUCCESS: Saved to {path}')
else:
print('Download button NOT found inside.')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == '__main__':
if not os.path.exists('/app/downloads'): os.makedirs('/app/downloads')
run()

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

1
scraper_gis/links.json Normal file
View file

@ -0,0 +1 @@
["\u0420\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u044b \u0438 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438", "\u0427\u0430\u0441\u0442\u043e \u0437\u0430\u0434\u0430\u0432\u0430\u0435\u043c\u044b\u0435 \u0432\u043e\u043f\u0440\u043e\u0441\u044b", "\u041a\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430", "\u0412\u0435\u0440\u0441\u0438\u044f \u0434\u043b\u044f \u0441\u043b\u0430\u0431\u043e\u0432\u0438\u0434\u044f\u0449\u0438\u0445", "\u0413\u0418\u0421 \u0416\u041a\u0425", "\u0413\u043e\u0441\u0443\u0434\u0430\u0440\u0441\u0442\u0432\u0435\u043d\u043d\u0430\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u0430\u044f \u0441\u0438\u0441\u0442\u0435\u043c\u0430 \u0436\u0438\u043b\u0438\u0449\u043d\u043e-\u043a\u043e\u043c\u043c\u0443\u043d\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u0445\u043e\u0437\u044f\u0439\u0441\u0442\u0432\u0430", "\u041e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u0435\n\u0432 \u0441\u043b\u0443\u0436\u0431\u0443 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0438", "\u0433\u043b\u0430\u0432\u043d\u0443\u044e \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0443", "\u0410\u041e \"\u041e\u041f\u0415\u0420\u0410\u0422\u041e\u0420 \u0418\u041d\u0424\u041e\u0420\u041c\u0410\u0426\u0418\u041e\u041d\u041d\u041e\u0419 \u0421\u0418\u0421\u0422\u0415\u041c\u042b\"", "\u041c\u0438\u043d\u0441\u0442\u0440\u043e\u0439 \u0420\u043e\u0441\u0441\u0438\u0438"]

View file

@ -0,0 +1,38 @@
from playwright.sync_api import sync_playwright
import time
def run():
with sync_playwright() as p:
# Подключаемся к уже открытому на экране браузеру
browser = p.chromium.connect_over_cdp('http://localhost:9222')
context = browser.contexts[0]
page = context.pages[0]
print(f'Подключился к браузеру. URL: {page.url}')
# 1. Переходим в Обращения (если мы не там)
if 'appeals' not in page.url:
print('Перехожу в раздел Обращений...')
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(5)
# 2. Подсвечиваем элементы, чтобы пользователь видел активность
# Мы будем рисовать рамки вокруг номеров обращений
print('Ищу номера обращений...')
page.evaluate('''() => {
const links = Array.from(document.querySelectorAll('a'));
links.filter(a => a.href.includes('appeals/view/')).forEach(a => {
a.style.border = '3px solid red';
a.style.backgroundColor = 'yellow';
});
}''')
page.screenshot(path='/app/live_discovery.png')
print('Сделал скриншот с подсветкой.')
# Не закрываем браузер! Просто отключаемся
browser.close()
if __name__ == '__main__':
run()

View file

@ -0,0 +1,14 @@
from playwright.sync_api import sync_playwright
import time
def run():
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
print(f'Navigating to appeals... Current: {page.url}')
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(10)
page.screenshot(path='/app/appeals_mapped.png', full_page=True)
browser.close()
if __name__ == '__main__':
run()

52
scraper_gis/recon.py Normal file
View file

@ -0,0 +1,52 @@
from playwright.sync_api import sync_playwright
import json, time, re
def run():
with sync_playwright() as p:
browser = p.chromium.launch_persistent_context(user_data_dir='/app/browser_context', headless=True)
page = browser.pages[0]
results = {}
# --- 1. ОБРАЩЕНИЯ ---
print('Разведка Обращений...')
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(7)
page.screenshot(path='/app/recon_appeals.png', full_page=True)
appeals = []
links = page.query_selector_all('a')
for link in links:
href = link.get_attribute('href') or ''
if 'appeals/view/' in href:
text = link.inner_text().strip()
if text:
appeals.append({'number': text, 'url': href})
results['appeals'] = appeals
print(f'Найдено обращений: {len(appeals)}')
# --- 2. ДОГОВОРЫ ---
print('Разведка Договоров...')
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/agreements/citizen/4b1aae97-01e6-4083-bf0c-029d01bbda2a?apartmentNumber=152')
page.wait_for_load_state('networkidle')
time.sleep(7)
page.screenshot(path='/app/recon_agreements.png', full_page=True)
agreements = []
links = page.query_selector_all('a')
for link in links:
href = link.get_attribute('href') or ''
if 'agreements/dogpoi/view/' in href:
text = link.inner_text().strip()
if text:
agreements.append({'name': text, 'url': href})
results['agreements'] = agreements
print(f'Найдено договоров: {len(agreements)}')
with open('/app/recon_data.json', 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
browser.close()
if __name__ == '__main__':
run()

View file

@ -0,0 +1,4 @@
{
"appeals": [],
"agreements": []
}

View file

@ -0,0 +1,62 @@
from playwright.sync_api import sync_playwright
import json, time, re
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(10)
# Нажимаем кнопку 'Свернуть/Развернуть поиск' если нужно
# На скриншоте видно панель поиска. Попробуем нажать 'Очистить' если она есть.
try:
# Очистка всех инпутов
page.evaluate('''() => {
document.querySelectorAll('input').forEach(i => { i.value = ''; });
// Пытаемся найти кнопку сброса
const reset = Array.from(document.querySelectorAll('button')).find(b => b.innerText.includes('Очистить'));
if (reset) reset.click();
}''')
time.sleep(5)
except: pass
# Нажимаем Найти
page.locator('button:has-text("Найти")').click()
time.sleep(10)
full_queue = []
while True:
page.evaluate('document.querySelectorAll(".p-dialog-mask, .modal-backdrop").forEach(el => el.remove())')
rows = page.locator('tr').all()
for row in rows:
text = row.inner_text()
if '73-20' in text:
link = row.locator('a[href*="appeals/view/"]').first
href = link.get_attribute('href')
num_match = re.search(r'73-202\d-\d+', text)
num = num_match.group(0) if num_match else f"case_{time.time()}"
if href and num not in [x['number'] for x in full_queue]:
full_queue.append({'number': num, 'href': href})
next_btn = page.locator('a:has-text("следующая"), .pagination-next a').first
if next_btn.is_visible() and next_btn.is_enabled():
page.evaluate('el => el.click()', next_btn.element_handle())
time.sleep(10)
else:
break
output_path = '/app/services/gis_harvester/data/full_queue.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(full_queue, f, ensure_ascii=False, indent=2)
print(f'ПОЛНЫЙ СПИСОК: {len(full_queue)} целей.')
browser.close()
except Exception as e:
print(f'ERROR: {e}')
if __name__ == "__main__":
run()

19
scraper_gis/restore.py Normal file
View file

@ -0,0 +1,19 @@
from playwright.sync_api import sync_playwright
import time
def run():
with sync_playwright() as p:
try:
browser = p.chromium.connect_over_cdp('http://127.0.0.1:9222')
page = browser.contexts[0].pages[0]
print(f'Navigating to appeals list... Current: {page.url}')
page.goto('https://my.dom.gosuslugi.ru/citizen-cabinet/#!/appeals/applicant/search')
page.wait_for_load_state('networkidle')
time.sleep(10)
print(f'Final URL: {page.url}')
browser.close() # disconnect actually
except Exception as e:
print(f'Error: {e}')
if __name__ == '__main__':
run()

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Some files were not shown because too many files have changed in this diff Show more