This commit is contained in:
admin
2025-08-10 22:22:38 +07:00
parent 0b3b957c0a
commit 6073b4b3c9
52 changed files with 1981 additions and 810 deletions

View File

@@ -1,36 +1,39 @@
# .dockerignore: Исключения для Docker сборки # Исключить скрытые системные каталоги, но не всё подряд
# Игнорировать всё, кроме необходимого для production .git/
.gitattributes
.gitignore
**/.git # Виртуальные окружения и Python-кэш
**/.gitignore .venv/
**/.dockerignore __pycache__/
**/Dockerfile *.py[cod]
**/README.md *.pyo
# Директории # IDE-файлы
**/__pycache__ .idea/
**/.mypy_cache .vscode/
**/.pytest_cache
**/.idea
**/.vscode
**/test
**/tests
**/docs
**/examples
# Файлы # Тесты и документация
**/*.pyc tests/
**/*.pyo test/
**/*.pyd docs/
**/*.egg-info examples/
**/*.log
**/*.logs
**/*.sqlite
**/*.db
config/.env
**/docker-compose*
# Артефакты сборки # Логи и артефакты сборки
**/build *.log
**/dist *.logs
**/node_modules Logs/
Log/
dist/
build/
# Примеры и шаблоны
env_example
.env
# Опционально (если не нужны в образе):
docker-compose.yml
poetry.lock
pyproject.toml
README.md
LICENSE

5
.gitignore vendored
View File

@@ -3,8 +3,8 @@
### Python ### ### Python ###
# Виртуальные окружения и настройки # Виртуальные окружения и настройки
config/.env configs/.env
../../../../Desktop/PostBot/.venv .venv
venv/ venv/
env/ env/
ENV/ ENV/
@@ -65,3 +65,4 @@ htmlcov/
.nox/ .nox/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
/.env

View File

@@ -6,9 +6,19 @@
<sourceFolder url="file://$MODULE_DIR$/BotCode/handlers/commands" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/BotCode/handlers/commands" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/BotCode/loggers" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/BotCode/loggers" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/BotCode/utils" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/BotCode/utils" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/bot" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/configs" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/middleware" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.13 (PRIMOSTORYFINAL)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.13 (PrimoStoryBot)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
<component name="TemplatesService">
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/locales" />
</list>
</option>
</component>
</module> </module>

111
.idea/workspace.xml generated
View File

@@ -5,40 +5,57 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="976a6336-6952-45ae-989f-7b10c5e394d4" name="Changes" comment=""> <list default="true" id="976a6336-6952-45ae-989f-7b10c5e394d4" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/.dockerignore" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/bots.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.env" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/core/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.gitattributes" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/handlers/commands/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/handlers/commands/help.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/PRIMOSTORYFINAL.iml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/handlers/commands/start.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/inspectionProfiles/profiles_settings.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/handlers/post/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/handlers/post/create_posts.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/keyboards/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/keyboards/inline/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/__init__.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/keyboards/inline/decision.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/config.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/keyboards/reply/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/core/__init__.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/templates/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/core/storage.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/templates/message_callback.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/__init__.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/utils/interesting_facts.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/callback.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/bot/utils/pagination.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/commands/__init__.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/configs/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/commands/start_cmd.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/configs/cmd_list.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/inline.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/configs/config.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/post/__init__.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/docker-compose.yml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/post/create_posts.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/env_example" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/handlers/post/post_list.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/middleware/loggers/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/loggers/__init__.py" afterDir="false" /> <change afterPath="$PROJECT_DIR$/middleware/loggers/logs.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/loggers/logs.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.dockerignore" beforeDir="false" afterPath="$PROJECT_DIR$/.dockerignore" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/utils/__init__.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.env" beforeDir="false" afterPath="$PROJECT_DIR$/.env" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/utils/md2_escape.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.gitattributes" beforeDir="false" afterPath="$PROJECT_DIR$/.gitattributes" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/utils/pagination.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
<change afterPath="$PROJECT_DIR$/BotCode/utils/usernames.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/Dockerfile" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/config.py" beforeDir="false" />
<change afterPath="$PROJECT_DIR$/LICENSE" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/core/__init__.py" beforeDir="false" />
<change afterPath="$PROJECT_DIR$/README.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/core/storage.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/core/storage.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/assets/start.jpg" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/handlers/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/handlers/__init__.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/main.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/handlers/callback.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/handlers/callback.py" afterDir="false" />
<change afterPath="$PROJECT_DIR$/postsOLD/posts_6751720805.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/handlers/commands/__init__.py" beforeDir="false" />
<change afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/BotCode/handlers/commands/start_cmd.py" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/handlers/inline.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/handlers/inline.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/handlers/post/__init__.py" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/handlers/post/create_posts.py" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/handlers/post/post_list.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/handlers/post/post_list.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/loggers/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/loggers/__init__.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/loggers/logs.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/loggers/logs.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/utils/__init__.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/utils/__init__.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/utils/md2_escape.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/utils/md2_escape.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/utils/pagination.py" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/BotCode/utils/usernames.py" beforeDir="false" afterPath="$PROJECT_DIR$/bot/utils/usernames.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Dockerfile" beforeDir="false" afterPath="$PROJECT_DIR$/Dockerfile" afterDir="false" />
<change beforePath="$PROJECT_DIR$/LICENSE" beforeDir="false" afterPath="$PROJECT_DIR$/LICENSE" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/assets/start.jpg" beforeDir="false" afterPath="$PROJECT_DIR$/assets/start.jpg" afterDir="false" />
<change beforePath="$PROJECT_DIR$/main.py" beforeDir="false" afterPath="$PROJECT_DIR$/main.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/posts/posts_6751720805.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -55,9 +72,21 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component> </component>
<component name="ProjectColorInfo"><![CDATA[{ <component name="GitHubPullRequestSearchHistory"><![CDATA[{
"associatedIndex": 2 "lastFilter": {
"state": "OPEN",
"assignee": "Whyverum"
}
}]]></component> }]]></component>
<component name="GithubPullRequestsUISettings"><![CDATA[{
"selectedUrlAndAccountId": {
"url": "https://github.com/Whyverum/PrimoStoryBot.git",
"accountId": "352bed01-4b87-43a7-83c1-cb6af2ca3770"
}
}]]></component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 2
}</component>
<component name="ProjectId" id="2xL1onhKjANEmVgLSfNYMKmFWc4" /> <component name="ProjectId" id="2xL1onhKjANEmVgLSfNYMKmFWc4" />
<component name="ProjectViewState"> <component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
@@ -68,6 +97,7 @@
"ModuleVcsDetector.initialDetectionPerformed": "true", "ModuleVcsDetector.initialDetectionPerformed": "true",
"Python.main.executor": "Run", "Python.main.executor": "Run",
"RunOnceActivity.ShowReadmeOnStart": "true", "RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true",
"RunOnceActivity.git.unshallow": "true", "RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "master", "git-widget-placeholder": "master",
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
@@ -94,8 +124,9 @@
<env name="PYTHONUNBUFFERED" value="1" /> <env name="PYTHONUNBUFFERED" value="1" />
</envs> </envs>
<option name="SDK_HOME" value="" /> <option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.13 (PrimoStoryBot)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="true" /> <option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" /> <option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" /> <option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" /> <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
@@ -117,8 +148,8 @@
<component name="SharedIndexes"> <component name="SharedIndexes">
<attachedChunks> <attachedChunks>
<set> <set>
<option value="bundled-js-predefined-d6986cc7102b-6a121458b545-JavaScript-PY-251.25410.122" /> <option value="bundled-js-predefined-d6986cc7102b-09060db00ec0-JavaScript-PY-251.26927.90" />
<option value="bundled-python-sdk-880ecab49056-36ea0e71a18c-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-251.25410.122" /> <option value="bundled-python-sdk-41e8cd69c857-64d779b69b7a-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-251.26927.90" />
</set> </set>
</attachedChunks> </attachedChunks>
</component> </component>
@@ -130,6 +161,7 @@
<option name="presentableId" value="Default" /> <option name="presentableId" value="Default" />
<updated>1747702717100</updated> <updated>1747702717100</updated>
<workItem from="1747702718164" duration="4191000" /> <workItem from="1747702718164" duration="4191000" />
<workItem from="1754837986649" duration="1081000" />
</task> </task>
<servers /> <servers />
</component> </component>
@@ -138,5 +170,6 @@
</component> </component>
<component name="com.intellij.coverage.CoverageDataManagerImpl"> <component name="com.intellij.coverage.CoverageDataManagerImpl">
<SUITE FILE_PATH="coverage/PRIMOSTORYFINAL$main.coverage" NAME="main Coverage Results" MODIFIED="1747706991539" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" /> <SUITE FILE_PATH="coverage/PRIMOSTORYFINAL$main.coverage" NAME="main Coverage Results" MODIFIED="1747706991539" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
<SUITE FILE_PATH="coverage/PrimoStoryBot$main.coverage" NAME="main Coverage Results" MODIFIED="1754838961735" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$" />
</component> </component>
</project> </project>

View File

@@ -1,19 +0,0 @@
# BotCode/config.py
from os import getenv
from ast import literal_eval
from dotenv import load_dotenv
# Загружаем переменные из файла .env
load_dotenv()
BOT_TOKEN: str|None = getenv('BOT_TOKEN', None)
BOT_DEBUG_TOKEN: str|None = getenv('BOT_DEBUG_TOKEN', None)
ADMIN_ID: tuple[int] = literal_eval(getenv('ADMIN_ID', '[6751720805]'))
PARSE_MODE: str = getenv('PARSE_MODE', "HTML")
LOGGING_TO_CONSOLE: bool = getenv('LOGGING_TO_CONSOLE', "False").lower() == 'true'
DEBUG_MODE: bool = getenv('DEBUG_MODE', "False").lower() == 'true'
POSTS_DIR: str = getenv('POSTS_DIR', "posts")

View File

@@ -1,7 +0,0 @@
from aiogram import Router
from .start_cmd import router as start_cmd_router
__all__ = ('router',)
router = Router(name="post_router")
router.include_routers(start_cmd_router,)

View File

@@ -1,36 +0,0 @@
# BotCode/handlers/commands/start_cmd.py
from aiogram import Router, types
from aiogram.filters import CommandStart
router = Router(name=__name__)
__all__ = ("router",)
@router.message(CommandStart())
async def start_cmd(message: types.Message) -> None:
"""
Обработчик команды /start.
:param message: Объект сообщения и информации о нем.
:return: Вывод сообщения для администратора, о выборе режимов работы.
"""
from BotCode.loggers import logs
from BotCode.utils import textmd2
logs.info(text="использовал(а) команду /start", log_type="Start", message=message)
if message.from_user.id:
# Создаем клавиатурный билдер
from aiogram.utils.keyboard import ReplyKeyboardBuilder
rkb: ReplyKeyboardBuilder = ReplyKeyboardBuilder()
rkb.row(types.KeyboardButton(text="Создать пост📔"))
rkb.row(types.KeyboardButton(text="Посмотреть список📋"))
# Отправка фотографии с текстом и клавиатурой
from aiogram.types.input_file import FSInputFile
await message.reply_photo(
photo=FSInputFile('assets/start.jpg'),
caption=textmd2("Добро пожаловать в систему, Босс!"),
reply_markup=rkb.as_markup(resize_keyboard=True)
)
else:
await message.reply(text=textmd2("Простите, вы не мой Босс!❌\nОбратитесь к @verdise!"))

View File

@@ -1,10 +0,0 @@
from aiogram import Router
from .create_posts import router as posts_router
from .post_list import router as post_list_router
router = Router(name="post_router")
router.include_routers(
posts_router,
post_list_router,
)

View File

@@ -1,210 +0,0 @@
import uuid
from threading import Lock
from aiogram import Router, F
from aiogram.types import (
Message, CallbackQuery,
InlineKeyboardButton, InlineKeyboardMarkup
)
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
from BotCode.core.storage import storage
from BotCode.utils import textmd2
router = Router()
class PostState(StatesGroup):
waiting_for_text = State()
waiting_for_privacy = State()
waiting_for_id = State()
waiting_for_image = State()
waiting_for_buttons = State()
post_id_lock = Lock()
# --- Utility functions ---
def make_inline_markup(rows: list[list[InlineKeyboardButton]]) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=rows)
def cancel_button() -> InlineKeyboardMarkup:
return make_inline_markup([[InlineKeyboardButton(text="Отмена", callback_data="cancel_creation")]])
def privacy_markup(is_private: bool) -> InlineKeyboardMarkup:
toggle = InlineKeyboardButton(
text="🔒 Приватный" if is_private else "🔓 Публичный",
callback_data="toggle_privacy"
)
cont = InlineKeyboardButton(text="Продолжить ➡️", callback_data="continue_creation")
return make_inline_markup([[toggle], [cont]])
def parse_buttons(text: str) -> list[list[dict]]:
rows: list[list[dict]] = []
current: list[dict] = []
for raw in text.splitlines():
line = raw.strip()
if not line:
if current:
rows.append(current)
current = []
continue
if '|' not in line:
raise ValueError(f"Неверный формат кнопки: '{line}'")
label, action = map(str.strip, line.split('|', 1))
btn: dict = {"text": label}
if action.startswith('notification:'):
btn['notification'] = action.split(':', 1)[1]
btn['show_alert'] = True
elif action.startswith('copy:'):
btn['callback_data'] = f"copy_{uuid.uuid4().hex}"
btn['copy_text'] = action.split(':', 1)[1]
elif action.startswith('switch_inline:'):
btn['switch_inline_query'] = action.split(':', 1)[1]
elif action.startswith('switch_inline_current:'):
btn['switch_inline_query_current_chat'] = action.split(':', 1)[1]
elif action.startswith('switch_inline_chosen:'):
btn['switch_inline_query_chosen_chat'] = action.split(':', 1)[1]
elif action.startswith(('http://', 'https://')):
btn['url'] = action
else:
btn['callback_data'] = action
current.append(btn)
if current:
rows.append(current)
return rows
# --- Handlers ---
@router.message(F.text == "Создать пост📔")
async def start_creation(message: Message, state: FSMContext):
await state.set_state(PostState.waiting_for_text)
await state.update_data(private=False, buttons=[])
await message.reply(
textmd2(
"""Отправьте текст вашего поста:
Тест для проверки @userbotname
<b>Жирный</b>
<i>Курсив</i>
<u>Подчёркнутый</u>
<s>Зачёркнутый</s>
<code>Моноширинный</code>
<pre>Предварительно отформатированный</pre>
<a href=\"https://example.com\">Ссылка</a>
"""
),
reply_markup=cancel_button(), parse_mode=None
)
@router.message(PostState.waiting_for_text)
async def got_text(message: Message, state: FSMContext):
await state.update_data(text=message.text or message.caption or "")
await state.set_state(PostState.waiting_for_privacy)
data = await state.get_data()
await message.reply(
"Выберите приватность поста:",
reply_markup=privacy_markup(data.get('private', False))
)
@router.callback_query(lambda c: c.data == "toggle_privacy")
async def toggle_privacy(cq: CallbackQuery, state: FSMContext):
data = await state.get_data()
is_priv = not data.get('private', False)
await state.update_data(private=is_priv)
await cq.message.edit_reply_markup(
reply_markup=privacy_markup(is_priv)
)
await cq.answer()
@router.callback_query(lambda c: c.data == "continue_creation")
async def continue_to_id(cq: CallbackQuery, state: FSMContext):
await state.set_state(PostState.waiting_for_id)
await cq.message.edit_text("Введите уникальный ID поста (латиница, цифры, подчёрки):")
await cq.answer()
@router.message(PostState.waiting_for_id)
async def got_id(message: Message, state: FSMContext):
pid = message.text.strip()
if not pid.replace('_', '').isalnum():
await message.reply(
"ID должен содержать только латиницу, цифры и подчёркивания.",
reply_markup=cancel_button()
)
return
with post_id_lock:
if not storage.is_post_available(pid):
await message.reply(
text="Этот ID уже занят, введите другой:",
reply_markup=cancel_button()
)
return
await state.update_data(post_id=pid)
await state.set_state(PostState.waiting_for_image)
await message.reply(
text="Отправьте ссылку на изображение или 'нет':\n"
"Пример: https://img4.teletype.in/files/f2/47/...",
reply_markup=cancel_button()
)
@router.message(PostState.waiting_for_image)
async def got_image(message: Message, state: FSMContext):
img = message.text.strip()
if img.lower() in ('нет', 'no', 'none'):
img = ''
await state.update_data(image=img)
await state.set_state(PostState.waiting_for_buttons)
await message.reply(
textmd2(
"""Отправьте кнопки по шаблону:
Кнопка заглушка | void
Уведомление | notification:Для вас!
Кнопка ссылка | https://google.com
Копирование | copy:Копирование текста!
Для одного | callback_data | allowed_ids=123 | unauthorized_message=Нет доступа
Пустая строка — новый ряд. /done — закончить."""
),
reply_markup=cancel_button(), parse_mode=None
)
@router.message(PostState.waiting_for_buttons)
async def got_buttons(message: Message, state: FSMContext):
text = message.text.strip()
data = await state.get_data()
uid = message.from_user.id
pid = data['post_id']
try:
if text.lower() in ('/done', 'none'):
btns = data.get('buttons', []) if text == '/done' else []
posts = storage.load_user_posts(uid)
posts[pid] = {
'user_id': uid,
'text': data['text'],
'image': data['image'],
'buttons': btns,
'private': data.get('private', False)
}
storage.save_user_posts(uid, posts)
await message.reply(
f"✅ Пост создан! ID: {pid}\n"
f"{'🔒 Приватный' if data.get('private') else '🔓 Публичный'}\n"
f"Используйте: <code>@{(await message.bot.me()).username} {pid}</code>"
)
await state.clear()
return
rows = parse_buttons(text)
existing = data.get('buttons', [])
await state.update_data(buttons=existing + rows)
await message.reply(
text="✅ Кнопки добавлены. Добавьте ещё или /done для окончания.",
reply_markup=cancel_button()
)
except ValueError as err:
await message.reply(f"{err}")
@router.callback_query(lambda c: c.data == "cancel_creation")
async def cancel(cq: CallbackQuery, state: FSMContext):
await state.clear()
await cq.message.reply(textmd2("Процесс создания поста отменён."))
await cq.answer()

View File

@@ -1,22 +0,0 @@
# BotCode/utils/pagination.py
from typing import List
from aiogram.types import InlineKeyboardButton
# Настройка экспорта в модули
__all__ = ('create_pagination_buttons',)
def create_pagination_buttons(action: str,
page: int = 0,
total_posts: int = 0,
bt_page: int = 5) -> List[InlineKeyboardButton]:
"""Создает кнопки для пагинации."""
navigation_buttons = []
if page > 0:
navigation_buttons.append(InlineKeyboardButton(
text="", callback_data=f"{action}_page_{page - 1}"
))
if (page + 1) * bt_page < total_posts:
navigation_buttons.append(InlineKeyboardButton(
text="", callback_data=f"{action}_page_{page + 1}"
))
return navigation_buttons

View File

@@ -1,10 +1,23 @@
FROM mwalbeck/python-poetry:2.1-3.11 # Используем официальный облегчённый образ Python 3.11
FROM python:3.11-slim
WORKDIR /PostBot # Задаём рабочую директорию внутри контейнера
WORKDIR /app
COPY pyproject.toml poetry.lock ./ # Обновляем pip для актуальной версии (необязательно, но рекомендуется)
RUN poetry install --no-interaction --no-root --only main RUN pip install --upgrade pip
COPY ../../../../Desktop/PostBot . # Копируем файл зависимостей в контейнер
COPY requirements.txt .
CMD ["poetry", "run", "python", "-m", "main"] # Устанавливаем зависимости из requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Копируем весь код проекта в рабочую директорию контейнера
COPY . .
# Опционально: задаём переменную окружения для оптимизации работы Python
ENV PYTHONUNBUFFERED=1
# Указываем команду запуска бота (можно изменить под ваш файл)
CMD ["python", "main.py"]

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) [2025] [Лейн] Copyright (c) [2025] [Verum]
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,31 +1,47 @@
# Создание поста PROJECT/
new_post = { ├── config/
"id": "cat_post", ├── __init__.py
"author_id": 123, ├── settings.py # Основные настройки
"mod": "HTML", └── roles_config.py # Конфиг ролей и прав
"type": "photo", ├── data/
"text": "Мой котик!", ├── database.db # SQLite база (или папка для миграций если PostgreSQL)
"media": "cat.jpg", ├── lists/ # JSON/CSV файлы списков (игроков, персонажей и т.д.)
"private": True, └── templates/ # Шаблоны сообщений
"allowed_users": [456, 789], ├── handlers/
"buttons": [[{ ├── __init__.py
"type": "share", ├── private/ # Обработчики ЛС
"name": "Поделиться", ├── commands.py
"params": {"message": "Посмотрите этого котика!"} ├── faq.py
}]] │ ├── reports.py
} │ │ └── notifications.py
│ ├── groups/ # Обработчики групповых чатов
post_id = storage.create_post(new_post) │ │ ├── flood.py
│ │ ├── roleplay.py
# Получение поста │ │ └── moderation.py
post = storage.get_post(post_id, user_id=456) # Доступ разрешен │ └── channels/ # Обработчики каналов
post = storage.get_post(post_id, user_id=000) # Доступ запрещен │ ├── info_updater.py
│ └── life_news.py
# Поиск постов ├── middlewares/
results = storage.search_posts("котик", user_id=456) │ ├── __init__.py
│ ├── throttling.py # Анти-спам
# Обновление поста │ ├── database.py # Интеграция БД
storage.update_post(post_id, updater_id=123, updates={"text": "Новый текст"}) │ └── mode_switcher.py # Переключение режимов
├── services/
# Удаление поста │ ├── __init__.py
storage.delete_post(post_id, deleter_id=123) │ ├── database.py # CRUD операции
│ ├── stats.py # Статистика сообщений
│ ├── list_manager.py # Управление списками
│ ├── notifier.py # Уведомления
│ └── antispam.py # Система спам-фильтрации
├── utils/
│ ├── __init__.py
│ ├── parsers.py # Парсинг сообщений
│ ├── keyboards.py # Генерация клавиатур
│ └── helpers.py # Вспомогательные функции
├── states/ # FSM состояния
│ ├── __init__.py
│ ├── user_registration.py
│ └── report_states.py
├── .env # Переменные окружения
├── requirements.txt # Зависимости
└── main.py # Точка входа

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,4 +1,3 @@
from .config import *
from .handlers import * from .handlers import *
from .utils import * from .utils import *
from .config import * from .bots import *

203
bot/bots.py Normal file
View File

@@ -0,0 +1,203 @@
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import User, ChatAdministratorRights, BotDescription, BotShortDescription
from aiogram.utils.i18n import ConstI18nMiddleware, I18n
from loggers.logs import loggers
from configs.config import BotSettings, BotEdit, Webhook
from middleware.loggers import log
# Экспортируем объекты модуля
__all__ = ("dp", "bot", "BotInfo", "i18n",)
# Инициализация i18n
i18n: I18n = I18n(path="locales", default_locale="ru", domain="bot")
# Диспетчер бота, языковых настроек и его хранилища
storage: MemoryStorage = MemoryStorage()
dp: Dispatcher = Dispatcher(storage=storage)
dp.message.outer_middleware(ConstI18nMiddleware(locale='ru', i18n=i18n))
dp["is_active"]: bool = True
# Экземпляр бота с настройками по умолчанию
bot: Bot = Bot(token=BotSettings.BOT_TOKEN,
default=DefaultBotProperties(
parse_mode=BotSettings.PARSE_MODE,
disable_notification=BotSettings.DISABLE_NOTIFICATION,
protect_content=BotSettings.PROTECT_CONTENT,
allow_sending_without_reply=BotSettings.ALLOW_SENDING_WITHOUT_REPLY,
link_preview_is_disabled=BotSettings.LINK_PREVIEW_IS_DISABLED,
link_preview_prefer_small_media=BotSettings.LINK_PREVIEW_PREFER_SMALL_MEDIA,
link_preview_prefer_large_media=BotSettings.LINK_PREVIEW_PREFER_LARGE_MEDIA,
link_preview_show_above_text=BotSettings.LINK_PREVIEW_SHOW_ABOVE_TEXT,
show_caption_above_media=BotSettings.SHOW_CAPTION_ABOVE_MEDIA
)
)
class BotInfo:
"""Класс для хранения и инициализации данных бота."""
id: int = None
url: str = None
first_name: str = None
last_name: str = None
username: str = None
description: str = None
short_description: str = None
language_code: str = BotSettings.BOT_LANGUAGE
prefix: str = BotSettings.PREFIX
bot_owner: str = BotSettings.OWNER
added_to_attachment_menu: bool = False
supports_inline_queries: bool = False
can_connect_to_business: bool = False
has_main_web_app: bool = False
can_join_groups: bool = False
can_read_all_group_messages: bool = False
@classmethod
@log(level='INFO', log_type='BOT', text='Настройка вебхука бота')
async def webhook(cls, bots: Bot = bot, delete_webhook: bool = Webhook.WEBHOOK) -> None:
"""
Удаление или установка вебхука.
:param bots: Объект бота для управления.
:param delete_webhook: Статус удаления, поумолчанию (true).
"""
if delete_webhook:
await bots.delete_webhook()
@classmethod
@log(level='INFO', log_type='BOT', text='Получение информации о боте')
async def info(cls, bots: Bot = bot) -> dict:
"""
Получает и сохраняет информацию о боте.
:param bots: Объект бота для управления.
:return: Словарь с персональными данными о боте.
"""
bot_info: User = await bots.get_me()
cls.id = bot_info.id
cls.url = f'tg://user?id={cls.id}'
cls.first_name = bot_info.first_name
cls.last_name = bot_info.last_name
cls.username = bot_info.username
cls.description = getattr(bot_info, 'description', '')
cls.short_description = getattr(bot_info, 'short_description', '')
cls.language_code = bot_info.language_code
cls.is_premium = bot_info.is_premium
cls.added_to_attachment_menu = bot_info.added_to_attachment_menu
cls.supports_inline_queries = bot_info.supports_inline_queries
cls.can_connect_to_business = bot_info.can_connect_to_business
cls.has_main_web_app = bot_info.has_main_web_app
cls.can_join_groups = getattr(bot_info, 'can_join_groups', False)
cls.can_read_all_group_messages = getattr(bot_info, 'can_read_all_group_messages', False)
return {
'id': cls.id,
'url': cls.url,
'first_name': cls.first_name,
'last_name': cls.last_name,
'username': cls.username,
'description': cls.description,
'short_description': cls.short_description,
'language_code': cls.language_code,
'prefix': cls.prefix,
'bot_owner': cls.bot_owner,
'is_premium': cls.is_premium,
'added_to_attachment_menu': cls.added_to_attachment_menu,
'supports_inline_queries': cls.supports_inline_queries,
'can_connect_to_business': cls.can_connect_to_business,
'has_main_web_app': cls.has_main_web_app,
'can_join_groups': cls.can_join_groups,
'can_read_all_group_messages': cls.can_read_all_group_messages,
}
@staticmethod
@log(level='INFO', log_type='BOT', text='Установка прав администратора')
async def set_administrator_rights(bots: Bot = bot, rights: ChatAdministratorRights = BotEdit.RIGHTS) -> None:
"""
Устанавливает права администратора по умолчанию.
:param bots: Объект бота для управления.
:param rights: Заданные права администратора бота, по умолчанию словарь из конфигов.
"""
bot_rights: ChatAdministratorRights = await bots.get_my_default_administrator_rights()
if bot_rights != rights:
await bots.set_my_default_administrator_rights(rights)
@staticmethod
@log(level='INFO', log_type='BOT', text='Обновление имени бота')
async def set_name(bots: Bot = bot, new_name: str = BotEdit.NAME) -> None:
"""
Устанавливает имя бота из конфига.
:param bots: Объект бота для управления.
:param new_name: Новое имя бота, по умолчанию из конфигов.
"""
current_name: str = (await bots.get_me()).first_name
if not (1 <= len(new_name) <= 32):
raise ValueError("Имя бота должно быть от 1 до 32 символов.")
if current_name != new_name:
await bots.set_my_name(new_name)
@staticmethod
@log(level='INFO', log_type='BOT', text='Обновление описания бота')
async def set_description(bots: Bot = bot, new_description: str = BotEdit.DESCRIPTION) -> None:
"""
Устанавливает полное описание бота.
:param bots: Объект бота для управления.
:param new_description: Новое описание бота, по умолчанию из конфигов.
"""
current_description: BotDescription = await bots.get_my_description()
if not (0 < len(new_description) <= 255):
raise ValueError("Описание должно быть от 1 до 255 символов.")
if current_description != new_description:
await bots.set_my_description(description=new_description)
@staticmethod
@log(level='INFO', log_type='BOT', text='Обновление короткого описания бота')
async def set_short_description(bots: Bot = bot, new_short: str = BotEdit.SHORT_DESCRIPTION) -> None:
"""
Устанавливает короткое описание виджета.
:param bots: Объект бота для управления.
:param new_short: Новое короткое описание бота, по умолчанию из конфигов.
"""
current_short: BotShortDescription = await bots.get_my_short_description()
if not (0 < len(new_short) <= 512):
raise ValueError("Короткое описание должно быть от 1 до 512 символов.")
if current_short != new_short:
await bots.set_my_short_description(short_description=new_short)
@classmethod
@log(level='INFO', log_type='START', text=f'Процесс запуска бота!!!!!')
async def setup(cls, bots: Bot = bot):
"""
Выполняет полную настройку бота.
:param bots: Объект бота для управления.
"""
await cls.webhook(bots=bots)
await cls.info(bots=bots)
await cls.set_administrator_rights(bots=bots)
await cls.set_description(bots=bots)
await cls.set_short_description(bots=bots)
await cls.set_name(bots=bots)
loggers.info(text=f"Бот @{BotInfo.username} запущен!!!")

1
bot/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .storage import *

View File

@@ -1,14 +1,16 @@
import json import json
from os import path, makedirs, listdir from os import path, makedirs, listdir
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from BotCode.config import POSTS_DIR from configs.config import Project
from BotCode.loggers import logs from bot.loggers import logs
# Настройки экспорта
__all__ = ("storage", )
class PostStorage: class PostStorage:
"""Класс для управления хранением постов и связанных уведомлений.""" """Класс для управления хранением постов и связанных уведомлений."""
def __init__(self, posts_dir: str = POSTS_DIR): def __init__(self, posts_dir: str = Project.POSTS_DIR):
self.posts_dir = posts_dir self.posts_dir = posts_dir
self.global_posts: Dict[str, Dict[str, Any]] = {} self.global_posts: Dict[str, Dict[str, Any]] = {}
self.notifications: Dict[str, Dict[str, Any]] = {} self.notifications: Dict[str, Dict[str, Any]] = {}
@@ -231,4 +233,4 @@ class PostStorage:
# Инициализация хранилища при импорте модуля # Инициализация хранилища при импорте модуля
storage = PostStorage() storage: PostStorage = PostStorage()

View File

@@ -1,13 +1,14 @@
from aiogram import Router from aiogram import Router
from .post import router as post_routers from .post import router as post_routers
from .commands import router as cmd_routers from .commands import router as cmd_routers
from .callback import router as callback_router from .callback import router as callback_router
from .inline import router as inline_router from .inline import router as inline_router
router = Router(name=__name__) # Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name="handlers_router")
# Include routers with different priorities # Подключение роутеров
router.include_routers( router.include_routers(
cmd_routers, cmd_routers,
callback_router, callback_router,

View File

@@ -1,15 +1,16 @@
# BotCode/handlers/callback.py from typing import Optional
from aiogram import Router, F from aiogram import Router, F
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from BotCode.core.storage import storage from bot.core import storage
router = Router(name="callback_router") router: Router = Router(name="callback_router")
@router.callback_query(F.data.startswith("bt_")) @router.callback_query(F.data.startswith("bt_"))
@router.callback_query(F.data.startswith("show_alert_")) @router.callback_query(F.data.startswith("show_alert_"))
async def handle_button_alert(callback_query: CallbackQuery) -> None: async def handle_button_alert(callback_query: CallbackQuery) -> None:
key = callback_query.data key: Optional[str] = callback_query.data
user_id = callback_query.from_user.id user_id: int = callback_query.from_user.id
# Получаем уведомление через хранилище # Получаем уведомление через хранилище
notif = storage.get_notification(key) notif = storage.get_notification(key)
@@ -29,11 +30,8 @@ async def handle_button_alert(callback_query: CallbackQuery) -> None:
try: try:
await callback_query.answer(text=text, show_alert=show_alert) await callback_query.answer(text=text, show_alert=show_alert)
except Exception as e: except Exception:
try:
await callback_query.answer(text="Произошла ошибка при отображении уведомления.", show_alert=True) await callback_query.answer(text="Произошла ошибка при отображении уведомления.", show_alert=True)
except:
pass
@router.callback_query(F.data == "void") @router.callback_query(F.data == "void")
@@ -44,5 +42,5 @@ async def handle_void_callback(callback_query: CallbackQuery) -> None:
""" """
try: try:
await callback_query.answer() await callback_query.answer()
except Exception as e: except Exception:
return return

View File

@@ -0,0 +1,12 @@
from aiogram import Router
from .start import router as start_cmd_router
from .help import router as help_cmd_router
# Настройка экспорта и роутера
__all__ = ('router',)
router: Router = Router(name="cmd_router")
# Подготовка роутера команд
router.include_routers(start_cmd_router,
help_cmd_router,
)

View File

@@ -0,0 +1,47 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, KeyboardButton
from aiogram.utils.keyboard import ReplyKeyboardBuilder
from aiogram.utils.i18n import gettext as _
from bot.templates import msg_photo
from bot.utils.interesting_facts import interesting_fact
from middleware.loggers import log
from bot.bots import BotInfo
from configs import COMMANDS, BotEdit
# Настройки экспорта и роутера
__all__ = ("router",)
CMD: str = "help".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
@router.callback_query(F.data == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
@log(level='INFO', log_type=CMD.upper(), text=f"использовал команду /{CMD}")
async def help_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""
Обработчик команды /help
Args:
message (Message | CallbackQuery): Сообщение или callback-запрос от пользователя.
state (FSMContext): Состояние пользователя бота.
"""
await state.clear()
# Создаем клавиатуру с кнопками
rkb: ReplyKeyboardBuilder = ReplyKeyboardBuilder()
rkb.row(KeyboardButton(text=_("Создать пост📔")))
rkb.row(KeyboardButton(text=_("Посмотреть список📋")))
# Формируем приветственное сообщение
text: str = _(
"""Добро пожаловать, <a href="{url}">{name}</a>!"""
).format(
url=message.from_user.url if message.from_user else "",
name=message.from_user.first_name if message.from_user else "пользователь",
)
# Отправляем сообщение
await msg_photo(message=message, text=text, file='assets/start.jpg', markup=rkb)

View File

@@ -0,0 +1,57 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, KeyboardButton
from aiogram.utils.keyboard import ReplyKeyboardBuilder
from aiogram.utils.i18n import gettext as _
from bot.templates import msg_photo
from bot.utils.interesting_facts import interesting_fact
from middleware.loggers import log
from bot.bots import BotInfo
from configs import COMMANDS, BotEdit
# Настройки экспорта и роутера
__all__ = ("router",)
CMD: str = "start".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
@router.callback_query(F.data == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
@log(level='INFO', log_type=CMD.upper(), text=f"использовал команду /{CMD}")
async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""
Обработчик команды /start
Args:
message (Message | CallbackQuery): Сообщение или callback-запрос от пользователя.
state (FSMContext): Состояние пользователя бота.
"""
await state.clear()
# Создаем клавиатуру с кнопками
rkb: ReplyKeyboardBuilder = ReplyKeyboardBuilder()
rkb.row(KeyboardButton(text=_("Создать пост📔")))
rkb.row(KeyboardButton(text=_("Посмотреть список📋")))
# Формируем приветственное сообщение
text: str = _(
"""Добро пожаловать, <a href="{url}">{name}</a>!
Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших историй!
Моя цель — помочь вам сориентироваться и сделать ваши истории куда интереснее!
Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре!
Интересный факт:
<blockquote>{fact}</blockquote>
"""
).format(
url=message.from_user.url if message.from_user else "",
name=message.from_user.first_name if message.from_user else "пользователь",
bot_name=BotEdit.PROJECT_NAME,
fact=interesting_fact(),
)
# Отправляем сообщение
await msg_photo(message=message, text=text, file='assets/start.jpg', markup=rkb)

View File

@@ -11,12 +11,10 @@ from aiogram.types import (
) )
from aiogram.utils.markdown import hide_link from aiogram.utils.markdown import hide_link
from BotCode.core.storage import storage from bot.core import storage
from BotCode.utils import textmd2 from bot.loggers import logs
from BotCode.config import PARSE_MODE
from BotCode.loggers import logs
router = Router(name="inline_send") router: Router = Router(name="inline_send")
@@ -140,7 +138,7 @@ async def inline_query_handler(inline_query: InlineQuery):
continue continue
# Тело сообщения # Тело сообщения
text = textmd2(post.get("text", "")) text = post.get("text", "")
image = post.get("image", "") image = post.get("image", "")
if image and image.startswith("http"): if image and image.startswith("http"):
text = f"{hide_link(image)}{text}" text = f"{hide_link(image)}{text}"
@@ -154,10 +152,7 @@ async def inline_query_handler(inline_query: InlineQuery):
title=f"Пост {post_id}", title=f"Пост {post_id}",
description=(post.get("text", "")[:100] + "...") if len(post.get("text", "")) > 100 else post.get( description=(post.get("text", "")[:100] + "...") if len(post.get("text", "")) > 100 else post.get(
"text", ""), "text", ""),
input_message_content=InputTextMessageContent( input_message_content=InputTextMessageContent(message_text=text),
message_text=text,
parse_mode=PARSE_MODE
),
reply_markup=markup reply_markup=markup
) )
) )

View File

@@ -0,0 +1,13 @@
from aiogram import Router
from .create_posts import router as posts_router
from .post_list import router as post_list_router
# Настройки экспорта и роутера
__all__ = ("router", )
router: Router = Router(name="post_router")
# Подключение роутеров
router.include_routers(
post_list_router,
posts_router,
)

View File

@@ -0,0 +1,420 @@
# bot/modules/create_post.py
import re
import uuid
from threading import Lock
from aiogram import Router, F
from aiogram.types import (
Message, CallbackQuery,
InlineKeyboardButton, InlineKeyboardMarkup
)
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
from bot.core import storage
router: Router = Router(name="create_post_router")
class PostState(StatesGroup):
waiting_for_text = State()
waiting_for_privacy = State()
waiting_for_id = State()
waiting_for_image = State()
waiting_for_buttons = State()
preview = State()
editing_choice = State()
post_id_lock: Lock = Lock()
# --- Utility functions ---
def make_inline_markup(rows: list[list[InlineKeyboardButton]]) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=rows)
def cancel_button() -> InlineKeyboardMarkup:
return make_inline_markup([[InlineKeyboardButton(text="Отмена", callback_data="cancel_creation")]])
def privacy_markup(is_private: bool) -> InlineKeyboardMarkup:
toggle = InlineKeyboardButton(
text="🔒 Приватный" if is_private else "🔓 Публичный",
callback_data="toggle_privacy"
)
cont = InlineKeyboardButton(text="Продолжить ➡️", callback_data="continue_creation")
return make_inline_markup([[toggle], [cont]])
def parse_buttons(text: str, post_id: str) -> list[list[dict]]:
"""
Поддерживается синтаксис:
Текст | msg:Только для боссов | 123,456 | msg:Для всех остальных
Текст | ntf:Без алерта | 789 | msg:Нет доступа
"""
rows: list[list[dict]] = []
button_index = 0
for line in text.splitlines():
line = line.strip()
if not line:
continue
# каждая строка может содержать несколько кнопок через ';'
btn_texts = [b.strip() for b in line.split(';') if b.strip()]
row: list[dict] = []
for raw in btn_texts:
parts = [p.strip() for p in raw.split('|')]
if len(parts) < 2:
raise ValueError(f"Неверный формат кнопки: '{raw}'")
btn = {"text": parts[0]}
primary_notification = None
primary_alert = False
allowed_ids = None
unauthorized_message = None
# обрабатываем параметры слева направо
for part in parts[1:]:
# URL / void
if part == "void":
btn["url"] = "http://void"
elif part.startswith("http") or part.startswith("tg://"):
btn["url"] = part
# первое уведомление (msg: — с алертом)
elif part.startswith("msg:") and primary_notification is None:
primary_notification = part.split(":", 1)[1]
primary_alert = True
# первое уведомление без алерта
elif part.startswith(("ntf:", "notification:")) and primary_notification is None:
primary_notification = part.split(":", 1)[1]
primary_alert = False
# список разрешённых ID
elif re.fullmatch(r'\d+(?:\s*,\s*\d+)*', part):
allowed_ids = [int(x.strip()) for x in part.split(",")]
# второе сообщение — для неавторизованных
elif part.startswith("msg:") and primary_notification is not None and allowed_ids is not None:
unauthorized_message = part.split(":", 1)[1]
# копирование текста
elif part.startswith("copy:"):
btn["callback_data"] = f"copy_{uuid.uuid4().hex}"
btn["copy_text"] = part.split(":", 1)[1]
# inline-параметры
elif part.startswith("inline:"):
btn["switch_inline_query"] = part.split(":", 1)[1]
elif part.startswith("inline_current:"):
btn["switch_inline_query_current_chat"] = part.split(":", 1)[1]
elif part.startswith("inline_chosen:"):
btn["switch_inline_query_chosen_chat"] = part.split(":", 1)[1]
# произвольный callback_data (если ещё не задан)
else:
if "callback_data" not in btn and "url" not in btn:
btn["callback_data"] = part
# если было уведомление — добавляем поля
if primary_notification is not None:
btn["callback_data"] = f"bt_{post_id}_{button_index}"
button_index += 1
btn["notification"] = primary_notification
btn["show_alert"] = primary_alert
if allowed_ids is not None:
btn["allowed_ids"] = allowed_ids
if unauthorized_message is not None:
btn["unauthorized_message"] = unauthorized_message
# финализируем кнопку
row.append(btn)
if row:
rows.append(row)
return rows
# --- Handlers ---
@router.message(F.text == "Создать пост📔")
async def start_creation(message: Message, state: FSMContext) -> None:
await state.set_state(PostState.waiting_for_text)
await state.update_data(private=False, buttons=[])
await message.reply(
text="Отправьте текст вашего поста:\n<i>Вы также можете использовать разметку</i>(<b>жирный</b>, <i>курсив</i> и <u>прочие</u>)!",
reply_markup=cancel_button()
)
@router.message(PostState.waiting_for_text)
async def got_text(message: Message, state: FSMContext) -> None:
html_text = message.html_text or message.text or message.caption or ""
await state.update_data(text=html_text)
await show_preview(message, state)
@router.callback_query(F.data == "toggle_privacy")
async def toggle_privacy(cq: CallbackQuery, state: FSMContext) -> None:
data = await state.get_data()
is_priv = not data.get('private', False)
await state.update_data(private=is_priv)
await cq.message.edit_reply_markup(reply_markup=privacy_markup(is_priv))
await cq.answer()
@router.callback_query(F.data == "continue_creation")
async def continue_to_id(cq: CallbackQuery, state: FSMContext) -> None:
await state.set_state(PostState.waiting_for_id)
await cq.message.edit_text(
"Введите уникальный ID поста (латиница, цифры, подчёрки):\n<i>Совет: инициалыРП_роль_тип_номер</i>\nПример: sgrp_dottore_post_4")
await cq.answer()
@router.message(PostState.waiting_for_id)
async def got_id(message: Message, state: FSMContext) -> None:
pid = message.text.strip()
if not pid.replace('_', '').isalnum():
await message.reply(text="ID должен содержать только латиницу, цифры и подчёркивания.",
reply_markup=cancel_button())
return
with post_id_lock:
if not storage.is_post_available(pid):
await message.reply(text="Этот ID уже занят, введите другой:", reply_markup=cancel_button())
return
# Создаем клавиатуру с кнопкой "Без изображения"
image_markup = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🚫 Без изображения", callback_data="no_image")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")]
])
await state.update_data(post_id=pid)
await state.set_state(PostState.waiting_for_image)
await message.reply(
text="Отправьте ссылку на изображение:\nПример: https://img4.teletype.in/files/f2/47/...\n\nСовет! Сохраняйте фотографии в teletype, а после копируйте ссылку на фотографию!\n\nИли нажмите 'Без изображения'.",
reply_markup=image_markup
)
@router.callback_query(F.data == "no_image", PostState.waiting_for_image)
async def no_image_callback(cq: CallbackQuery, state: FSMContext):
await state.update_data(image='')
await state.set_state(PostState.waiting_for_buttons)
await cq.message.delete()
buttons_markup = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🚫 Без кнопок", callback_data="no_buttons")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")]
])
await cq.message.answer(
text="""Отправьте кнопки по шаблону:
Кнопка заглушка | void
Уведомление | msg:Для вас!
Уведомление в закрепе | ntf:Сообщение
Кнопка ссылка | https://google.com
Копирование | copy:Текст для копирования
Для уведомлений с ограничением:
Уведомление | msg:Для вас! | 123,456 | msg:Для всех остальных!
Уведомление без алерта | ntf:Сообщение | 789 | msg:Нет доступа
Разделять кнопки через ;
Кнопка1 | void ; Кнопка2 | void ; ....
Или нажмите "Без кнопок".""",
reply_markup=buttons_markup,
parse_mode=None
)
await cq.answer()
@router.message(PostState.waiting_for_image)
async def got_image(message: Message, state: FSMContext) -> None:
img: str = message.text.strip()
if img.lower() in ('нет', 'no', 'none', 'без изображения'):
img: str = ''
await state.update_data(image=img)
await show_preview(message, state)
@router.callback_query(PostState.waiting_for_buttons, F.data == "no_buttons")
async def no_buttons_handler(callback: CallbackQuery, state: FSMContext) -> None:
await state.update_data(buttons=[])
await show_preview(callback.message, state)
await callback.answer()
@router.callback_query(PostState.waiting_for_buttons, F.data == "finish_buttons")
async def finish_buttons_handler(callback: CallbackQuery, state: FSMContext) -> None:
data = await state.get_data()
# Формируем финальные кнопки
final = []
for row in data.get('buttons', []):
final_row = []
for b in row:
btn = {"text": b["text"]}
if "url" in b:
btn["url"] = b["url"]
if "switch_inline_query" in b:
btn["switch_inline_query"] = b["switch_inline_query"]
if "callback_data" in b:
btn["callback_data"] = b["callback_data"]
if "notification" in b:
btn["notification"] = b["notification"]
btn["show_alert"] = b.get("show_alert", False)
final_row.append(btn)
final.append(final_row)
await state.update_data(buttons=final)
await show_preview(callback.message, state)
await callback.answer()
@router.callback_query(F.data == "cancel_creation")
async def cancel_handler(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text("❌ Создание поста отменено")
await callback.answer()
# --- Preview and Edit Handlers ---
async def show_preview(message: Message, state: FSMContext) -> None:
data = await state.get_data()
text = data.get('text', '')
image = data.get('image', '')
buttons = data.get('buttons', [])
private = data.get('private', False)
post_id = data.get('post_id', '')
# Формируем текст предпросмотра
preview_text = f"<b>ПРЕДПРОСМОТР ПОСТА</b>\n\n{text}\n\n"
preview_text += f"🆔 ID: <code>{post_id}</code>\n"
preview_text += f"🔒 Приватность: {'Приватный' if private else 'Публичный'}\n"
if image:
preview_text += f"🖼 Изображение: {image}\n"
else:
preview_text += f"🖼 Изображение: отсутствует\n"
if buttons:
preview_text += "\n🔘 Кнопки:\n"
for row in buttons:
preview_text += " | ".join([btn['text'] for btn in row]) + "\n"
else:
preview_text += "\n🔘 Кнопки: отсутствуют\n"
# Клавиатура предпросмотра
preview_markup = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="Изменить", callback_data="edit_post"),
InlineKeyboardButton(text="Подтвердить", callback_data="confirm_post")
],
[
InlineKeyboardButton(text="Отменить создание", callback_data="cancel_creation")
]
])
await state.set_state(PostState.preview)
await message.answer(preview_text, reply_markup=preview_markup, disable_web_page_preview=True)
@router.callback_query(PostState.preview, F.data == "edit_post")
async def edit_post_handler(cq: CallbackQuery, state: FSMContext) -> None:
# Клавиатура выбора поля для редактирования
edit_markup = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="Текст", callback_data="edit_field:text"),
InlineKeyboardButton(text="Изображение", callback_data="edit_field:image"),
],
[
InlineKeyboardButton(text="Кнопки", callback_data="edit_field:buttons"),
InlineKeyboardButton(text="ID", callback_data="edit_field:id"),
],
[
InlineKeyboardButton(text="Приватность", callback_data="edit_field:privacy"),
InlineKeyboardButton(text="Назад", callback_data="back_to_preview"),
]
])
await cq.message.edit_text("Выберите что изменить:", reply_markup=edit_markup)
await state.set_state(PostState.editing_choice)
await cq.answer()
@router.callback_query(PostState.editing_choice, F.data == "back_to_preview")
async def back_to_preview(cq: CallbackQuery, state: FSMContext) -> None:
await show_preview(cq.message, state)
await cq.answer()
@router.callback_query(PostState.editing_choice, F.data.startswith("edit_field:"))
async def handle_field_edit(cq: CallbackQuery, state: FSMContext) -> None:
field = cq.data.split(":")[1]
if field == "text":
await state.set_state(PostState.waiting_for_text)
await cq.message.edit_text("Введите новый текст поста:", reply_markup=cancel_button())
elif field == "image":
await state.set_state(PostState.waiting_for_image)
markup = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🚫 Без изображения", callback_data="no_image")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")]
])
await cq.message.edit_text(
"Отправьте новую ссылку на изображение или нажмите 'Без изображения':",
reply_markup=markup
)
elif field == "buttons":
await state.set_state(PostState.waiting_for_buttons)
markup = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🚫 Без кнопок", callback_data="no_buttons")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_creation")]
])
await cq.message.edit_text(
"Отправьте новые кнопки по шаблону или нажмите 'Без кнопок':",
reply_markup=markup
)
elif field == "id":
await state.set_state(PostState.waiting_for_id)
await cq.message.edit_text("Введите новый ID поста:", reply_markup=cancel_button())
elif field == "privacy":
data = await state.get_data()
await state.set_state(PostState.waiting_for_privacy)
await cq.message.edit_text(
"Измените приватность поста:",
reply_markup=privacy_markup(data.get('private', False))
)
await cq.answer()
@router.callback_query(PostState.preview, F.data == "confirm_post")
async def confirm_post_handler(cq: CallbackQuery, state: FSMContext) -> None:
data = await state.get_data()
post_id = data['post_id']
# Сохранение поста в хранилище
storage.save_post(post_id, {
'text': data['text'],
'image': data.get('image', ''),
'buttons': data.get('buttons', []),
'private': data['private'],
'post_id': post_id
})
await cq.message.edit_text(f"✅ Пост успешно создан с ID: <code>{post_id}</code>")
await state.clear()
await cq.answer()

