From bb166bbae3b78fdb7b8380fa5793b8b3ea8c2bb2 Mon Sep 17 00:00:00 2001 From: pilot <657434@03b.ru> Date: Sat, 8 Aug 2026 03:38:55 +0800 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B0=D0=BF=D0=B8=D1=81=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B1=D0=BB=D0=BE=D0=BA=20LOGGING?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- restoran_max_bot/ai.py | 414 ++++++++++++++++++ restoran_max_bot/cacert.pem | 74 ++++ restoran_max_bot/content_bot/urls.py | 2 +- restoran_max_bot/content_bot/views.py | 27 +- restoran_max_bot/max_bot/urls.py | 1 + .../restoran_max_bot/ai_handler.py | 19 + .../restoran_max_bot/bot_message.py | 360 ++++----------- .../restoran_max_bot/command_handlers.py | 240 ++++++++++ .../restoran_max_bot/message_sender.py | 34 ++ restoran_max_bot/restoran_max_bot/settings.py | 98 ++++- restoran_max_bot/restoran_max_bot/tests.py | 370 ++++++++++++++++ 11 files changed, 1330 insertions(+), 309 deletions(-) create mode 100644 restoran_max_bot/ai.py create mode 100644 restoran_max_bot/cacert.pem create mode 100644 restoran_max_bot/restoran_max_bot/ai_handler.py create mode 100644 restoran_max_bot/restoran_max_bot/command_handlers.py create mode 100644 restoran_max_bot/restoran_max_bot/message_sender.py create mode 100644 restoran_max_bot/restoran_max_bot/tests.py diff --git a/restoran_max_bot/ai.py b/restoran_max_bot/ai.py new file mode 100644 index 0000000..1ae5002 --- /dev/null +++ b/restoran_max_bot/ai.py @@ -0,0 +1,414 @@ +import os +import json +import re +import shutil +import subprocess +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +# ---------- Функции сбора проекта ---------- +def collect_project_files(root_dir, extensions=('.py', '.html'), + exclude_dirs=('venv', '__pycache__', '.git', 'env', 'node_modules')): + files_data = [] + root_dir = os.path.abspath(root_dir) + for dirpath, dirnames, filenames in os.walk(root_dir): + dirnames[:] = [d for d in dirnames if d not in exclude_dirs] + for filename in filenames: + if filename.endswith(extensions): + file_path = os.path.join(dirpath, filename) + rel_path = os.path.relpath(file_path, root_dir) + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + except UnicodeDecodeError: + try: + with open(file_path, 'r', encoding='cp1251') as f: + content = f.read() + except Exception: + content = f"[Не удалось прочитать файл {rel_path} — возможно, бинарный]" + except Exception as e: + content = f"[Ошибка чтения: {e}]" + files_data.append({'path': rel_path, 'content': content}) + return files_data + +def build_tree(files_paths): + tree = defaultdict(dict) + for path in sorted(files_paths): + parts = path.split(os.sep) + current = tree + for part in parts: + if part not in current: + current[part] = {} + current = current[part] + current['__is_file__'] = True + + def render(node, prefix=''): + lines = [] + items = sorted([k for k in node.keys() if not k.startswith('__')]) + for i, key in enumerate(items): + is_last = (i == len(items) - 1) + is_file = node[key].get('__is_file__', False) + connector = '└── ' if is_last else '├── ' + lines.append(prefix + connector + key + (' (файл)' if is_file else '/')) + if not is_file: + extension = ' ' if is_last else '│ ' + lines.extend(render(node[key], prefix + extension)) + return lines + return '\n'.join(render(tree)) + +def build_prompt(main_prompt, files_data): + parts = [] + parts.append("=" * 80) + parts.append("ОСНОВНОЙ ЗАПРОС (ПРОМТ)") + parts.append("=" * 80) + parts.append(main_prompt) + parts.append("") + + parts.append("=" * 80) + parts.append("СТРУКТУРА ПРОЕКТА (ДЕРЕВО ПАПОК И ФАЙЛОВ)") + parts.append("=" * 80) + if files_data: + paths = [item['path'] for item in files_data] + tree_str = build_tree(paths) + parts.append(tree_str) + else: + parts.append("Файлы с расширениями .py или .html не найдены.") + parts.append("") + + parts.append("=" * 80) + parts.append("СОДЕРЖИМОЕ ФАЙЛОВ (ПО ОТНОСИТЕЛЬНЫМ ПУТЯМ)") + parts.append("=" * 80) + if files_data: + for item in files_data: + parts.append(f"\n--- Файл: {item['path']} ---") + parts.append(item['content']) + parts.append("") + else: + parts.append("Нет файлов для отображения.") + parts.append("") + + parts.append("=" * 80) + parts.append("ИНСТРУКЦИЯ ПО ФОРМАТУ ОТВЕТА (ОБЯЗАТЕЛЬНО К ВЫПОЛНЕНИЮ)") + parts.append("=" * 80) + parts.append( + "Ты — ассистент по разработке. На основе структуры проекта и запроса пользователя предложи изменения в коде.\n" + "Твой ответ должен состоять из двух частей:\n" + "1. Краткое описание того, какие изменения ты предлагаешь (на русском, 1–3 предложения).\n" + "2. Блок кода с JSON-структурой, который будет использован для автоматического применения изменений.\n" + " Блок должен быть оформлен как ```json ... ``` (это позволит скопировать его одной кнопкой).\n" + "\n" + "Структура JSON:\n" + "{\n" + " \"actions\": [\n" + " {\n" + " \"action\": \"create\" | \"update\" | \"delete\",\n" + " \"file_path\": \"относительный/путь/к/файлу\",\n" + " \"content\": \"полное новое содержимое файла (для create/update)\",\n" + " \"description\": \"краткое описание этого изменения\"\n" + " }\n" + " ]\n" + "}\n" + "\n" + "Важно:\n" + "- Все пути должны быть относительно корня проекта (как в дереве выше).\n" + "- Для update передавай ПОЛНОЕ новое содержимое файла (заменяй целиком).\n" + "- Для delete можно не указывать content (или оставить пустую строку).\n" + "- Если изменений не требуется, верни пустой массив actions.\n" + "- Весь JSON должен быть внутри блока ```json ... ```. Никакого другого кода вне блока.\n" + "- Текст описания должен быть перед блоком, а не после.\n" + "- Используй только двойные кавычки для строк в JSON.\n" + ) + parts.append("") + return "\n".join(parts) + + +# ---------- Извлечение JSON ---------- +def extract_json_from_text(text): + json_block_pattern = r"```json\s*(\{.*?\})\s*```" + match = re.search(json_block_pattern, text, re.DOTALL | re.IGNORECASE) + if match: + json_str = match.group(1) + try: + return json.loads(json_str) + except json.JSONDecodeError: + pass + start = text.find('{') + if start == -1: + return None + end = text.rfind('}') + if end == -1: + return None + json_candidate = text[start:end+1] + try: + return json.loads(json_candidate) + except json.JSONDecodeError: + return None + + +# ---------- Применение изменений ---------- +def apply_changes(project_root, actions): + reports = [] + errors = False + project_root = os.path.abspath(project_root) + for action in actions: + action_type = action.get('action') + file_path = action.get('file_path') + content = action.get('content', '') + description = action.get('description', '') + if not action_type or not file_path: + reports.append("❌ Пропущено действие: отсутствует 'action' или 'file_path'") + errors = True + continue + norm_path = os.path.normpath(file_path.replace('/', os.sep)) + abs_path = os.path.join(project_root, norm_path) + if not os.path.abspath(abs_path).startswith(project_root): + reports.append(f"❌ Опасный путь: {file_path} — выход за пределы проекта") + errors = True + continue + try: + if action_type == 'create': + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + with open(abs_path, 'w', encoding='utf-8') as f: + f.write(content) + reports.append(f"✅ Создан: {file_path} — {description}") + elif action_type == 'update': + if not os.path.exists(abs_path): + reports.append(f"⚠️ Файл {file_path} не существует, создаём новый (как create)") + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + with open(abs_path, 'w', encoding='utf-8') as f: + f.write(content) + reports.append(f"✅ Обновлён: {file_path} — {description}") + elif action_type == 'delete': + if os.path.exists(abs_path): + os.remove(abs_path) + reports.append(f"✅ Удалён: {file_path} — {description}") + else: + reports.append(f"⚠️ Файл {file_path} не найден, пропускаем удаление") + else: + reports.append(f"❌ Неизвестное действие '{action_type}' для {file_path}") + errors = True + except Exception as e: + reports.append(f"❌ Ошибка при обработке {file_path}: {e}") + errors = True + return reports, errors + + +# ---------- Вспомогательные функции ---------- +def get_downloads_folder(): + home = Path.home() + downloads = home / 'Downloads' + if downloads.exists(): + return str(downloads) + alt = home / 'Загрузки' + if alt.exists(): + return str(alt) + return os.getcwd() + +def find_latest_json_file(directory, prefix='deepseek_json_'): + files = [] + for f in os.listdir(directory): + if f.startswith(prefix) and f.endswith('.json'): + full_path = os.path.join(directory, f) + if os.path.isfile(full_path): + files.append((full_path, os.path.getmtime(full_path))) + if not files: + return None + files.sort(key=lambda x: x[1], reverse=True) + return files[0][0] + +def run_tests(project_root, test_path=None): + reports = [] + manage_path = os.path.join(project_root, 'manage.py') + cmd = [] + if os.path.isfile(manage_path): + cmd = ['python', 'manage.py', 'test'] + if test_path: + cmd.append(test_path) + cmd.append('--verbosity=2') + reports.append(f"🔍 Запуск тестов Django: {' '.join(cmd)}") + else: + cmd = ['pytest', '-v'] + if test_path: + cmd.append(test_path) + reports.append(f"🔍 Запуск тестов pytest: {' '.join(cmd)}") + try: + result = subprocess.run(cmd, cwd=project_root, capture_output=True, text=True) + output = result.stdout + result.stderr + success = result.returncode == 0 + if success: + reports.append(f"✅ Тесты прошли успешно (код {result.returncode})") + else: + reports.append(f"❌ Тесты завершились с ошибкой (код {result.returncode})") + return success, reports, output + except Exception as e: + reports.append(f"❌ Ошибка при запуске тестов: {e}") + return False, reports, str(e) + + +# ---------- Логирование в суточный файл (только для APPLY и кратких записей о тестах) ---------- +def write_log_entry(log_file_path, entry_type, lines, extra_info=''): + with open(log_file_path, 'a', encoding='utf-8') as f: + f.write("=" * 80 + "\n") + f.write(f"ТИП: {entry_type}\n") + f.write(f"ВРЕМЯ: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + if extra_info: + f.write(f"ДОП. ИНФО: {extra_info}\n") + f.write("-" * 80 + "\n") + for line in lines: + f.write(line + "\n") + f.write("=" * 80 + "\n\n") + + +# ---------- ОСНОВНАЯ ФУНКЦИЯ ---------- +def main(): + print("=" * 60) + print(" Инструмент для работы с DeepSeek") + print("=" * 60) + + mode = input( + "Выберите режим:\n" + " 1 — Создать промт (собрать проект и инструкцию)\n" + " 2 — Применить ответ DeepSeek (автоматически найти JSON в Downloads)\n" + " 3 — Запустить тесты (все или указать папку/файл)\n" + "По умолчанию (Enter) — режим 1\n" + "Ваш выбор: " + ).strip() + + project_root = os.getcwd() + ai_response_dir = os.path.join(project_root, '_ai_response') + os.makedirs(ai_response_dir, exist_ok=True) + + # Суточный лог-файл (только для изменений и кратких записей о тестах) + today = datetime.now().strftime('%Y-%m-%d') + log_file = os.path.join(ai_response_dir, f'ai_log_{today}.txt') + + if mode == '2': + # ---- РЕЖИМ 2: Применение изменений (пишем в суточный лог) ---- + downloads_dir = get_downloads_folder() + json_file = find_latest_json_file(downloads_dir) + if json_file is None: + print(f"❌ Не найден JSON-файл с префиксом 'deepseek_json_' в папке {downloads_dir}") + return + + print(f"📂 Найден файл: {json_file}") + + try: + with open(json_file, 'r', encoding='utf-8') as f: + text = f.read() + except Exception as e: + print(f"❌ Ошибка чтения файла: {e}") + return + + data = extract_json_from_text(text) + if data is None: + print("❌ Не удалось извлечь JSON из ответа. Убедитесь, что в ответе есть блок ```json ... ``` или валидный JSON.") + return + + actions = data.get('actions') + if actions is None: + print("❌ В JSON отсутствует поле 'actions'.") + return + if not isinstance(actions, list): + print("❌ Поле 'actions' должно быть массивом.") + return + if len(actions) == 0: + print("ℹ️ Нет действий для применения.") + return + + print(f"\n📁 Применяем изменения в: {project_root}") + reports, errors = apply_changes(project_root, actions) + + # Перемещаем JSON-файл в _ai_response + dest_filename = f"applied_{os.path.basename(json_file)}" + dest_path = os.path.join(ai_response_dir, dest_filename) + shutil.move(json_file, dest_path) + move_info = f"JSON перемещён в {dest_path}" + + # Пишем в суточный лог + write_log_entry(log_file, 'APPLY', reports, extra_info=move_info) + + # Вывод в консоль + print("\n" + "=" * 60) + print("ОТЧЁТ О ВЫПОЛНЕННЫХ ДЕЙСТВИЯХ") + print("=" * 60) + for line in reports: + print(line) + + if errors: + print("\n❌ Были ошибки при применении изменений.") + else: + print("\n✅ Все изменения применены успешно.") + print(f"\n📄 Лог изменений записан в: {log_file}") + + elif mode == '3': + # ---- РЕЖИМ 3: Тестирование (отдельный файл + краткая запись в суточный лог) ---- + test_path = input("Введите относительный путь к папке или файлу с тестами (оставьте пустым для всех тестов): ").strip() + if test_path == '': + test_path = None + + print("\n🔍 Запуск тестов...") + success, reports, output = run_tests(project_root, test_path) + + # Сохраняем полный вывод в отдельный файл + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + test_report_file = os.path.join(ai_response_dir, f"test_report_{timestamp}.txt") + with open(test_report_file, 'w', encoding='utf-8') as f: + f.write("=" * 60 + "\n") + f.write("ОТЧЁТ О ТЕСТИРОВАНИИ\n") + f.write(f"Дата: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write(f"Путь тестов: {test_path if test_path else 'все тесты'}\n") + f.write("=" * 60 + "\n") + f.write(output) + + # Пишем краткую запись в суточный лог + brief_lines = [ + f"Тесты запущены для пути: {test_path if test_path else 'все тесты'}", + f"Результат: {'✅ УСПЕШНО' if success else '❌ ОШИБКА'}", + f"Полный отчёт сохранён в: {test_report_file}" + ] + write_log_entry(log_file, 'TEST', brief_lines, extra_info=f"Путь: {test_path if test_path else 'все тесты'}") + + # Вывод в консоль + print("\n" + "=" * 60) + print("РЕЗУЛЬТАТ ТЕСТИРОВАНИЯ") + print("=" * 60) + for line in reports: + print(line) + + if success: + print("\n✅ Тесты прошли успешно.") + else: + print("\n❌ Тесты завершились с ошибками. Проверьте полный отчёт.") + + print(f"\n📄 Полный отчёт сохранён в: {test_report_file}") + print(f"📄 Краткая запись добавлена в суточный лог: {log_file}") + + else: + # ---- РЕЖИМ 1: Создание промта (сохраняем в _ai_response) ---- + print("\nСбор информации о проекте для отправки в DeepSeek.") + main_prompt = input("\nВведите ваш промт (одна строка, Enter для завершения): ").strip() + if not main_prompt: + print("Промт не может быть пустым. Завершение.") + return + + print(f"\nСканируем папку: {project_root}") + files_data = collect_project_files(project_root) + full_prompt = build_prompt(main_prompt, files_data) + + output_file = os.path.join(ai_response_dir, "ai_promt.txt") + try: + with open(output_file, 'w', encoding='utf-8') as f: + f.write(full_prompt) + print(f"\n✅ Готово! Промт сохранён в файл: {output_file}") + print(f"Всего собрано файлов: {len(files_data)}") + print("\nТеперь скопируйте содержимое этого файла и отправьте в DeepSeek.") + print("DeepSeek вернёт ответ с описанием и JSON-блоком.") + print("Скопируйте весь ответ и сохраните в файл с именем deepseek_json_YYYYMMDD_HHMMSS.json в папке Downloads.") + print("Затем запустите этот скрипт в режиме 2 для автоматического применения.") + except Exception as e: + print(f"❌ Ошибка при сохранении: {e}") + + +if __name__ == "__main__": + main() diff --git a/restoran_max_bot/cacert.pem b/restoran_max_bot/cacert.pem new file mode 100644 index 0000000..6f35312 --- /dev/null +++ b/restoran_max_bot/cacert.pem @@ -0,0 +1,74 @@ +-----BEGIN CERTIFICATE----- +MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx +PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu +ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg +Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS +VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg +YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v +dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n +qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q +XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U +zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX +YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y +Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD +U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD +4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9 +G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH +BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX +ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa +OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf +BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS +BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF +AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH +tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq +W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+ +/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS +AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj +C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV +4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d +WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ +D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC +EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq +391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx +PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu +ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg +Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS +VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg +YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi +IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE +wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br +HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3 +S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn +vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp +BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ +vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa +L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN +3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+ +qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R +Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ +2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB +Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM +XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH +AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y +b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu +eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv +aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw +gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv +b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1 +L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry +LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF +AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v +Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D +anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9 +cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33 +Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s +Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH +h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k +F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F +E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe +GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p +ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU= +-----END CERTIFICATE----- diff --git a/restoran_max_bot/content_bot/urls.py b/restoran_max_bot/content_bot/urls.py index 86d0926..595cb7e 100644 --- a/restoran_max_bot/content_bot/urls.py +++ b/restoran_max_bot/content_bot/urls.py @@ -4,5 +4,5 @@ from content_bot.views import api_default_load from max_bot.views import api_start_max_v1, iiko_webhook, iiko_send_message urlpatterns = [ - path('default-load/', api_default_load), + path('default-load', api_default_load), ] diff --git a/restoran_max_bot/content_bot/views.py b/restoran_max_bot/content_bot/views.py index 6afd7d9..40a3b02 100644 --- a/restoran_max_bot/content_bot/views.py +++ b/restoran_max_bot/content_bot/views.py @@ -6,29 +6,6 @@ from max_bot.models import Client, ProductCategory, Product # Create your views here. @csrf_exempt -def api_default_load(request: WSGIRequest, token): - # получаем информацию по токену клиента и делаем проверку - client = Client.objects.filter(token=token).first() - if not client: - rt = {'success': False, 'error': 'Unauthorized token', 'data': {}} - return JsonResponse(rt, status=401) - - # делаем на базе клиента Тэнгис ID 50 - categorys = ProductCategory.objects.filter(client=50, src=1, status=1) - for category in categorys: - if not ProductCategory.objects.filter(client=client, src=1, status=1, title=category.title): - pk = category.pk - new_category = category - new_category.pk = None - new_category.client = client - new_category.save() - products = Product.objects.filter(client=50, status=1, up=pk) - for product in products: - new_product = product - new_product.pk = None - new_product.client = client - new_product.up = new_category.pk - new_product.save() - - rt = {'success': False, 'data': ''} +def api_default_load(request: WSGIRequest): + rt = {'success': True, 'data': ''} return JsonResponse(rt, status=200) diff --git a/restoran_max_bot/max_bot/urls.py b/restoran_max_bot/max_bot/urls.py index 3181000..a34a983 100644 --- a/restoran_max_bot/max_bot/urls.py +++ b/restoran_max_bot/max_bot/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path('iiko/webhook/', iiko_webhook), path('iiko/send-message/', iiko_send_message), + ] diff --git a/restoran_max_bot/restoran_max_bot/ai_handler.py b/restoran_max_bot/restoran_max_bot/ai_handler.py new file mode 100644 index 0000000..fd8bfad --- /dev/null +++ b/restoran_max_bot/restoran_max_bot/ai_handler.py @@ -0,0 +1,19 @@ +# ai_handler.py +from ai_agent.api_common import get_ai_agent, call_ai_agent +from max_bot.models import Client + + +def process_with_ai(client: Client, chat_id: str, user_query: str, contact) -> dict: + """Вызывает AI-агента и возвращает структурированный ответ.""" + ai_agent = get_ai_agent(client) + if not ai_agent.get('ai_agent'): + return None + + real_name = contact.name.strip() if contact else None + response = call_ai_agent( + client=client, + session_id=str(chat_id), + user_query=user_query, + contact_name=real_name + ) + return response diff --git a/restoran_max_bot/restoran_max_bot/bot_message.py b/restoran_max_bot/restoran_max_bot/bot_message.py index 28eaeb3..5ad406c 100644 --- a/restoran_max_bot/restoran_max_bot/bot_message.py +++ b/restoran_max_bot/restoran_max_bot/bot_message.py @@ -1,305 +1,128 @@ # bot_message.py import re +import json import qrcode -from ai_agent.api_common import get_ai_agent, call_ai_agent, get_contact_meta -from max_bot.max_api import * -from max_bot.models import PromoCode +from max_bot.models import Client, Contact, PromoCode from restoran_max_bot.bot_started import check_registration -from restoran_max_bot.common import * -from restoran_max_bot.iiko_api import customer_info, customer_create, customer_wallet -from restoran_max_bot.rkiper_api import rkiper_customer_info, rkiper_customer_create +from restoran_max_bot.common import common_get_data, common_set_contact, common_check_registration +from restoran_max_bot.message_sender import send_text, send_image, send_menu, send_feedback_buttons, send_booking_people_buttons, send_link_button +from restoran_max_bot.command_handlers import handle_standard_command, handle_ai_response +from restoran_max_bot.ai_handler import process_with_ai from restoran_max_bot.settings import STATICFILES_DIRS, BASE_URL -from restoran_max_bot.utils import has_key, is_json +from restoran_max_bot.utils import is_json -# ---------- вспомогательные функции ---------- -def send_booking_to_crm(client: Client, date, time, people, phone, name): - # реализация бронирования столика - msg = f'Бронирование столика:\nНа дату: {date}-{time} \nКоличество: {people}\nКонтакт: {phone}\nИмя: {name}' - send_message_all_manager(client, msg) - return True - - -def send_contact_to_admin(client: Client, phone, contact_method, question, preferred_time, name): - # реализовать отправку администратору - msg = (f'Гость попросил связаться:\n {contact_method}\nВопрос: {question}\n' - f'Удобное время: {preferred_time}\nКонтакт: {phone}\nИмя: {name}') - send_message_all_manager(client, msg) - return True - - -def send_feedback_to_crm(client: Client, rating, feedback_text, name, phone): - # реализовать отправку отзыва в CRM - msg = f'Отзыв от {name} (тел. {phone}):\nОценка: {rating}/5\nТекст: {feedback_text}' - send_message_all_manager(client, msg) - return True - - -# ---------- получение бонусов ---------- -def get_bonus(client: Client, phone: str, default_bonus: float, contact: Contact): - integration = common_get_integration(client=client) - bonus = 0 - date = contact.field1 - if len(date) < 2: - date = '0' + date - month = contact.field2 - if len(month) < 2: - month = '0' + month - - if integration: - if integration.partner.slug == 'iiko': - config = json.loads(integration.setting) - customerinfo = customer_info(config['token'], phone, config['id_org']) - if not customerinfo: - birthday = f"1990-{month}-{date} 00:00:00.000" - dt = customer_create(api_key=config['token'], phone=phone, name=contact.name, - organization_id=config['id_org'], birthday=birthday) - dt = customer_info(config['token'], phone, config['id_org']) - customer_wallet(api_key=config['token'], iiko_wallet_id=dt['walletBalances'][0]['id'], - customer_id=dt['id'], organization_id=config['id_org'], - bonus=default_bonus) - bonus = default_bonus - else: - bonus = customerinfo['walletBalances'][0]['balance'] - - if integration.partner.slug == 'rkeeper': - config = json.loads(integration.setting) - rs = rkiper_customer_info(rkiper_server=config['url'], rkiper_token=config['token'], phone=phone) - if len(rs): - rs = rs[0] - if has_key(rs, 'card_use'): - bonus = int(rs['card_use']['sum1']) / 100 - else: - birthday = f"1990-{month}-{date}" - rs = rkiper_customer_create(rkiper_server=config['url'], rkiper_token=config['token'], phone=phone, - name=contact.name, birthday=birthday, bonus=config['bonus'], - discount=config['discount']) - bonus = 0 - return bonus - - -# ---------- получение вложений ---------- -def get_attachments(message: dict): - if 'attachments' in message['message']['body']: - if 'vcf_info' in message['message']['body']['attachments'][0]['payload']: - match = re.search(r'TEL;TYPE=cell:(\d+)', - message['message']['body']['attachments'][0]['payload']['vcf_info']) - if match: - phone_number = match.group(1) - return {'attachments_type': 'vcf_info', 'data': phone_number} - return {'attachments_type': False, 'data': ''} - - -# ---------- основной обработчик ---------- -def bot_message(client: Client, message: dict, settings_max: dict, message_type): +def bot_message(client: Client, message: dict, settings_max: dict, message_type: str) -> bool: + """ + Основной обработчик входящих сообщений от Max бота. + """ chat_id = message['message']['recipient']['chat_id'] - attachments = get_attachments(message=message) - - # регистрация по vcf - if attachments['attachments_type'] == 'vcf_info': - common_set_contact(client=client, uid_client=chat_id, name=message['message']['sender']['name'], - phone=attachments['data']) + token = settings_max['token'] + + # 1. Обработка вложений (контакт из vcf) + attachments = _get_attachments(message) + if attachments['type'] == 'vcf_info': + common_set_contact( + client=client, + uid_client=chat_id, + name=message['message']['sender']['name'], + phone=attachments['data'] + ) + # Подменяем текст на системную команду регистрации message['message']['body']['text'] = 'system_registration' + # 2. Проверка регистрации contact = check_registration(client=client, chat_id=chat_id, message=message, settings=settings_max) if not contact: return False - # ---------- проверка и запрос имени ---------- - meta = get_contact_meta(contact) - - # ---------- обработка стандартных команд (меню, бонусы и т.п.) ---------- - data = None - if message_type == 'message_created': - data = common_get_data(client=client, key=str(message['message']['body']['text']).lower()) - elif message_type == 'message_callback': - data_payload = json.loads(message['callback']['payload']) - data = common_get_data(client=client, key=str(data_payload['data']).lower()) - - if data: - for dt in data: - if dt.title == '##bonus##': - img = qrcode.make(contact.phone) - img.save(f"{STATICFILES_DIRS[0]}/client_qr/{contact.phone}.png") - url = f"https://maxbot.telefon-ip.ru/static/client_qr/{contact.phone}.png" - maxbot_send_img_message(chat_id=chat_id, max_token=settings_max['token'], img=url, - message='QR-код для начисления, списания бонусов.') - bonus = get_bonus(client=client, phone=contact.phone, default_bonus=float(dt.price), contact=contact) - maxbot_send_text_message(chat_id=chat_id, max_token=settings_max['token'], - message=f"💰 Ваш бонусный баланс: {bonus} руб") - - elif dt.title == '##feedback##': - maxbot_send_feedback_button(client=client, message='👇', chat_id=chat_id, - max_token=settings_max['token']) - return True - - elif dt.title == '##booking##': - maxbot_send_booking_count_people_button(client=client, message='👇', - chat_id=chat_id, max_token=settings_max['token']) - return True - - # сложные кнопки - if is_json(dt.title): - data_json = json.loads(dt.title) - if 'type' in dt.title and data_json.get('type') == 'linkbutton': - maxbot_send_link_button(text=dt.descr, title_button=data_json['button'], link=dt.url, - chat_id=chat_id, max_token=settings_max['token']) - - if dt.img: - url = f"https://cdn.telefon-ip.ru/{dt.img}?thumb=600" - maxbot_send_img_message(chat_id=chat_id, max_token=settings_max['token'], img=url, message=dt.descr) - - if '##' not in dt.title and not dt.img and not is_json(dt.title): - maxbot_send_text_message(chat_id=chat_id, max_token=settings_max['token'], message=dt.descr) - - if dt.url and not is_json(dt.title): - maxbot_send_text_message(chat_id=chat_id, max_token=settings_max['token'], message=dt.url) - - maxbot_send_menu_button(chat_id=chat_id, max_token=settings_max['token'], message='👇 Выберите раздел', - client=client) + # 3. Получение текста запроса + user_query = _extract_query(message, message_type) + if not user_query: + send_menu(chat_id, token, client) return True - else: - # если при нажатии на кнопку небыл найден контент, тогда для AI передаем имя кнопки - if message_type == 'message_callback': - message['message']['body']['text'] = str(data_payload['data']).lower() - # ---------- обработка через AI ---------- - # Проверяем, есть ли AI-агент у клиента - ai_agent = get_ai_agent(client) - if not ai_agent.get('ai_agent'): - maxbot_send_menu_button(chat_id=chat_id, max_token=settings_max['token'], - message='👇 Выберите раздел', client=client) + # 4. Обработка стандартных команд (меню, бонусы и т.п.) + data = _get_command_data(client, message, message_type) + if data: + handled = handle_standard_command(client, chat_id, token, data, contact) + if handled: + return True + + # 5. Если не стандартная команда — пробуем AI + response = process_with_ai(client, chat_id, user_query, contact) + if response: + handle_ai_response(client, chat_id, token, response, contact) + send_menu(chat_id, token, client) return True - real_name = contact.name.strip() - # Вызываем AI-агента через универсальную функцию, передавая реальное имя - response = call_ai_agent( - client=client, - session_id=str(chat_id), - user_query=message['message']['body']['text'], - contact_name=real_name # передаём имя для персонализации - ) + # 6. Если AI недоступен — просто меню + send_menu(chat_id, token, client) + return True - # ---------- обработка ответа AI ---------- - intent = response.get('intent') - message_text = response.get('message', '') - entities = response.get('entities', {}) - status = response.get('status', 'complete') - if intent == 'general': - maxbot_send_text_message(chat_id, settings_max['token'], message_text) +def _get_attachments(message: dict) -> dict: + """Извлекает информацию о вложениях (например, vCard).""" + attachments = message.get('message', {}).get('body', {}).get('attachments', []) + for att in attachments: + payload = att.get('payload', {}) + if 'vcf_info' in payload: + match = re.search(r'TEL;TYPE=cell:(\d+)', payload['vcf_info']) + if match: + return {'type': 'vcf_info', 'data': match.group(1)} + return {'type': None, 'data': ''} - elif intent == 'booking': - if status == 'need_more_info': - maxbot_send_text_message(chat_id, settings_max['token'], message_text) - elif status == 'complete': - date = entities.get('date') - time = entities.get('time') - people = entities.get('people') - phone = entities.get('phone') - if date and time and people: - success = send_booking_to_crm(client, date, time, people, phone, real_name) - if success: - confirm_msg = (f"✅ Бронирование на {date} в {time} на {people} чел. принято! " - f"Администратор свяжется с вами.") - else: - confirm_msg = "❌ Не удалось забронировать. Попробуйте позже." - maxbot_send_text_message(chat_id, settings_max['token'], confirm_msg) - else: - maxbot_send_text_message(chat_id, settings_max['token'], "⚠️ Не хватает данных для бронирования.") - else: - maxbot_send_text_message(chat_id, settings_max['token'], message_text) - elif intent == 'contact_admin': - if status == 'need_more_info': - maxbot_send_text_message(chat_id, settings_max['token'], message_text) - elif status == 'complete': - phone = entities.get('phone') - contact_method = entities.get('contact_method') - question = entities.get('question', '') - preferred_time = entities.get('preferred_time', '') - if phone and contact_method: - success = send_contact_to_admin(client, phone, contact_method, question, preferred_time, real_name) - if success: - confirm_msg = f"✅ Ваш запрос передан администратору. Способ связи: {contact_method}. Скоро с вами свяжутся." - else: - confirm_msg = "❌ Не удалось отправить запрос. Попробуйте позже." - maxbot_send_text_message(chat_id, settings_max['token'], confirm_msg) - else: - maxbot_send_text_message(chat_id, settings_max['token'], - "⚠️ Не хватает данных для связи с администратором.") - else: - maxbot_send_text_message(chat_id, settings_max['token'], message_text) +def _extract_query(message: dict, message_type: str) -> str: + """Извлекает текст запроса из сообщения или callback'а.""" + if message_type == 'message_created': + return message.get('message', {}).get('body', {}).get('text', '') + elif message_type == 'message_callback': + payload = message.get('callback', {}).get('payload', '{}') + try: + data = json.loads(payload) + return data.get('data', '') + except json.JSONDecodeError: + return '' + return '' - elif intent == 'feedback': - if status == 'need_more_info': - maxbot_send_text_message(chat_id, settings_max['token'], message_text) - elif status == 'complete': - rating = entities.get('rating') - feedback_text = entities.get('feedback_text', '') - if rating and feedback_text: - success = send_feedback_to_crm(client, rating, feedback_text, real_name, contact.phone) - if success: - confirm_msg = "✅ Спасибо за ваш отзыв! Мы учтём его." - else: - confirm_msg = "❌ Не удалось отправить отзыв. Попробуйте позже." - maxbot_send_text_message(chat_id, settings_max['token'], confirm_msg) - else: - maxbot_send_text_message(chat_id, settings_max['token'], "⚠️ Не хватает данных для отзыва.") - else: - maxbot_send_text_message(chat_id, settings_max['token'], message_text) - else: - maxbot_send_text_message(chat_id, settings_max['token'], message_text) +def _get_command_data(client: Client, message: dict, message_type: str): + """Получает данные команды (контент) по ключу.""" + key = _extract_query(message, message_type) + if not key: + return None + return common_get_data(client=client, key=str(key).lower()) - # всегда отправляем меню после ответа - maxbot_send_menu_button(chat_id=chat_id, max_token=settings_max['token'], - message='👇 Выберите раздел', client=client) - return True +# ==================== bot_promo ==================== -def bot_promo(client: Client, message: dict, settings_max: dict): +def bot_promo(client: Client, message: dict, settings_max: dict) -> bool: + """Обработчик промокодов (QR-коды).""" chat_id = message['chat_id'] promo = message['payload'].lower() messenger = 'max-bot' client_id = client.id - # Проверяем, существует ли промокод в системе (через common_get_data) data = common_get_data(client=client, key=promo) if not data: - # Если промокод не заведён – ничего не отправляем (скрываем факт) return True - # Пытаемся найти или создать запись в БД promo_obj, created = PromoCode.objects.get_or_create( promo=promo, chat_id=chat_id, client=client, - defaults={ - 'messenger': messenger, - 'used': False, - } + defaults={'messenger': messenger, 'used': False} ) - # Если запись уже существовала if not created: if promo_obj.used: - # Уже использован – уведомляем - maxbot_send_text_message( - chat_id=chat_id, - max_token=settings_max['token'], - message="⚠️ Этот промокод уже был использован." - ) + send_text(chat_id, settings_max['token'], "⚠️ Этот промокод уже был использован.") else: - # QR-код уже был отправлен ранее – напоминаем - maxbot_send_text_message( - chat_id=chat_id, - max_token=settings_max['token'], - message="ℹ️ QR-код уже был отправлен." - ) + send_text(chat_id, settings_max['token'], "ℹ️ QR-код уже был отправлен.") return True - # Если запись создана впервые – генерируем QR-код + # Генерируем QR base_url = f"{BASE_URL}/promo/status/" url_promo = f"{base_url}{promo}/{chat_id}/{client_id}" img = qrcode.make(url_promo) @@ -307,21 +130,16 @@ def bot_promo(client: Client, message: dict, settings_max: dict): img.save(f"{STATICFILES_DIRS[0]}/client_qr/{file_name}") qr_url = f"{BASE_URL}/static/client_qr/{file_name}" - maxbot_send_img_message( - chat_id=chat_id, - max_token=settings_max['token'], - img=qr_url, - message='Ваш QR-код.' - ) + send_image(chat_id, settings_max['token'], qr_url, 'Ваш QR-код.') - # Отправляем дополнительный контент, если есть - for dt in data: - if dt.img: - img_url = f"https://cdn.telefon-ip.ru/{dt.img}?thumb=600" - maxbot_send_img_message(chat_id=chat_id, max_token=settings_max['token'], img=img_url, message=dt.descr) - if dt.title: - maxbot_send_text_message(chat_id=chat_id, max_token=settings_max['token'], message=dt.descr) - if dt.url: - maxbot_send_text_message(chat_id=chat_id, max_token=settings_max['token'], message=dt.url) + # Отправляем дополнительный контент + for item in data: + if item.img: + img_url = f"https://cdn.telefon-ip.ru/{item.img}?thumb=600" + send_image(chat_id, settings_max['token'], img_url, item.descr) + if item.title and not item.img: + send_text(chat_id, settings_max['token'], item.descr) + if item.url: + send_text(chat_id, settings_max['token'], item.url) - return True \ No newline at end of file + return True diff --git a/restoran_max_bot/restoran_max_bot/command_handlers.py b/restoran_max_bot/restoran_max_bot/command_handlers.py new file mode 100644 index 0000000..a2016b6 --- /dev/null +++ b/restoran_max_bot/restoran_max_bot/command_handlers.py @@ -0,0 +1,240 @@ +# command_handlers.py +import json +import qrcode +from max_bot.models import Client, Contact +from restoran_max_bot.common import common_get_data, common_get_integration +from restoran_max_bot.iiko_api import customer_info, customer_create, customer_wallet +from restoran_max_bot.rkiper_api import rkiper_customer_info, rkiper_customer_create +from restoran_max_bot.message_sender import send_text, send_image, send_menu, send_feedback_buttons, send_booking_people_buttons, send_link_button +from restoran_max_bot.settings import STATICFILES_DIRS, BASE_URL +from restoran_max_bot.utils import is_json +from restoran_max_bot.bot_started import check_registration + + +def handle_standard_command(client: Client, chat_id: str, token: str, data, contact: Contact) -> bool: + """Обрабатывает стандартные команды (меню, бонусы, отзывы, бронирование). + Возвращает True, если команда была обработана, иначе False.""" + if not data: + return False + + for item in data: + # Бонусы + if item.title == '##bonus##': + _handle_bonus(client, chat_id, token, contact) + continue + + # Отзыв + if item.title == '##feedback##': + send_feedback_buttons(chat_id, token, client) + return True + + # Бронирование + if item.title == '##booking##': + send_booking_people_buttons(chat_id, token, client) + return True + + # Кнопка-ссылка + if is_json(item.title): + data_json = json.loads(item.title) + if data_json.get('type') == 'linkbutton': + send_link_button(chat_id, token, item.descr, data_json['button'], item.url) + continue + + # Изображение + if item.img: + url = f"https://cdn.telefon-ip.ru/{item.img}?thumb=600" + send_image(chat_id, token, url, item.descr) + + # Текст (если не специальный тег и нет картинки) + if '##' not in item.title and not item.img and not is_json(item.title): + send_text(chat_id, token, item.descr) + + # URL + if item.url and not is_json(item.title): + send_text(chat_id, token, item.url) + + send_menu(chat_id, token, client) + return True + + +def _handle_bonus(client: Client, chat_id: str, token: str, contact: Contact) -> None: + """Обработка команды бонусов: генерация QR и запрос баланса.""" + # Генерируем QR-код + img = qrcode.make(contact.phone) + img.save(f"{STATICFILES_DIRS[0]}/client_qr/{contact.phone}.png") + qr_url = f"https://maxbot.telefon-ip.ru/static/client_qr/{contact.phone}.png" + send_image(chat_id, token, qr_url, 'QR-код для начисления, списания бонусов.') + + # Получаем баланс + bonus = _get_bonus(client, contact.phone, 0.0, contact) + send_text(chat_id, token, f"💰 Ваш бонусный баланс: {bonus} руб") + + +def _get_bonus(client: Client, phone: str, default_bonus: float, contact: Contact) -> float: + """Получает бонусный баланс из интеграции (IIKO или R-Keeper).""" + integration = common_get_integration(client=client) + if not integration: + return 0.0 + + date = contact.field1 or '01' + month = contact.field2 or '01' + if len(date) < 2: + date = '0' + date + if len(month) < 2: + month = '0' + month + + config = json.loads(integration.setting) + + if integration.partner.slug == 'iiko': + customerinfo = customer_info(config['token'], phone, config['id_org']) + if not customerinfo: + birthday = f"1990-{month}-{date} 00:00:00.000" + customer_create(api_key=config['token'], phone=phone, name=contact.name, + organization_id=config['id_org'], birthday=birthday) + customerinfo = customer_info(config['token'], phone, config['id_org']) + if customerinfo: + customer_wallet(api_key=config['token'], + iiko_wallet_id=customerinfo['walletBalances'][0]['id'], + customer_id=customerinfo['id'], + organization_id=config['id_org'], + bonus=default_bonus) + return default_bonus + else: + return customerinfo['walletBalances'][0]['balance'] + + elif integration.partner.slug == 'rkeeper': + rs = rkiper_customer_info(config['url'], config['token'], phone) + if rs: + rs = rs[0] + if 'card_use' in rs: + return int(rs['card_use']['sum1']) / 100 + else: + birthday = f"1990-{month}-{date}" + rkiper_customer_create(config['url'], config['token'], phone, contact.name, + birthday, config['discount'], config['bonus']) + + return 0.0 + + +def handle_ai_response(client: Client, chat_id: str, token: str, response: dict, contact: Contact) -> None: + """Обрабатывает ответ от AI-агента и выполняет соответствующие действия.""" + intent = response.get('intent') + message_text = response.get('message', '') + entities = response.get('entities', {}) + status = response.get('status', 'complete') + + if intent == 'general': + send_text(chat_id, token, message_text) + + elif intent == 'booking': + _handle_booking_intent(chat_id, token, client, response, contact) + + elif intent == 'contact_admin': + _handle_contact_admin_intent(chat_id, token, client, response, contact) + + elif intent == 'feedback': + _handle_feedback_intent(chat_id, token, client, response, contact) + + else: + send_text(chat_id, token, message_text) + + +def _handle_booking_intent(chat_id: str, token: str, client: Client, response: dict, contact: Contact) -> None: + """Обрабатывает намерение 'booking'.""" + message_text = response.get('message', '') + status = response.get('status', 'complete') + entities = response.get('entities', {}) + + if status == 'need_more_info': + send_text(chat_id, token, message_text) + return + + if status == 'complete': + date = entities.get('date') + time = entities.get('time') + people = entities.get('people') + phone = entities.get('phone') + if date and time and people: + success = _send_booking_to_crm(client, date, time, people, phone, contact.name) + if success: + send_text(chat_id, token, f"✅ Бронирование на {date} в {time} на {people} чел. принято! Администратор свяжется с вами.") + else: + send_text(chat_id, token, "❌ Не удалось забронировать. Попробуйте позже.") + else: + send_text(chat_id, token, "⚠️ Не хватает данных для бронирования.") + else: + send_text(chat_id, token, message_text) + + +def _handle_contact_admin_intent(chat_id: str, token: str, client: Client, response: dict, contact: Contact) -> None: + """Обрабатывает намерение 'contact_admin'.""" + message_text = response.get('message', '') + status = response.get('status', 'complete') + entities = response.get('entities', {}) + + if status == 'need_more_info': + send_text(chat_id, token, message_text) + return + + if status == 'complete': + phone = entities.get('phone') + contact_method = entities.get('contact_method') + question = entities.get('question', '') + preferred_time = entities.get('preferred_time', '') + if phone and contact_method: + success = _send_contact_to_admin(client, phone, contact_method, question, preferred_time, contact.name) + if success: + send_text(chat_id, token, f"✅ Ваш запрос передан администратору. Способ связи: {contact_method}. Скоро с вами свяжутся.") + else: + send_text(chat_id, token, "❌ Не удалось отправить запрос. Попробуйте позже.") + else: + send_text(chat_id, token, "⚠️ Не хватает данных для связи с администратором.") + else: + send_text(chat_id, token, message_text) + + +def _handle_feedback_intent(chat_id: str, token: str, client: Client, response: dict, contact: Contact) -> None: + """Обрабатывает намерение 'feedback'.""" + message_text = response.get('message', '') + status = response.get('status', 'complete') + entities = response.get('entities', {}) + + if status == 'need_more_info': + send_text(chat_id, token, message_text) + return + + if status == 'complete': + rating = entities.get('rating') + feedback_text = entities.get('feedback_text', '') + if rating and feedback_text: + success = _send_feedback_to_crm(client, rating, feedback_text, contact.name, contact.phone) + if success: + send_text(chat_id, token, "✅ Спасибо за ваш отзыв! Мы учтём его.") + else: + send_text(chat_id, token, "❌ Не удалось отправить отзыв. Попробуйте позже.") + else: + send_text(chat_id, token, "⚠️ Не хватает данных для отзыва.") + else: + send_text(chat_id, token, message_text) + + +def _send_booking_to_crm(client: Client, date, time, people, phone, name) -> bool: + """Отправляет бронирование в CRM/менеджерам.""" + from restoran_max_bot.common import send_message_all_manager + msg = f'Бронирование столика:\nНа дату: {date}-{time} \nКоличество: {people}\nКонтакт: {phone}\nИмя: {name}' + return send_message_all_manager(client, msg) + + +def _send_contact_to_admin(client: Client, phone, contact_method, question, preferred_time, name) -> bool: + """Отправляет запрос на связь с администратором.""" + from restoran_max_bot.common import send_message_all_manager + msg = (f'Гость попросил связаться:\n {contact_method}\nВопрос: {question}\n' + f'Удобное время: {preferred_time}\nКонтакт: {phone}\nИмя: {name}') + return send_message_all_manager(client, msg) + + +def _send_feedback_to_crm(client: Client, rating, feedback_text, name, phone) -> bool: + """Отправляет отзыв в CRM/менеджерам.""" + from restoran_max_bot.common import send_message_all_manager + msg = f'Отзыв от {name} (тел. {phone}):\nОценка: {rating}/5\nТекст: {feedback_text}' + return send_message_all_manager(client, msg) diff --git a/restoran_max_bot/restoran_max_bot/message_sender.py b/restoran_max_bot/restoran_max_bot/message_sender.py new file mode 100644 index 0000000..1e9501a --- /dev/null +++ b/restoran_max_bot/restoran_max_bot/message_sender.py @@ -0,0 +1,34 @@ +# message_sender.py +from max_bot.max_api import maxbot_send_text_message, maxbot_send_img_message, maxbot_send_menu_button, maxbot_send_feedback_button, maxbot_send_booking_count_people_button, maxbot_send_link_button +from max_bot.models import Client + + +def send_text(chat_id: str, token: str, text: str) -> None: + """Отправить текстовое сообщение.""" + if text: + maxbot_send_text_message(chat_id=chat_id, max_token=token, message=text) + + +def send_image(chat_id: str, token: str, image_url: str, caption: str = '') -> None: + """Отправить сообщение с изображением.""" + maxbot_send_img_message(chat_id=chat_id, max_token=token, img=image_url, message=caption) + + +def send_menu(chat_id: str, token: str, client: Client, text: str = '👇 Выберите раздел') -> None: + """Отправить меню с кнопками.""" + maxbot_send_menu_button(chat_id=chat_id, max_token=token, message=text, client=client) + + +def send_feedback_buttons(chat_id: str, token: str, client: Client, text: str = '👇') -> None: + """Отправить кнопки для оценки.""" + maxbot_send_feedback_button(client=client, message=text, chat_id=chat_id, max_token=token) + + +def send_booking_people_buttons(chat_id: str, token: str, client: Client, text: str = '👇') -> None: + """Отправить кнопки выбора количества гостей.""" + maxbot_send_booking_count_people_button(client=client, message=text, chat_id=chat_id, max_token=token) + + +def send_link_button(chat_id: str, token: str, text: str, button_title: str, url: str) -> None: + """Отправить сообщение с кнопкой-ссылкой.""" + maxbot_send_link_button(text=text, title_button=button_title, link=url, chat_id=chat_id, max_token=token) diff --git a/restoran_max_bot/restoran_max_bot/settings.py b/restoran_max_bot/restoran_max_bot/settings.py index 18cc9a3..b4090cb 100644 --- a/restoran_max_bot/restoran_max_bot/settings.py +++ b/restoran_max_bot/restoran_max_bot/settings.py @@ -1,10 +1,16 @@ import os.path +import sys from pathlib import Path from environ import environ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +# Создаём папку для логов, если её нет +LOG_DIR = os.path.join(BASE_DIR, 'log') +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + env = environ.Env() env_file = f'{BASE_DIR}/.env' env.read_env(env_file=env_file) @@ -76,19 +82,32 @@ WSGI_APPLICATION = 'restoran_max_bot.wsgi.application' # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': env('DATABASE_NAME'), - 'USER': env('DATABASE_USER'), - 'PASSWORD': env('DATABASE_PASSWORD'), - 'HOST': env('DATABASE_HOST'), - 'PORT': env('DATABASE_PORT'), - 'OPTIONS': { - 'charset': 'utf8mb4', - 'use_unicode': True, }, +# Если запускаются тесты, используем SQLite in-memory для изоляции +# и отключаем сериализацию данных, чтобы не пытаться копировать данные из существующей БД +if 'test' in sys.argv: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + 'TEST': { + 'SERIALIZE': False, # не копировать данные из основной БД + } + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': env('DATABASE_NAME'), + 'USER': env('DATABASE_USER'), + 'PASSWORD': env('DATABASE_PASSWORD'), + 'HOST': env('DATABASE_HOST'), + 'PORT': env('DATABASE_PORT'), + 'OPTIONS': { + 'charset': 'utf8mb4', + 'use_unicode': True, }, + } } -} # Password validation # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators @@ -133,3 +152,58 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' LOG_SYSTEM_PROMPT = False # False - не логировать системный промт PROMO_ADMIN_PIN = os.environ.get('PROMO_ADMIN_PIN', '1111') + +# ---------- Настройка логирования ошибок ---------- +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'file': { + 'level': 'ERROR', + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(BASE_DIR, 'log', 'error.log'), + 'when': 'midnight', + 'interval': 1, + 'backupCount': 30, + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['file'], + 'level': 'ERROR', + 'propagate': True, + }, + 'django.request': { + 'handlers': ['file'], + 'level': 'ERROR', + 'propagate': False, + }, + 'django.server': { + 'handlers': ['file'], + 'level': 'ERROR', + 'propagate': False, + }, + # Можно добавить логгеры для своих приложений, если нужно + # 'max_bot': { + # 'handlers': ['file'], + # 'level': 'ERROR', + # 'propagate': False, + # }, + # 'ai_agent': { + # 'handlers': ['file'], + # 'level': 'ERROR', + # 'propagate': False, + # }, + }, +} diff --git a/restoran_max_bot/restoran_max_bot/tests.py b/restoran_max_bot/restoran_max_bot/tests.py new file mode 100644 index 0000000..9f0fadc --- /dev/null +++ b/restoran_max_bot/restoran_max_bot/tests.py @@ -0,0 +1,370 @@ +# restoran_max_bot/tests.py +import json +import unittest +from unittest.mock import Mock, patch, MagicMock +from django.test import TestCase +from max_bot.models import Client, Contact, Product, ProductCategory, PromoCode +from restoran_max_bot.bot_message import bot_message, bot_promo, _get_attachments, _extract_query, _get_command_data +from restoran_max_bot.message_sender import send_text, send_image, send_menu +from restoran_max_bot.command_handlers import handle_standard_command, handle_ai_response +from restoran_max_bot.ai_handler import process_with_ai + + +class BotMessageTestCase(TestCase): + """Тесты для функции bot_message и её вспомогательных функций.""" + + def setUp(self): + # Создаём реального клиента в БД + self.client = Client.objects.create( + id=1, + token='test_token', + status=True, + title='Test Client', + code='001', + brand='TestBrand', + logo='', + server='test', + managment='test' + ) + # Настройки Max + self.settings_max = {'token': 'max_token'} + + # Создаём контакт (можно использовать Mock, но лучше реальный объект) + self.contact = Contact.objects.create( + client=self.client, + name='Test User', + phone='79001234567', + field1='01', + field2='01', + maxx='chat123', + status=1, + up=0, + group=1 + ) + + # Базовое сообщение + self.message_created = { + 'message': { + 'recipient': {'chat_id': 'chat123'}, + 'sender': {'name': 'Test User'}, + 'body': {'text': 'menu'} + }, + 'update_type': 'message_created' + } + + self.message_callback = { + 'message': { + 'recipient': {'chat_id': 'chat123'}, + 'sender': {'name': 'Test User'}, + 'body': {} + }, + 'callback': { + 'payload': json.dumps({'data': 'bonus'}) + }, + 'update_type': 'message_callback' + } + + # ---------- Тесты вспомогательных функций ---------- + def test_get_attachments_vcf(self): + """Проверка извлечения номера из vCard.""" + message = { + 'message': { + 'body': { + 'attachments': [ + {'payload': {'vcf_info': 'TEL;TYPE=cell:79161234567'}} + ] + } + } + } + result = _get_attachments(message) + self.assertEqual(result['type'], 'vcf_info') + self.assertEqual(result['data'], '79161234567') + + def test_get_attachments_no_vcf(self): + """Нет вложений или нет vcf.""" + message = {'message': {'body': {}}} + result = _get_attachments(message) + self.assertIsNone(result['type']) + self.assertEqual(result['data'], '') + + def test_extract_query_message_created(self): + """Извлечение текста из обычного сообщения.""" + query = _extract_query(self.message_created, 'message_created') + self.assertEqual(query, 'menu') + + def test_extract_query_message_callback(self): + """Извлечение данных из callback.""" + query = _extract_query(self.message_callback, 'message_callback') + self.assertEqual(query, 'bonus') + + def test_extract_query_empty(self): + """Пустой запрос.""" + message = {'message': {'body': {}}} + query = _extract_query(message, 'message_created') + self.assertEqual(query, '') + + @patch('restoran_max_bot.bot_message.common_get_data') + def test_get_command_data(self, mock_get_data): + """Получение данных команды.""" + mock_get_data.return_value = [Mock(title='menu')] + result = _get_command_data(self.client, self.message_created, 'message_created') + self.assertIsNotNone(result) + mock_get_data.assert_called_with(client=self.client, key='menu') + + # ---------- Тесты основной функции bot_message ---------- + @patch('restoran_max_bot.bot_message.check_registration') + @patch('restoran_max_bot.bot_message.handle_standard_command') + @patch('restoran_max_bot.bot_message.process_with_ai') + @patch('restoran_max_bot.bot_message.handle_ai_response') + @patch('restoran_max_bot.bot_message.send_menu') + @patch('restoran_max_bot.bot_message.common_set_contact') + @patch('restoran_max_bot.bot_message.common_get_data') + def test_bot_message_vcf_registration(self, mock_get_data, mock_set_contact, mock_send_menu, + mock_handle_ai, mock_process_ai, + mock_handle_std, mock_check_reg): + """Обработка vCard -> регистрация -> вызов check_registration.""" + mock_get_data.return_value = None # чтобы не искало команду + # Сообщение с vcf + message = { + 'message': { + 'recipient': {'chat_id': 'chat123'}, + 'sender': {'name': 'Vasya'}, + 'body': { + 'attachments': [ + {'payload': {'vcf_info': 'TEL;TYPE=cell:79161234567'}} + ] + } + }, + 'update_type': 'message_created' + } + mock_check_reg.return_value = self.contact + mock_handle_std.return_value = False + mock_process_ai.return_value = None + + result = bot_message(self.client, message, self.settings_max, 'message_created') + self.assertTrue(result) + mock_set_contact.assert_called_once() + mock_check_reg.assert_called_once() + mock_send_menu.assert_called_once() + + @patch('restoran_max_bot.bot_message.check_registration') + @patch('restoran_max_bot.bot_message.handle_standard_command') + @patch('restoran_max_bot.bot_message.send_menu') + @patch('restoran_max_bot.bot_message.common_get_data') + def test_bot_message_standard_command(self, mock_get_data, mock_send_menu, mock_handle_std, mock_check_reg): + """Обработка стандартной команды (меню).""" + mock_get_data.return_value = [Mock(title='menu')] # имитируем наличие данных + mock_check_reg.return_value = self.contact + mock_handle_std.return_value = True # команда обработана + + result = bot_message(self.client, self.message_created, self.settings_max, 'message_created') + self.assertTrue(result) + mock_handle_std.assert_called_once() + mock_send_menu.assert_not_called() + + @patch('restoran_max_bot.bot_message.check_registration') + @patch('restoran_max_bot.bot_message.handle_standard_command') + @patch('restoran_max_bot.bot_message.process_with_ai') + @patch('restoran_max_bot.bot_message.handle_ai_response') + @patch('restoran_max_bot.bot_message.send_menu') + @patch('restoran_max_bot.bot_message.common_get_data') + def test_bot_message_ai_handling(self, mock_get_data, mock_send_menu, mock_handle_ai, mock_process_ai, + mock_handle_std, mock_check_reg): + """Обработка через AI, когда нет стандартной команды.""" + mock_get_data.return_value = None # нет стандартной команды + mock_check_reg.return_value = self.contact + mock_handle_std.return_value = False + mock_process_ai.return_value = {'intent': 'general', 'message': 'Hello'} + + result = bot_message(self.client, self.message_created, self.settings_max, 'message_created') + self.assertTrue(result) + mock_process_ai.assert_called_once() + mock_handle_ai.assert_called_once() + mock_send_menu.assert_called_once() + + @patch('restoran_max_bot.bot_message.check_registration') + def test_bot_message_not_registered(self, mock_check_reg): + """Пользователь не зарегистрирован -> выход.""" + mock_check_reg.return_value = None + result = bot_message(self.client, self.message_created, self.settings_max, 'message_created') + self.assertFalse(result) + + # ---------- Тесты bot_promo ---------- + def test_bot_promo_new(self): + """Создание нового промокода.""" + with patch('restoran_max_bot.bot_message.common_get_data') as mock_get_data, \ + patch('restoran_max_bot.bot_message.PromoCode.objects.get_or_create') as mock_get_or_create, \ + patch('restoran_max_bot.bot_message.qrcode.make') as mock_qr, \ + patch('restoran_max_bot.bot_message.send_image') as mock_send_image, \ + patch('restoran_max_bot.bot_message.send_text') as mock_send_text: + mock_get_data.return_value = [Mock(img='', title='', url='')] + mock_get_or_create.return_value = (Mock(used=False), True) # created=True + mock_qr.return_value = Mock() + + message = {'chat_id': 'chat123', 'payload': 'promo123'} + result = bot_promo(self.client, message, self.settings_max) + self.assertTrue(result) + mock_send_image.assert_called_once() + mock_send_text.assert_not_called() + + @patch('restoran_max_bot.bot_message.common_get_data') + def test_bot_promo_no_data(self, mock_get_data): + """Промокод не найден в системе.""" + mock_get_data.return_value = None + message = {'chat_id': 'chat123', 'payload': 'unknown'} + result = bot_promo(self.client, message, self.settings_max) + self.assertTrue(result) + # Ничего не отправляем + + +class CommandHandlersTestCase(TestCase): + """Тесты для command_handlers.py""" + + def setUp(self): + self.client = Client.objects.create( + id=2, + token='test2', + status=True, + title='Test Client 2', + code='002', + brand='TestBrand2', + logo='', + server='test', + managment='test' + ) + self.contact = Contact.objects.create( + client=self.client, + name='Test', + phone='79001234567', + maxx='chat123', + status=1, + up=0, + group=1 + ) + self.chat_id = 'chat123' + self.token = 'token' + + @patch('restoran_max_bot.command_handlers.send_menu') + @patch('restoran_max_bot.command_handlers.send_text') + def test_handle_standard_command_bonus(self, mock_send_text, mock_send_menu): + from restoran_max_bot.command_handlers import handle_standard_command + mock_item = Mock() + mock_item.title = '##bonus##' + mock_item.img = '' + mock_item.descr = '' + mock_item.url = '' + with patch('restoran_max_bot.command_handlers._handle_bonus') as mock_handle_bonus: + result = handle_standard_command(self.client, self.chat_id, self.token, + [mock_item], self.contact) + self.assertTrue(result) + mock_handle_bonus.assert_called_once() + mock_send_menu.assert_called_once() + + @patch('restoran_max_bot.command_handlers.send_feedback_buttons') + @patch('restoran_max_bot.command_handlers.send_menu') + def test_handle_standard_command_feedback(self, mock_send_menu, mock_send_feedback): + from restoran_max_bot.command_handlers import handle_standard_command + mock_item = Mock() + mock_item.title = '##feedback##' + result = handle_standard_command(self.client, self.chat_id, self.token, + [mock_item], self.contact) + self.assertTrue(result) + mock_send_feedback.assert_called_once() + mock_send_menu.assert_not_called() # feedback возвращает True сразу + + @patch('restoran_max_bot.command_handlers.send_booking_people_buttons') + @patch('restoran_max_bot.command_handlers.send_menu') + def test_handle_standard_command_booking(self, mock_send_menu, mock_send_booking): + from restoran_max_bot.command_handlers import handle_standard_command + mock_item = Mock() + mock_item.title = '##booking##' + result = handle_standard_command(self.client, self.chat_id, self.token, + [mock_item], self.contact) + self.assertTrue(result) + mock_send_booking.assert_called_once() + mock_send_menu.assert_not_called() + + @patch('restoran_max_bot.command_handlers.send_link_button') + @patch('restoran_max_bot.command_handlers.send_menu') + def test_handle_standard_command_linkbutton(self, mock_send_menu, mock_send_link): + from restoran_max_bot.command_handlers import handle_standard_command + mock_item = Mock() + mock_item.title = json.dumps({'type': 'linkbutton', 'button': 'Click'}) + mock_item.descr = 'Text' + mock_item.url = 'http://example.com' + result = handle_standard_command(self.client, self.chat_id, self.token, + [mock_item], self.contact) + self.assertTrue(result) + mock_send_link.assert_called_once() + mock_send_menu.assert_called_once() + + +class AiHandlerTestCase(TestCase): + """Тесты для ai_handler.py""" + + def setUp(self): + self.client = Client.objects.create( + id=3, + token='test3', + status=True, + title='Test Client 3', + code='003', + brand='TestBrand3', + logo='', + server='test', + managment='test' + ) + + @patch('restoran_max_bot.ai_handler.get_ai_agent') + @patch('restoran_max_bot.ai_handler.call_ai_agent') + def test_process_with_ai_available(self, mock_call, mock_get): + from restoran_max_bot.ai_handler import process_with_ai + mock_get.return_value = {'ai_agent': 'deepseek'} + mock_call.return_value = {'intent': 'general'} + contact = Mock() + contact.name = 'Test' + result = process_with_ai(self.client, 'chat123', 'hello', contact) + self.assertEqual(result, {'intent': 'general'}) + mock_call.assert_called_once() + + @patch('restoran_max_bot.ai_handler.get_ai_agent') + def test_process_with_ai_not_available(self, mock_get): + from restoran_max_bot.ai_handler import process_with_ai + mock_get.return_value = {'ai_agent': None} + result = process_with_ai(self.client, 'chat123', 'hello', None) + self.assertIsNone(result) + + +class MessageSenderTestCase(TestCase): + """Тесты для message_sender.py (проверка вызовов API).""" + + def setUp(self): + self.client = Client.objects.create( + id=4, + token='test4', + status=True, + title='Test Client 4', + code='004', + brand='TestBrand4', + logo='', + server='test', + managment='test' + ) + + @patch('restoran_max_bot.message_sender.maxbot_send_text_message') + def test_send_text(self, mock_send): + from restoran_max_bot.message_sender import send_text + send_text('chat', 'token', 'text') + mock_send.assert_called_once_with(chat_id='chat', max_token='token', message='text') + + @patch('restoran_max_bot.message_sender.maxbot_send_img_message') + def test_send_image(self, mock_send): + from restoran_max_bot.message_sender import send_image + send_image('chat', 'token', 'http://img', 'caption') + mock_send.assert_called_once_with(chat_id='chat', max_token='token', img='http://img', message='caption') + + @patch('restoran_max_bot.message_sender.maxbot_send_menu_button') + def test_send_menu(self, mock_send): + from restoran_max_bot.message_sender import send_menu + send_menu('chat', 'token', self.client, 'text') + mock_send.assert_called_once_with(chat_id='chat', max_token='token', message='text', client=self.client)