diff --git a/Dockerfile b/Dockerfile
index 614e352..75aa4e5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,5 +6,5 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-COPY bot.py apinex_client.py user_store.py ./
+COPY bot.py apinex_client.py user_store.py help_message.py VERSION ./
CMD ["python", "bot.py"]
diff --git a/IDEA.MD b/IDEA.MD
index 1e32fca..6ac44bb 100644
--- a/IDEA.MD
+++ b/IDEA.MD
@@ -44,4 +44,5 @@ Telegram-бот, который по запросу пользователя г
- [x] Последний development deploy работает без traceback
- [x] Обновление ToV галереей: одна обработка группы, ToV-инструкция показывается пользователю
- [x] Варианты постов показываются без нумерации и вступительного текста
-- [ ] Проверить галерею, обновление ToV и генерацию в Telegram на тестовом аккаунте
+- [x] Help command uses new Rich Message, with a plain-text fallback.
+- [x] Development deployment/version notice and user-visible error details.
diff --git a/README.md b/README.md
index d922e52..abf3959 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,8 @@
The bot prepares social-media posts only when the user explicitly asks. It transcribes Russian voice notes with APInex STT, classifies intent with an LLM, and uses Gemini 3.8 Flash via APInex to write/revise posts or infer Tone of Voice from a screenshot. A post request/revision returns three separate messages. The bot shows the Telegram typing indicator while classifying, transcribing, reading images, and generating. It stores no conversation history.
- Runtime: Python 3.12, aiogram 3.x
+- Release version is stored in `VERSION` and incremented for each code change; dev sends a version notice on boot.
+- In development, processing errors are returned to the user with a concise, secret-redacted reason.
- Coolify: project `Social Post Writer Bot`, environment `development`, server `spb-easy-dale`
- Gitea: `admin/social-post-writer-bot`, branch `dev`
- API key and model variables are stored only in Coolify: `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`.
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..2a3ad9e
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0+5
diff --git a/bot.py b/bot.py
index 3a79db1..6e1cd21 100644
--- a/bot.py
+++ b/bot.py
@@ -1,8 +1,11 @@
import asyncio
+import html
import io
import logging
import os
+import re
from contextlib import asynccontextmanager, nullcontext
+from pathlib import Path
import aiohttp
from aiogram import Bot, Dispatcher, F, Router
@@ -12,10 +15,23 @@ from aiogram.filters import CommandStart
from aiogram.types import Message
from apinex_client import APINEX_STT_URL, classify_intent, extract_tone_from_images, extract_transcript, generate_posts, transcribe_audio
-from user_store import get_tone_of_voice, init_db, save_tone_of_voice
+from help_message import HELP_RICH_HTML, rich_to_plain
+from user_store import (
+ get_deploy_notice_version,
+ get_tone_of_voice,
+ init_db,
+ save_tone_of_voice,
+ set_deploy_notice_version,
+)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
+VERSION = Path(__file__).with_name("VERSION").read_text(encoding="utf-8").strip()
+BOT_ENV = os.getenv("BOT_ENV", "test").strip().lower()
+DEPLOY_ENV = os.getenv("DEPLOY_ENV", "production").strip().lower()
+DEV_CHAT_ID = os.getenv("DEV_CHAT_ID", "").strip()
+HELP_COMMANDS = {"help", "помощь"}
+_deploy_notice_lock = asyncio.Lock()
router = Router()
MAX_AUDIO_BYTES = 20 * 1024 * 1024
@@ -24,6 +40,13 @@ TYPING_REFRESH_SECONDS = 4
MEDIA_GROUP_COLLECT_SECONDS = 0.8
_media_groups: dict[tuple[int, str], dict[str, object]] = {}
_media_groups_lock = asyncio.Lock()
+_deploy_notice_sent = False
+_deploy_notice_lock = asyncio.Lock()
+
+
+def _read_version() -> str:
+ version_path = Path(__file__).with_name("VERSION")
+ return version_path.read_text(encoding="utf-8").strip()
def split_text(text: str, limit: int = 4000) -> list[str]:
@@ -40,14 +63,89 @@ def split_text(text: str, limit: int = 4000) -> list[str]:
return chunks
+async def report_error(message: Message, stage: str, exc: Exception) -> None:
+ detail = str(exc).strip()
+ detail = re.sub(r"(?i)(?:sk-[A-Za-z0-9_-]{12,}|[0-9]{8,}:[A-Za-z0-9_-]{20,})", "[секрет скрыт]", detail)
+ detail = re.sub(r"https?://\S+", "[внешний сервис]", detail)
+ detail = detail or "нет подробностей"
+ try:
+ await message.answer(f"❌ Не получилось: {stage}.\nПричина: {html.escape(detail[:240])}", parse_mode="HTML")
+ except Exception as send_exc:
+ logger.error("Unable to report processing error: %s", type(send_exc).__name__)
+ logger.error("User operation failed at %s (%s): %s", stage, type(exc).__name__, detail[:240])
+
+
+async def notify_development_deploy(bot: Bot) -> None:
+ if DEPLOY_ENV != "development":
+ return
+ chat_id = os.getenv("DEV_CHAT_ID", "").strip()
+ if not chat_id:
+ logger.warning("DEV_CHAT_ID is missing; no version notice will be sent")
+ return
+ async with _deploy_notice_lock:
+ previous = await asyncio.to_thread(get_deploy_notice_version)
+ if previous == VERSION:
+ return
+ try:
+ await asyncio.wait_for(
+ bot.send_message(chat_id=int(chat_id), text=f"🚀 Бот обновлён\nВерсия: {VERSION}"),
+ timeout=8,
+ )
+ except Exception as exc:
+ logger.error("Development deploy notice failed (%s): %s", type(exc).__name__, str(exc)[:160])
+ return
+ await asyncio.to_thread(set_deploy_notice_version, VERSION)
+ logger.info("Development deploy notice sent: version=%s", VERSION)
+
+
+async def send_rich_help(message: Message) -> bool:
+ bot = message.bot
+ payload = {"chat_id": message.chat.id, "rich_message": {"html": HELP_RICH_HTML}}
+ if message.message_thread_id is not None:
+ payload["message_thread_id"] = message.message_thread_id
+ url = bot.session.api.api_url(token=bot.token, method="sendRichMessage")
+ timeout = aiohttp.ClientTimeout(total=15)
+ try:
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(
+ url, json=payload,
+ headers={"User-Agent": "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)"},
+ ) as response:
+ data = await response.json(content_type=None)
+ if response.status == 200 and data.get("ok"):
+ return True
+ logger.warning("Rich help rejected with HTTP %s", response.status)
+ return False
+ except Exception as exc:
+ logger.warning("Rich help delivery error (%s): %s", type(exc).__name__, str(exc)[:160])
+ return False
+
+
+
@router.message(CommandStart())
async def start(message: Message) -> None:
+ await notify_development_deploy(message.bot)
await message.answer(
"Я помогаю писать посты для соцсетей. Пришли скриншот своих постов, чтобы настроить стиль, "
- "или попроси написать пост, например: «Придумай пост про запуск нового продукта»."
+ "или попроси написать пост, например: «Придумай пост про запуск нового продукта». "
+ "Подробная инструкция — /help."
)
+async def help_command(message: Message) -> None:
+ try:
+ rich_sent = await send_rich_help(message)
+ except Exception as exc:
+ rich_sent = False
+ logger.warning("Rich help send failed (%s): %s", type(exc).__name__, str(exc)[:180])
+ if DEPLOY_ENV == "development":
+ await report_error(message, "отправка Rich Message помощи", exc)
+ if not rich_sent:
+ if DEPLOY_ENV == "development":
+ await report_error(message, "отправка Rich Message помощи", RuntimeError("sendRichMessage returned false"))
+ await message.answer(rich_to_plain(HELP_RICH_HTML))
+
+
async def _send_three_posts(message: Message, posts: list[str]) -> None:
for post in posts:
await message.answer(post)
@@ -106,12 +204,8 @@ async def _classify_and_respond(message: Message, text: str, show_typing: bool =
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
posts = await generate_posts(intent, tone)
await _send_three_posts(message, posts)
- except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc:
- logger.warning("Post request failed: %s", type(exc).__name__)
- await message.answer("Не получилось подготовить посты. Попробуй ещё раз позже.")
- except Exception:
- logger.exception("Unexpected post generation error")
- await message.answer("Произошла ошибка при подготовке постов.")
+ except Exception as exc:
+ await report_error(message, "классификация или генерация поста", exc)
async def _download_message_image(message: Message, bot: Bot) -> tuple[bytes, str] | None:
@@ -202,6 +296,7 @@ async def _finish_media_group(key: tuple[int, str]) -> None:
async def _handle_image_message(message: Message, bot: Bot) -> None:
+ await notify_development_deploy(bot)
try:
if message.media_group_id:
messages = await _collect_media_group(message, bot)
@@ -211,12 +306,8 @@ async def _handle_image_message(message: Message, bot: Bot) -> None:
messages = [message]
async with typing_status(bot, message.chat.id):
await _process_image_group(messages, bot)
- except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc:
- logger.warning("Tone of voice update failed: %s", type(exc).__name__)
- await message.answer("Не получилось прочитать скриншот. Попробуй отправить его ещё раз.")
- except Exception:
- logger.exception("Unexpected image tone extraction error")
- await message.answer("Произошла ошибка при чтении скриншота.")
+ except Exception as exc:
+ await report_error(message, "чтение скриншота или обновление ToV", exc)
@router.message(F.photo | (F.document & F.document.mime_type.startswith("image/")))
@@ -253,19 +344,25 @@ async def _process_voice(message: Message, bot: Bot) -> None:
@router.message(F.voice | F.audio)
async def transcribe_voice(message: Message, bot: Bot) -> None:
+ await notify_development_deploy(bot)
try:
async with typing_status(bot, message.chat.id):
await _process_voice(message, bot)
- except (aiohttp.ClientError, asyncio.TimeoutError, RuntimeError, ValueError) as exc:
- logger.warning("Voice post request failed: %s", type(exc).__name__)
- await message.answer("Не удалось обработать голосовое. Попробуй ещё раз или напиши текстом.")
- except Exception:
- logger.exception("Unexpected voice post processing failure")
- await message.answer("Произошла ошибка при обработке голосового.")
+ except Exception as exc:
+ await report_error(message, "обработка голосового и генерация", exc)
+
+
+@router.message(F.text & F.text.startswith("/"))
+async def handle_command_text(message: Message) -> None:
+ await notify_development_deploy(message.bot)
+ command = (message.text or "").strip().split(maxsplit=1)[0].split("@", 1)[0].casefold()
+ if command in {"/help", "/помощь"}:
+ await help_command(message)
@router.message(F.text & ~F.text.startswith("/"))
async def handle_text(message: Message) -> None:
+ await notify_development_deploy(message.bot)
if message.text:
await _classify_and_respond(message, message.text)
@@ -279,6 +376,7 @@ async def main() -> None:
test_env = os.environ.get("BOT_ENV", "test").strip().lower() == "test"
session = AiohttpSession(api=TEST) if test_env else None
bot = Bot(token=token, session=session)
+ await notify_development_deploy(bot)
dp = Dispatcher()
dp.include_router(router)
diff --git a/help_message.py b/help_message.py
new file mode 100644
index 0000000..68ee2bb
--- /dev/null
+++ b/help_message.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import html
+
+HELP_RICH_HTML = """
Я помогаю писать и редактировать посты для социальных сетей. Генерирую посты только по явной просьбе.
+В сообщениях с вариантами будет только текст поста: без нумерации и вступлений, с абзацами, переносами строк и уместными эмодзи.
+Пришли скриншот или галерею скриншотов своих опубликованных постов. Я прочитаю изображения вместе, составлю инструкцию стиля, сохраню её для твоего аккаунта и покажу тебе. Новая настройка заменяет предыдущую.
+Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.
+Я не поддерживаю историю беседы и не публикую посты в соцсетях. Каждый запрос обрабатывается отдельно, кроме правки через reply.
""" + + +def rich_to_plain(rich_html: str) -> str: + import re + + text = re.sub(r"