commit 8d71819caf20e705b18b3f5c1bad6d5cf79dbb14 Author: benya Date: Thu Apr 30 18:38:38 2026 +0300 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..964a789 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +VK_TOKEN=replace_me +SYNC_ADMINS=123456789,987654321 +GOOGLE_CREDENTIALS_FILE=creds.json +GOOGLE_SPREADSHEET_ID=replace_me +LOG_SPREADSHEET_ID=replace_me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8a577e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +creds.json +database/*.db +database/*.db-* +.venv/ +__pycache__/ +*.py[cod] +.idea/ diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..8bb3da5 --- /dev/null +++ b/bot.py @@ -0,0 +1,122 @@ +import asyncio + +from vkbottle.bot import Bot, Message, MessageEvent +from vkbottle import Keyboard, Text +from vkbottle_types.events import GroupEventType + +from config import Token, SYNC_ADMINS +from database.init_db import init_db + +from menus.engine import show_menu +from handlers.menu import handle_menu +from handlers.fallback import handle_fallback, handle_tech_feedback_event +from services.sync_google import sync_all +from services.log_export import log_message, export_chatlog_and_freq +from services.instructions import handle_pause_response, handle_pause_event + +# ─── INIT ──────────────────────────────────────────────── + +bot = Bot(token=Token) + +# ─── START / MAIN MENU ─────────────────────────────────── + +@bot.on.message(text=["Начать", "начать", "Старт", "Главное меню"]) +async def start_handler(message: Message): + await message.answer( + "👋 Привет! Кратко опишите проблему в одном сообщении — я попробую найти инструкцию.\n" + "🔗 Полезные ссылки доступны в меню «Важные ссылки»." + ) + await show_menu(message, "main") + +# ─── SYNC COMMAND (ADMIN) ──────────────────────────────── + +@bot.on.message(text=["синхра", "sync", "/sync"]) +async def sync_handler(message: Message): + if message.from_id not in SYNC_ADMINS: + await message.answer("⛔ Нет доступа к синхронизации.") + return + + await message.answer("🔄 Запускаю синхронизацию...") + try: + await asyncio.to_thread(sync_all) + await message.answer("✅ Данные успешно обновлены") + except Exception as e: + await message.answer(f"❌ Ошибка синхронизации:\n{e}") + +@bot.on.message(text=["/export_logs", "export_logs"]) +async def export_logs_handler(message: Message): + if message.from_id not in SYNC_ADMINS: + await message.answer("⛔ Нет доступа к экпорту логов.") + return + + await message.answer("🔄 Запускаю экпорт логов...") + try: + await asyncio.to_thread(export_chatlog_and_freq) + await message.answer("✅ Экспорт логов успешен") + except Exception as e: + await message.answer(f"❌ Ошибка экспорта:\n{e}") + + +# ─── MENU HANDLER ──────────────────────────────────────── + +@bot.on.message() +async def menu_handler(message: Message): + print("➡ handler got:", message.text) + + cmd_text = (message.text or "").strip().lower() + if cmd_text in ['/export_logs', '/sync', 'export_logs', 'sync']: + return + + if not message.text and getattr(message, "attachments", None): + await message.answer( + "⚠️ Я пока не умею работать с голосовыми, " + "изображениями, кружками и видео. " + "Опишите проблему текстом." + ) + return + + if message.text: + log_message(message.from_id, message.text) + + if await handle_pause_response(message): + return + + handled = await handle_menu(message) + print("➡ handled:", handled) + + if handled: + return + + await handle_fallback(message) + + +@bot.on.raw_event(GroupEventType.MESSAGE_EVENT, MessageEvent) +async def pause_event_handler(event: MessageEvent): + payload = event.get_payload_json() + if payload and payload.get("pause") in ("yes", "no"): + if event.conversation_message_id is not None: + await event.ctx_api.messages.delete( + peer_id=event.peer_id, + conversation_message_ids=[event.conversation_message_id], + delete_for_all=1 + ) + await handle_pause_event(event) + await event.send_empty_answer() + return + + if payload and payload.get("tech_feedback") in ("yes", "no"): + if event.conversation_message_id is not None: + await event.ctx_api.messages.delete( + peer_id=event.peer_id, + conversation_message_ids=[event.conversation_message_id], + delete_for_all=1 + ) + await handle_tech_feedback_event(event) + await event.send_empty_answer() + +# ─── RUN ───────────────────────────────────────────────── + +if __name__ == "__main__": + init_db() + print("🤖 Bot started") + bot.run_forever() diff --git a/config.py b/config.py new file mode 100644 index 0000000..fc3c52b --- /dev/null +++ b/config.py @@ -0,0 +1,38 @@ +import os +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent + + +def _load_dotenv() -> None: + env_path = PROJECT_ROOT / ".env" + if not env_path.exists(): + return + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +def _parse_admins(value: str) -> list[int]: + if not value: + return [] + return [int(part) for part in value.split(",") if part.strip().isdigit()] + + +_load_dotenv() + +Token = os.getenv("VK_TOKEN", "") +SYNC_ADMINS = _parse_admins(os.getenv("SYNC_ADMINS", "")) + +_credentials_file = Path(os.getenv("GOOGLE_CREDENTIALS_FILE", "creds.json")) +CREDENTIALS_FILE = str(_credentials_file if _credentials_file.is_absolute() else PROJECT_ROOT / _credentials_file) +SPREADSHEET_ID = os.getenv("GOOGLE_SPREADSHEET_ID", "") +LOG_SPREADSHEET_ID = os.getenv("LOG_SPREADSHEET_ID", "") + +if not Token: + raise RuntimeError("VK_TOKEN is not set") diff --git a/database/db.py b/database/db.py new file mode 100644 index 0000000..0bb48a7 --- /dev/null +++ b/database/db.py @@ -0,0 +1,11 @@ +import sqlite3 + +from config import PROJECT_ROOT + +DB_PATH = PROJECT_ROOT / "database" / "data_base.db" + + +def get_connection(): + conn = sqlite3.connect(DB_PATH) + conn.execute("PRAGMA foreign_keys = ON") + return conn diff --git a/database/init_db.py b/database/init_db.py new file mode 100644 index 0000000..a5c5a0f --- /dev/null +++ b/database/init_db.py @@ -0,0 +1,293 @@ +from database.db import get_connection + + +def _table_sql(cur, table_name: str) -> str: + cur.execute( + """ + SELECT sql + FROM sqlite_master + WHERE type = 'table' AND name = ? + """, + (table_name,), + ) + row = cur.fetchone() + return row[0] if row and row[0] else "" + + +def _rebuild_table(cur, table_name: str, create_sql: str, copy_sql: str) -> None: + temp_name = f"{table_name}_new" + cur.execute(f"DROP TABLE IF EXISTS {temp_name}") + cur.execute(create_sql.format(table=temp_name)) + cur.execute(copy_sql.format(table=temp_name)) + cur.execute(f"DROP TABLE {table_name}") + cur.execute(f"ALTER TABLE {temp_name} RENAME TO {table_name}") + + +def init_db(): + with get_connection() as conn: + cur = conn.cursor() + + # ─── PINPAD ERRORS ─────────────────────── + cur.execute(""" + CREATE TABLE IF NOT EXISTS pinpad_errors ( + code INTEGER PRIMARY KEY, + reason TEXT NOT NULL, + action TEXT NOT NULL + ); + """) + + # ─── TERMINAL INSTRUCTIONS ─────────────── + cur.execute(""" + CREATE TABLE IF NOT EXISTS terminal_instructions ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL + ); + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS terminal_instruction_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instruction_id INTEGER NOT NULL, + key TEXT NOT NULL, + key_type TEXT CHECK(key_type IN ('code','text')) NOT NULL, + FOREIGN KEY (instruction_id) + REFERENCES terminal_instructions(id) + ON DELETE CASCADE + ); + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS terminal_instruction_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instruction_id INTEGER NOT NULL, + step_order INTEGER NOT NULL, + type TEXT CHECK(type IN ('text','image','pause','goto')) NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY (instruction_id) + REFERENCES terminal_instructions(id) + ON DELETE CASCADE + ); + """) + + # Tech problems + cur.execute(""" + CREATE TABLE IF NOT EXISTS tech_problems ( + id TEXT PRIMARY KEY, + task_type TEXT CHECK(task_type IN ('ADMIN','TECH')) NOT NULL, + keywords TEXT NOT NULL + ); + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS tech_problem_solutions ( + problem_id TEXT PRIMARY KEY, + problem_name TEXT NOT NULL, + task_type TEXT CHECK(task_type IN ('ADMIN','TECH')) NOT NULL, + can_fix_self TEXT CHECK(can_fix_self IN ('YES','NO')) NOT NULL, + need_result_feedback TEXT CHECK(need_result_feedback IN ('YES','NO')) NOT NULL, + solution_steps TEXT NOT NULL, + tools_needed TEXT NOT NULL, + when_stop_and_report TEXT NOT NULL, + FOREIGN KEY (problem_id) + REFERENCES tech_problems(id) + ON DELETE CASCADE + ); + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS tech_problem_progress ( + user_id INTEGER PRIMARY KEY, + problem_id TEXT NOT NULL, + task_type TEXT CHECK(task_type IN ('ADMIN','TECH')) NOT NULL, + need_result_feedback TEXT CHECK(need_result_feedback IN ('YES','NO')) NOT NULL, + FOREIGN KEY (problem_id) + REFERENCES tech_problems(id) + ON DELETE CASCADE + ); + """) + + # Stores per-user pause progress for terminal instructions + cur.execute(""" + CREATE TABLE IF NOT EXISTS instruction_progress ( + user_id INTEGER PRIMARY KEY, + instruction_id INTEGER NOT NULL, + next_step INTEGER NOT NULL, + pause_at_end INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (instruction_id) + REFERENCES terminal_instructions(id) + ON DELETE CASCADE + ); + """) + + # Chat logs + cur.execute(""" + CREATE TABLE IF NOT EXISTS message_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + message TEXT NOT NULL + ); + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS event_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + event_type TEXT NOT NULL, + event_value TEXT NOT NULL + ); + """) + + # Migrate terminal_instruction_steps if it doesn't allow 'pause' or 'goto' + terminal_steps_sql = _table_sql(cur, "terminal_instruction_steps") + if terminal_steps_sql and ("pause" not in terminal_steps_sql or "goto" not in terminal_steps_sql): + cur.execute(""" + CREATE TABLE terminal_instruction_steps_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instruction_id INTEGER NOT NULL, + step_order INTEGER NOT NULL, + type TEXT CHECK(type IN ('text','image','pause','goto')) NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY (instruction_id) + REFERENCES terminal_instructions(id) + ON DELETE CASCADE + ); + """) + cur.execute(""" + INSERT INTO terminal_instruction_steps_new + (id, instruction_id, step_order, type, content) + SELECT id, instruction_id, step_order, type, content + FROM terminal_instruction_steps + """) + cur.execute("DROP TABLE terminal_instruction_steps") + cur.execute("ALTER TABLE terminal_instruction_steps_new RENAME TO terminal_instruction_steps") + + if "FOREIGN KEY" not in _table_sql(cur, "terminal_instruction_keys").upper(): + _rebuild_table( + cur, + "terminal_instruction_keys", + """ + CREATE TABLE {table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instruction_id INTEGER NOT NULL, + key TEXT NOT NULL, + key_type TEXT CHECK(key_type IN ('code','text')) NOT NULL, + FOREIGN KEY (instruction_id) + REFERENCES terminal_instructions(id) + ON DELETE CASCADE + ); + """, + """ + INSERT INTO {table} (id, instruction_id, key, key_type) + SELECT k.id, k.instruction_id, k.key, k.key_type + FROM terminal_instruction_keys k + JOIN terminal_instructions i ON i.id = k.instruction_id + """, + ) + + if "FOREIGN KEY" not in _table_sql(cur, "terminal_instruction_steps").upper(): + _rebuild_table( + cur, + "terminal_instruction_steps", + """ + CREATE TABLE {table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instruction_id INTEGER NOT NULL, + step_order INTEGER NOT NULL, + type TEXT CHECK(type IN ('text','image','pause','goto')) NOT NULL, + content TEXT NOT NULL, + FOREIGN KEY (instruction_id) + REFERENCES terminal_instructions(id) + ON DELETE CASCADE + ); + """, + """ + INSERT INTO {table} (id, instruction_id, step_order, type, content) + SELECT s.id, s.instruction_id, s.step_order, s.type, s.content + FROM terminal_instruction_steps s + JOIN terminal_instructions i ON i.id = s.instruction_id + """, + ) + + if "FOREIGN KEY" not in _table_sql(cur, "tech_problem_solutions").upper(): + _rebuild_table( + cur, + "tech_problem_solutions", + """ + CREATE TABLE {table} ( + problem_id TEXT PRIMARY KEY, + problem_name TEXT NOT NULL, + task_type TEXT CHECK(task_type IN ('ADMIN','TECH')) NOT NULL, + can_fix_self TEXT CHECK(can_fix_self IN ('YES','NO')) NOT NULL, + need_result_feedback TEXT CHECK(need_result_feedback IN ('YES','NO')) NOT NULL, + solution_steps TEXT NOT NULL, + tools_needed TEXT NOT NULL, + when_stop_and_report TEXT NOT NULL, + FOREIGN KEY (problem_id) + REFERENCES tech_problems(id) + ON DELETE CASCADE + ); + """, + """ + INSERT INTO {table} + (problem_id, problem_name, task_type, can_fix_self, + need_result_feedback, solution_steps, tools_needed, + when_stop_and_report) + SELECT s.problem_id, s.problem_name, s.task_type, s.can_fix_self, + s.need_result_feedback, s.solution_steps, s.tools_needed, + s.when_stop_and_report + FROM tech_problem_solutions s + JOIN tech_problems p ON p.id = s.problem_id + """, + ) + + if "FOREIGN KEY" not in _table_sql(cur, "tech_problem_progress").upper(): + _rebuild_table( + cur, + "tech_problem_progress", + """ + CREATE TABLE {table} ( + user_id INTEGER PRIMARY KEY, + problem_id TEXT NOT NULL, + task_type TEXT CHECK(task_type IN ('ADMIN','TECH')) NOT NULL, + need_result_feedback TEXT CHECK(need_result_feedback IN ('YES','NO')) NOT NULL, + FOREIGN KEY (problem_id) + REFERENCES tech_problems(id) + ON DELETE CASCADE + ); + """, + """ + INSERT INTO {table} (user_id, problem_id, task_type, need_result_feedback) + SELECT p.user_id, p.problem_id, p.task_type, p.need_result_feedback + FROM tech_problem_progress p + JOIN tech_problems t ON t.id = p.problem_id + """, + ) + + if "FOREIGN KEY" not in _table_sql(cur, "instruction_progress").upper(): + _rebuild_table( + cur, + "instruction_progress", + """ + CREATE TABLE {table} ( + user_id INTEGER PRIMARY KEY, + instruction_id INTEGER NOT NULL, + next_step INTEGER NOT NULL, + pause_at_end INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (instruction_id) + REFERENCES terminal_instructions(id) + ON DELETE CASCADE + ); + """, + """ + INSERT INTO {table} (user_id, instruction_id, next_step, pause_at_end) + SELECT p.user_id, p.instruction_id, p.next_step, p.pause_at_end + FROM instruction_progress p + JOIN terminal_instructions i ON i.id = p.instruction_id + """, + ) + + conn.commit() + + print("✅ Database initialized") diff --git a/database/pinpad.py b/database/pinpad.py new file mode 100644 index 0000000..5acbabe --- /dev/null +++ b/database/pinpad.py @@ -0,0 +1,14 @@ +from database.db import get_connection + + +def get_pinpad_error(code: str): + """ + Возвращает (reason, action) или None + """ + with get_connection() as conn: + cur = conn.cursor() + cur.execute( + "SELECT reason, action FROM pinpad_errors WHERE code = ?", + (code,) + ) + return cur.fetchone() diff --git a/database/repository.py b/database/repository.py new file mode 100644 index 0000000..ec3b6a7 --- /dev/null +++ b/database/repository.py @@ -0,0 +1,311 @@ +from database.db import get_connection +import re + + +_STOPWORDS = { + "и", "в", "во", "на", "с", "со", "к", "ко", "от", "до", "по", + "за", "из", "у", "о", "об", "про", "для", "при", "без", "не", + "нет", "ли", "же", "а", "но", "или", "то", "это", "все", "всё", +} + + +def _normalize_query(query: str): + text = re.sub(r"[^\w\s]+", " ", query.lower(), flags=re.UNICODE) + parts = [p for p in text.split() if p and p not in _STOPWORDS] + # Keep tokens of length >= 3 to reduce noise + return [p for p in parts if len(p) >= 3] + + +def _normalize_title(text: str) -> str: + text = (text or "").strip().lower() + text = re.sub(r"\s+", " ", text) + return text + + +def _levenshtein(a: str, b: str) -> int: + if a == b: + return 0 + if not a: + return len(b) + if not b: + return len(a) + + if len(a) < len(b): + a, b = b, a + + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, start=1): + cur = [i] + for j, cb in enumerate(b, start=1): + ins = cur[j - 1] + 1 + delete = prev[j] + 1 + sub = prev[j - 1] + (0 if ca == cb else 1) + cur.append(min(ins, delete, sub)) + prev = cur + return prev[-1] + + +def _fuzzy_match(token: str, word: str) -> bool: + if token in word: + return True + dist = _levenshtein(token, word) + if len(token) <= 5: + return dist <= 1 + if len(token) <= 8: + return dist <= 2 + return dist <= 3 + +def find_instructions(query: str): + query = query.lower() + tokens = _normalize_query(query) + + with get_connection() as conn: + cur = conn.cursor() + + # 1️⃣ точное совпадение по коду + cur.execute(""" + SELECT DISTINCT i.id, i.title + FROM terminal_instruction_keys k + JOIN terminal_instructions i ON i.id = k.instruction_id + WHERE k.key_type = 'code' AND k.key = ? + """, (query,)) + rows = cur.fetchall() + if rows: + return rows + + # 2️⃣ частичное совпадение по тексту + if tokens: + like_parts = " OR ".join(["k.key LIKE ?"] * len(tokens)) + params = [f"%{t}%" for t in tokens] + cur.execute( + f""" + SELECT i.id, i.title, k.key + FROM terminal_instruction_keys k + JOIN terminal_instructions i ON i.id = k.instruction_id + WHERE k.key_type = 'text' AND ({like_parts}) + """, + params, + ) + rows = cur.fetchall() + if rows: + scores = {} + titles = {} + for iid, title, key in rows: + key_l = (key or "").lower() + title_l = (title or "").lower() + matched = set() + for t in tokens: + if t in key_l or t in title_l: + matched.add(t) + if matched: + scores[iid] = max(scores.get(iid, 0), len(matched)) + titles[iid] = title + if scores: + ordered = sorted(scores.items(), key=lambda x: (-x[1], x[0])) + return [(iid, titles[iid]) for iid, _ in ordered] + + cur.execute(""" + SELECT DISTINCT i.id, i.title + FROM terminal_instruction_keys k + JOIN terminal_instructions i ON i.id = k.instruction_id + WHERE k.key_type = 'text' AND k.key LIKE ? + """, (f"%{query}%",)) + + rows = cur.fetchall() + if rows: + return rows + + # 3️⃣ fuzzy поиск по ключевым словам (опечатки) + if tokens: + cur.execute(""" + SELECT i.id, i.title, k.key + FROM terminal_instruction_keys k + JOIN terminal_instructions i ON i.id = k.instruction_id + WHERE k.key_type = 'text' + """) + all_rows = cur.fetchall() + scores = {} + titles = {} + for iid, title, key in all_rows: + key_words = [w for w in re.split(r"\s+", (key or "").lower()) if w] + title_words = [w for w in re.split(r"\s+", (title or "").lower()) if w] + matched = set() + for t in tokens: + if any(_fuzzy_match(t, w) for w in key_words) or any(_fuzzy_match(t, w) for w in title_words): + matched.add(t) + if matched: + scores[iid] = max(scores.get(iid, 0), len(matched)) + titles[iid] = title + if scores: + ordered = sorted(scores.items(), key=lambda x: (-x[1], x[0])) + return [(iid, titles[iid]) for iid, _ in ordered] + + return [] + +def get_terminal_steps(instruction_id: int): + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT type, content + FROM terminal_instruction_steps + WHERE instruction_id = ? + ORDER BY step_order + """, (instruction_id,)) + return cur.fetchall() + + +def get_instruction_id_by_title(title: str): + normalized = _normalize_title(title) + if not normalized: + return None + + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT id, title + FROM terminal_instructions + """) + for iid, db_title in cur.fetchall(): + if _normalize_title(db_title) == normalized: + return iid + return None + + +def find_tech_problems(query: str): + tokens = _normalize_query(query) + if not tokens: + return [] + + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT id, task_type, keywords + FROM tech_problems + """) + rows = cur.fetchall() + + scores = {} + types = {} + for pid, task_type, keywords in rows: + key_words = [w for w in re.split(r"[,\s]+", (keywords or "").lower()) if w] + matched = set() + for t in tokens: + if any(_fuzzy_match(t, w) for w in key_words): + matched.add(t) + if matched: + scores[pid] = max(scores.get(pid, 0), len(matched)) + types[pid] = task_type + + if not scores: + return [] + + ordered = sorted(scores.items(), key=lambda x: (-x[1], x[0])) + return [(pid, types[pid]) for pid, _ in ordered] + + +def get_tech_solution(problem_id: str): + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT problem_id, problem_name, task_type, can_fix_self, + need_result_feedback, solution_steps, tools_needed, + when_stop_and_report + FROM tech_problem_solutions + WHERE problem_id = ? + """, (problem_id,)) + return cur.fetchone() + + +def get_tech_problem_by_name(problem_name: str): + normalized = _normalize_title(problem_name) + if not normalized: + return None + + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT problem_id, problem_name, task_type + FROM tech_problem_solutions + """) + for pid, name, task_type in cur.fetchall(): + if _normalize_title(name) == normalized: + return pid, name, task_type + return None + + +def find_tech_solutions_by_name_contains(fragment: str): + frag = _normalize_title(fragment) + if not frag: + return [] + with get_connection() as conn: + cur = conn.cursor() + cur.execute( + """ + SELECT problem_id, problem_name, task_type + FROM tech_problem_solutions + WHERE lower(problem_name) LIKE ? + """, + (f"%{frag}%",), + ) + return cur.fetchall() + + +def set_tech_problem_progress(user_id: int, problem_id: str, task_type: str, need_result_feedback: str): + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO tech_problem_progress (user_id, problem_id, task_type, need_result_feedback) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + problem_id = excluded.problem_id, + task_type = excluded.task_type, + need_result_feedback = excluded.need_result_feedback + """, (user_id, problem_id, task_type, need_result_feedback)) + conn.commit() + + +def get_tech_problem_progress(user_id: int): + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT problem_id, task_type, need_result_feedback + FROM tech_problem_progress + WHERE user_id = ? + """, (user_id,)) + return cur.fetchone() + + +def clear_tech_problem_progress(user_id: int): + with get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM tech_problem_progress WHERE user_id = ?", (user_id,)) + conn.commit() + +def set_instruction_progress(user_id: int, instruction_id: int, next_step: int, pause_at_end: bool): + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + INSERT INTO instruction_progress (user_id, instruction_id, next_step, pause_at_end) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + instruction_id = excluded.instruction_id, + next_step = excluded.next_step, + pause_at_end = excluded.pause_at_end + """, (user_id, instruction_id, next_step, 1 if pause_at_end else 0)) + conn.commit() + +def get_instruction_progress(user_id: int): + with get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT instruction_id, next_step, pause_at_end + FROM instruction_progress + WHERE user_id = ? + """, (user_id,)) + return cur.fetchone() + +def clear_instruction_progress(user_id: int): + with get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM instruction_progress WHERE user_id = ?", (user_id,)) + conn.commit() diff --git a/handlers/errors.py b/handlers/errors.py new file mode 100644 index 0000000..22fc368 --- /dev/null +++ b/handlers/errors.py @@ -0,0 +1,7 @@ +from vkbottle.bot import Message +from services.instructions import send_instruction +from keyboards.factory import back_to_main + +async def handle_error(message: Message, error_code: str): + keyboard = back_to_main() + await send_instruction(message, error_code, keyboard) diff --git a/handlers/fallback.py b/handlers/fallback.py new file mode 100644 index 0000000..da86244 --- /dev/null +++ b/handlers/fallback.py @@ -0,0 +1,293 @@ +from database.repository import ( + find_instructions, + get_instruction_id_by_title, + find_tech_problems, + get_tech_solution, + get_tech_problem_by_name, + find_tech_solutions_by_name_contains, + set_tech_problem_progress, + get_tech_problem_progress, + clear_tech_problem_progress, +) +from services.instructions import ( + send_pinpad_error, + send_terminal_instruction, +) +from keyboards.factory import back_to_main, build_keyboard, tech_feedback_keyboard + +_DOOR_MAIN_LABELS = {"дверь", "двери"} +_DOOR_CATEGORY_LABELS = { + "Дверца накопителя", + "Входная дверь", + "Рекламная дверь", + "Дверь кассовой зоны", +} + +_DOOR_ACCUMULATOR_QUERIES = ["дверца накопителя"] +_DOOR_ENTRANCE_QUERIES = ["входная дверь", "входной двери", "ключ от входной двери"] +_DOOR_ADS_QUERIES = ["рекламная дверь"] +_DOOR_KZ_QUERIES = ["дверь кз", "дверь кассовой зоны"] +_DOOR_ACCUMULATOR_REQUIRED = ["накопител"] +_DOOR_ENTRANCE_REQUIRED = ["входн"] +_DOOR_ADS_REQUIRED = ["рекламн"] +_DOOR_KZ_REQUIRED = ["кз", "кассов"] + + +async def handle_fallback(message): + text = (message.text or "").strip().lower() + + if not text: + await message.answer( + "✍️ Введите код ошибки или опишите проблему.", + keyboard=back_to_main(), + ) + return + + if message.text in _DOOR_CATEGORY_LABELS: + if message.text == "Дверца накопителя": + await _send_door_submenu( + message, + _DOOR_ACCUMULATOR_QUERIES, + "Выберите проблему с дверцей накопителя:", + required_substrings=_DOOR_ACCUMULATOR_REQUIRED, + ) + return + if message.text == "Входная дверь": + await _send_door_submenu( + message, + _DOOR_ENTRANCE_QUERIES, + "Выберите проблему с входной дверью:", + required_substrings=_DOOR_ENTRANCE_REQUIRED, + ) + return + if message.text == "Рекламная дверь": + await _send_door_submenu( + message, + _DOOR_ADS_QUERIES, + "Выберите проблему с рекламной дверью:", + required_substrings=_DOOR_ADS_REQUIRED, + ) + return + if message.text == "Дверь кассовой зоны": + await _send_door_submenu( + message, + _DOOR_KZ_QUERIES, + "Выберите проблему с дверью кассовой зоны:", + required_substrings=_DOOR_KZ_REQUIRED, + ) + return + + # tech problem selection by name (from list) + tech_choice = get_tech_problem_by_name(text) + if tech_choice: + problem_id, _problem_name, task_type = tech_choice + await _send_tech_solution(message, problem_id, task_type) + return + + # door уточнение + if text in _DOOR_MAIN_LABELS or any(part.startswith("двер") for part in text.split()): + keyboard = build_keyboard( + [{"title": t} for t in _DOOR_CATEGORY_LABELS], + back=True, + ) + await message.answer( + "🚪 Уточните, какая дверь:", + keyboard=keyboard, + ) + return + + # tech problem feedback (text fallback) + if text in ("да", "нет"): + progress = get_tech_problem_progress(message.from_id) + if progress: + await _handle_tech_feedback( + user_id=message.from_id, + is_yes=(text == "да"), + send_fn=lambda msg: message.answer(msg, keyboard=back_to_main()), + ) + return + + # terminal instruction selection by title + instruction_id = get_instruction_id_by_title(text) + if instruction_id: + await send_terminal_instruction( + message, + instruction_id, + keyboard=back_to_main(), + ) + return + + # 1️⃣ PINPAD — быстрый путь + if text.isdigit(): + handled = await send_pinpad_error( + message, + text, + keyboard=back_to_main(), + ) + if handled: + return + + # 2️⃣ TERMINAL — поиск инструкций (приоритет для кассы/эвотор/чек) + found = find_instructions(text) + + if not found: + # fall through to tech problems + found = [] + + if len(found) == 1: + instruction_id, _ = found[0] + await send_terminal_instruction( + message, + instruction_id, + keyboard=back_to_main(), + ) + return + + if len(found) > 1: + buttons = [ + {"title": title, "instruction_id": iid} + for iid, title in found + ] + + keyboard = build_keyboard(buttons, back=True) + + await message.answer( + "🔎 Найдено несколько вариантов. Выберите нужный:", + keyboard=keyboard, + ) + return + + # 3️⃣ TECH PROBLEMS — поиск по тех. проблемам + # (дойти сюда можно только если терминал ничего не нашёл) + tech_found = find_tech_problems(text) + if tech_found: + if len(tech_found) > 1: + buttons = [] + for pid, _task_type in tech_found[:6]: + solution = get_tech_solution(pid) + if solution: + buttons.append({"title": solution[1]}) + if buttons: + keyboard = build_keyboard(buttons, back=True) + await message.answer( + "🔎 Найдено несколько тех. проблем. Выберите нужную:", + keyboard=keyboard, + ) + return + + problem_id, task_type = tech_found[0] + await _send_tech_solution(message, problem_id, task_type) + return + + await message.answer( + "❌ Не удалось найти подходящую инструкцию.\n" + "✍️ Попробуйте изменить формулировку.", + keyboard=back_to_main(), + ) + + +async def _send_tech_solution(message, problem_id: str, task_type: str): + solution = get_tech_solution(problem_id) + if not solution: + await message.answer( + ("📝 Заполните форму:\nhttps://example.com/admin-form" + if task_type == "ADMIN" + else "📝 Заполните форму:\nhttps://example.com/tech-form"), + keyboard=back_to_main(), + ) + return + + ( + _pid, problem_name, task_type, can_fix_self, + need_result_feedback, solution_steps, tools_needed, + when_stop_and_report, + ) = solution + + if can_fix_self == "NO": + await message.answer( + f"⚠️ {problem_name}\n" + "Самостоятельно не исправляется.\n" + + ("📝 Форма:\nhttps://example.com/admin-form" + if task_type == "ADMIN" + else "📝 Форма:\nhttps://example.com/tech-form"), + keyboard=back_to_main(), + ) + return + + msg = f"🛠 {problem_name}\n\n✅ Шаги:\n{solution_steps}" + if tools_needed and tools_needed not in ("—", "-"): + msg += f"\n\n🧰 Инструменты:\n{tools_needed}" + if when_stop_and_report: + msg += f"\n\n⛔ Когда остановиться и сообщить:\n{when_stop_and_report}" + + await message.answer(msg, keyboard=back_to_main()) + + if need_result_feedback == "YES": + set_tech_problem_progress(message.from_id, problem_id, task_type, need_result_feedback) + await message.answer("❓ Помогло?", keyboard=tech_feedback_keyboard()) + + +def _name_has_required(name: str, required_substrings): + if not required_substrings: + return True + name_l = (name or "").lower() + return any(sub in name_l for sub in required_substrings) + + +async def _send_door_submenu(message, queries, prompt: str, required_substrings=None): + uniq = {} + for q in queries: + # by solution name + for pid, name, task_type in find_tech_solutions_by_name_contains(q): + if _name_has_required(name, required_substrings): + uniq[pid] = (name, task_type) + # by keywords (TechProblems) + for pid, _task_type in find_tech_problems(q): + solution = get_tech_solution(pid) + if solution: + if _name_has_required(solution[1], required_substrings): + uniq[pid] = (solution[1], solution[2]) + + if not uniq: + await message.answer( + "❌ Не удалось найти варианты по этой категории.", + keyboard=back_to_main(), + ) + return + + if len(uniq) == 1: + pid, (name, task_type) = next(iter(uniq.items())) + await _send_tech_solution(message, pid, task_type) + return + + buttons = [{"title": name} for name, _t in list(uniq.values())[:6]] + keyboard = build_keyboard(buttons, back=True) + await message.answer(prompt, keyboard=keyboard) + + +async def _handle_tech_feedback(user_id: int, is_yes: bool, send_fn): + progress = get_tech_problem_progress(user_id) + if not progress: + return + + _problem_id, task_type, _need_result_feedback = progress + clear_tech_problem_progress(user_id) + if is_yes: + await send_fn("✅ Отлично, рад помочь!") + return + + form_url = "https://example.com/admin-form" if task_type == "ADMIN" else "https://example.com/tech-form" + await send_fn(f"📝 Заполните форму:\n{form_url}") + + +async def handle_tech_feedback_event(event) -> bool: + payload = event.get_payload_json() if hasattr(event, "get_payload_json") else None + if not payload or payload.get("tech_feedback") not in ("yes", "no"): + return False + + await _handle_tech_feedback( + user_id=event.user_id, + is_yes=(payload.get("tech_feedback") == "yes"), + send_fn=lambda msg: event.send_message(msg, keyboard=back_to_main()), + ) + return True diff --git a/handlers/menu.py b/handlers/menu.py new file mode 100644 index 0000000..0741f21 --- /dev/null +++ b/handlers/menu.py @@ -0,0 +1,67 @@ +from vkbottle.bot import Message +from menus.menu_config import MENUS +from menus.engine import show_menu +from services.instructions import ( + send_pinpad_error, + send_terminal_instruction, +) +from keyboards.factory import back_to_main + + +async def handle_menu(message: Message) -> bool: + if not message.text: + return False + + text = message.text.lower() + + for menu_name, menu in MENUS.items(): + for btn in menu["buttons"]: + if btn["title"].lower() == text: + + if "goto" in btn: + if btn["goto"] == "main": + await message.answer( + "👋 Привет! Кратко опишите проблему в одном сообщении — я попробую найти инструкцию.\n" + "🔗 Полезные ссылки доступны в меню «Важные ссылки»." + ) + await show_menu(message, btn["goto"]) + return True + + if "error" in btn: + await send_pinpad_error( + message, + str(btn["error"]), + keyboard=back_to_main(), + ) + return True + + if "terminal_instruction" in btn: + await send_terminal_instruction( + message, + btn["terminal_instruction"], + keyboard=back_to_main(), + ) + return True + + if btn.get("action") == "admins_links": + await message.answer( + "🔗 Распределение админов:\nhttps://docs.google.com/spreadsheets/d/1uLVYGyX8PzjHowpoKE8ZK6XX4NtNHbl5a153KoOpzww/", + keyboard=back_to_main(), + ) + return True + + if btn.get("action") == "instructions_links": + await message.answer( + "🔗 Технические инструкции:\nhttps://docs.google.com/document/d/1prGV8WdVsu2IAaqN1kuebXaQP-WNW5ySrHMMOS1TQhY/", + keyboard=back_to_main(), + ) + return True + + if btn.get("action") == "accounts_links": + await message.answer( + "🔗 Полный список ссылок:\nhttps://docs.google.com/spreadsheets/d/1nzah5AUJEn-0mrindXK8BtKWFIj1ac6BCTe2HSO83Oc", + keyboard=back_to_main(), + ) + return True + + return False diff --git a/keyboards/factory.py b/keyboards/factory.py new file mode 100644 index 0000000..197d82a --- /dev/null +++ b/keyboards/factory.py @@ -0,0 +1,37 @@ +from vkbottle import Keyboard, Text +from vkbottle.tools.keyboard import Callback + + +def back_to_main(): + kb = Keyboard(one_time=False) + kb.add(Text("Главное меню")) + return kb.get_json() + + +def build_keyboard(buttons, back: bool = False): + kb = Keyboard(one_time=False) + + for i, btn in enumerate(buttons): + kb.add(Text(btn["title"])) + if (i + 1) % 2 == 0: + kb.row() + + if back: + kb.row() + kb.add(Text("Назад")) + + return kb.get_json() + + +def pause_keyboard(): + kb = Keyboard(one_time=False, inline=True) + kb.add(Callback("Да, помогло", payload={"pause": "yes"})) + kb.add(Callback("Нет, дальше", payload={"pause": "no"})) + return kb.get_json() + + +def tech_feedback_keyboard(): + kb = Keyboard(one_time=False, inline=True) + kb.add(Callback("Да", payload={"tech_feedback": "yes"})) + kb.add(Callback("Нет", payload={"tech_feedback": "no"})) + return kb.get_json() diff --git a/menus/engine.py b/menus/engine.py new file mode 100644 index 0000000..0f250e8 --- /dev/null +++ b/menus/engine.py @@ -0,0 +1,12 @@ +from menus.menu_config import MENUS +from keyboards.factory import build_keyboard + +async def show_menu(message, menu_name: str): + menu = MENUS.get(menu_name) + + if not menu: + await message.answer("Меню не найдено.") + return + + keyboard = build_keyboard(menu["buttons"]) + await message.answer(menu["text"], keyboard=keyboard) diff --git a/menus/menu_config.py b/menus/menu_config.py new file mode 100644 index 0000000..2aa65e2 --- /dev/null +++ b/menus/menu_config.py @@ -0,0 +1,18 @@ +MENUS = { + "main": { + "text": "📋 Главное меню", + "buttons": [ + {"title": "Важные ссылки", "goto": "important_links"}, + ] + }, + + "important_links": { + "text": "🔗 Важные ссылки", + "buttons": [ + {"title": "Распределение админов", "action": "admins_links"}, + {"title": "Технические инструкции", "action": "instructions_links"}, + {"title": "Полные ссылки", "action": "accounts_links"}, + {"title": "Назад", "goto": "main"}, + ] + }, +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..66537f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +vkbottle==4.6.2 +vkbottle-types==5.199.99.12 +google-api-python-client==2.188.0 +google-auth-httplib2==0.3.0 +oauth2client==4.1.3 +httplib2==0.31.2 diff --git a/services/google.py b/services/google.py new file mode 100644 index 0000000..54d57cb --- /dev/null +++ b/services/google.py @@ -0,0 +1,20 @@ +import httplib2 +import googleapiclient.discovery +from oauth2client.service_account import ServiceAccountCredentials + +from config import CREDENTIALS_FILE, SPREADSHEET_ID + + +def get_google_service(): + credentials = ServiceAccountCredentials.from_json_keyfile_name( + CREDENTIALS_FILE, + [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive", + ], + ) + + http = credentials.authorize(httplib2.Http()) + return googleapiclient.discovery.build( + "sheets", "v4", http=http, cache_discovery=False + ) diff --git a/services/instructions.py b/services/instructions.py new file mode 100644 index 0000000..f4efc45 --- /dev/null +++ b/services/instructions.py @@ -0,0 +1,260 @@ +from database.db import get_connection +from database.repository import ( + get_terminal_steps, + set_instruction_progress, + get_instruction_progress, + clear_instruction_progress, + find_instructions, +) +from keyboards.factory import pause_keyboard, back_to_main, build_keyboard +from services.log_export import log_event + +PAUSE_PROMPT = "Помогла ли эта часть?" +PAUSE_YES = {"да, помогло", "да помогло", "да"} +PAUSE_NO = {"нет, дальше", "нет дальше", "нет"} + + +# ───────────────────────────────────────────── +# PINPAD — простой ответ +# ───────────────────────────────────────────── + +async def send_pinpad_error(message, code: str, keyboard=None): + """ + Отправляет простой ответ для ошибок пинпада: + причина + что делать + """ + with get_connection() as conn: + cur = conn.cursor() + cur.execute( + "SELECT reason, action FROM pinpad_errors WHERE code = ?", + (code,) + ) + row = cur.fetchone() + + if not row: + return False # сигнал, что ошибки нет + + reason, action = row + log_event(message.from_id, "pinpad_error", str(code)) + action_text = (action or "").strip() + if action_text.lower().startswith("terminal:"): + query = action_text.split(":", 1)[1].strip() + if query: + found = find_instructions(query.lower()) + if len(found) == 1: + instruction_id, _ = found[0] + await send_terminal_instruction( + message, + instruction_id, + keyboard=keyboard or back_to_main(), + ) + return True + if len(found) > 1: + buttons = [{"title": title} for _, title in found] + kb = build_keyboard(buttons, back=True) + await message.answer( + "🔎 Найдено несколько вариантов. Выберите нужный:", + keyboard=kb, + ) + return True + + await message.answer( + f"❗ Ошибка пинпада {code}\n\n" + f"📌 Причина:\n{reason}\n\n" + f"❌ Не удалось найти инструкцию по ключу: {query or 'не задан'}", + keyboard=keyboard or back_to_main(), + ) + return True + + await message.answer( + f"❗ Ошибка пинпада {code}\n\n" + f"📌 Причина:\n{reason}\n\n" + f"✅ Что делать:\n{action}", + keyboard=keyboard + ) + return True + + +# ───────────────────────────────────────────── +# TERMINAL — подробная инструкция +# ───────────────────────────────────────────── + +async def _send_text(target, text: str, keyboard=None): + if hasattr(target, "answer"): + await target.answer(text, keyboard=keyboard) + else: + await target.send_message(text, keyboard=keyboard) + + +async def _send_attachment(target, attachment: str): + if hasattr(target, "answer"): + await target.answer(attachment=attachment) + else: + await target.send_message(attachment=attachment) + + +def _parse_goto(value: str): + parts = (value or "").strip().split(":", 1) + if not parts[0].isdigit(): + return None + instr_id = int(parts[0]) + step_index = 0 + if len(parts) > 1: + if not parts[1].isdigit(): + return None + step_num = int(parts[1]) + if step_num > 0: + step_index = step_num - 1 + return instr_id, step_index + + +async def _send_terminal_from_index(target, instruction_id: int, start_index: int, keyboard=None, max_hops: int = 5): + """ + Отправляет пошаговую инструкцию терминала: + несколько сообщений подряд (text / image) + """ + steps = get_terminal_steps(instruction_id) + + if not steps: + await _send_text(target, "❌ Для этой инструкции нет шагов.", keyboard=keyboard) + return + + for idx, (step_type, content) in enumerate(steps[start_index:], start=start_index): + if step_type == "text": + await _send_text(target, content, keyboard=keyboard) + elif step_type == "image": + await _send_attachment(target, content) + elif step_type == "pause": + await _send_text(target, PAUSE_PROMPT, keyboard=pause_keyboard()) + pause_at_end = (idx == len(steps) - 1) + set_instruction_progress( + target.from_id if hasattr(target, "from_id") else target.user_id, + instruction_id, + idx + 1, + pause_at_end + ) + return + elif step_type == "goto": + if max_hops <= 0: + await _send_text(target, "❌ Превышено число переходов между инструкциями.", keyboard=keyboard) + return + parsed = _parse_goto(content) + if not parsed: + await _send_text(target, f"❌ Некорректный goto: {content}", keyboard=keyboard) + return + next_id, next_index = parsed + user_id = target.from_id if hasattr(target, "from_id") else target.user_id + log_event(user_id, "terminal_instruction", str(next_id)) + clear_instruction_progress(target.from_id if hasattr(target, "from_id") else target.user_id) + await _send_terminal_from_index( + target, + next_id, + next_index, + keyboard=keyboard, + max_hops=max_hops - 1, + ) + return + + clear_instruction_progress(target.from_id if hasattr(target, "from_id") else target.user_id) + + +async def send_terminal_instruction(message, instruction_id: int, keyboard=None): + log_event(message.from_id, "terminal_instruction", str(instruction_id)) + await _send_terminal_from_index(message, instruction_id, 0, keyboard) + + +# ───────────────────────────────────────────── +# УНИВЕРСАЛЬНЫЙ ХЕЛПЕР (по желанию) +# ───────────────────────────────────────────── + +async def send_instruction(message, instruction_id: int, keyboard=None): + """ + Алиас, чтобы использовать одно имя в коде + """ + await send_terminal_instruction(message, instruction_id, keyboard) + + +async def handle_pause_response(message) -> bool: + if not message.text: + return False + + progress = get_instruction_progress(message.from_id) + if not progress: + return False + + text = message.text.strip().lower() + instruction_id, next_step, pause_at_end = progress + + if text in PAUSE_YES: + clear_instruction_progress(message.from_id) + await message.answer("Отлично, остановил инструкцию.", keyboard=back_to_main()) + return True + + if text in PAUSE_NO: + if pause_at_end: + clear_instruction_progress(message.from_id) + await message.answer( + "Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n" + "https://example.com/google-form", + keyboard=back_to_main() + ) + return True + + steps = get_terminal_steps(instruction_id) + if next_step >= len(steps): + clear_instruction_progress(message.from_id) + await message.answer( + "Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n" + "https://example.com/google-form", + keyboard=back_to_main() + ) + return True + + await _send_terminal_from_index(message, instruction_id, next_step, keyboard=back_to_main()) + return True + + return False + + +async def handle_pause_event(event) -> bool: + payload = event.get_payload_json() if hasattr(event, "get_payload_json") else None + if not payload or payload.get("pause") not in ("yes", "no"): + return False + + progress = get_instruction_progress(event.user_id) + if not progress: + return False + + instruction_id, next_step, pause_at_end = progress + + if payload["pause"] == "yes": + clear_instruction_progress(event.user_id) + await event.send_message("Отлично, всё работает.", keyboard=back_to_main()) + return True + + if pause_at_end: + clear_instruction_progress(event.user_id) + await event.send_message( + "Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n" + "https://example.com/google-form", + keyboard=back_to_main() + ) + return True + + steps = get_terminal_steps(instruction_id) + if next_step >= len(steps): + clear_instruction_progress(event.user_id) + await event.send_message( + "Похоже, инструкция не помогла. Заполните, пожалуйста, форму:\n" + "https://example.com/google-form", + keyboard=back_to_main() + ) + return True + + await _send_terminal_from_index( + event, + instruction_id, + next_step, + keyboard=back_to_main() + ) + return True diff --git a/services/log_export.py b/services/log_export.py new file mode 100644 index 0000000..9e73cb7 --- /dev/null +++ b/services/log_export.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Dict, List, Tuple + +from config import LOG_SPREADSHEET_ID +from database.db import get_connection +from services.google import get_google_service + +_IGNORE_MESSAGES = {"начать", "главное меню", "назад"} + + +def _now_iso() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _format_ts(value: str) -> str: + if not value: + return "" + try: + dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + return dt.strftime("%d.%m.%Y %H:%M:%S") + except ValueError: + return value + + +def log_message(user_id: int, text: str) -> None: + if not text: + print("LOG message: skipped empty text") + return + normalized = text.strip().lower() + if normalized in _IGNORE_MESSAGES: + print(f"LOG message: ignored user_id={user_id} text={normalized!r}") + return + with get_connection() as conn: + cur = conn.cursor() + created_at = _now_iso() + cur.execute( + """ + INSERT INTO message_log (user_id, created_at, message) + VALUES (?, ?, ?) + """, + (user_id, created_at, text.strip()), + ) + conn.commit() + print(f"LOG message: stored user_id={user_id} at={created_at}") + + +def log_event(user_id: int, event_type: str, event_value: str) -> None: + if not event_type or not event_value: + print("LOG event: skipped empty event_type or event_value") + return + with get_connection() as conn: + cur = conn.cursor() + created_at = _now_iso() + cur.execute( + """ + INSERT INTO event_log (user_id, created_at, event_type, event_value) + VALUES (?, ?, ?, ?) + """, + (user_id, created_at, event_type, event_value), + ) + conn.commit() + print(f"LOG event: stored user_id={user_id} type={event_type} value={event_value}") + + +def _fetch_chatlog_rows() -> List[List[str]]: + print("LOG export: building ChatLog rows from SQLite") + with get_connection() as conn: + cur = conn.cursor() + cur.execute("SELECT DISTINCT user_id FROM message_log ORDER BY user_id") + users = [row[0] for row in cur.fetchall()] + + user_msgs: Dict[int, List[Tuple[str, str]]] = {} + for user_id in users: + cur.execute( + """ + SELECT created_at, message + FROM message_log + WHERE user_id = ? + ORDER BY id + """, + (user_id,), + ) + user_msgs[user_id] = cur.fetchall() + + if not users: + print("LOG export: no users in message_log") + return [["Время сообщения", "vk.com/id0"]] + + header: List[str] = [] + for i, user_id in enumerate(users): + header.extend(["Время сообщения", f"vk.com/id{user_id}"]) + if i != len(users) - 1: + header.append("") + + max_len = max(len(user_msgs[u]) for u in users) + rows = [header] + + for idx in range(max_len): + row: List[str] = [] + for i, user_id in enumerate(users): + if idx < len(user_msgs[user_id]): + ts, msg = user_msgs[user_id][idx] + row.extend([_format_ts(ts), msg]) + else: + row.extend(["", ""]) + if i != len(users) - 1: + row.append("") + rows.append(row) + + return rows + + +def _fetch_freq_rows(days: int = 30, limit: int = 50) -> List[List[str]]: + print(f"LOG export: building FreqMsg rows for last {days} days") + since = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + with get_connection() as conn: + cur = conn.cursor() + cur.execute( + """ + SELECT message, COUNT(*) + FROM message_log + WHERE created_at >= ? + GROUP BY message + ORDER BY COUNT(*) DESC + LIMIT ? + """, + (since, limit), + ) + messages = [row[0] for row in cur.fetchall()] + + cur.execute( + """ + SELECT event_value, COUNT(*) + FROM event_log + WHERE event_type = 'pinpad_error' AND created_at >= ? + GROUP BY event_value + """, + (since,), + ) + pinpad_counts = {row[0]: row[1] for row in cur.fetchall()} + + cur.execute( + """ + SELECT event_value, COUNT(*) + FROM event_log + WHERE event_type = 'terminal_instruction' AND created_at >= ? + GROUP BY event_value + """, + (since,), + ) + terminal_counts = {row[0]: row[1] for row in cur.fetchall()} + + cur.execute("SELECT code FROM pinpad_errors ORDER BY code") + pinpad_codes = [str(row[0]) for row in cur.fetchall()] + + cur.execute("SELECT id, title FROM terminal_instructions ORDER BY id") + terminal_items = [(str(row[0]), row[1]) for row in cur.fetchall()] + + errors_with_counts: List[Tuple[str, int]] = [] + for code in pinpad_codes: + count = int(pinpad_counts.get(code, 0)) + if count > 0: + errors_with_counts.append((f"PinPad {code}", count)) + + for instr_id, title in terminal_items: + count = int(terminal_counts.get(instr_id, 0)) + if count > 0: + label = title or f"Terminal {instr_id}" + errors_with_counts.append((label, count)) + + errors_with_counts.sort(key=lambda x: (-x[1], x[0].lower())) + errors = [label for label, _count in errors_with_counts] + + max_len = max(len(messages), len(errors), 1) + rows = [["Частые сообщения", "Частые ошибки"]] + for i in range(max_len): + msg = messages[i] if i < len(messages) else "" + err = errors[i] if i < len(errors) else "" + rows.append([msg, err]) + return rows + + +def export_chatlog_and_freq() -> None: + print("LOG export: start") + service = get_google_service() + + chat_rows = _fetch_chatlog_rows() + print(f"LOG export: writing ChatLog rows={len(chat_rows)}") + service.spreadsheets().values().clear( + spreadsheetId=LOG_SPREADSHEET_ID, + range="ChatLog", + body={}, + ).execute() + service.spreadsheets().values().update( + spreadsheetId=LOG_SPREADSHEET_ID, + range="ChatLog!A1", + valueInputOption="RAW", + body={"values": chat_rows}, + ).execute() + + freq_rows = _fetch_freq_rows() + print(f"LOG export: writing FreqMsg rows={len(freq_rows)}") + service.spreadsheets().values().clear( + spreadsheetId=LOG_SPREADSHEET_ID, + range="FreqMsg", + body={}, + ).execute() + service.spreadsheets().values().update( + spreadsheetId=LOG_SPREADSHEET_ID, + range="FreqMsg!A1", + valueInputOption="RAW", + body={"values": freq_rows}, + ).execute() + print("LOG export: done") diff --git a/services/recognizer.py b/services/recognizer.py new file mode 100644 index 0000000..88c8249 --- /dev/null +++ b/services/recognizer.py @@ -0,0 +1,10 @@ +import re +from typing import Optional + + +def recognize_pinpad_code(text: str) -> Optional[int]: + if not text: + return None + + match = re.search(r"\b\d{3}\b", text) + return int(match.group()) if match else None diff --git a/services/sync_google.py b/services/sync_google.py new file mode 100644 index 0000000..df1a2a0 --- /dev/null +++ b/services/sync_google.py @@ -0,0 +1,386 @@ +import httplib2 +import googleapiclient.discovery +from oauth2client.service_account import ServiceAccountCredentials + +from config import CREDENTIALS_FILE, SPREADSHEET_ID +from database.db import get_connection + +SCOPES = [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive", +] + + +def get_google_service(): + credentials = ServiceAccountCredentials.from_json_keyfile_name( + CREDENTIALS_FILE, SCOPES + ) + http = credentials.authorize(httplib2.Http()) + return googleapiclient.discovery.build( + "sheets", "v4", http=http, cache_discovery=False + ) + + +def _fetch_rows(service, sheet_range: str): + return service.spreadsheets().values().get( + spreadsheetId=SPREADSHEET_ID, + range=sheet_range, + majorDimension="ROWS", + ).execute().get("values", []) + + +def _require_columns(row, count: int, sheet_name: str, idx: int): + if len(row) < count: + raise ValueError(f"{sheet_name} row {idx}: expected {count} columns, got {len(row)}") + + +def _require_non_empty(value: str, sheet_name: str, idx: int, column_name: str) -> str: + value = (value or "").strip() + if not value: + raise ValueError(f"{sheet_name} row {idx}: {column_name} is empty") + return value + + +def _is_empty_row(row) -> bool: + return not row or not any((value or "").strip() for value in row) + + +def _validate_pinpad_errors(rows): + if not rows: + raise ValueError("PinPad: table is empty") + + parsed = [] + seen_codes = set() + for idx, row in enumerate(rows, start=2): + _require_columns(row, 3, "PinPad", idx) + code = int(_require_non_empty(row[0], "PinPad", idx, "code")) + if code in seen_codes: + raise ValueError(f"PinPad row {idx}: duplicate code {code}") + seen_codes.add(code) + parsed.append( + ( + code, + _require_non_empty(row[1], "PinPad", idx, "reason"), + _require_non_empty(row[2], "PinPad", idx, "action"), + ) + ) + return parsed + + +def _validate_terminal(rows, key_rows, step_rows): + if not rows: + raise ValueError("Terminal_Instructions: table is empty") + if not key_rows: + raise ValueError("Terminal_Keys: table is empty") + if not step_rows: + raise ValueError("Terminal_Steps: table is empty") + + instructions = [] + instruction_ids = set() + for idx, row in enumerate(rows, start=2): + _require_columns(row, 2, "Terminal_Instructions", idx) + instruction_id = int(_require_non_empty(row[0], "Terminal_Instructions", idx, "id")) + if instruction_id in instruction_ids: + raise ValueError(f"Terminal_Instructions row {idx}: duplicate id {instruction_id}") + instruction_ids.add(instruction_id) + instructions.append( + ( + instruction_id, + _require_non_empty(row[1], "Terminal_Instructions", idx, "title"), + ) + ) + + keys = [] + for idx, row in enumerate(key_rows, start=2): + _require_columns(row, 3, "Terminal_Keys", idx) + instruction_id = int(_require_non_empty(row[0], "Terminal_Keys", idx, "instruction_id")) + if instruction_id not in instruction_ids: + raise ValueError(f"Terminal_Keys row {idx}: unknown instruction_id {instruction_id}") + key_type = _require_non_empty(row[2], "Terminal_Keys", idx, "key_type").lower() + if key_type not in ("code", "text"): + raise ValueError(f"Terminal_Keys row {idx}: key_type must be code or text") + keys.append( + ( + instruction_id, + _require_non_empty(row[1], "Terminal_Keys", idx, "key").lower(), + key_type, + ) + ) + + steps = [] + for idx, row in enumerate(step_rows, start=2): + if _is_empty_row(row): + continue + if len(row) < 3: + print(f"WARN Terminal_Steps row {idx}: skipped incomplete row") + continue + instruction_id = int(_require_non_empty(row[0], "Terminal_Steps", idx, "instruction_id")) + if instruction_id not in instruction_ids: + raise ValueError(f"Terminal_Steps row {idx}: unknown instruction_id {instruction_id}") + step_type = _require_non_empty(row[2], "Terminal_Steps", idx, "type").lower() + if step_type not in ("text", "image", "pause", "goto"): + raise ValueError(f"Terminal_Steps row {idx}: type must be text, image, pause or goto") + content = (row[3] if len(row) > 3 else "").strip() + if step_type != "pause" and not content: + print(f"WARN Terminal_Steps row {idx}: skipped {step_type} step with empty content") + continue + steps.append( + ( + instruction_id, + int(_require_non_empty(row[1], "Terminal_Steps", idx, "step_order")), + step_type, + content, + ) + ) + + if not steps: + raise ValueError("Terminal_Steps: no valid steps found") + + return instructions, keys, steps + + +def _validate_tech_problems(rows): + if not rows: + raise ValueError("TechProblems: table is empty") + + problems = [] + problem_ids = set() + for idx, row in enumerate(rows, start=2): + _require_columns(row, 3, "TechProblems", idx) + problem_id = _require_non_empty(row[0], "TechProblems", idx, "id") + if problem_id in problem_ids: + raise ValueError(f"TechProblems row {idx}: duplicate id {problem_id}") + task_type = _require_non_empty(row[1], "TechProblems", idx, "task_type").upper() + if task_type not in ("ADMIN", "TECH"): + raise ValueError(f"TechProblems row {idx}: task_type must be ADMIN or TECH") + problem_ids.add(problem_id) + problems.append( + ( + problem_id, + task_type, + _require_non_empty(row[2], "TechProblems", idx, "keywords"), + ) + ) + return problems, problem_ids + + +def _validate_tech_solutions(rows, problem_ids): + if not rows: + raise ValueError("TechProblems_Solution: table is empty") + + solutions = [] + seen_ids = set() + for idx, row in enumerate(rows, start=2): + _require_columns(row, 8, "TechProblems_Solution", idx) + problem_id = _require_non_empty(row[0], "TechProblems_Solution", idx, "problem_id") + if problem_id in seen_ids: + raise ValueError(f"TechProblems_Solution row {idx}: duplicate problem_id {problem_id}") + if problem_id not in problem_ids: + raise ValueError(f"TechProblems_Solution row {idx}: unknown problem_id {problem_id}") + task_type = _require_non_empty(row[2], "TechProblems_Solution", idx, "task_type").upper() + can_fix_self = _require_non_empty(row[3], "TechProblems_Solution", idx, "can_fix_self").upper() + need_result_feedback = _require_non_empty(row[4], "TechProblems_Solution", idx, "need_result_feedback").upper() + if task_type not in ("ADMIN", "TECH"): + raise ValueError(f"TechProblems_Solution row {idx}: task_type must be ADMIN or TECH") + if can_fix_self not in ("YES", "NO"): + raise ValueError(f"TechProblems_Solution row {idx}: can_fix_self must be YES or NO") + if need_result_feedback not in ("YES", "NO"): + raise ValueError(f"TechProblems_Solution row {idx}: need_result_feedback must be YES or NO") + seen_ids.add(problem_id) + solutions.append( + ( + problem_id, + _require_non_empty(row[1], "TechProblems_Solution", idx, "problem_name"), + task_type, + can_fix_self, + need_result_feedback, + _require_non_empty(row[5], "TechProblems_Solution", idx, "solution_steps"), + (row[6] or "").strip(), + (row[7] or "").strip(), + ) + ) + return solutions + + +def _write_sync_data(pinpad_errors, terminal_data, tech_problems, tech_solutions): + instructions, keys, steps = terminal_data + with get_connection() as conn: + cur = conn.cursor() + cur.execute("BEGIN") + + cur.execute("DELETE FROM terminal_instruction_steps") + cur.execute("DELETE FROM terminal_instruction_keys") + cur.execute("DELETE FROM terminal_instructions") + cur.execute("DELETE FROM pinpad_errors") + cur.execute("DELETE FROM tech_problem_solutions") + cur.execute("DELETE FROM tech_problems") + + cur.executemany( + """ + INSERT INTO pinpad_errors (code, reason, action) + VALUES (?, ?, ?) + """, + pinpad_errors, + ) + cur.executemany( + "INSERT INTO terminal_instructions (id, title) VALUES (?, ?)", + instructions, + ) + cur.executemany( + """ + INSERT INTO terminal_instruction_keys + (instruction_id, key, key_type) + VALUES (?, ?, ?) + """, + keys, + ) + cur.executemany( + """ + INSERT INTO terminal_instruction_steps + (instruction_id, step_order, type, content) + VALUES (?, ?, ?, ?) + """, + steps, + ) + cur.executemany( + """ + INSERT INTO tech_problems (id, task_type, keywords) + VALUES (?, ?, ?) + """, + tech_problems, + ) + cur.executemany( + """ + INSERT INTO tech_problem_solutions + (problem_id, problem_name, task_type, can_fix_self, + need_result_feedback, solution_steps, tools_needed, + when_stop_and_report) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + tech_solutions, + ) + conn.commit() + + +def sync_pinpad_errors(service): + rows = _fetch_rows(service, "PinPad!A2:C1000") + pinpad_errors = _validate_pinpad_errors(rows) + with get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM pinpad_errors") + cur.executemany( + """ + INSERT INTO pinpad_errors (code, reason, action) + VALUES (?, ?, ?) + """, + pinpad_errors, + ) + conn.commit() + + +def sync_terminal(service): + terminal_data = _validate_terminal( + _fetch_rows(service, "Terminal_Instructions!A2:B1000"), + _fetch_rows(service, "Terminal_Keys!A2:C2000"), + _fetch_rows(service, "Terminal_Steps!A2:D3000"), + ) + instructions, keys, steps = terminal_data + with get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM terminal_instruction_steps") + cur.execute("DELETE FROM terminal_instruction_keys") + cur.execute("DELETE FROM terminal_instructions") + cur.executemany( + "INSERT INTO terminal_instructions (id, title) VALUES (?, ?)", + instructions, + ) + cur.executemany( + """ + INSERT INTO terminal_instruction_keys + (instruction_id, key, key_type) + VALUES (?, ?, ?) + """, + keys, + ) + cur.executemany( + """ + INSERT INTO terminal_instruction_steps + (instruction_id, step_order, type, content) + VALUES (?, ?, ?, ?) + """, + steps, + ) + conn.commit() + + +def sync_tech_problems(service): + tech_problems, _problem_ids = _validate_tech_problems( + _fetch_rows(service, "TechProblems!A2:C2000") + ) + with get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM tech_problem_solutions") + cur.execute("DELETE FROM tech_problems") + cur.executemany( + """ + INSERT INTO tech_problems (id, task_type, keywords) + VALUES (?, ?, ?) + """, + tech_problems, + ) + conn.commit() + + +def sync_tech_problem_solutions(service): + tech_problems, problem_ids = _validate_tech_problems( + _fetch_rows(service, "TechProblems!A2:C2000") + ) + tech_solutions = _validate_tech_solutions( + _fetch_rows(service, "TechProblems_Solution!A2:H2000"), + problem_ids, + ) + with get_connection() as conn: + cur = conn.cursor() + cur.execute("DELETE FROM tech_problem_solutions") + cur.execute("DELETE FROM tech_problems") + cur.executemany( + """ + INSERT INTO tech_problems (id, task_type, keywords) + VALUES (?, ?, ?) + """, + tech_problems, + ) + cur.executemany( + """ + INSERT INTO tech_problem_solutions + (problem_id, problem_name, task_type, can_fix_self, + need_result_feedback, solution_steps, tools_needed, + when_stop_and_report) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + tech_solutions, + ) + conn.commit() + + +def sync_all(): + service = get_google_service() + print("SYNC fetch data") + + pinpad_errors = _validate_pinpad_errors(_fetch_rows(service, "PinPad!A2:C1000")) + terminal_data = _validate_terminal( + _fetch_rows(service, "Terminal_Instructions!A2:B1000"), + _fetch_rows(service, "Terminal_Keys!A2:C2000"), + _fetch_rows(service, "Terminal_Steps!A2:D3000"), + ) + tech_problems, problem_ids = _validate_tech_problems( + _fetch_rows(service, "TechProblems!A2:C2000") + ) + tech_solutions = _validate_tech_solutions( + _fetch_rows(service, "TechProblems_Solution!A2:H2000"), + problem_ids, + ) + + print("SYNC write data") + _write_sync_data(pinpad_errors, terminal_data, tech_problems, tech_solutions) + print("SYNC done")