View File

@@ -1,5 +1,8 @@
from math import ceil from math import ceil
from typing import Final
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext
from aiogram.types import ( from aiogram.types import (
Message, CallbackQuery, Message, CallbackQuery,
InlineKeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton, InlineKeyboardMarkup,
@@ -8,14 +11,12 @@ from aiogram.types import (
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
from aiogram.utils.markdown import hide_link from aiogram.utils.markdown import hide_link
from BotCode.core.storage import storage from bot.core import storage
from BotCode.utils.pagination import create_pagination_buttons from bot.utils import pagination_btn
from BotCode.utils import textmd2
from BotCode.config import PARSE_MODE
router = Router(name="posts_manager") router: Router = Router(name="posts_manager_router")
PAGE_SIZE = 5 PAGE_SIZE: Final[int] = 5
async def send_posts_list( async def send_posts_list(
message: Message = None, message: Message = None,
@@ -54,7 +55,7 @@ async def send_posts_list(
rows.append([btn]) rows.append([btn])
# Пагинация # Пагинация
nav_buttons = create_pagination_buttons( nav_buttons = pagination_btn(
action="open_post_list", action="open_post_list",
page=page, page=page,
total_posts=total, total_posts=total,
@@ -67,7 +68,7 @@ async def send_posts_list(
rows.append([InlineKeyboardButton(text="Закрыть❌", callback_data="cancel_list")]) rows.append([InlineKeyboardButton(text="Закрыть❌", callback_data="cancel_list")])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows) keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
header = "Список ваших постов:" header: str = "Список ваших постов:"
try: try:
if callback_query: if callback_query:
@@ -83,21 +84,25 @@ async def send_posts_list(
# --- Хендлеры списка --- # --- Хендлеры списка ---
@router.message(F.text.lower() == "посмотреть список📋") @router.message(F.text.lower() == "посмотреть список📋")
async def cmd_list(message: Message): async def cmd_list(message: Message, state: FSMContext):
# Сбрасываем состояние перед показом списка
await state.clear()
await send_posts_list(message=message) await send_posts_list(message=message)
@router.callback_query(F.data == "open_post_list") @router.callback_query(F.data == "open_post_list")
async def cb_open_list(cq: CallbackQuery): async def cb_open_list(cq: CallbackQuery, state: FSMContext):
await state.clear()
await send_posts_list(callback_query=cq) await send_posts_list(callback_query=cq)
await cq.answer() await cq.answer()
@router.callback_query(lambda c: c.data and c.data.startswith("open_post_list_page_")) @router.callback_query(F.data.startswith("open_post_list_page_"))
async def cb_paginate(cq: CallbackQuery): async def cb_paginate(cq: CallbackQuery, state: FSMContext):
try: try:
page = int(cq.data.rsplit("_", 1)[-1]) page = int(cq.data.rsplit("_", 1)[-1])
except ValueError: except ValueError:
await cq.answer("Некорректная страница", show_alert=True) await cq.answer("Некорректная страница", show_alert=True)
return return
await state.clear()
await send_posts_list(callback_query=cq, page=page) await send_posts_list(callback_query=cq, page=page)
await cq.answer() await cq.answer()
@@ -106,9 +111,9 @@ async def cb_cancel(cq: CallbackQuery):
await cq.message.delete() await cq.message.delete()
await cq.answer() await cq.answer()
# --- Просмотр отдельного поста --- @router.callback_query(F.data.startswith("view_post_"))
@router.callback_query(lambda c: c.data and c.data.startswith("view_post_"))
async def view_post_callback(cq: CallbackQuery): async def view_post_callback(cq: CallbackQuery):
"""Просмотр отдельного поста"""
pid = cq.data.replace("view_post_", "") pid = cq.data.replace("view_post_", "")
uid = cq.from_user.id uid = cq.from_user.id
posts = storage.load_user_posts(uid) posts = storage.load_user_posts(uid)
@@ -117,7 +122,7 @@ async def view_post_callback(cq: CallbackQuery):
return return
post = posts[pid] post = posts[pid]
text = textmd2(post.get("text", "")) text = post.get("text", "")
img = post.get("image", "") img = post.get("image", "")
if img.startswith("http"): if img.startswith("http"):
text = f"{hide_link(img)}{text}" text = f"{hide_link(img)}{text}"
@@ -181,20 +186,24 @@ async def view_post_callback(cq: CallbackQuery):
InlineKeyboardButton(text="Удалить❌", callback_data=f"delete_post_{pid}"), InlineKeyboardButton(text="Удалить❌", callback_data=f"delete_post_{pid}"),
InlineKeyboardButton(text="Назад◀️", callback_data="open_post_list") InlineKeyboardButton(text="Назад◀️", callback_data="open_post_list")
]) ])
rows.append(
[InlineKeyboardButton(text="Отправить↪️", switch_inline_query=f"{pid}")])
keyboard = InlineKeyboardMarkup(inline_keyboard=rows) keyboard = InlineKeyboardMarkup(inline_keyboard=rows)
await cq.message.answer(text=text, reply_markup=keyboard, parse_mode=PARSE_MODE) await cq.message.answer(text=text, reply_markup=keyboard)
await cq.message.delete() await cq.message.delete()
await cq.answer() await cq.answer()
# --- Удаление поста ---
@router.callback_query(lambda c: c.data and c.data.startswith("delete_post_")) @router.callback_query(lambda c: c.data and c.data.startswith("delete_post_"))
async def delete_post_callback(cq: CallbackQuery): async def delete_post_callback(cq: CallbackQuery, state: FSMContext):
"""Удаление поста."""
pid = cq.data.replace("delete_post_", "") pid = cq.data.replace("delete_post_", "")
uid = cq.from_user.id uid = cq.from_user.id
if storage.delete_user_post(uid, pid): if storage.delete_user_post(uid, pid):
await cq.answer(f"Пост {pid} удалён") await cq.answer(f"Пост {pid} удалён")
await state.clear()
await send_posts_list(callback_query=cq) await send_posts_list(callback_query=cq)
else: else:
await cq.answer("Не удалось удалить пост", show_alert=True) await cq.answer(text="Не удалось удалить пост", show_alert=True)

View File

@@ -0,0 +1 @@
# bot/keyboards/__init__.py

View File

@@ -0,0 +1,3 @@
# bot/keyboards/inline/__init__.py
from .decision import *

View File

@@ -0,0 +1,22 @@
# bot/keyboards/decision.py
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# Настройка экспорта
__all__ = ("get_decision_keyboard",)
def get_decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
"""
Создание клавиатуры принять\отклонить.
:param thread_id: Айди запроса.
:param kind: Вид предполагаемого действия.
:return: Разметку клавиатуры для сообщения бота.
"""
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(
InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
)
return ikb.as_markup()

View File

@@ -0,0 +1,2 @@
# bot/keyboards/reply/__init__.py

View File

@@ -16,7 +16,7 @@ from loguru import logger
from aiogram.types import Message, User from aiogram.types import Message, User
try: try:
from config import LogConfig from configs.config import LogConfig
except ImportError: except ImportError:
class LogConfig: class LogConfig:
"""Запасные настройки логирования, если config недоступен.""" """Запасные настройки логирования, если config недоступен."""

View File

@@ -0,0 +1 @@
from .message_callback import *

View File

@@ -0,0 +1,77 @@
from typing import Union
from aiogram.types import FSInputFile, CallbackQuery, Message, ReplyKeyboardMarkup, InlineKeyboardMarkup
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
# Настройка экспорта
__all__ = ('msg', 'msg_photo')
async def msg(
message: Message | CallbackQuery,
text: str,
markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None
) -> None:
"""
Шаблон для ответа на сообщение текстом.
:param message: Объект сообщения или callback-запроса.
:param text: Текст отправного сообщения от бота.
:param markup: Кнопки сообщения (инлайн или реплай).
"""
# Преобразуем клавиатуру
reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None
if markup:
if isinstance(markup, InlineKeyboardBuilder):
reply_markup = markup.as_markup()
elif isinstance(markup, ReplyKeyboardBuilder):
reply_markup = markup.as_markup(resize_keyboard=True)
# Обработчик ответа на сообщение
if isinstance(message, Message):
await message.reply(
text=text,
reply_markup=reply_markup
)
# Обработчик ответа на callback
else:
await message.message.reply(
text=text,
reply_markup=reply_markup
)
async def msg_photo(
message: Message | CallbackQuery,
text: str,
file: str,
markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None
) -> None:
"""
Шаблон для ответа на сообщение фотографией.
:param message: Объект сообщения или callback-запроса.
:param file: Путь к фотографии для ответа.
:param text: Подпись к фото.
:param markup: Кнопки сообщения (инлайн или реплай).
"""
# Преобразуем клавиатуру
reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None
if markup:
if isinstance(markup, InlineKeyboardBuilder):
reply_markup = markup.as_markup()
elif isinstance(markup, ReplyKeyboardBuilder):
reply_markup = markup.as_markup(resize_keyboard=True)
# Обработчик ответа на сообщение
if isinstance(message, Message):
await message.reply_photo(
photo=FSInputFile(file),
caption=text,
reply_markup=reply_markup
)
# Обработчик ответа на callback
else:
await message.message.reply_photo(
photo=FSInputFile(file),
caption=text,
reply_markup=reply_markup
)

View File

@@ -1,3 +1,4 @@
from .interesting_facts import *
from .md2_escape import * from .md2_escape import *
from .usernames import * from .usernames import *
from .pagination import * from .pagination import *

View File

@@ -0,0 +1,29 @@
from random import choice
from configs.config import Lists
# Настройки экспорта
__all__ = ("interesting_fact",)
def interesting_fact(mode: str = "факт", lists: list[str] = None) -> str:
"""
Возвращает случайный факт, анекдот или цитату, в зависимости от режима.
:param mode: Строка, определяющая тип контента ("факт", "анекдот", "цитата").
:param lists: Необязательный список строк, из которого можно выбирать вручную.
:return: Случайный элемент из соответствующего списка.
"""
if lists is not None:
return choice(lists)
mode: str = mode.lower()
if mode == "анекдот":
source: list[str] = Lists.jokes
elif mode == "цитата":
source: list[str] = Lists.quotes
else:
source: list[str] = Lists.facts
return choice(source)

View File

@@ -1,11 +1,11 @@
# BotCode/utils/md2_escape.py from re import sub, escape
from BotCode.config import PARSE_MODE from configs.config import BotSettings
# Настройка экспорта в модули # Настройка экспорта в модули
__all__ = ("textmd2",) __all__ = ("textmd2",)
def textmd2(msg: str, def textmd2(msg: str,
parse_mode: str = PARSE_MODE, parse_mode: str = BotSettings.PARSE_MODE,
special_chars: str = r"_*[]()~`>#+-=|{}.!") -> str: special_chars: str = r"_*[]()~`>#+-=|{}.!") -> str:
""" """
Экранирует специальные символы MarkdownV2 в переданном тексте. Экранирует специальные символы MarkdownV2 в переданном тексте.
@@ -18,7 +18,6 @@ def textmd2(msg: str,
:raises TypeError: Если передан не строковый тип данных. :raises TypeError: Если передан не строковый тип данных.
:raises ValueError: Если parse_mode задан некорректно. :raises ValueError: Если parse_mode задан некорректно.
""" """
from re import sub, escape
if not isinstance(msg, str): if not isinstance(msg, str):
raise TypeError(f"Ожидается строка, но получено {type(msg).__name__}") raise TypeError(f"Ожидается строка, но получено {type(msg).__name__}")

28
bot/utils/pagination.py Normal file
View File

@@ -0,0 +1,28 @@
from aiogram.types import InlineKeyboardButton
# Настройка экспорта в модули
__all__ = ('pagination_btn',)
def pagination_btn(action: str,
page: int = 0,
total_posts: int = 0,
bt_page: int = 5) -> list[InlineKeyboardButton]:
"""
Создает кнопки для пагинации.
:param action: Действие в котором нужна пангинация.
:param page: Номер начальной страницы, по умолчанию 0.
:param total_posts: Количество постов.
:param bt_page: Количество кнопок на одной странице.
:return: Готовый лист списка инлайн-кнопок.
"""
navigation_buttons: list[InlineKeyboardButton] = []
if page > 0:
navigation_buttons.append(InlineKeyboardButton(
text="", callback_data=f"{action}_page_{page - 1}"
))
if (page + 1) * bt_page < total_posts:
navigation_buttons.append(InlineKeyboardButton(
text="", callback_data=f"{action}_page_{page + 1}"
))
return navigation_buttons

View File

@@ -1,4 +1,3 @@
# BotCode/utils/username.py
from aiogram.types import Message from aiogram.types import Message
# Настройка экспорта в модули # Настройка экспорта в модули

2
configs/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .config import *
from .cmd_list import *

77
configs/cmd_list.py Normal file
View File

@@ -0,0 +1,77 @@
from typing import Final
# Список команд по ключу
COMMANDS: Final[dict[str, list[str]]] = {
"start": [
"start", "старт", "почати",
"ыефке", "cnfhn", "on", "вкл", "щт", "drk"
],
"help": [
"help", "помощь", "допомога",
"рудзщь", "dopomoga", "?"
],
"menu": [
"menu", "меню", "менюшка",
"ьщкф", "menyu"
],
"create": [
"create", "создать", "створити",
"сщзду", "sozdat", "stvoriti"
],
"report": [
"report", "репорт", "скарга",
"кщзщтв", "repert"
],
"mute": [
"mute", "заглушить", "заглушити",
"угуыщцук", "zaglushit"
],
"kick": [
"kick", "кик", "викинути",
"куиф", "vikynuty"
],
"ban": [
"ban", "бан", "забанити",
"ьфд", "zabanyty"
],
"stats": [
"stats", "статистика", "статистика",
"ыпщз", "statystyka"
],
"settings": [
"settings", "настройки", "налаштування",
"гшеукефьз", "nastroyky"
],
"info": [
"info", "инфо", "інфо",
"шкещ", "info"
],
"feedback": [
"feedback", "обратная связь", "зворотній зв’язок",
"гуеекфьз", "obratnaia_svyaz"
],
"subscribe": [
"subscribe", "подписаться", "підписатися",
"подписатсь", "pidpysatysia"
],
"unsubscribe": [
"unsubscribe", "отписаться", "відписатися",
"отписаться", "vidpysatysia"
],
"language": [
"language", "язык", "мова",
"йцукефь", "mova"
],
"cancel": [
"cancel", "отмена", "скасувати",
"утпщге", "skasuvaty"
],
"list": [
"list", "список", "список",
"дшззщк", "spysok"
],
"forward": [
"forward", "переслать", "переслати",
"дшпекщву", "pereslaty"
],
}

381
configs/config.py Normal file
View File

@@ -0,0 +1,381 @@
from pathlib import Path
from urllib.parse import urlparse
from typing import ClassVar, Final, Optional, Any
from pydantic import field_validator, model_validator, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from aiogram.types import ChatAdministratorRights
class Settings(BaseSettings):
"""Улучшенный класс настроек с комплексной валидацией"""
# Конфигурация загрузки переменных окружения
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
validate_default=True,
)
# Режимы и базовые параметры
PYTHONUNBUFFERED: str = "1"
LOCALE_PATH: str = "locales"
DEBUG: bool = False
OWNER: str = "@verdise"
# Токены бота
BOT_TOKEN: Optional[str] = None
BOT_DEBUG_TOKEN: Optional[str] = None
# Параметры сообщений
PARSE_MODE: str = "HTML"
ENCOD: str = "utf-8"
TIME_FORMAT: str = "%Y-%m-%d %H:%M:%S"
PREFIX: str = "/!.&?"
BOT_LANGUAGE: str = "Aiogram3"
# Настройки сообщений
DISABLE_NOTIFICATION: bool = False
PROTECT_CONTENT: bool = False
ALLOW_SENDING_WITHOUT_REPLY: bool = True
LINK_PREVIEW_IS_DISABLED: bool = False
LINK_PREVIEW_PREFER_SMALL_MEDIA: bool = False
LINK_PREVIEW_PREFER_LARGE_MEDIA: bool = True
LINK_PREVIEW_SHOW_ABOVE_TEXT: bool = True
SHOW_CAPTION_ABOVE_MEDIA: bool = False
# Разрешения и логирование
BOT_EDIT: bool = False
START_INFO_CONSOLE: bool = True
START_INFO_TO_FILE: bool = True
LOG_CONSOLE: bool = True
LOG_FILE: bool = True
LOG_DIR: Path = Path('Logs')
LOG_FILE_INFO: Path = Path('bot_info.log')
# Вебхук
WEBHOOK: bool = False
WEBHOOK_HOST: str = "https://bot_1.primo.dpdns.org"
WEBHOOK_PATH: str = "/webhook"
WEBHOOK_URL: str = f"{WEBHOOK_HOST}{WEBHOOK_PATH}"
# API ключи
API_KEY: Optional[str] = None
WEB_API_KEY: Optional[str] = None
WEATHER_API_KEY: Optional[str] = None
# Пользовательские данные
TG_API_UID: int = 0
TG_API_HASH: Optional[str] = None
# Идентификаторы
ADMIN_ID: int = 0
MODERATOR_ID: int = 0
IMPORTANT_ID: int = 0
IMPORTANT_GROUP_ID: int = 0
IMPORTANT_CHANNEL_ID: int = 0
SUPPORT_CHAT_ID: int = 0
# Настройки бота
PROJECT_NAME: str = "PRIMO"
BOT_NAME: str = "Первозданная Жемчужина"
BOT_DESCRIPTION: Optional[str] = None
BOT_SHORT_DESCRIPTION: Optional[str] = None
# Ролевой проект
RP_NAME: Optional[str] = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥"
INFO_URL: Optional[HttpUrl] = None
RP_OWNER: Optional[str] = None
# Права администратора
ANONYMOUS: bool = False
MANAGE_CHAT: bool = True
CHANGE_INFO: bool = True
PROMOTE_MEMBERS: bool = True
RESTRICT_MEMBERS: bool = True
POST_MESSAGE: bool = True
MANAGE_TOPICS: bool = True
INVITE_USER: bool = True
DELETE_MESSAGES: bool = True
MANAGE_VIDEO_CHATS: bool = True
EDIT_MESSAGES: bool = True
PIN_MESSAGE: bool = True
POST_STORIES: bool = True
EDIT_STORIES: bool = True
DELETE_STORIES: bool = True
# ================= ВАЛИДАТОРЫ =================
@field_validator('PYTHONUNBUFFERED')
def validate_unbuffered(cls, v: str) -> str:
"""Проверка корректности значения буферизации"""
if v not in ('0', '1'):
raise ValueError("PYTHONUNBUFFERED должен быть '0' или '1'")
return v
@field_validator('PARSE_MODE')
def validate_parse_mode(cls, v: str) -> str:
"""Проверка допустимого режима разметки"""
allowed_modes = {"HTML", "Markdown", "MarkdownV2"}
if v not in allowed_modes:
raise ValueError(f"Недопустимый PARSE_MODE. Допустимые значения: {', '.join(allowed_modes)}")
return v
@field_validator('PREFIX')
def validate_prefix(cls, v: str) -> str:
"""Очистка и проверка префиксов команд"""
cleaned = ''.join(sorted(set(v), key=v.index)) # Удаление дубликатов с сохранением порядка
if len(cleaned) < 1:
raise ValueError("PREFIX должен содержать хотя бы один символ")
return cleaned
@field_validator('LOG_DIR', 'LOG_FILE_INFO', mode='before')
def validate_paths(cls, v: Any) -> Path:
"""Преобразование путей в объекты Path"""
return Path(v) if isinstance(v, str) else v
@field_validator('TG_API_UID', 'ADMIN_ID', 'MODERATOR_ID')
def validate_ids(cls, v: int) -> int:
"""Проверка корректности идентификаторов"""
if v < 0:
raise ValueError("ID не может быть отрицательным")
return v
@field_validator('WEBHOOK_URL')
def validate_webhook_url(cls, v: str) -> str:
"""Базовая проверка URL вебхука"""
parsed = urlparse(v)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError("Некорректный URL вебхука")
return v
@field_validator('BOT_NAME', 'PROJECT_NAME', 'OWNER')
def validate_non_empty(cls, v: str) -> str:
"""Проверка непустых строк"""
if not v.strip():
raise ValueError("Поле не может быть пустым")
return v
@model_validator(mode='after')
def validate_bot_token(cls, setting: "Settings") -> "Settings":
"""Проверка наличия необходимых токенов"""
if setting.DEBUG and not setting.BOT_DEBUG_TOKEN:
raise ValueError("Требуется BOT_DEBUG_TOKEN в режиме DEBUG")
if not setting.DEBUG and not setting.BOT_TOKEN:
raise ValueError("Требуется BOT_TOKEN для рабочего режима")
return setting
@model_validator(mode='after')
def validate_webhook_config(cls, setting: "Settings") -> "Settings":
"""Проверка конфигурации вебхука"""
if setting.WEBHOOK and not setting.WEBHOOK_URL:
raise ValueError("WEBHOOK_URL обязателен при включенном WEBHOOK")
return setting
@model_validator(mode='after')
def validate_logging_paths(cls, setting: "Settings") -> "Settings":
"""Создание директорий для логов при необходимости"""
if setting.LOG_FILE and not setting.LOG_DIR.exists():
setting.LOG_DIR.mkdir(parents=True, exist_ok=True) # Исправлено: setting вместо settings
return setting
@model_validator(mode='after')
def set_dynamic_descriptions(cls, setting: "Settings") -> "Settings":
"""Динамическая установка описаний бота"""
if setting.BOT_DESCRIPTION is None:
# Исправлено: setting вместо settings
setting.BOT_DESCRIPTION = f"Ваш помощник в удивительные миры! Prod. by:『{setting.OWNER}"
if setting.BOT_SHORT_DESCRIPTION is None:
setting.BOT_SHORT_DESCRIPTION = f"Тех.поддержка: {setting.OWNER}"
return setting
# ================= СВОЙСТВА =================
@property
def rights(self) -> ChatAdministratorRights:
"""Права администратора бота"""
return ChatAdministratorRights(
is_anonymous=self.ANONYMOUS,
can_manage_chat=self.MANAGE_CHAT,
can_delete_messages=self.DELETE_MESSAGES,
can_manage_video_chats=self.MANAGE_VIDEO_CHATS,
can_restrict_members=self.RESTRICT_MEMBERS,
can_promote_members=self.PROMOTE_MEMBERS,
can_change_info=self.CHANGE_INFO,
can_invite_users=self.INVITE_USER,
can_post_stories=self.POST_STORIES,
can_edit_stories=self.EDIT_STORIES,
can_delete_stories=self.DELETE_STORIES,
can_post_messages=self.POST_MESSAGE,
can_edit_messages=self.EDIT_MESSAGES,
can_pin_messages=self.PIN_MESSAGE,
can_manage_topics=self.MANAGE_TOPICS,
)
@property
def active_bot_token(self) -> str:
"""Активный токен бота в зависимости от режима"""
token = self.BOT_DEBUG_TOKEN if self.DEBUG else self.BOT_TOKEN
if not token:
raise ValueError("Активный токен бота отсутствует")
return token
@property
def log_dir_absolute(self) -> Path:
"""Абсолютный путь к директории логов"""
return self.LOG_DIR.absolute()
# Инициализация настроек
settings: Settings = Settings()
# Классы для обратной совместимости и удобства использования
class BotSettings:
"""Алиасы для настроек бота."""
DEBUG: Final[bool] = settings.DEBUG
OWNER: Final[str] = settings.OWNER
BOT_TOKEN: Final[str] = settings.active_bot_token
PARSE_MODE: Final[str] = settings.PARSE_MODE
ENCOD: Final[str] = settings.ENCOD
TIME_FORMAT: Final[str] = settings.TIME_FORMAT
PREFIX: Final[str] = settings.PREFIX
BOT_LANGUAGE: Final[str] = settings.BOT_LANGUAGE
DISABLE_NOTIFICATION: Final[bool] = settings.DISABLE_NOTIFICATION
PROTECT_CONTENT: Final[bool] = settings.PROTECT_CONTENT
ALLOW_SENDING_WITHOUT_REPLY: Final[bool] = settings.ALLOW_SENDING_WITHOUT_REPLY
LINK_PREVIEW_IS_DISABLED: Final[bool] = settings.LINK_PREVIEW_IS_DISABLED
LINK_PREVIEW_PREFER_SMALL_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_SMALL_MEDIA
LINK_PREVIEW_PREFER_LARGE_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_LARGE_MEDIA
LINK_PREVIEW_SHOW_ABOVE_TEXT: Final[bool] = settings.LINK_PREVIEW_SHOW_ABOVE_TEXT
SHOW_CAPTION_ABOVE_MEDIA: Final[bool] = settings.SHOW_CAPTION_ABOVE_MEDIA
class Permission:
"""Алиасы для разрешений."""
BOT_EDIT: Final[bool] = settings.BOT_EDIT
START_INFO_CONSOLE: Final[bool] = settings.START_INFO_CONSOLE
START_INFO_TO_FILE: Final[bool] = settings.START_INFO_TO_FILE
class LogConfig:
"""Алиасы для конфигурации логов."""
CONSOLE: Final[bool] = settings.LOG_CONSOLE
FILE: Final[bool] = settings.LOG_FILE
DIR: Final[Path] = settings.LOG_DIR
FILE_INFO: Final[Path] = settings.LOG_FILE_INFO
ROTATION: ClassVar[str] = '100 MB'
RETENTION: ClassVar[str] = '7 days'
class Webhook:
"""Алиасы для вебхука."""
WEBHOOK: Final[bool] = settings.WEBHOOK
WEBHOOK_HOST = settings.WEBHOOK_HOST
WEBHOOK_PATH = settings.WEBHOOK_PATH
WEBHOOK_URL = settings.WEBHOOK_URL
class APISettings:
"""Алиасы для API."""
API_KEY: Final[Optional[str]] = settings.API_KEY
WEB_API_KEY: Final[Optional[str]] = settings.WEB_API_KEY
WEATHER_API_KEY: Final[Optional[str]] = settings.WEATHER_API_KEY
class UserIn:
"""Алиасы для пользовательских данных."""
TG_API_UID: Final[int] = settings.TG_API_UID
TG_API_HASH: Final[Optional[str]] = settings.TG_API_HASH
class ImportantID:
"""Алиасы для важных ID."""
ADMIN_ID: Final[int] = settings.ADMIN_ID
MODERATOR_ID: Final[int] = settings.MODERATOR_ID
IMPORTANT_ID: Final[int] = settings.IMPORTANT_ID
IMPORTANT_GROUP_ID: Final[int] = settings.IMPORTANT_GROUP_ID
IMPORTANT_CHANNEL_ID: Final[int] = settings.IMPORTANT_CHANNEL_ID
class BotEdit:
"""Алиасы для настроек редактирования бота."""
ALLOW_PERMISSION: Final[bool] = settings.BOT_EDIT
PROJECT_NAME: Final[str] = settings.PROJECT_NAME
NAME: Final[str] = settings.BOT_NAME
DESCRIPTION: Final[str] = settings.BOT_DESCRIPTION
SHORT_DESCRIPTION: Final[str] = settings.BOT_SHORT_DESCRIPTION
ANONYMOUS: Final[bool] = settings.ANONYMOUS
MANAGE_CHAT: Final[bool] = settings.MANAGE_CHAT
CHANGE_INFO: Final[bool] = settings.CHANGE_INFO
PROMOTE_MEMBERS: Final[bool] = settings.PROMOTE_MEMBERS
RESTRICT_MEMBERS: Final[bool] = settings.RESTRICT_MEMBERS
POST_MESSAGE: Final[bool] = settings.POST_MESSAGE
MANAGE_TOPICS: Final[bool] = settings.MANAGE_TOPICS
INVITE_USER: Final[bool] = settings.INVITE_USER
DELETE_MESSAGES: Final[bool] = settings.DELETE_MESSAGES
MANAGE_VIDEO_CHATS: Final[bool] = settings.MANAGE_VIDEO_CHATS
EDIT_MESSAGES: Final[bool] = settings.EDIT_MESSAGES
PIN_MESSAGE: Final[bool] = settings.PIN_MESSAGE
POST_STORIES: Final[bool] = settings.POST_STORIES
EDIT_STORIES: Final[bool] = settings.EDIT_STORIES
DELETE_STORIES: Final[bool] = settings.DELETE_STORIES
RIGHTS: Final[ChatAdministratorRights] = settings.rights
class RpValue:
"""Переменные связанные с ролевым проектом."""
RP_NAME: Final[str] = settings.RP_NAME
INFO_URL: str = settings.INFO_URL
RP_OWNER: str = settings.RP_OWNER
class Project:
POSTS_DIR: ClassVar[Path] = Path('posts')
class Lists:
"""Интересные списки фактов, цитат и анекдотов."""
facts: list[str] = [
"Python был создан Гвидо ван Россумом в 1991 году.",
"Имена Python и Monty Python связаны — язык назван в честь шоу.",
"Python — язык с динамической типизацией.",
"В Python всё является объектом, даже функции и типы данных.",
"Списки в Python — это изменяемые коллекции, в отличие от кортежей.",
"Python поддерживает парадигмы ООП, функционального и императивного программирования.",
"Zen of Python можно увидеть, набрав `import this` в интерпретаторе.",
]
jokes: list[str] = [
"1",
"2",
"3",
"4",
]
quotes: list[str] = [
"5",
"6",
"7",
"8",
]
# Экспорт совместимых компонентов
__all__ = (
"BotSettings",
"LogConfig",
"Webhook",
"APISettings",
"UserIn",
"ImportantID",
"Permission",
"BotEdit",
"Project",
"RpValue",
'settings',
'Lists',
)

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
app:
build: .
container_name: SystemReverseRPBot
volumes:
- .:/app
working_dir: /app
command: python main.py

92
env_example Normal file
View File

@@ -0,0 +1,92 @@
# Токены бота
BOT_TOKEN=your_bot_token_here
BOT_DEBUG_TOKEN=your_debug_bot_token_here
# Режим отладки
DEBUG=False
# Владелец бота
OWNER=@verdise
# Основные настройки
PARSE_MODE=HTML
ENCOD=utf-8
TIME_FORMAT=%Y-%m-%d %H:%M:%S
PREFIX=/!.&?
BOT_LANGUAGE=Aiogram3
# Настройки сообщений
DISABLE_NOTIFICATION=False
PROTECT_CONTENT=False
ALLOW_SENDING_WITHOUT_REPLY=True
LINK_PREVIEW_IS_DISABLED=False
LINK_PREVIEW_PREFER_SMALL_MEDIA=False
LINK_PREVIEW_PREFER_LARGE_MEDIA=True
LINK_PREVIEW_SHOW_ABOVE_TEXT=False
SHOW_CAPTION_ABOVE_MEDIA=False
# Разрешения
BOT_EDIT=False
START_INFO_CONSOLE=True
START_INFO_TO_FILE=True
# Логирование
LOG_CONSOLE=True
LOG_FILE=True
LOG_DIR=Logs
LOG_FILE_INFO=bot_info.log
# Вебхук
WEBHOOK=False
# API ключи
API_KEY=your_api_key
WEB_API_KEY=your_web_api_key
WEATHER_API_KEY=your_weather_api_key
# Telegram API ID и HASH
TG_API_UID=123456
TG_API_HASH=your_tg_api_hash
# Важные ID
ADMIN_ID=123456789
MODERATOR_ID=987654321
IMPORTANT_ID=1122334455
IMPORTANT_GROUP_ID=-1001122334455
IMPORTANT_CHANNEL_ID=-1009988776655
# Настройки бота
PROJECT_NAME=PRIMO
BOT_NAME=Первозданная Жемчужина
BOT_DESCRIPTION=Ваш помощник в удивительные миры! Prod. by:『@verdise』
BOT_SHORT_DESCRIPTION=Тех.поддержка: @verdise
# Настройки ролевого проекта
RP_NAME: str = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥"
# Права администратора
ANONYMOUS=False
MANAGE_CHAT=True
CHANGE_INFO=True
PROMOTE_MEMBERS=True
RESTRICT_MEMBERS=True
POST_MESSAGE=True
MANAGE_TOPICS=True
INVITE_USER=True
DELETE_MESSAGES=True
MANAGE_VIDEO_CHATS=True
EDIT_MESSAGES=True
PIN_MESSAGE=True
POST_STORIES=True
EDIT_STORIES=True
DELETE_STORIES=True
# Поддержка
SUPPORT_CHAT_ID=0

33
main.py
View File

@@ -1,30 +1,25 @@
# main.py # main.py
from aiogram import Bot, Dispatcher # Основной код проекта, который и соединяет в себе все его возможности
from aiogram.client.default import DefaultBotProperties
from BotCode.config import BOT_TOKEN, BOT_DEBUG_TOKEN, DEBUG_MODE, PARSE_MODE
dp: Dispatcher = Dispatcher() from asyncio import run
TOKEN: str = BOT_DEBUG_TOKEN if DEBUG_MODE else BOT_TOKEN from middleware.loggers import setup_logging
bot: Bot = Bot( from bot import *
token=TOKEN,
default=DefaultBotProperties(
parse_mode=PARSE_MODE,
link_preview_show_above_text=True,
)
)
async def main() -> None: async def main() -> None:
from aiogram.types import User """Входная точка проекта. Запуск бота."""
from BotCode.loggers import logs # Запуск логирования
from BotCode.handlers import router as main_router setup_logging()
bot_info: User = await bot.get_me() # Получение информации о боте
logs.start(text=f"Бот @{bot_info.username} запущен!") await BotInfo.setup(bot)
dp.include_router(main_router) # Подключение главного маршрутизатора
dp.include_router(router)
# Включение опроса бота
await dp.start_polling(bot) await dp.start_polling(bot)
# Вечная загрузка бота
if __name__ == "__main__": if __name__ == "__main__":
from asyncio import run
run(main()) run(main())

View File

@@ -0,0 +1 @@
from .logs import *

234
middleware/loggers/logs.py Normal file
View File

@@ -0,0 +1,234 @@
from sys import stderr
from pathlib import Path
from functools import wraps
from inspect import iscoroutinefunction
from typing import Any, Callable, Optional, TypeVar, cast, Final
from loguru import logger
from aiogram.types import Message, User
from configs.config import BotEdit, LogConfig
# Экспортируемые объекты
__all__ = ('Logger', 'setup_logging', 'loggers', 'log',)
# Универсальный тип для функций
F: TypeVar = TypeVar('F', bound=Callable[..., Any])
class Logger:
"""
Кастомный логгер с поддержкой декораторов и прямого вызова.
Attributes:
system_name: Имя системы для логирования
_log_format: Формат логов
"""
_log_format: Final[str] = (
'<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> <red>|</red> '
'<blue>{extra[system]}-{extra[log_type]}</blue> <red>| '
'{extra[user]} |</red> <level>{message}</level>'
)
def __init__(self, system_name: str = BotEdit.PROJECT_NAME) -> None:
"""
Инициализация логгера.
:param system_name: Имя системы для логирования
"""
self.system_name = system_name
self._setup_done = False
def setup(self, start: bool = True) -> None:
"""
Настройка обработчиков Loguru: консоль и файлы.
:param start: Если True, сразу логирует запуск проекта
"""
if self._setup_done:
return
# Полная очистка настроек
logger.remove()
# Создание директории для файловых логов
log_dir: Path = Path(getattr(LogConfig, 'DIR', 'logs'))
log_dir.mkdir(parents=True, exist_ok=True)
# Консольный лог
if getattr(LogConfig, 'CONSOLE', False):
logger.add(
sink=stderr,
format=self._log_format,
colorize=True,
level='DEBUG',
filter=lambda rec: rec['extra'].get('log_type') != 'DEBUG'
)
# Файловые логи
if getattr(LogConfig, 'FILE', False):
# Общий лог
logger.add(
sink=log_dir / 'bot.log',
rotation=getattr(LogConfig, 'ROTATION', '100 MB'),
retention=getattr(LogConfig, 'RETENTION', '7 days'),
format=self._log_format,
level='DEBUG',
enqueue=True,
backtrace=True,
diagnose=True
)
# Раздельные логи по уровням
for level_name in ['INFO', 'WARNING', 'ERROR', 'DEBUG', 'CRITICAL']:
logger.add(
sink=log_dir / f'{level_name.lower()}.log',
rotation='10 MB',
retention='7 days',
format=self._log_format,
level=level_name,
filter=lambda rec, lvl=level_name: rec['level'].name == lvl,
enqueue=True
)
self._setup_done = True
# Логируем старт
if start:
self.log_entry(
level='INFO',
text='Запуск проекта...',
log_type='START'
)
@staticmethod
def _format_user(message: Optional[Message] = None) -> str:
"""
Форматирует имя пользователя из объекта Message.
:param message: Объект aiogram.types.Message
:return: Строка '@username' или 'id<user_id>'
"""
if message is None or message.from_user is None:
return '@System'
user: User = message.from_user
return f"@{user.username}" if user.username else f"id{user.id}"
def log_entry(
self,
level: str,
text: str,
log_type: str,
user: Optional[str] = None,
message: Optional[Message] = None
) -> None:
"""
Основной метод для записи логов.
:param level: Уровень логирования (например, 'INFO')
:param text: Сообщение для логирования
:param log_type: Кастомный тип лога (например, 'HANDLER')
:param user: Явно указанный пользователь
:param message: Объект Message для извлечения юзера
"""
actual_user: str = user or self._format_user(message)
logger.bind(
system=self.system_name,
user=actual_user,
log_type=log_type
).log(level, text)
def log(
self,
level: str = 'INFO',
log_type: str = '',
text: Optional[str] = None
) -> Callable[[F], F]:
"""
Декоратор для логирования функций.
:param level: Уровень логирования
:param log_type: Категория лога
:param text: Кастомный текст сообщения
:return: Декорированную функцию
"""
def decorator(func: F) -> F:
is_coroutine = iscoroutinefunction(func)
action_text = text or f'Вызов {func.__name__}'
@wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
message = self._find_message(args)
self.log_entry(level, f"[START] {action_text}", log_type, message=message)
try:
result = func(*args, **kwargs)
self.log_entry(level, f"[SUCCESS] {action_text}", log_type, message=message)
return result
except Exception as e:
self.log_entry(
'ERROR',
f"[ERROR] {action_text} | Exception: {e!r}",
log_type,
message=message
)
raise
@wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
message = self._find_message(args)
self.log_entry(level, f"[START] {action_text}", log_type, message=message)
try:
result = await func(*args, **kwargs)
self.log_entry(level, f"[SUCCESS] {action_text}", log_type, message=message)
return result
except Exception as e:
self.log_entry(
'ERROR',
f"[ERROR] {action_text} | Exception: {e!r}",
log_type,
message=message
)
raise
return cast(F, async_wrapper if is_coroutine else sync_wrapper)
return decorator
@staticmethod
def _find_message(args: tuple[Any, ...]) -> Optional[Message]:
"""
Ищет объект Message в аргументах функции.
:param args: Аргументы функции
:return: Найденный Message или None
"""
return next((arg for arg in args if isinstance(arg, Message)), None)
# Методы для прямого вызова
def debug(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('DEBUG', text, log_type, user, message)
def info(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('INFO', text, log_type, user, message)
def warning(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('WARNING', text, log_type, user, message)
def error(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('ERROR', text, log_type, user, message)
def critical(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('CRITICAL', text, log_type, user, message)
# Создаем глобальный экземпляр логгера
loggers: Logger = Logger()
# Экспортируемые функции для обратной совместимости
setup_logging = loggers.setup
log = loggers.log

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +0,0 @@
[project]
name = "primostorybot"
version = "1.4"
description = "Бот для отправки постов с кнопками и разметкой сообщений"
authors = [
{name = "Verum",email = "sergeyzavalin@outlook.com"}
]
license = {text = "None"}
readme = "README.md"
requires-python = ">=3.10,<4.0"
dependencies = [
"aiogram (>=3.20.0.post0,<4.0.0)",
"loguru (>=0.7.3,<0.8.0)",
"dotenv (>=0.9.9,<0.10.0)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

BIN
requirements.txt Normal file

Binary file not shown.