Initial commit
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
creds.json
|
||||||
|
database/*.db
|
||||||
|
database/*.db-*
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.idea/
|
||||||
122
bot.py
Normal file
122
bot.py
Normal file
@@ -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()
|
||||||
38
config.py
Normal file
38
config.py
Normal file
@@ -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")
|
||||||
11
database/db.py
Normal file
11
database/db.py
Normal file
@@ -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
|
||||||
293
database/init_db.py
Normal file
293
database/init_db.py
Normal file
@@ -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")
|
||||||
14
database/pinpad.py
Normal file
14
database/pinpad.py
Normal file
@@ -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()
|
||||||
311
database/repository.py
Normal file
311
database/repository.py
Normal file
@@ -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()
|
||||||
7
handlers/errors.py
Normal file
7
handlers/errors.py
Normal file
@@ -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)
|
||||||
293
handlers/fallback.py
Normal file
293
handlers/fallback.py
Normal file
@@ -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
|
||||||
67
handlers/menu.py
Normal file
67
handlers/menu.py
Normal file
@@ -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
|
||||||
37
keyboards/factory.py
Normal file
37
keyboards/factory.py
Normal file
@@ -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()
|
||||||
12
menus/engine.py
Normal file
12
menus/engine.py
Normal file
@@ -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)
|
||||||
18
menus/menu_config.py
Normal file
18
menus/menu_config.py
Normal file
@@ -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"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -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
|
||||||
20
services/google.py
Normal file
20
services/google.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
260
services/instructions.py
Normal file
260
services/instructions.py
Normal file
@@ -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
|
||||||
216
services/log_export.py
Normal file
216
services/log_export.py
Normal file
@@ -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")
|
||||||
10
services/recognizer.py
Normal file
10
services/recognizer.py
Normal file
@@ -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
|
||||||
386
services/sync_google.py
Normal file
386
services/sync_google.py
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user