From 29097a18c3fd6a9a31f7eaf1da24c2c6837b1dae Mon Sep 17 00:00:00 2001 From: Kenik Date: Wed, 24 Jun 2026 18:39:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=83=D0=BF=D0=B5=D1=80-=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD,=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D1=8B/=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B0,=20=D0=B8=D0=BD=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD-=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=BC=D0=B0=D1=81=D0=BA=D0=B0=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D1=84=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Супер-админ (config.SUPER_ADMIN_IDS, только через код): добавляет/удаляет обычных админов из бота (раздел «Администраторы 👑»), таблица admins. _is_admin теперь учитывает супер-админов, .env и добавленных из бота. - «Когда удобно созвониться» → выбор конкретного дня (кнопки на ближайшие даты) и времени (слоты), сохраняется как «День, ЧЧ:ММ». - Шаг согласия: инлайн-кнопки со ссылками на оферту и политику ПД (PDF), затем подтверждение. Старое вложение .docx убрано. - Маска отображения телефона: +7 (999) 123-45-67 в карточке и уведомлениях. Co-Authored-By: Claude Opus 4.8 --- bot.py | 246 +++++++++++++++++++++++++++++++++++++++++----------- config.py | 11 +++ database.py | 32 +++++++ 3 files changed, 236 insertions(+), 53 deletions(-) diff --git a/bot.py b/bot.py index 5039212..8b075bc 100644 --- a/bot.py +++ b/bot.py @@ -66,6 +66,17 @@ def normalize_phone(text: str) -> Optional[str]: return None +def format_phone_display(phone: str) -> str: + """Красивое отображение номера: +7 (999) 123-45-67 для РФ, иначе как есть.""" + if not phone: + return phone + digits = re.sub(r"\D", "", phone) + if len(digits) == 11 and digits[0] in ("7", "8"): + d = digits + return f"+7 ({d[1:4]}) {d[4:7]}-{d[7:9]}-{d[9:11]}" + return phone + + def is_valid_name(text: str) -> bool: """Имя должно состоять только из букв, пробела, дефиса или апострофа (без цифр и спецсимволов).""" return bool(NAME_REGEX.match(text.strip())) @@ -94,8 +105,8 @@ def get_main_keyboard(is_admin: bool = False) -> str: return keyboard.get_keyboard() -def get_admin_keyboard() -> str: - """Клавиатура админ-панели с разделами.""" +def get_admin_keyboard(is_super: bool = False) -> str: + """Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы».""" keyboard = VkKeyboard(one_time=False) keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Кандидаты 👥", color=VkKeyboardColor.SECONDARY) @@ -105,6 +116,9 @@ def get_admin_keyboard() -> str: keyboard.add_button("Быстрые ответы 💬", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY) keyboard.add_line() + if is_super: + keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.NEGATIVE) + keyboard.add_line() keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.PRIMARY) return keyboard.get_keyboard() @@ -175,6 +189,15 @@ def get_faq_keyboard() -> str: return keyboard.get_keyboard() +def get_consent_docs_keyboard() -> str: + """Инлайн-кнопки со ссылками на оферту и политику обработки ПД.""" + keyboard = VkKeyboard(inline=True) + keyboard.add_openlink_button("📄 Оферта", getattr(config, "OFFER_URL", "https://infocyber.pro/")) + keyboard.add_line() + keyboard.add_openlink_button("🔐 Политика обработки ПД", getattr(config, "POLICY_URL", "https://infocyber.pro/")) + return keyboard.get_keyboard() + + def get_socials_keyboard() -> str: """Клавиатура с кнопками-ссылками на площадки ИКП (открываются в один тап).""" keyboard = VkKeyboard(one_time=False) @@ -229,15 +252,35 @@ def get_returning_keyboard() -> str: return keyboard.get_keyboard() -def get_calltime_keyboard() -> str: - """Клавиатура выбора удобного времени для звонка.""" +def get_calldate_keyboard() -> str: + """Клавиатура выбора ДНЯ для звонка (сегодня/завтра + ближайшие даты).""" + from datetime import datetime, timedelta + wd = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] + today = datetime.now() keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Как можно скорее ⏰", color=VkKeyboardColor.PRIMARY) + keyboard.add_button("Сегодня", color=VkKeyboardColor.PRIMARY) + keyboard.add_button("Завтра", color=VkKeyboardColor.PRIMARY) keyboard.add_line() - keyboard.add_button("Сегодня вечером 🌆", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Завтра ☀️", color=VkKeyboardColor.SECONDARY) + for i in (2, 3, 4): + d = today + timedelta(days=i) + keyboard.add_button(f"{wd[d.weekday()]} {d.strftime('%d.%m')}", color=VkKeyboardColor.SECONDARY) keyboard.add_line() - keyboard.add_button("В выходные 🎮", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() + + +def get_calltime_keyboard() -> str: + """Клавиатура выбора ВРЕМЕНИ (слоты в рабочие часы).""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("10:00", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("12:00", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("14:00", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("16:00", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("18:00", color=VkKeyboardColor.SECONDARY) + keyboard.add_button("20:00", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("Любое время", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) return keyboard.get_keyboard() @@ -368,9 +411,20 @@ class VKBotApplication: self._msg_times[user_id] = times return len(times) > FLOOD_MAX_MSGS + def _is_super_admin(self, user_id: int) -> bool: + """Супер-админ (определён только в config.SUPER_ADMIN_IDS, меняется в коде).""" + return user_id in getattr(config, "SUPER_ADMIN_IDS", []) + def _is_admin(self, user_id: int) -> bool: - """Является ли пользователь администратором (по config.ADMIN_IDS).""" - return hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS + """Админ: супер-админ, либо из config.ADMIN_IDS, либо добавленный в рантайме.""" + if self._is_super_admin(user_id): + return True + if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS: + return True + try: + return user_id in db.get_admins() + except Exception: + return False def _operator_ids(self) -> list: """Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID.""" @@ -395,16 +449,84 @@ class VKBotApplication: def _send_admin_panel(self, user_id: int): """Главный экран админ-панели с кнопками разделов.""" + is_super = self._is_super_admin(user_id) + extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else "" self.send_message( user_id, "⚙️ Админ-панель ИКП\n\n" "Выберите раздел на кнопках ниже:\n" "• 📊 Статистика — заявки, отклики, диалоги\n" + "• 👥 Кандидаты — отклики и их статусы\n" "• ⚙️ Управление услугами — добавить/изменить/удалить\n" - "• 📣 Рассылка — сообщение подписчикам", - keyboard=get_admin_keyboard() + "• 💬 Быстрые ответы — авто-ответы по ключевым словам\n" + "• 📣 Рассылка — сообщение подписчикам" + + extra, + keyboard=get_admin_keyboard(is_super=is_super) ) + def _send_admins_panel(self, user_id: int): + """Раздел управления администраторами (только супер-админ).""" + if not self._is_super_admin(user_id): + self.send_message(user_id, "⛔ Раздел доступен только супер-админу.") + return + runtime = db.get_admins() + supers = getattr(config, "SUPER_ADMIN_IDS", []) + cfg = [a for a in getattr(config, "ADMIN_IDS", []) if a not in supers] + lines = "👑 Супер-админы (меняются только в коде):\n" + lines += ("".join(f" • vk.com/id{a}\n" for a in supers) or " —\n") + lines += "\n🛡 Админы из .env (ADMIN_IDS):\n" + lines += ("".join(f" • vk.com/id{a}\n" for a in cfg) or " —\n") + lines += "\n➕ Добавленные из бота:\n" + lines += ("".join(f" • vk.com/id{a}\n" for a in runtime) or " —\n") + self.send_message( + user_id, + "👑 Управление администраторами\n\n" + f"{lines}\n" + "Команды:\n" + "• Добавить админа: админ доб [VK_ID]\n" + "• Удалить админа: админ удал [VK_ID]\n" + " (снять можно только добавленных из бота)\n\n" + "ℹ️ Сменить/добавить СУПЕР-АДМИНА можно только в коде: откройте config.py, " + "впишите VK ID в список SUPER_ADMIN_IDS и перезапустите бота.", + keyboard=get_admin_keyboard(is_super=True) + ) + + def _handle_admin_add_admin(self, user_id: int, text: str): + if not self._is_super_admin(user_id): + self.send_message(user_id, "⛔ Добавлять админов может только супер-админ.") + return + arg = text.split(" ", 2)[-1].strip() + if not arg.lstrip("-").isdigit(): + self.send_message(user_id, "⚠️ Формат: админ доб [VK_ID числом]") + return + new_id = int(arg) + if db.add_admin(new_id): + self.send_message(user_id, f"✅ Пользователь vk.com/id{new_id} теперь администратор.") + try: + self.send_message(new_id, "🛡 Вам выданы права администратора бота ИКП. " + "Откройте меню — появится кнопка «Админка ⚙️».") + except Exception: + pass + else: + self.send_message(user_id, f"ℹ️ Пользователь vk.com/id{new_id} уже администратор.") + self._send_admins_panel(user_id) + + def _handle_admin_remove_admin(self, user_id: int, text: str): + if not self._is_super_admin(user_id): + self.send_message(user_id, "⛔ Удалять админов может только супер-админ.") + return + arg = text.split(" ", 2)[-1].strip() + if not arg.lstrip("-").isdigit(): + self.send_message(user_id, "⚠️ Формат: админ удал [VK_ID числом]") + return + rem_id = int(arg) + if db.remove_admin(rem_id): + self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.") + else: + self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота " + "(админов из .env и супер-админов отсюда снять нельзя).") + self._send_admins_panel(user_id) + def _notify_allowed(self, user_id: int) -> bool: """Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе.""" now = time.time() @@ -488,6 +610,15 @@ class VKBotApplication: elif text_lower.startswith("статус "): self._handle_candidate_status(user_id, text_clean) return + elif text_lower in ["администраторы 👑", "администраторы", "админы"]: + self._send_admins_panel(user_id) + return + elif text_lower.startswith("админ доб"): + self._handle_admin_add_admin(user_id, text_clean) + return + elif text_lower.startswith("админ удал"): + self._handle_admin_remove_admin(user_id, text_clean) + return elif text_lower.startswith("ред "): self._handle_admin_edit(user_id, text_clean) return @@ -552,10 +683,10 @@ class VKBotApplication: self._handle_form_phone(user_id, text_clean) elif state == "FORM_QUESTION": self._handle_form_question(user_id, text_clean, user_data) + elif state == "FORM_CALLDATE": + self._handle_form_calldate(user_id, text_clean) elif state == "FORM_CALLTIME": self._handle_form_calltime(user_id, text_clean) - elif state == "FORM_EDIT_CALLTIME": - self._handle_form_edit_calltime(user_id, text_clean) elif state == "FORM_CONFIRM_QUESTION": self._handle_form_confirm_question(user_id, text_clean, user_data) elif state == "FORM_REVIEW": @@ -857,13 +988,14 @@ class VKBotApplication: if selected_service: # Сохраняем временный выбор в поле question db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})") - # Переходим к выбору удобного времени для звонка - db.update_user_state(user_id, "FORM_CALLTIME") + # Сбрасываем возможную старую дату и переходим к выбору ДНЯ звонка + db.update_user_field(user_id, "call_time", None) + db.update_user_state(user_id, "FORM_CALLDATE") self.send_message( user_id, f"Отличный выбор — {selected_service['name']}!\n\n" - "🕒 Когда вам удобно, чтобы менеджер с вами связался?", - keyboard=get_calltime_keyboard() + "📅 В какой день вам удобно, чтобы менеджер с вами связался?", + keyboard=get_calldate_keyboard() ) else: # Формируем список услуг с ценами для повторного показа @@ -887,39 +1019,45 @@ class VKBotApplication: db.update_user_state(user_id, "FORM_REVIEW") self._send_review_message(user_id, user_data) - def _handle_form_calltime(self, user_id: int, text: str): - """Сохранение удобного времени звонка и переход к финальной проверке.""" - call_time = text.strip() - if len(call_time) < 2 or len(call_time) > 100: + def _handle_form_calldate(self, user_id: int, text: str): + """Сохранение выбранного ДНЯ и переход к выбору времени.""" + day = text.strip() + if len(day) < 2 or len(day) > 40: self.send_message( user_id, - "🕒 Пожалуйста, выберите время на клавиатуре или коротко напишите, когда вам удобно.", - keyboard=get_calltime_keyboard() + "📅 Выберите день на клавиатуре ниже или коротко напишите дату.", + keyboard=get_calldate_keyboard() ) return - db.update_user_field(user_id, "call_time", call_time) - db.update_user_state(user_id, "FORM_REVIEW") - self._send_review_message(user_id, db.get_user_data(user_id)) + # Временно храним выбранный день в call_time (время добавится следующим шагом) + db.update_user_field(user_id, "call_time", day) + db.update_user_state(user_id, "FORM_CALLTIME") + self.send_message( + user_id, + f"📅 День: {day}.\n\n🕒 В какое время удобно? Выберите слот или напишите своё время:", + keyboard=get_calltime_keyboard() + ) - def _handle_form_edit_calltime(self, user_id: int, text: str): - """Сохранение нового времени звонка и возврат к проверке.""" - call_time = text.strip() - if len(call_time) < 2 or len(call_time) > 100: + def _handle_form_calltime(self, user_id: int, text: str): + """Сохранение времени (день+время) и переход к финальной проверке.""" + time_part = text.strip() + if len(time_part) < 2 or len(time_part) > 40: self.send_message( user_id, - "🕒 Пожалуйста, выберите время на клавиатуре или коротко напишите, когда вам удобно.", + "🕒 Выберите время на клавиатуре или коротко напишите его (например, 15:30).", keyboard=get_calltime_keyboard() ) return - db.update_user_field(user_id, "call_time", call_time) + day = (db.get_user_data(user_id).get("call_time") or "").strip() + combined = f"{day}, {time_part}" if day else time_part + db.update_user_field(user_id, "call_time", combined) db.update_user_state(user_id, "FORM_REVIEW") - self.send_message(user_id, "✅ Время звонка обновлено!") self._send_review_message(user_id, db.get_user_data(user_id)) def _send_review_message(self, user_id: int, user_data: Dict[str, Any]): """Отправляет сводную карточку проверки данных перед финальным шагом.""" name = user_data.get("name", "Не указано") - phone = user_data.get("phone", "Не указано") + phone = format_phone_display(user_data.get("phone", "Не указано")) question = user_data.get("question", "Не указано") call_time = user_data.get("call_time") or "Не указано" @@ -940,22 +1078,23 @@ class VKBotApplication: if text_lower in ["да, всё верно! ✅", "да, всё верно", "все верно", "да", "верно"]: # Переходим к шагу оферты и согласий db.update_user_state(user_id, "FORM_CONSENT_1") - - # Загружаем документ оферты для ознакомления - doc_attachment = self.get_offer_document_attachment(user_id) - - # Отправляем значительно сокращенное и дружелюбное сообщение-согласие + + # Сообщение 1 — документы инлайн-кнопками (открываются по ссылке) self.send_message( user_id, - "📄 Согласие с условиями и офертой\n\n" - "Для завершения отправки заявки подтвердите, пожалуйста, ваше согласие с условиями Публичной оферты, " - "Политикой обработки персональных данных и записью разговора.\n\n" - "«Я подтверждаю, что ознакомлен(а) с договором оферты (прикреплен ниже), принимаю его условия, " - "соглашаюсь на обработку персональных данных и запись консультации.»", - keyboard=get_binary_keyboard(show_skip=False), - attachment=doc_attachment + "📄 Перед отправкой ознакомьтесь, пожалуйста, с документами (кнопки ниже):", + keyboard=get_consent_docs_keyboard() ) - + # Сообщение 2 — подтверждение согласия (кнопки снизу) + self.send_message( + user_id, + "Подтвердите согласие с условиями Публичной оферты, Политикой обработки персональных данных " + "и записью консультации:\n\n" + "«Я ознакомлен(а) с документами, принимаю условия оферты, соглашаюсь на обработку " + "персональных данных и запись консультации.»", + keyboard=get_binary_keyboard(show_skip=False) + ) + elif text_lower in ["изменить данные 🔄", "изменить данные", "изменить", "редактировать", "поменять"]: # Переходим в меню изменения полей db.update_user_state(user_id, "FORM_EDIT_MENU") @@ -1007,11 +1146,12 @@ class VKBotApplication: keyboard=get_services_keyboard() ) elif text_lower in ["изменить время 🕒", "изменить время", "время", "4"]: - db.update_user_state(user_id, "FORM_EDIT_CALLTIME") + db.update_user_field(user_id, "call_time", None) + db.update_user_state(user_id, "FORM_CALLDATE") self.send_message( user_id, - "🕒 Когда вам удобно, чтобы менеджер с вами связался?", - keyboard=get_calltime_keyboard() + "📅 В какой день вам удобно, чтобы менеджер с вами связался?", + keyboard=get_calldate_keyboard() ) elif text_lower in ["назад к проверке ↩️", "назад к проверке", "назад", "вернуть", "5"]: db.update_user_state(user_id, "FORM_REVIEW") @@ -1419,7 +1559,7 @@ class VKBotApplication: confirm_msg = ( "🎉 Заявка успешно оформлена!\n\n" - f"Наш менеджер свяжется с вами ({callback_time}) по номеру {phone}, " + f"Наш менеджер свяжется с вами ({callback_time}) по номеру {format_phone_display(phone)}, " f"удобное время: {call_time}.\n\n" ) if promo: @@ -1753,7 +1893,7 @@ class VKBotApplication: operator_msg = ( f"⚡ Получена новая заявка!\n\n" f"👤 Имя: {name}\n" - f"📞 Телефон: {phone}\n" + f"📞 Телефон: {format_phone_display(phone)}\n" f"🎯 Выбранная услуга: {question}\n" f"🕒 Удобное время звонка: {call_time}\n" f"{promo_line}\n" diff --git a/config.py b/config.py index 167f29a..fad56fd 100644 --- a/config.py +++ b/config.py @@ -40,6 +40,17 @@ if admin_ids_raw: else: ADMIN_IDS = [OPERATOR_PEER_ID] +# ============================================================ +# СУПЕР-АДМИН. Меняется ТОЛЬКО ЗДЕСЬ, в коде (не через .env, не через бота). +# Супер-админ может добавлять/удалять обычных администраторов прямо из бота. +# Чтобы сменить/добавить супер-админа — впишите сюда VK ID (числом): +SUPER_ADMIN_IDS = [350793273] +# ============================================================ + +# Ссылки на юридические документы (инлайн-кнопки в боте) +OFFER_URL = os.getenv("OFFER_URL", "https://infocyber.pro/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf") +POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf") + # Несколько операторов: уведомления о заявках/откликах/вызовах уходят ВСЕМ из списка. # По умолчанию = один OPERATOR_PEER_ID. Задать через .env: OPERATOR_PEER_IDS=111,222,333 _op_ids_raw = os.getenv("OPERATOR_PEER_IDS") diff --git a/database.py b/database.py index 93ce0f6..fb43186 100644 --- a/database.py +++ b/database.py @@ -123,6 +123,14 @@ class DatabaseManager: except sqlite3.OperationalError: pass + # Table for runtime-added admins (добавляются супер-админом из бота) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS admins ( + user_id INTEGER PRIMARY KEY, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + # Table for editable quick FAQ answers (быстрые ответы по ключевым словам) cursor.execute(""" CREATE TABLE IF NOT EXISTS custom_faq ( @@ -426,6 +434,30 @@ class DatabaseManager: ) conn.commit() + # --- Администраторы (добавленные в рантайме супер-админом) --- + def add_admin(self, user_id: int) -> bool: + with self._get_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute("INSERT INTO admins (user_id) VALUES (?)", (user_id,)) + conn.commit() + return True + except sqlite3.IntegrityError: + return False # уже админ + + def remove_admin(self, user_id: int) -> bool: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM admins WHERE user_id = ?", (user_id,)) + conn.commit() + return cursor.rowcount > 0 + + def get_admins(self) -> list: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT user_id FROM admins ORDER BY user_id") + return [r[0] for r in cursor.fetchall()] + # --- Быстрые ответы (custom FAQ) --- def add_custom_faq(self, keyword: str, answer: str) -> int: with self._get_connection() as conn: