- seconds // 86400 floored to N-1 (bought 10 -> showed 9); use ceil instead - concrete MSK expiry time stays only for the last day (< 24h remaining)
666 lines
28 KiB
Python
666 lines
28 KiB
Python
from contextlib import closing
|
||
import datetime
|
||
import json
|
||
import logging
|
||
import os
|
||
import sqlite3
|
||
from pathlib import Path
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DEFAULT_DB_PATH = "/app/data/social_writer.sqlite3"
|
||
API_LOG_RETENTION_DAYS = 30
|
||
API_LOG_MAX_FIELD_BYTES = 100_000
|
||
|
||
|
||
def connect(db_path: str | None = None) -> sqlite3.Connection:
|
||
path = Path(db_path or os.environ.get("BOT_DB_PATH", DEFAULT_DB_PATH))
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
connection = sqlite3.connect(path, timeout=10)
|
||
connection.row_factory = sqlite3.Row
|
||
connection.execute("PRAGMA busy_timeout = 10000")
|
||
connection.execute("PRAGMA journal_mode = WAL")
|
||
return connection
|
||
|
||
|
||
def init_db(db_path: str | None = None) -> None:
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
telegram_user_id INTEGER PRIMARY KEY,
|
||
tone_of_voice TEXT NOT NULL DEFAULT '',
|
||
is_trained INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
"""
|
||
)
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS user_post_samples (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
telegram_user_id INTEGER NOT NULL,
|
||
post_text TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE (telegram_user_id, post_text)
|
||
)
|
||
"""
|
||
)
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS user_training_notes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
telegram_user_id INTEGER NOT NULL,
|
||
note_text TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE (telegram_user_id, note_text)
|
||
)
|
||
"""
|
||
)
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS generated_posts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
telegram_user_id INTEGER NOT NULL,
|
||
post_text TEXT NOT NULL,
|
||
origin TEXT NOT NULL DEFAULT 'generated',
|
||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
"""
|
||
)
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS generated_ideas (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
telegram_user_id INTEGER NOT NULL,
|
||
title TEXT NOT NULL,
|
||
angle TEXT NOT NULL,
|
||
why TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
"""
|
||
)
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS api_call_logs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
telegram_user_id INTEGER,
|
||
stage TEXT NOT NULL,
|
||
model TEXT NOT NULL DEFAULT '',
|
||
prompt_sha256 TEXT NOT NULL DEFAULT '',
|
||
request_json TEXT NOT NULL,
|
||
response_json TEXT NOT NULL DEFAULT '',
|
||
status_code INTEGER,
|
||
error TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
"""
|
||
)
|
||
user_columns = {
|
||
row["name"] for row in connection.execute("PRAGMA table_info(users)").fetchall()
|
||
}
|
||
if "is_trained" not in user_columns:
|
||
connection.execute("ALTER TABLE users ADD COLUMN is_trained INTEGER NOT NULL DEFAULT 0")
|
||
if "training_summary" not in user_columns:
|
||
connection.execute(
|
||
"ALTER TABLE users ADD COLUMN training_summary TEXT NOT NULL DEFAULT ''"
|
||
)
|
||
connection.execute(
|
||
"""
|
||
UPDATE users SET is_trained = 1
|
||
WHERE TRIM(tone_of_voice) != ''
|
||
OR EXISTS (SELECT 1 FROM user_post_samples p WHERE p.telegram_user_id = users.telegram_user_id)
|
||
OR EXISTS (SELECT 1 FROM user_training_notes n WHERE n.telegram_user_id = users.telegram_user_id)
|
||
"""
|
||
)
|
||
api_log_columns = {
|
||
row["name"] for row in connection.execute("PRAGMA table_info(api_call_logs)").fetchall()
|
||
}
|
||
if "operation_id" not in api_log_columns:
|
||
connection.execute("ALTER TABLE api_call_logs ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''")
|
||
if "duration_ms" not in api_log_columns:
|
||
connection.execute("ALTER TABLE api_call_logs ADD COLUMN duration_ms INTEGER")
|
||
connection.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_api_call_logs_created_at ON api_call_logs(created_at)"
|
||
)
|
||
connection.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_api_call_logs_user_stage ON api_call_logs(telegram_user_id, stage)"
|
||
)
|
||
connection.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||
telegram_user_id INTEGER PRIMARY KEY,
|
||
trial_started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
monthly_days INTEGER NOT NULL DEFAULT 30,
|
||
last_paid_at TEXT,
|
||
is_active INTEGER NOT NULL DEFAULT 1,
|
||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
"""
|
||
)
|
||
|
||
|
||
# Единый бесплатный старт и разовые тарифы подписки.
|
||
TRIAL_DAYS = 3
|
||
SUBSCRIPTION_MONTH_DAYS = 30 # Совместимость со старым dev-сценарием.
|
||
SUBSCRIPTION_PRICE_STARS = 450
|
||
# Время окончания подписки показываем по Москве (MSK = фиксированный UTC+3).
|
||
MSK_TIMEZONE = datetime.timezone(datetime.timedelta(hours=3))
|
||
SUBSCRIPTION_PLANS = {
|
||
0: 1,
|
||
1: 15,
|
||
10: 150,
|
||
30: 450,
|
||
}
|
||
|
||
|
||
def _subscription_expires_at(sub: sqlite3.Row) -> datetime.datetime | None:
|
||
if not sub or sub["is_active"] == 0:
|
||
return None
|
||
|
||
def utc(value: str) -> datetime.datetime:
|
||
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||
if parsed.tzinfo is not None:
|
||
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||
return parsed
|
||
|
||
if sub["last_paid_at"] is None:
|
||
return utc(sub["trial_started_at"]) + datetime.timedelta(days=TRIAL_DAYS)
|
||
return utc(sub["last_paid_at"])
|
||
|
||
|
||
def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int:
|
||
"""Сколько дней осталось от оплаты (триал или подписка). Отрицательно = истекло/отключено."""
|
||
if not sub:
|
||
return -1
|
||
if sub["is_active"] == 0:
|
||
return -1
|
||
expires = _subscription_expires_at(sub)
|
||
if expires is None:
|
||
return -1
|
||
now = now or datetime.datetime.now(datetime.timezone.utc)
|
||
now = now.astimezone(datetime.timezone.utc).replace(tzinfo=None) if now.tzinfo else now
|
||
delta = expires - now
|
||
return delta.days if delta.total_seconds() > 0 else -1
|
||
|
||
|
||
def subscription_time_left(
|
||
sub: sqlite3.Row,
|
||
now: datetime.datetime | None = None,
|
||
display_tz: datetime.timezone | None = None,
|
||
) -> tuple[bool, int | str, str]:
|
||
expires = _subscription_expires_at(sub)
|
||
if expires is None:
|
||
return False, 0, ""
|
||
now = now or datetime.datetime.now(datetime.timezone.utc)
|
||
now = now.astimezone(datetime.timezone.utc).replace(tzinfo=None) if now.tzinfo else now
|
||
seconds = int((expires - now).total_seconds())
|
||
if seconds <= 0:
|
||
return False, 0, ""
|
||
if seconds >= 86400:
|
||
# Округляем вверх: купленные N дней показываем как "N дн.", а не "N-1".
|
||
return True, -(-seconds // 86400), "дн."
|
||
# Меньше суток (последний день) — показываем конкретное время окончания вместо "N ч.".
|
||
display_tz = display_tz or MSK_TIMEZONE
|
||
stamp = (expires + datetime.timedelta(microseconds=1)).replace(tzinfo=datetime.timezone.utc)
|
||
return True, stamp.astimezone(display_tz).strftime("%d.%m.%Y %H:%M") + " (МСК)", ""
|
||
|
||
|
||
def subscription_status_text(
|
||
sub: sqlite3.Row | None,
|
||
now: datetime.datetime | None = None,
|
||
display_tz: datetime.timezone | None = None,
|
||
) -> str:
|
||
"""Единый текст статуса подписки: дни (>= суток) или конкретное время (< суток)."""
|
||
if not sub:
|
||
return "Подписка закончилась"
|
||
active, value, unit = subscription_time_left(sub, now=now, display_tz=display_tz)
|
||
if not active:
|
||
return "Подписка закончилась"
|
||
if unit == "":
|
||
return f"Подписка активна\nЗакончится в {value}"
|
||
return f"Подписка активна\nЗакончится через {value} {unit}"
|
||
|
||
def subscription_row(user_id: int, db_path: str | None = None) -> sqlite3.Row | None:
|
||
with closing(connect(db_path)) as connection:
|
||
return connection.execute(
|
||
"SELECT * FROM subscriptions WHERE telegram_user_id = ?", (user_id,)
|
||
).fetchone()
|
||
|
||
|
||
def ensure_subscription(user_id: int, db_path: str | None = None) -> None:
|
||
"""Создаёт запись подписки с триалом, если её нет."""
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"INSERT OR IGNORE INTO subscriptions (telegram_user_id) VALUES (?)", (user_id,)
|
||
)
|
||
|
||
|
||
def activate_subscription_days(user_id: int, days: int, db_path: str | None = None) -> int:
|
||
"""Добавляет срок к текущей подписке и возвращает оставшиеся дни."""
|
||
if days <= 0:
|
||
raise ValueError("Subscription days must be positive")
|
||
with closing(connect(db_path)) as connection, connection:
|
||
sub = subscription_row(user_id, db_path)
|
||
if not sub:
|
||
raise RuntimeError("Subscription record not found")
|
||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||
current_days = _days_until(sub, now)
|
||
if current_days > 0:
|
||
current_expires = now + datetime.timedelta(days=current_days, seconds=1)
|
||
new_expires = current_expires + datetime.timedelta(days=days)
|
||
else:
|
||
new_expires = now + datetime.timedelta(days=days)
|
||
connection.execute(
|
||
"""
|
||
UPDATE subscriptions
|
||
SET last_paid_at = ?, is_active = 1, monthly_days = ?, updated_at = CURRENT_TIMESTAMP
|
||
WHERE telegram_user_id = ?
|
||
""",
|
||
(new_expires.isoformat(), days, user_id),
|
||
)
|
||
return max(0, (new_expires - now).days)
|
||
|
||
|
||
def activate_subscription(user_id: int, months: int = 1, db_path: str | None = None) -> int:
|
||
"""Совместимый интерфейс старого dev-сценария: добавляет месячный срок."""
|
||
return activate_subscription_days(user_id, SUBSCRIPTION_MONTH_DAYS * months, db_path)
|
||
|
||
|
||
def deactivate_subscription(user_id: int, db_path: str | None = None) -> None:
|
||
"""Отключает подписку (период догорает до конца)."""
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"UPDATE subscriptions SET is_active = 0 WHERE telegram_user_id = ?", (user_id,)
|
||
)
|
||
|
||
|
||
def is_subscribed(user_id: int, db_path: str | None = None) -> bool:
|
||
"""Правда ли подписка активна (срок не истёк)."""
|
||
sub = subscription_row(user_id, db_path)
|
||
if not sub:
|
||
return False
|
||
if not sub["is_active"]:
|
||
return False
|
||
return _days_until(sub) > 0
|
||
|
||
|
||
def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]:
|
||
samples = [post.strip() for post in posts if post and post.strip()]
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute("INSERT OR IGNORE INTO users (telegram_user_id) VALUES (?)", (user_id,))
|
||
if samples:
|
||
connection.execute(
|
||
"UPDATE users SET is_trained = 1, updated_at = CURRENT_TIMESTAMP WHERE telegram_user_id = ?",
|
||
(user_id,),
|
||
)
|
||
connection.executemany(
|
||
"INSERT OR IGNORE INTO user_post_samples (telegram_user_id, post_text) VALUES (?, ?)",
|
||
[(user_id, post) for post in samples],
|
||
)
|
||
rows = connection.execute(
|
||
"SELECT post_text FROM user_post_samples WHERE telegram_user_id = ? ORDER BY id",
|
||
(user_id,),
|
||
).fetchall()
|
||
return [str(row["post_text"]) for row in rows]
|
||
|
||
|
||
def get_post_samples(user_id: int, db_path: str | None = None) -> list[str]:
|
||
with closing(connect(db_path)) as connection:
|
||
rows = connection.execute(
|
||
"SELECT post_text FROM user_post_samples WHERE telegram_user_id = ? ORDER BY id",
|
||
(user_id,),
|
||
).fetchall()
|
||
return [str(row["post_text"]) for row in rows]
|
||
def add_training_note(user_id: int, note: str, db_path: str | None = None) -> list[str]:
|
||
text = note.strip()
|
||
if not text:
|
||
return get_training_notes(user_id, db_path)
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute("INSERT OR IGNORE INTO users (telegram_user_id) VALUES (?)", (user_id,))
|
||
connection.execute(
|
||
"UPDATE users SET is_trained = 1, updated_at = CURRENT_TIMESTAMP WHERE telegram_user_id = ?",
|
||
(user_id,),
|
||
)
|
||
connection.execute(
|
||
"INSERT OR IGNORE INTO user_training_notes (telegram_user_id, note_text) VALUES (?, ?)",
|
||
(user_id, text),
|
||
)
|
||
rows = connection.execute(
|
||
"SELECT note_text FROM user_training_notes WHERE telegram_user_id = ? ORDER BY id",
|
||
(user_id,),
|
||
).fetchall()
|
||
return [str(row["note_text"]) for row in rows]
|
||
|
||
|
||
def get_training_notes(user_id: int, db_path: str | None = None) -> list[str]:
|
||
with closing(connect(db_path)) as connection:
|
||
rows = connection.execute(
|
||
"SELECT note_text FROM user_training_notes WHERE telegram_user_id = ? ORDER BY id",
|
||
(user_id,),
|
||
).fetchall()
|
||
return [str(row["note_text"]) for row in rows]
|
||
|
||
|
||
def _bounded_json(value: str, max_bytes: int = API_LOG_MAX_FIELD_BYTES) -> str:
|
||
if len(value.encode("utf-8")) <= max_bytes:
|
||
return value
|
||
preview = value.encode("utf-8")[:max_bytes // 4].decode("utf-8", errors="ignore")
|
||
return json.dumps({"truncated": True, "preview": preview}, ensure_ascii=False)
|
||
|
||
|
||
def save_api_call_log(
|
||
*,
|
||
stage: str,
|
||
request_json: str,
|
||
response_json: str = "",
|
||
model: str = "",
|
||
prompt_sha256: str = "",
|
||
user_id: int | None = None,
|
||
status_code: int | None = None,
|
||
error: str = "",
|
||
operation_id: str | None = None,
|
||
duration_ms: int | None = None,
|
||
db_path: str | None = None,
|
||
) -> int:
|
||
request_json = _bounded_json(request_json)
|
||
response_json = _bounded_json(response_json) if response_json else ""
|
||
with closing(connect(db_path)) as connection, connection:
|
||
cursor = connection.execute(
|
||
"""
|
||
INSERT INTO api_call_logs (
|
||
telegram_user_id, stage, model, prompt_sha256, request_json,
|
||
response_json, status_code, error, operation_id, duration_ms
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(user_id, stage[:100], model[:200], prompt_sha256[:64], request_json,
|
||
response_json, status_code, error[:2000], (operation_id or "")[:32], duration_ms),
|
||
)
|
||
connection.execute(
|
||
"DELETE FROM api_call_logs WHERE created_at < datetime('now', ?)",
|
||
(f"-{API_LOG_RETENTION_DAYS} days",),
|
||
)
|
||
if cursor.lastrowid is None:
|
||
raise RuntimeError("Could not save API log record")
|
||
return cursor.lastrowid
|
||
|
||
|
||
def list_api_call_logs(
|
||
limit: int = 50, user_id: int | None = None, db_path: str | None = None
|
||
) -> list[dict[str, object]]:
|
||
limit = max(1, min(limit, 200))
|
||
with closing(connect(db_path)) as connection:
|
||
if user_id is None:
|
||
rows = connection.execute(
|
||
"SELECT id, telegram_user_id, stage, model, prompt_sha256, status_code, error, operation_id, duration_ms, created_at, "
|
||
"length(request_json) AS request_chars, length(response_json) AS response_chars "
|
||
"FROM api_call_logs ORDER BY id DESC LIMIT ?", (limit,),
|
||
).fetchall()
|
||
else:
|
||
rows = connection.execute(
|
||
"SELECT id, telegram_user_id, stage, model, prompt_sha256, status_code, error, operation_id, duration_ms, created_at, "
|
||
"length(request_json) AS request_chars, length(response_json) AS response_chars "
|
||
"FROM api_call_logs WHERE telegram_user_id = ? ORDER BY id DESC LIMIT ?", (user_id, limit),
|
||
).fetchall()
|
||
return [dict(row) for row in rows]
|
||
|
||
|
||
def get_api_call_log(log_id: int, db_path: str | None = None) -> dict[str, object] | None:
|
||
with closing(connect(db_path)) as connection:
|
||
row = connection.execute("SELECT * FROM api_call_logs WHERE id = ?", (log_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def clear_user_tone_data(user_id: int, db_path: str | None = None) -> None:
|
||
clear_user_data(user_id, db_path)
|
||
|
||
|
||
def get_post_history(user_id: int, limit: int = 30, db_path: str | None = None) -> list[dict[str, object]]:
|
||
limit = max(1, min(limit, 100))
|
||
with closing(connect(db_path)) as connection:
|
||
rows = connection.execute(
|
||
"SELECT post_text, origin, created_at FROM generated_posts "
|
||
"WHERE telegram_user_id = ? ORDER BY id DESC LIMIT ?",
|
||
(user_id, limit),
|
||
).fetchall()
|
||
return [dict(row) for row in rows]
|
||
|
||
|
||
def save_generated_posts(user_id: int, posts: list[str], origin: str, db_path: str | None = None) -> None:
|
||
cleaned = [post.strip() for post in posts if post and post.strip()]
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute("INSERT OR IGNORE INTO users (telegram_user_id) VALUES (?)", (user_id,))
|
||
connection.executemany(
|
||
"INSERT INTO generated_posts (telegram_user_id, post_text, origin) VALUES (?, ?, ?)",
|
||
[(user_id, post, origin[:30]) for post in cleaned],
|
||
)
|
||
|
||
|
||
def get_recent_post_history_text(user_id: int, limit: int = 30, char_limit: int = 12000, db_path: str | None = None) -> list[str]:
|
||
history = get_post_history(user_id, limit, db_path)
|
||
result: list[str] = []
|
||
total = 0
|
||
for row in history:
|
||
text = str(row["post_text"])
|
||
if total + len(text) > char_limit:
|
||
break
|
||
result.append(text)
|
||
total += len(text)
|
||
return result
|
||
|
||
|
||
def save_idea(user_id: int, idea: dict[str, str], db_path: str | None = None) -> None:
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute("INSERT OR IGNORE INTO users (telegram_user_id) VALUES (?)", (user_id,))
|
||
connection.execute(
|
||
"INSERT INTO generated_ideas (telegram_user_id, title, angle, why) VALUES (?, ?, ?, ?)",
|
||
(user_id, idea["title"], idea["angle"], idea["why"]),
|
||
)
|
||
|
||
|
||
def get_idea_history(user_id: int, limit: int = 30, db_path: str | None = None) -> list[dict[str, str]]:
|
||
with closing(connect(db_path)) as connection:
|
||
rows = connection.execute(
|
||
"SELECT title, angle, why FROM generated_ideas WHERE telegram_user_id = ? ORDER BY id DESC LIMIT ?",
|
||
(user_id, max(1, min(limit, 100))),
|
||
).fetchall()
|
||
return [{"title": str(row["title"]), "angle": str(row["angle"]), "why": str(row["why"])} for row in rows]
|
||
|
||
|
||
def clear_user_data(user_id: int, db_path: str | None = None) -> None:
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute("DELETE FROM user_training_notes WHERE telegram_user_id = ?", (user_id,))
|
||
connection.execute("DELETE FROM user_post_samples WHERE telegram_user_id = ?", (user_id,))
|
||
connection.execute("DELETE FROM generated_posts WHERE telegram_user_id = ?", (user_id,))
|
||
connection.execute("DELETE FROM generated_ideas WHERE telegram_user_id = ?", (user_id,))
|
||
connection.execute("DELETE FROM users WHERE telegram_user_id = ?", (user_id,))
|
||
|
||
|
||
def get_tone_of_voice(user_id: int, db_path: str | None = None) -> str:
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"INSERT OR IGNORE INTO users (telegram_user_id) VALUES (?)", (user_id,)
|
||
)
|
||
row = connection.execute(
|
||
"SELECT tone_of_voice FROM users WHERE telegram_user_id = ?", (user_id,)
|
||
).fetchone()
|
||
return str(row["tone_of_voice"] or "") if row else ""
|
||
|
||
|
||
def is_user_trained(user_id: int, db_path: str | None = None) -> bool:
|
||
with closing(connect(db_path)) as connection:
|
||
row = connection.execute(
|
||
"SELECT is_trained FROM users WHERE telegram_user_id = ?", (user_id,)
|
||
).fetchone()
|
||
return bool(row["is_trained"]) if row else False
|
||
|
||
|
||
def support_profile_summary(user_id: int, db_path: str | None = None) -> dict[str, object]:
|
||
"""Сводка профиля для карточки обращения в поддержку.
|
||
|
||
Возвращает словарь с полями: user_id, days_used, renewals, days_left,
|
||
period (trial|paid|none), is_trained.
|
||
"""
|
||
summary: dict[str, object] = {
|
||
"user_id": int(user_id),
|
||
"days_used": 0,
|
||
"renewals": 0,
|
||
"days_left": -1,
|
||
"period": "none",
|
||
"is_trained": False,
|
||
}
|
||
with closing(connect(db_path)) as connection:
|
||
row = connection.execute(
|
||
"SELECT created_at, is_trained FROM users WHERE telegram_user_id = ?",
|
||
(user_id,),
|
||
).fetchone()
|
||
sub = connection.execute(
|
||
"SELECT * FROM subscriptions WHERE telegram_user_id = ?", (user_id,)
|
||
).fetchone()
|
||
try:
|
||
renewals = connection.execute(
|
||
"SELECT COUNT(*) AS total FROM payments WHERE telegram_user_id = ?",
|
||
(user_id,),
|
||
).fetchone()
|
||
except sqlite3.OperationalError as exc:
|
||
# Таблица платежей создаётся payments.init_payments_table().
|
||
logger.warning("Payments table is unavailable: %s", exc)
|
||
renewals = None
|
||
|
||
if row is not None:
|
||
summary["is_trained"] = bool(row["is_trained"])
|
||
created = _parse_utc(row["created_at"])
|
||
if created is not None:
|
||
summary["days_used"] = max(
|
||
0, (datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - created).days
|
||
)
|
||
if sub is not None:
|
||
days_left = _days_until(sub)
|
||
summary["days_left"] = days_left
|
||
summary["period"] = "paid" if sub["last_paid_at"] else "trial"
|
||
if sub["last_paid_at"] is None:
|
||
started = _parse_utc(sub["trial_started_at"])
|
||
if started is not None and (row is None or row["created_at"] is None):
|
||
summary["days_used"] = max(
|
||
0,
|
||
(datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - started).days,
|
||
)
|
||
if renewals is not None:
|
||
summary["renewals"] = int(renewals["total"])
|
||
return summary
|
||
|
||
|
||
def _parse_utc(value: str | None) -> datetime.datetime | None:
|
||
"""Разбирает SQLite CURRENT_TIMESTAMP в naive UTC; None при пустом/битом значении."""
|
||
if not value:
|
||
return None
|
||
try:
|
||
parsed = datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return None
|
||
if parsed.tzinfo is not None:
|
||
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||
return parsed
|
||
|
||
|
||
def get_deploy_notice_version(db_path: str | None = None) -> str:
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"CREATE TABLE IF NOT EXISTS app_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||
)
|
||
row = connection.execute(
|
||
"SELECT value FROM app_state WHERE key = 'deploy_notice_version'"
|
||
).fetchone()
|
||
return str(row["value"]) if row else ""
|
||
|
||
|
||
def set_deploy_notice_version(version: str, db_path: str | None = None) -> None:
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"CREATE TABLE IF NOT EXISTS app_state (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
||
)
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO app_state (key, value) VALUES ('deploy_notice_version', ?)
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||
""",
|
||
(version,),
|
||
)
|
||
|
||
|
||
def save_tone_of_voice(user_id: int, tone_of_voice: str, db_path: str | None = None) -> None:
|
||
tone = tone_of_voice.strip()[:1200]
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO users (telegram_user_id, tone_of_voice, updated_at)
|
||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(telegram_user_id) DO UPDATE SET
|
||
tone_of_voice = excluded.tone_of_voice,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
""",
|
||
(user_id, tone),
|
||
)
|
||
|
||
|
||
TRAINING_SUMMARY_FIELDS = ("about", "topics", "style", "audience")
|
||
|
||
|
||
def get_training_summary(user_id: int, db_path: str | None = None) -> dict[str, str]:
|
||
"""Возвращает короткую суммаризацию обучения (что бот знает о пользователе)."""
|
||
with closing(connect(db_path)) as connection:
|
||
connection.execute(
|
||
"INSERT OR IGNORE INTO users (telegram_user_id) VALUES (?)", (user_id,)
|
||
)
|
||
row = connection.execute(
|
||
"SELECT training_summary FROM users WHERE telegram_user_id = ?", (user_id,)
|
||
).fetchone()
|
||
raw = str(row["training_summary"] or "") if row else ""
|
||
if not raw.strip():
|
||
return {}
|
||
try:
|
||
parsed = json.loads(raw)
|
||
except (TypeError, ValueError):
|
||
logger.warning("Training summary JSON is broken for user %s", user_id)
|
||
return {}
|
||
if not isinstance(parsed, dict):
|
||
return {}
|
||
return {
|
||
field: str(parsed.get(field, "")).strip()
|
||
for field in TRAINING_SUMMARY_FIELDS
|
||
}
|
||
|
||
|
||
def get_training_updated_at(user_id: int, db_path: str | None = None) -> str:
|
||
"""Время последнего обучения (UTC, naive) — updated_at строки профиля."""
|
||
with closing(connect(db_path)) as connection:
|
||
row = connection.execute(
|
||
"SELECT updated_at FROM users WHERE telegram_user_id = ?", (user_id,)
|
||
).fetchone()
|
||
return str(row["updated_at"] or "") if row else ""
|
||
|
||
|
||
def save_training_summary(
|
||
user_id: int, summary: dict[str, str], db_path: str | None = None
|
||
) -> None:
|
||
"""Сохраняет суммаризацию обучения, сформированную после обновления профиля."""
|
||
payload = json.dumps(
|
||
{
|
||
field: str(summary.get(field, "")).strip()[:400]
|
||
for field in TRAINING_SUMMARY_FIELDS
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
with closing(connect(db_path)) as connection, connection:
|
||
connection.execute(
|
||
"""
|
||
INSERT INTO users (telegram_user_id, training_summary, updated_at)
|
||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||
ON CONFLICT(telegram_user_id) DO UPDATE SET
|
||
training_summary = excluded.training_summary,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
""",
|
||
(user_id, payload),
|
||
)
|