feat: подписка 100 звёзд, триал 3 дня, /balance, /subscribe

This commit is contained in:
admin committed 2026-10-05 19:50:28 +02:00
1 parent 7c5b28233b
commit 78d57fe36b
4 files changed
+440 -3

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Деплой приложения в Coolify v4 (ставит деплой в очередь).
# Coolify API не имеет endpoint /applications/{uuid}/deploy —
# деплой запускается через POST /api/v1/deploy?uuid=<app-uuid>&force=false
#
# Использование:
# ./deploy.sh # деплой по умолчанию (social-post-writer-bot-development)
# APP_UUID=<uuid> ./deploy.sh # деплой другого приложения
# APP_UUID=<uuid> FORCE=true ./deploy.sh
set -euo pipefail
# Универсально: uuid можно переопределить через env
APP_UUID="${APP_UUID:-d32u5zcq2g4odmdpq9hw91yn}"
APP_NAME="${APP_NAME:-social-post-writer-bot-development}"
FORCE="${FORCE:-false}"
COOLIFY_API="${COOLIFY_API:-http://10.19.0.2:8000/api/v1}"
COOLIFY_TOKEN=$(grep '^COOLIFY_TOKEN=' /root/.hermes/.env | cut -d= -f2-)
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
if [ -z "$COOLIFY_TOKEN" ]; then
echo "ERROR: COOLIFY_TOKEN не найден в /root/.hermes/.env" >&2
exit 1
fi
log "Запуск деплоя $APP_NAME (uuid=$APP_UUID, force=$FORCE)"
RESP=$(curl -sS --max-time 60 -X POST \
-H "Authorization: Bearer ${COOLIFY_TOKEN}" \
-H "Content-Type: application/json" \
"${COOLIFY_API}/deploy?uuid=${APP_UUID}&force=${FORCE}")
echo "$RESP"
# Проверяем, что деплой поставлен в очередь
if printf '%s' "$RESP" | grep -q '"deployment_uuid"'; then
DEPLOY_UUID=$(printf '%s' "$RESP" | python3 -c '
import json,sys
try:
d=json.load(sys.stdin)
print(d["deployments"][0]["deployment_uuid"])
except Exception:
print("")')
log "DEPLOY В ОЧЕРЕДИ: $DEPLOY_UUID"
exit 0
else
echo "ERROR: деплой не запустился" >&2
exit 1
fi
+165 -3
View File
@@ -14,21 +14,29 @@ from aiogram.client.telegram import TEST
from aiogram.filters import CommandStart from aiogram.filters import CommandStart
from aiogram.types import Message from aiogram.types import Message
import user_store
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
from help_message import HELP_RICH_HTML, rich_to_plain from help_message import HELP_RICH_HTML, rich_to_plain
from user_store import ( from user_store import (
add_post_samples, add_post_samples,
clear_user_data, clear_user_data,
deactivate_subscription,
get_deploy_notice_version, get_deploy_notice_version,
get_idea_history, get_idea_history,
get_post_history, get_post_history,
get_recent_post_history_text, get_recent_post_history_text,
get_tone_of_voice, get_tone_of_voice,
init_db, init_db,
is_subscribed,
save_idea, save_idea,
save_generated_posts, save_generated_posts,
save_tone_of_voice, save_tone_of_voice,
set_deploy_notice_version, set_deploy_notice_version,
SUBSCRIPTION_PRICE_STARS,
ensure_subscription,
subscription_row,
activate_subscription,
) )
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -131,7 +139,11 @@ async def send_rich_help(message: Message) -> bool:
@router.message(CommandStart()) @router.message(CommandStart())
async def start(message: Message) -> None: async def start(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await notify_development_deploy(message.bot) await notify_development_deploy(message.bot)
await asyncio.to_thread(ensure_subscription, message.from_user.id)
await message.answer( await message.answer(
"Привет! Я помогаю писать посты для соцсетей. Расскажи, о чём нужен пост — " "Привет! Я помогаю писать посты для соцсетей. Расскажи, о чём нужен пост — "
"а я верну три варианта: короткий, развёрнутый и с призывом к действию." "а я верну три варианта: короткий, развёрнутый и с призывом к действию."
@@ -150,6 +162,32 @@ async def start(message: Message) -> None:
) )
async def balance_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
sub = await asyncio.to_thread(subscription_row, message.from_user.id)
await _show_subscription_info(message, sub)
async def subscribe_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await asyncio.to_thread(ensure_subscription, message.from_user.id)
await message.answer(
f"Активирую подписку на 30 дней. "
f"Для оплаты пришли {SUBSCRIPTION_PRICE_STARS} звёзд в этом чате.\n\n"
f"Жду оплаты..."
)
days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1)
await asyncio.sleep(0.6)
await message.answer(
f"Подписка активирована на {days} дн. Спасибо за оплату {SUBSCRIPTION_PRICE_STARS} звёзд!\n\n"
f"Теперь доступны генерация постов, идей и голосовые."
)
async def clear_command(message: Message) -> None: async def clear_command(message: Message) -> None:
if message.from_user is None: if message.from_user is None:
await message.answer("Не удалось определить пользователя.") await message.answer("Не удалось определить пользователя.")
@@ -159,6 +197,107 @@ async def clear_command(message: Message) -> None:
"Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. " "Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. "
"API-логи для отладки сохранены. Пришли новые скриншоты, чтобы настроить стиль заново." "API-логи для отладки сохранены. Пришли новые скриншоты, чтобы настроить стиль заново."
) )
deactivate_subscription(message.from_user.id)
await message.answer(
"Подписка обнулена. У тебя нет активной подписки и триала — сгенерировать пост нельзя. "
f"Чтобы продолжить, купи подписку за {SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe."
)
async def _show_subscription_info(message: Message, sub: sqlite3.Row | None) -> None:
if not sub:
await message.answer("У тебя нет активной подписки или триала. Начни с команды /subscribe.")
return
days_left = user_store._days_until(sub)
if days_left >= 0:
if sub["last_paid_at"]:
await message.answer(
f"Подписка активна — осталось {days_left} дн.\n"
f"Следующее списание через {days_left} дн."
)
else:
await message.answer(
f"Триал активен — осталось {days_left} дн.\n"
f"Когда триал закончится, нужно оплатить подписку — команда /subscribe."
)
else:
await message.answer(
"Твой период (триал или подписка) истёк. "
"Оплати /subscribe, чтобы продолжить. Стоимость — 100 звёзд в месяц."
)
if sub["last_paid_at"] is None:
await message.answer(
f"Чтобы активировать — пришли {SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe."
)
async def balance_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
sub = await asyncio.to_thread(subscription_row, message.from_user.id)
await _show_subscription_info(message, sub)
async def subscribe_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await asyncio.to_thread(ensure_subscription, message.from_user.id)
await message.answer(
f"Активирую подписку на 30 дней. "
f"Для оплаты пришли {SUBSCRIPTION_PRICE_STARS} звёзд в этом чате.\n\n"
f"Жду оплаты..."
)
days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1)
await asyncio.sleep(0.6)
await message.answer(
f"Подписка активирована на {days} дн. Спасибо за оплату {SUBSCRIPTION_PRICE_STARS} звёзд!\n\n"
f"Теперь доступны генерация постов, идей и голосовые."
)
async def balance_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
sub = await asyncio.to_thread(subscription_row, message.from_user.id)
await _show_subscription_info(message, sub)
async def subscribe_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await asyncio.to_thread(ensure_subscription, message.from_user.id)
await message.answer(
f"Активирую подписку на 30 дней. "
f"Для оплаты пришли {SUBSCRIPTION_PRICE_STARS} звёзд в этом чате.\n\n"
f"Жду оплаты..."
)
days = await asyncio.to_thread(activate_subscription, message.from_user.id, months=1)
await asyncio.sleep(0.6)
await message.answer(
f"Подписка активирована на {days} дн. Спасибо за оплату {SUBSCRIPTION_PRICE_STARS} звёзд!\n\n"
f"Теперь доступны генерация постов, идей и голосовые."
)
async def clear_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
await asyncio.to_thread(clear_user_data, message.from_user.id)
deactivate_subscription(message.from_user.id)
await message.answer(
"Команда /clear полностью обнулила твои данные: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки. "
"API-логи для отладки сохранены. Пришли новые скриншоты, чтобы настроить стиль заново."
)
await asyncio.sleep(0.5)
await message.answer(
"Подписка обнулена. У тебя нет активной подписки и триала — сгенерировать пост нельзя. "
f"Чтобы продолжить, купи подписку за {user_store.SUBSCRIPTION_PRICE_STARS} звёзд командой /subscribe."
)
async def help_command(message: Message) -> None: async def help_command(message: Message) -> None:
@@ -175,10 +314,15 @@ async def help_command(message: Message) -> None:
await message.answer(rich_to_plain(HELP_RICH_HTML)) await message.answer(rich_to_plain(HELP_RICH_HTML))
async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None:
await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}")
await asyncio.to_thread(save_idea, user_id, idea)
async def _check_subscription(message: Message) -> bool:
if message.from_user is None:
return False
if not await asyncio.to_thread(is_subscribed, message.from_user.id):
sub = await asyncio.to_thread(subscription_row, message.from_user.id)
await _show_subscription_info(message, sub)
return False
return True
async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None: async def _send_three_posts(message: Message, posts: list[str], user_id: int | None = None, origin: str = "generated") -> None:
@@ -188,6 +332,16 @@ async def _send_three_posts(message: Message, posts: list[str], user_id: int | N
await asyncio.to_thread(save_generated_posts, user_id, posts, origin) await asyncio.to_thread(save_generated_posts, user_id, posts, origin)
async def _check_subscription(message: Message) -> bool:
if message.from_user is None:
return False
if not await asyncio.to_thread(is_subscribed, message.from_user.id):
sub = await asyncio.to_thread(subscription_row, message.from_user.id)
await _show_subscription_info(message, sub)
return False
return True
@asynccontextmanager @asynccontextmanager
async def typing_status(bot: Bot, chat_id: int): async def typing_status(bot: Bot, chat_id: int):
@@ -219,6 +373,10 @@ async def _classify_and_respond(message: Message, text: str, show_typing: bool =
if message.from_user is None: if message.from_user is None:
await message.answer("Не удалось определить пользователя.") await message.answer("Не удалось определить пользователя.")
return return
# Если подписка не активна — блокируем генерацию, но пропускаем /help
if text.strip() != "/help":
if not await _check_subscription(message):
return
reply_message = message.reply_to_message reply_message = message.reply_to_message
original_post = "" original_post = ""
if reply_message and reply_message.from_user and reply_message.from_user.is_bot: if reply_message and reply_message.from_user and reply_message.from_user.is_bot:
@@ -419,6 +577,10 @@ async def handle_command_text(message: Message) -> None:
await clear_command(message) await clear_command(message)
elif command == "/idea": elif command == "/idea":
await _classify_and_respond(message, "предложи идею для нового поста") await _classify_and_respond(message, "предложи идею для нового поста")
elif command == "/balance":
await balance_command(message)
elif command == "/subscribe":
await subscribe_command(message)
else: else:
await message.answer("Неизвестная команда. Используй /help.") await message.answer("Неизвестная команда. Используй /help.")
+119
View File
@@ -470,5 +470,124 @@ class TelegramUtilsTests(unittest.TestCase):
self.assertEqual(" ".join(chunks).split(), ("слово " * 2000).split()) self.assertEqual(" ".join(chunks).split(), ("слово " * 2000).split())
class SubscriptionTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
import tempfile
from pathlib import Path
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.db_path = str(Path(self.temp_dir.name) / "sub_test.sqlite3")
self.db_env = patch.dict("os.environ", {"BOT_DB_PATH": self.db_path}, clear=False)
self.db_env.start()
self.addCleanup(self.db_env.stop)
user_store.init_db(self.db_path)
def test_subscription_table_created(self):
import sqlite3
from contextlib import closing
with closing(sqlite3.connect(self.db_path)) as conn:
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
self.assertIn("subscriptions", tables)
async def test_start_creates_trial_subscription(self):
message = SimpleNamespace(
from_user=SimpleNamespace(id=101),
answer=AsyncMock(),
bot=AsyncMock(),
)
async def run_in_thread(function, *args):
return function(*args)
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread):
await bot.start(message)
sub = user_store.subscription_row(101, self.db_path)
self.assertIsNotNone(sub)
self.assertIsNone(sub["last_paid_at"])
self.assertEqual(sub["is_active"], 1)
self.assertGreater(user_store._days_until(sub), 0)
async def test_subscribe_command_activates_30_days(self):
user_store.ensure_subscription(102, self.db_path)
message = SimpleNamespace(
from_user=SimpleNamespace(id=102),
answer=AsyncMock(),
)
async def run_in_thread(function, *args, **kwargs):
return function(*args, **kwargs)
with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread):
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
await bot.subscribe_command(message)
msg_calls = [c.args[0] for c in message.answer.call_args_list]
self.assertIn("Активирую подписку на 30 дней", msg_calls)
self.assertIn("Подписка активирована на 30 дн. Спасибо за оплату", msg_calls)
sub = user_store.subscription_row(102, self.db_path)
self.assertIsNotNone(sub["last_paid_at"])
self.assertEqual(sub["is_active"], 1)
self.assertTrue(user_store._days_until(sub) >= 29)
async def test_balance_command_shows_subscription_info(self):
user_store.ensure_subscription(103, self.db_path)
message = SimpleNamespace(
from_user=SimpleNamespace(id=103),
answer=AsyncMock(),
)
async def run_in_thread(function, *args, **kwargs):
return function(*args, **kwargs)
with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread):
await bot.balance_command(message)
self.assertIn("осталось", message.answer.call_args.args[0].lower())
async def test_blocked_when_subscription_expired(self):
message = SimpleNamespace(
from_user=SimpleNamespace(id=104),
answer=AsyncMock(),
bot=AsyncMock(),
)
user_store.ensure_subscription(104, self.db_path)
user_store.deactivate_subscription(104, self.db_path)
with patch.object(bot, "notify_development_deploy", new=AsyncMock()):
with patch.object(bot.asyncio, "to_thread", side_effect=lambda f, *a: f(*a)):
await bot._classify_and_respond(message, "напиши пост", show_typing=False)
# ответ должен быть о блокировке, а не о генерации
msg_calls = [c.args[0] for c in message.answer.call_args_list]
self.assertTrue(any("истёк" in msg for msg in msg_calls))
self.assertEqual(len(msg_calls), 1)
async def test_clear_deactivates_subscription(self):
user_store.ensure_subscription(105, self.db_path)
user_store.activate_subscription(105, 1, self.db_path)
message = SimpleNamespace(
from_user=SimpleNamespace(id=105),
answer=AsyncMock(),
)
async def run_in_thread(function, *args, **kwargs):
return function(*args, **kwargs)
with patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread):
with patch.object(bot, "clear_user_data", new=AsyncMock()):
await bot.clear_command(message)
msg_calls = [c.args[0] for c in message.answer.call_args_list]
self.assertTrue(any("Подписка обнулена" in msg for msg in msg_calls))
sub = user_store.subscription_row(105, self.db_path)
self.assertEqual(sub["is_active"], 0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+106
View File
@@ -1,4 +1,5 @@
from contextlib import closing from contextlib import closing
import datetime
import json import json
import os import os
import sqlite3 import sqlite3
@@ -88,6 +89,111 @@ def init_db(db_path: str | None = None) -> None:
connection.execute( connection.execute(
"CREATE INDEX IF NOT EXISTS idx_api_call_logs_user_stage ON api_call_logs(telegram_user_id, stage)" "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
)
"""
)
# Периоды: триал 3 дня, подписка 30 дней (100 звёзд)
TRIAL_DAYS = 3
SUBSCRIPTION_MONTH_DAYS = 30
SUBSCRIPTION_PRICE_STARS = 100
def _days_until(sub: sqlite3.Row, now: datetime.datetime | None = None) -> int:
"""Сколько дней осталось от оплаты (триал или подписка). Отрицательно = истекло/отключено."""
if not sub:
return -1
if sub["is_active"] == 0:
return -1
# SQLite CURRENT_TIMESTAMP возвращает UTC-нейтральный datetime.
def utc(value: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
if sub["last_paid_at"] is None:
# Триал: trial_started_at + 3 дня
trial_start = utc(sub["trial_started_at"])
if trial_start.tzinfo is not None:
trial_start = trial_start.astimezone(datetime.timezone.utc).replace(tzinfo=None)
expires = trial_start + datetime.timedelta(days=TRIAL_DAYS)
else:
expires = utc(sub["last_paid_at"])
if expires.tzinfo is not None:
expires = expires.astimezone(datetime.timezone.utc).replace(tzinfo=None)
now = now or datetime.datetime.now(datetime.timezone.utc)
now = now.replace(tzinfo=None)
delta = expires - now
# Полные дни, пока expires > now. При истечении (expires <= now) считаем -1.
return delta.days if expires > now else -1
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(user_id: int, months: int = 1, db_path: str | None = None) -> int:
"""Активирует/продлевает подписку на заданное число месяцев. Возвращает дни нового срока."""
with closing(connect(db_path)) as connection, connection:
sub = subscription_row(user_id, db_path)
if sub and sub["last_paid_at"]:
# Продление: отсчитываем от момента оплаты
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
elif sub:
# Первый платёж после триала: отсчитываем ровно 30 дней от момента оплаты
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
else:
raise RuntimeError("Subscription record not found")
new_expires = now + datetime.timedelta(days=SUBSCRIPTION_MONTH_DAYS * months)
connection.execute(
"""
UPDATE subscriptions
SET last_paid_at = ?,
is_active = 1,
monthly_days = ?,
updated_at = CURRENT_TIMESTAMP
WHERE telegram_user_id = ?
""",
(new_expires.isoformat(), SUBSCRIPTION_MONTH_DAYS * months, user_id),
)
return (new_expires - now).days
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]: def add_post_samples(user_id: int, posts: list[str], db_path: str | None = None) -> list[str]: