Кнопочное управление услугами (+смена картинок) и быстрыми ответами

- Приём фото-вложений от админа (handle_message получает attachments).
- Раздел «Услуги»: кнопки Добавить / Изменить / Удалить / Сменить картинку.
  Картинка скачивается, обрезается под 13:8 (650×400) и сохраняется как
  service_<id>.jpg; карусель берёт её приоритетно. Мастера на состояниях ADMIN_*.
- Раздел «Быстрые ответы»: кнопки Добавить / Изменить / Удалить с выбором.
- Контекст мастеров в памяти (_admin_ctx), отмена из любого шага.

Заметка: CRUD «Карьеры» (направления игроков/должности) — отдельным заходом,
т.к. опции там пока захардкожены и требуют перевода на БД.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kenik
2026-06-24 18:50:47 +03:00
parent 29097a18c3
commit 755e364d42
+306 -25
View File
@@ -123,6 +123,54 @@ def get_admin_keyboard(is_super: bool = False) -> str:
return keyboard.get_keyboard() return keyboard.get_keyboard()
def get_svc_admin_keyboard() -> str:
"""Клавиатура раздела управления услугами (направлениями)."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button(" Добавить услугу", color=VkKeyboardColor.POSITIVE)
keyboard.add_line()
keyboard.add_button("✏️ Изменить услугу", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("🖼 Сменить картинку", color=VkKeyboardColor.SECONDARY)
keyboard.add_line()
keyboard.add_button("🗑 Удалить услугу", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line()
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.PRIMARY)
return keyboard.get_keyboard()
def get_faq_admin_keyboard() -> str:
"""Клавиатура раздела управления быстрыми ответами."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button(" Добавить ответ", color=VkKeyboardColor.POSITIVE)
keyboard.add_line()
keyboard.add_button("✏️ Изменить ответ", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("🗑 Удалить ответ", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line()
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.PRIMARY)
return keyboard.get_keyboard()
def get_pick_keyboard(items: list) -> str:
"""Клавиатура выбора из списка пар (id, label). Кнопка отправляет '<id>. <label>'."""
keyboard = VkKeyboard(one_time=False)
count = 0
for _id, label in items[:10]:
if count and count % 2 == 0:
keyboard.add_line()
short = label if len(label) <= 30 else label[:29] + ""
keyboard.add_button(f"{_id}. {short}", color=VkKeyboardColor.SECONDARY)
count += 1
keyboard.add_line()
keyboard.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_cancel_admin_keyboard() -> str:
"""Кнопка отмены для админских мастеров ввода."""
keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_vacancy_choice_keyboard() -> str: def get_vacancy_choice_keyboard() -> str:
"""Выбор направления в разделе 'Карьера в ИКП'.""" """Выбор направления в разделе 'Карьера в ИКП'."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
@@ -354,6 +402,8 @@ class VKBotApplication:
self.longpoll = None self.longpoll = None
# Тексты рассылок, ожидающих подтверждения админом: {admin_id: text} # Тексты рассылок, ожидающих подтверждения админом: {admin_id: text}
self._pending_broadcasts: Dict[int, str] = {} self._pending_broadcasts: Dict[int, str] = {}
# Контекст админских мастеров (что редактируем): {admin_id: {...}}
self._admin_ctx: Dict[int, dict] = {}
# Кэш загруженных в VK фото: {путь_к_файлу: {"attachment": "...", "raw": "..."}} # Кэш загруженных в VK фото: {путь_к_файлу: {"attachment": "...", "raw": "..."}}
self._photo_cache: Dict[str, Dict[str, str]] = {} self._photo_cache: Dict[str, Dict[str, str]] = {}
# Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске) # Анти-флуд и анти-спам (in-memory, сбрасываются при перезапуске)
@@ -535,10 +585,11 @@ class VKBotApplication:
self._last_notify[user_id] = now self._last_notify[user_id] = now
return True return True
def handle_message(self, user_id: int, text: str): def handle_message(self, user_id: int, text: str, attachments: Optional[list] = None):
"""Маршрутизатор входящих сообщений на основе состояний пользователя.""" """Маршрутизатор входящих сообщений на основе состояний пользователя."""
text_clean = text.strip() text_clean = text.strip()
text_lower = text_clean.lower() text_lower = text_clean.lower()
attachments = attachments or []
# Анти-флуд + временный бан (админы — исключение) # Анти-флуд + временный бан (админы — исключение)
if not self._is_admin(user_id): if not self._is_admin(user_id):
@@ -579,9 +630,55 @@ class VKBotApplication:
# Глобальная обработка админ-команд и кнопок (только для ADMIN_IDS) # Глобальная обработка админ-команд и кнопок (только для ADMIN_IDS)
if self._is_admin(user_id): if self._is_admin(user_id):
# Мастера админки (ожидают ввод текста/выбор/фото) — ловим по состоянию
if state.startswith("ADMIN_"):
self._handle_admin_wizard(user_id, text_clean, text_lower, state, attachments)
return
if text_lower in ["админка", "админка ⚙️", "/admin"]: if text_lower in ["админка", "админка ⚙️", "/admin"]:
self._send_admin_panel(user_id) self._send_admin_panel(user_id)
return return
# Кнопки-действия раздела «Услуги»
elif text_lower in [" добавить услугу", "добавить услугу"]:
db.update_user_state(user_id, "ADMIN_SVC_ADD")
self.send_message(user_id, "➕ Новая услуга. Отправьте: Название | Цена | Описание\n"
"Пример: Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы.",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["✏️ изменить услугу", "изменить услугу"]:
items = [(k, s["name"]) for k, s in db.get_services().items()]
db.update_user_state(user_id, "ADMIN_SVC_EDIT_PICK")
self.send_message(user_id, "✏️ Какую услугу изменить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🖼 сменить картинку", "сменить картинку"]:
items = [(k, s["name"]) for k, s in db.get_services().items()]
db.update_user_state(user_id, "ADMIN_SVC_IMG_PICK")
self.send_message(user_id, "🖼 Для какой услуги сменить картинку? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🗑 удалить услугу", "удалить услугу"]:
items = [(k, s["name"]) for k, s in db.get_services().items()]
db.update_user_state(user_id, "ADMIN_SVC_DEL_PICK")
self.send_message(user_id, "🗑 Какую услугу удалить? Выберите:", keyboard=get_pick_keyboard(items))
return
# Кнопки-действия раздела «Быстрые ответы»
elif text_lower in [" добавить ответ", "добавить ответ"]:
db.update_user_state(user_id, "ADMIN_FAQ_ADD")
self.send_message(user_id, "➕ Новый быстрый ответ. Отправьте: ключевое слово | текст ответа\n"
"Пример: расписание | Тренировки ежедневно с 10:00 до 22:00.",
keyboard=get_cancel_admin_keyboard())
return
elif text_lower in ["✏️ изменить ответ", "изменить ответ"]:
items = [(i, kw) for i, kw, _ in db.get_custom_faq()]
db.update_user_state(user_id, "ADMIN_FAQ_EDIT_PICK")
self.send_message(user_id, "✏️ Какой ответ изменить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["🗑 удалить ответ", "удалить ответ"]:
items = [(i, kw) for i, kw, _ in db.get_custom_faq()]
db.update_user_state(user_id, "ADMIN_FAQ_DEL_PICK")
self.send_message(user_id, "🗑 Какой ответ удалить? Выберите:", keyboard=get_pick_keyboard(items))
return
elif text_lower in ["в админку ↩️", "в админку"]:
self._send_admin_panel(user_id)
return
elif text_lower in ["статистика 📊", "стата", "статистика", "/stats"]: elif text_lower in ["статистика 📊", "стата", "статистика", "/stats"]:
self._handle_admin_stats(user_id) self._handle_admin_stats(user_id)
return return
@@ -1326,6 +1423,43 @@ class VKBotApplication:
logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}") logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}")
return None return None
@staticmethod
def _first_photo(attachments: list) -> Optional[dict]:
"""Возвращает первое фото-вложение из сообщения (или None)."""
for att in attachments or []:
if isinstance(att, dict) and att.get("type") == "photo":
return att.get("photo")
return None
def _save_service_image(self, service_id: str, photo: dict) -> bool:
"""Скачивает присланное фото, обрезает под 13:8 (650×400) и сохраняет как service_<id>.jpg."""
import os
try:
sizes = photo.get("sizes", [])
if not sizes:
return False
url = max(sizes, key=lambda s: s.get("width", 0)).get("url")
import requests
from PIL import Image
from io import BytesIO
data = requests.get(url, timeout=15).content
im = Image.open(BytesIO(data)).convert("RGB")
TARGET = 13 / 8
w, h = im.size
if w / h > TARGET:
nw = int(h * TARGET); x = (w - nw) // 2; box = (x, 0, x + nw, h)
else:
nh = int(w / TARGET); y = (h - nh) // 2; box = (0, y, w, y + nh)
im = im.crop(box).resize((650, 400), Image.LANCZOS)
path = os.path.join(os.path.dirname(__file__), "help_img", f"service_{service_id}.jpg")
im.save(path, "JPEG", quality=88)
self._photo_cache.pop(path, None) # сбросить кэш, чтобы перезалить в VK
logger.info(f"Картинка услуги {service_id} обновлена: {path}")
return True
except Exception as e:
logger.error(f"Ошибка сохранения картинки услуги {service_id}: {e}")
return False
def get_logo_attachment(self, peer_id: int) -> Optional[str]: def get_logo_attachment(self, peer_id: int) -> Optional[str]:
"""Attachment-строка с логотипом ИКП для прикрепления к приветствию.""" """Attachment-строка с логотипом ИКП для прикрепления к приветствию."""
import os import os
@@ -1369,7 +1503,10 @@ class VKBotApplication:
img_by_id = {"1": "cs2.jpg", "2": "dota2.jpg", "3": "wot.jpg"} img_by_id = {"1": "cs2.jpg", "2": "dota2.jpg", "3": "wot.jpg"}
elements = [] elements = []
for key, svc in config.SERVICES.items(): for key, svc in config.SERVICES.items():
disc_img = os.path.join(base, img_by_id.get(key, "")) # Сначала ищем загруженную админом картинку service_<id>.jpg, затем легаси-имя
uploaded = os.path.join(base, f"service_{key}.jpg")
legacy = os.path.join(base, img_by_id.get(key, ""))
disc_img = uploaded if os.path.exists(uploaded) else legacy
info = self._upload_photo(peer_id, disc_img) if os.path.exists(disc_img) else None info = self._upload_photo(peer_id, disc_img) if os.path.exists(disc_img) else None
element = { element = {
"title": svc["name"][:80], "title": svc["name"][:80],
@@ -1933,6 +2070,164 @@ class VKBotApplication:
# ЗАПУСК И ОБРАБОТКА LONGPOLL # ЗАПУСК И ОБРАБОТКА LONGPOLL
# ------------------------------------------ # ------------------------------------------
def _handle_admin_wizard(self, user_id: int, text: str, text_lower: str, state: str, attachments: list):
"""Мастера админки: добавление/изменение/удаление услуг и быстрых ответов, смена картинок."""
if text_lower in ["отмена ❌", "отмена", "в админку ↩️", "в админку", "назад"]:
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
return
def back_to_services(msg=None):
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
if msg:
self.send_message(user_id, msg)
self._send_admin_menu(user_id)
def back_to_faq(msg=None):
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
if msg:
self.send_message(user_id, msg)
self._send_faq_admin(user_id)
def pick_id(t):
t = t.strip()
return t.split(".")[0].split()[0] if t else ""
# ---------- Услуги ----------
if state == "ADMIN_SVC_ADD":
parts = [p.strip() for p in text.split("|")]
if len(parts) < 3 or not all(parts[:3]):
self.send_message(user_id, "⚠️ Формат: Название | Цена | Описание", keyboard=get_cancel_admin_keyboard())
return
new_id = db.add_service(parts[0], parts[1], parts[2])
config.SERVICES = db.get_services()
back_to_services(f"✅ Услуга добавлена (№{new_id}). Картинку можно задать через «🖼 Сменить картинку».")
return
if state == "ADMIN_SVC_EDIT_PICK":
sid = pick_id(text)
services = db.get_services()
if sid not in services:
self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.")
return
self._admin_ctx[user_id] = {"svc_id": sid}
db.update_user_state(user_id, "ADMIN_SVC_EDIT_VAL")
self.send_message(user_id,
f"✏️ Услуга «{services[sid]['name']}». Отправьте: поле | значение\n"
"Поля: имя, цена, описание\nПример: цена | 1 900 ₽/час",
keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_SVC_EDIT_VAL":
sid = self._admin_ctx.get(user_id, {}).get("svc_id")
if not sid or sid not in db.get_services():
back_to_services("⚠️ Услуга не найдена.")
return
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: поле | значение", keyboard=get_cancel_admin_keyboard())
return
field, value = [p.strip() for p in text.split("|", 1)]
mapping = {"имя": "name", "название": "name", "цена": "price", "стоимость": "price", "описание": "description"}
if field.lower() not in mapping or not value:
self.send_message(user_id, "⚠️ Поле должно быть: имя / цена / описание.", keyboard=get_cancel_admin_keyboard())
return
db.update_service(sid, mapping[field.lower()], value)
config.SERVICES = db.get_services()
back_to_services(f"✅ Услуга №{sid} обновлена.")
return
if state == "ADMIN_SVC_IMG_PICK":
sid = pick_id(text)
if sid not in db.get_services():
self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.")
return
self._admin_ctx[user_id] = {"svc_id": sid}
db.update_user_state(user_id, "ADMIN_SVC_IMG_WAIT")
self.send_message(user_id,
"🖼 Пришлите картинку (именно фото). Лучше широкую (~13:8) — я сам аккуратно обрежу.",
keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_SVC_IMG_WAIT":
photo = self._first_photo(attachments)
if not photo:
self.send_message(user_id, "⚠️ Прикрепите именно фото (картинкой), не файлом.", keyboard=get_cancel_admin_keyboard())
return
sid = self._admin_ctx.get(user_id, {}).get("svc_id")
ok = self._save_service_image(sid, photo) if sid else False
if ok:
import os
self._upload_photo(config.OPERATOR_PEER_ID,
os.path.join(os.path.dirname(__file__), "help_img", f"service_{sid}.jpg"))
back_to_services("✅ Картинка обновлена." if ok else "⚠️ Не удалось сохранить картинку.")
return
if state == "ADMIN_SVC_DEL_PICK":
sid = pick_id(text)
if sid not in db.get_services():
self.send_message(user_id, "⚠️ Выберите услугу кнопкой ниже.")
return
db.delete_service(sid)
config.SERVICES = db.get_services()
back_to_services(f"✅ Услуга №{sid} удалена.")
return
# ---------- Быстрые ответы ----------
if state == "ADMIN_FAQ_ADD":
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard())
return
kw, ans = [p.strip() for p in text.split("|", 1)]
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard())
return
db.add_custom_faq(kw, ans)
back_to_faq("✅ Быстрый ответ добавлен.")
return
if state == "ADMIN_FAQ_EDIT_PICK":
fid = pick_id(text)
ids = [str(i) for i, _, _ in db.get_custom_faq()]
if fid not in ids:
self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.")
return
self._admin_ctx[user_id] = {"faq_id": int(fid)}
db.update_user_state(user_id, "ADMIN_FAQ_EDIT_VAL")
self.send_message(user_id, "✏️ Отправьте новый вариант: ключевое слово | текст ответа",
keyboard=get_cancel_admin_keyboard())
return
if state == "ADMIN_FAQ_EDIT_VAL":
fid = self._admin_ctx.get(user_id, {}).get("faq_id")
if "|" not in text:
self.send_message(user_id, "⚠️ Формат: ключевое слово | текст ответа", keyboard=get_cancel_admin_keyboard())
return
kw, ans = [p.strip() for p in text.split("|", 1)]
if not kw or not ans:
self.send_message(user_id, "⚠️ Нужны и ключевое слово, и текст ответа.", keyboard=get_cancel_admin_keyboard())
return
if fid:
db.delete_custom_faq(fid)
db.add_custom_faq(kw, ans)
back_to_faq("✅ Быстрый ответ обновлён.")
return
if state == "ADMIN_FAQ_DEL_PICK":
fid = pick_id(text)
if not fid.isdigit() or not db.delete_custom_faq(int(fid)):
self.send_message(user_id, "⚠️ Выберите ответ кнопкой ниже.")
return
back_to_faq(f"✅ Ответ №{fid} удалён.")
return
# Неизвестное ADMIN_-состояние — сброс
self._admin_ctx.pop(user_id, None)
db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id)
def _send_admin_menu(self, user_id: int): def _send_admin_menu(self, user_id: int):
"""Отправляет администратору панель управления услугами с текущим списком.""" """Отправляет администратору панель управления услугами с текущим списком."""
services = db.get_services() services = db.get_services()
@@ -1944,26 +2239,13 @@ class VKBotApplication:
services_list_text += f" 📝 Описание: {svc['description']}\n\n" services_list_text += f" 📝 Описание: {svc['description']}\n\n"
admin_text = ( admin_text = (
"⚙️ Панель управления услугами (Админ-меню)\n\n" "⚙️ Управление услугами (направлениями)\n\n"
"Здесь вы можете редактировать существующие услуги или добавлять новые.\n\n" "📋 Текущие направления:\n\n"
"📋 Текущий список активных услуг:\n\n"
f"{services_list_text}" f"{services_list_text}"
"🔧 Доступные команды управления:\n\n" "Управляйте кнопками ниже:\n"
"1. Редактирование услуги:\n" "• ➕ Добавить • ✏️ Изменить • 🖼 Сменить картинку • 🗑 Удалить"
" 👉 Отправьте: ред [номер] [поле] [новое значение]\n"
" Доступные поля: имя, цена, описание.\n"
" *Пример:* ред 1 цена 1 800 ₽/час\n\n"
"2. Добавление услуги:\n"
" 👉 Отправьте: доб [Имя] | [Цена] | [Описание]\n"
" *Пример:* доб Тренировка по Valorant 🎯 | 1 500 ₽/час | Разбор стрельбы и aim.\n\n"
"3. Удаление услуги:\n"
" 👉 Отправьте: удал [номер]\n"
" *Пример:* удал 4\n\n"
"📊 Статистика: отправьте стата\n\n"
"📣 Рассылка по подписчикам:\n"
" 👉 рассылка [текст] → предпросмотр → рассылка подтвердить"
) )
self.send_message(user_id, admin_text) self.send_message(user_id, admin_text, keyboard=get_svc_admin_keyboard())
def _handle_admin_edit(self, user_id: int, text: str): def _handle_admin_edit(self, user_id: int, text: str):
"""Обрабатывает редактирование услуги администратором.""" """Обрабатывает редактирование услуги администратором."""
@@ -2149,10 +2431,8 @@ class VKBotApplication:
"💬 Быстрые ответы (по ключевым словам)\n\n" "💬 Быстрые ответы (по ключевым словам)\n\n"
"Если в сообщении клиента встречается ключевое слово — бот сразу отвечает заданным текстом.\n\n" "Если в сообщении клиента встречается ключевое слово — бот сразу отвечает заданным текстом.\n\n"
f"📋 Текущие:\n{listing}\n" f"📋 Текущие:\n{listing}\n"
"Команды:\n" "Управляйте кнопками ниже: ➕ добавить • ✏️ изменить • 🗑 удалить",
"• Добавить: фак доб ключевое слово | текст ответа\n" keyboard=get_faq_admin_keyboard()
" Пример: фак доб расписание | Тренировки идут ежедневно с 10:00 до 22:00.\n"
"• Удалить: фак удал [номер]"
) )
def _handle_faq_add(self, user_id: int, text: str): def _handle_faq_add(self, user_id: int, text: str):
@@ -2329,8 +2609,9 @@ class VKBotApplication:
message_obj = event.obj.message message_obj = event.obj.message
user_id = message_obj["from_id"] user_id = message_obj["from_id"]
text = message_obj["text"] text = message_obj["text"]
attachments = message_obj.get("attachments", [])
try: try:
self.handle_message(user_id, text) self.handle_message(user_id, text, attachments)
except Exception as e: except Exception as e:
# Ошибка обработки одного сообщения не должна останавливать бота # Ошибка обработки одного сообщения не должна останавливать бота
logger.error(f"Ошибка обработки сообщения от {user_id}: {e}") logger.error(f"Ошибка обработки сообщения от {user_id}: {e}")