First commit

This commit is contained in:
taphix 2024-07-15 10:15:51 +03:00
parent 3fbc7dfbac
commit a37efbf01b
22 changed files with 919 additions and 0 deletions

164
.gitignore vendored Normal file
View File

@ -0,0 +1,164 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.DS_Store
.vscode
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

100
docker-compose.yml Normal file
View File

@ -0,0 +1,100 @@
version: '3.9'
services:
tgbot_app:
build:
context: ./tgbot_app
dockerfile: Dockerfile
restart: always
environment:
- REDIS_HOST=redis
- DB_HOST=db
depends_on:
- redis
ports:
- "222:222"
networks:
- test-mission_default
redis:
image: redis
container_name: redis
restart: always
command: redis-server --requirepass ${R_PASSWORD}
environment:
- R_PASSWORD=${R_PASSWORD}
ports:
- "6379:6379"
networks:
- test-mission_default
db:
image: postgres
container_name: db
shm_size: 128mb
restart: always
volumes:
- some_volume:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
ports:
- "5432:5432"
networks:
- test-mission_default
fastapi_app:
build: ./fastapi_app
container_name: fastapi_app
restart: always
depends_on:
- db
ports:
- "8000:8000"
networks:
- test-mission_default
nginx:
image: nginx
container_name: my-nginx
restart: always
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/robots.txt:/var/www/html/robots.txt
- etc-letsencrypt:/etc/letsencrypt
- www-html:/var/www/html
ports:
- "80:80"
- "443:443"
networks:
- test-mission_default
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- etc-letsencrypt:/etc/letsencrypt
- www-html:/var/www/html
depends_on:
- nginx
command: certonly --webroot -w /var/www/html --email anakinnikitaa@gmail.com -d anakinnikita.ru --cert-name=certfolder --key-type rsa --agree-tos
ports:
- "6000:80"
networks:
- test-mission_default
volumes:
some_volume:
www-html:
etc-letsencrypt:
networks:
test-mission_default:
external: true

11
fastapi_app/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM python:3.11.4
COPY . /fastapi_app
WORKDIR /fastapi_app
EXPOSE 8000
RUN pip3 install --no-cache-dir -r requirements.txt
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

12
fastapi_app/config.py Normal file
View File

@ -0,0 +1,12 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
POSTGRES_USER: str
POSTGRES_DB: str
POSTGRES_PASSWORD: str
model_config = SettingsConfigDict(env_file='.env')
settings = Settings()

View File

@ -0,0 +1,41 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (AsyncSession, async_sessionmaker,
create_async_engine)
from config import settings
URL = f'postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@db:5432/{settings.POSTGRES_DB}'
class DataBase:
def __init__(self, url, pool_size=5):
self.engine = create_async_engine(
url=url,
echo=True,
pool_size=pool_size
)
self.session_maker = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
autoflush=False,
expire_on_commit=False
)
async def dispose(self):
await self.engine.dispose()
async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
async with self.session_maker() as session:
yield session
async def create_db(self, base):
async with self.engine.begin() as conn:
await conn.run_sync(base.metadata.create_all)
async def drop_db(self, base):
async with self.engine.begin() as conn:
await conn.run_sync(base.metadata.drop_all)
database = DataBase(url=URL)

View File

@ -0,0 +1,28 @@
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(AsyncAttrs, DeclarativeBase):
__abstract__ = True
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
created: Mapped[DateTime] = mapped_column(DateTime, default=func.now())
updated: Mapped[DateTime] = mapped_column(
DateTime,
default=func.now(),
onupdate=func.now()
)
class Users(Base):
__tablename__ = 'users'
username: Mapped[str] = mapped_column(String(150), unique=True)
class UserComments(Base):
__tablename__ = 'usercomments'
username: Mapped[int] = mapped_column(ForeignKey(Users.username))
comment: Mapped[str] = mapped_column(Text)

View File

@ -0,0 +1,55 @@
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import UserComments, Users
from schemas.comments import CommentsCreate
from schemas.users import UsersCreate
async def orm_get_users(
session: AsyncSession
) -> Sequence[Users]:
users = await session.execute(select(Users))
result = users.scalars()
return result.all()
async def orm_add_users(
session: AsyncSession,
users_create: UsersCreate
) -> Users:
users = Users(**users_create.model_dump())
user = await session.execute(
select(Users)
.where(Users.username == users.username)
)
if not user.first():
session.add(users)
await session.commit()
return users
async def orm_get_comments(
session: AsyncSession,
) -> Sequence[UserComments]:
comments = await session.execute(select(UserComments))
result = comments.scalars()
return result
async def orm_add_comments(
session: AsyncSession,
comments_create: CommentsCreate
) -> UserComments:
comments = UserComments(**comments_create.model_dump())
users_check = await session.execute(
select(Users)
.where(Users.username == comments.username)
)
if not users_check.first():
return
session.add(comments)
await session.commit()
return comments

View File

@ -0,0 +1,49 @@
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from database.engine import database
from database.orm import (orm_add_comments, orm_add_users, orm_get_comments,
orm_get_users)
from schemas.comments import CommentsCreate, CommentsRead
from schemas.users import UsersCreate, UsersRead
router = APIRouter()
@router.post('/users-add/')
async def add_users(
users_create: UsersCreate,
session: AsyncSession = Depends(database.get_session),
):
users = await orm_add_users(session=session, users_create=users_create)
return users
@router.get('/users/', response_model=List[UsersRead])
async def get_users(
session: AsyncSession = Depends(database.get_session)
):
users = await orm_get_users(session=session)
return users
@router.get('/comments/', response_model=List[CommentsRead])
async def get_comments(
session: AsyncSession = Depends(database.get_session)
):
comments = await orm_get_comments(session=session)
return comments
@router.post('/comments-add/')
async def add_comments(
comments_create: CommentsCreate,
session: AsyncSession = Depends(database.get_session)
):
comments = await orm_add_comments(
session=session,
comments_create=comments_create
)
return comments

33
fastapi_app/main.py Normal file
View File

@ -0,0 +1,33 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from database.engine import database
from database.models import Base
from handlers.router import router
logging.basicConfig(
level=logging.INFO,
filename='bot_log.log',
format="%(asctime)s %(levelname)s %(message)s"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.create_db(base=Base)
yield
await database.dispose()
app = FastAPI(
lifespan=lifespan,
default_response_class=ORJSONResponse
)
app.include_router(
router=router,
prefix='/api'
)

View File

@ -0,0 +1,4 @@
fastapi==0.111.0
pydantic-settings==2.3.4
SQLAlchemy==2.0.31
asyncpg==0.29.0

View File

@ -0,0 +1,14 @@
from pydantic import BaseModel
class UserComments(BaseModel):
username: str
comment: str
class CommentsCreate(UserComments):
pass
class CommentsRead(UserComments):
id: int

View File

@ -0,0 +1,13 @@
from pydantic import BaseModel
class UsersBase(BaseModel):
username: str
class UsersCreate(UsersBase):
pass
class UsersRead(UsersBase):
id: int

90
nginx/nginx.conf Normal file
View File

@ -0,0 +1,90 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 1024;
}
http {
sendfile on;
tcp_nopush on;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80;
server_name anakinnikita.ru;
location /.well-known/acme-challenge {
allow all;
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name anakinnikita.ru;
root /var/www/html;
ssl_certificate /etc/letsencrypt/live/certfolder/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/certfolder/privkey.pem;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://fastapi_app:8000;
}
location /.well-known/acme-challenge {
allow all;
root /var/www/html;
}
location /robots.txt {
root /var/www/html;
}
}
server {
listen 80;
server_name denis.anakinnikita.ru;
location /.well-known/acme-challenge {
allow all;
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name denis.anakinnikita.ru;
root /var/www/html;
ssl_certificate /etc/letsencrypt/live/denis/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/denis/privkey.pem;
location / {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://xls:8080;
}
location /.well-known/acme-challenge {
allow all;
root /var/www/html;
}
location /robots.txt {
root /var/www/html;
}
}
}

2
nginx/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

11
tgbot_app/Dockerfile Normal file
View File

@ -0,0 +1,11 @@
FROM python:3.11.4
COPY . /tg_bot
WORKDIR /tg_bot
EXPOSE 222
RUN pip3 install --no-cache-dir -r requirements.txt
CMD ["python3", "main.py"]

View File

@ -0,0 +1,42 @@
import logging
import aiohttp
class APIClient:
headers = headers = {"Content-Type": "application/json"}
async def get_query(self, url, prompt=None):
async with aiohttp.ClientSession() as session:
try:
async with session.get(
url=url,
headers=self.headers,
json=prompt,
ssl=False
) as response:
response.raise_for_status()
data = await response.json()
return data
except aiohttp.ClientError as e:
logging.error(e)
return 'Произошла ошибка'
async def post_query(self, url, prompt):
async with aiohttp.ClientSession() as session:
try:
async with session.post(
url=url,
headers=self.headers,
json=prompt,
ssl=False
) as response:
response.raise_for_status()
data = await response.text()
return data
except aiohttp.ClientError as e:
logging.error(e)
return 'Произошла ошибка'
api_client = APIClient()

11
tgbot_app/config.py Normal file
View File

@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
T_TOKEN: str
R_PASSWORD: str
model_config = SettingsConfigDict(env_file='.env')
settings = Settings()

View File

@ -0,0 +1,10 @@
from aiogram import types
from aiogram.filters import Filter
class ChatTypeFilter(Filter):
def __init__(self, chat_types: tuple[str]) -> None:
self.chat_types = chat_types
async def __call__(self, message: types.Message) -> bool:
return message.chat.type in self.chat_types

View File

@ -0,0 +1,134 @@
import logging
from aiogram import F, Router, types
from aiogram.filters import CommandStart
from aiogram.filters.logic import or_f
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from api.api_client import api_client
from filters.chat_types import ChatTypeFilter
from kb.reply_kb import ADD_KB, START_KB
user_private_router = Router()
user_private_router.message.filter(ChatTypeFilter(('private')))
class AddInfo(StatesGroup):
text = State()
@user_private_router.message(or_f(CommandStart(), (F.text == '↩️ Назад')))
async def start(message: types.Message):
hello_msg = 'Выбери, чем хочешь заняться🐶'
if message.text == '/start':
hello_msg = (f'Добро пожаловать, *{message.from_user.full_name}*!\n\n'
'Я тестовый *Бот*, ') + hello_msg
await message.answer(
hello_msg,
reply_markup=START_KB,
)
@user_private_router.message(F.text.lower() == 'добавить комментарий.')
async def create(message: types.Message, state: FSMContext):
await message.answer(
'Введите информацию',
reply_markup=ADD_KB
)
await state.set_state(AddInfo.text)
@user_private_router.message(
AddInfo.text,
F.text.lower() == 'выйти без сохранения'
)
async def post_comment_quit(
message: types.Message,
state: FSMContext,
):
await state.clear()
text = 'Вы вышли.'
await message.answer(
text,
reply_markup=START_KB
)
@user_private_router.message(AddInfo.text, F.text)
async def post_comment(
message: types.Message,
state: FSMContext,
):
await state.set_data({
'comment': message.text,
'username': message.from_user.username
})
data = await state.get_data()
await state.clear()
logging.info(data)
response = await api_client.post_query(
url='https://my-nginx/api/comments-add/',
prompt=data
)
text = 'Успешно'
if response == 'Произошла ошибка':
text = 'Ошибка'
await message.answer(
text,
reply_markup=START_KB
)
@user_private_router.message(F.text.lower() == 'зарегистрироваться.')
async def post_user(
message: types.Message,
):
data = {'username': message.from_user.username}
response = await api_client.post_query(
url='https://my-nginx/api/users-add/',
prompt=data
)
text = 'Успешно'
if response == 'Произошла ошибка':
text = 'Ошибка'
await message.answer(text)
@user_private_router.message(
F.text.lower() == 'посмотреть всех пользователей.'
)
async def get_users(
message: types.Message,
):
response = await api_client.get_query(
url='https://my-nginx/api/users/',
)
text = ''
if isinstance(response, str):
text = 'Пусто'
await message.answer(text)
return
for elem in response:
text += f'{elem.get("id")}. *{elem.get("username")}*\n'
logging.debug(text)
await message.answer(text)
@user_private_router.message(F.text.lower() == 'посмотреть все комментарии.')
async def get_comments(
message: types.Message,
):
response = await api_client.get_query(
url='https://my-nginx/api/comments/',
)
text = ''
if isinstance(response, str):
text = 'Пусто'
await message.answer(text)
return
for elem in response:
text += f'*{elem.get("username")}*:\n{elem.get("comment")}\n'
logging.debug(text)
await message.answer(text)

36
tgbot_app/kb/reply_kb.py Normal file
View File

@ -0,0 +1,36 @@
from aiogram.types import KeyboardButton
from aiogram.utils.keyboard import ReplyKeyboardBuilder
def get_kb(
*buttons: str,
placeholder: str | None = None,
request_contact: int | None = None,
request_location: int | None = None,
sizes: tuple[int] = (2,)
):
keyboard = ReplyKeyboardBuilder()
for idx, text in enumerate(buttons):
if request_contact and request_contact == idx + 1:
keyboard.add(KeyboardButton(text=text, request_contact=True))
elif request_location and request_location == idx + 1:
keyboard.add(KeyboardButton(text=text, request_location=True))
else:
keyboard.add(KeyboardButton(text=text))
return keyboard.adjust(*sizes).as_markup(
resize_keyboard=True, input_field_placeholder=placeholder
)
START_KB = get_kb(
'Зарегистрироваться.',
'Добавить комментарий.',
'Посмотреть всех пользователей.',
'Посмотреть все комментарии.',
placeholder='Что вас интересует?',
sizes=(1,)
)
ADD_KB = get_kb(
'Выйти без сохранения'
)

56
tgbot_app/main.py Normal file
View File

@ -0,0 +1,56 @@
import asyncio
import logging
from aiogram import Bot, Dispatcher, types
from aiogram.client.bot import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.fsm.storage.redis import DefaultKeyBuilder, RedisStorage
from aiogram.fsm.strategy import FSMStrategy
from config import settings
from handlers.user_private import user_private_router
BOT = Bot(
token=settings.T_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.MARKDOWN)
)
REDIS_URL_1 = f'redis://:{settings.R_PASSWORD}@redis:6379/0?decode_responses=True&protocol=3'
redis_fsm_storage = RedisStorage.from_url(
url=REDIS_URL_1,
key_builder=DefaultKeyBuilder(
with_bot_id=True,
with_destiny=True
)
)
logging.basicConfig(
level=logging.INFO,
filename='bot_log.log',
format="%(asctime)s %(levelname)s %(message)s"
)
ALLOWED_UPDATES = ['message', 'edited_message', 'callback_query']
dp = Dispatcher(
fsm_strategy=FSMStrategy.GLOBAL_USER,
storage=redis_fsm_storage
)
dp.include_router(user_private_router)
@dp.shutdown()
async def on_shutdown():
"""Закрытие сессии redis перед выключением бота"""
await redis_fsm_storage.close()
async def main():
await BOT.delete_webhook(drop_pending_updates=True)
await dp.start_polling(BOT, allowed_updates=ALLOWED_UPDATES)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,3 @@
aiogram==3.6.0
pydantic-settings==2.3.4
redis==5.0.7