Compare commits

..

No commits in common. "main" and "v0.1" have entirely different histories.
main ... v0.1

17 changed files with 74 additions and 479 deletions

117
Jenkinsfile vendored
View File

@ -1,117 +0,0 @@
pipeline {
environment {
REGISTRY_URL = "https://proxy.docker.dataekb.ru/local_cache"
REGISTRY = "proxy.docker.dataekb.ru/local_cache"
BOT_IMAGE_NAME = "bot_open_sesam"
TUNNEL_IMAGE_NAME = "tunnel_open_sesam"
BOT_IMAGE_TAG = "latest"
TUNNEL_IMAGE_TAG = "latest"
IMAGE_TAG = "${env.BUILD_NUMBER}"
}
agent { label 'agent_smith'}
options {
buildDiscarder(logRotator(numToKeepStr: '10'))
disableConcurrentBuilds() // Prevent cleanup conflicts
timeout(time: 30, unit: 'MINUTES')
}
stages {
stage ('build bot image and push') {
steps {
script {
docker.withRegistry("${REGISTRY_URL}", 'jenkins_harbor') {
def BotImage = docker.build(
"${REGISTRY}/${BOT_IMAGE_NAME}:${env.BUILD_NUMBER}",
"--label build_number=${IMAGE_TAG} " +
"--label git_commit=${env.GIT_COMMIT} " +
"."
)
BotImage.push()
}
}
}
}
stage ('build tunnel image and push') {
steps {
dir('tunnel'){
script {
docker.withRegistry("${REGISTRY_URL}", 'jenkins_harbor') {
def TunnelImage = docker.build(
"${REGISTRY}/${TUNNEL_IMAGE_NAME}:${env.BUILD_NUMBER}",
"--label build_number=${IMAGE_TAG} " +
"--label git_commit=${env.GIT_COMMIT} " +
"."
)
TunnelImage.push()
}
}
}
}
}
stage ('clear after build and push') {
steps {
script {
sh "docker image prune --filter label=stage=builder"
}
}
}
stage("Deploy") {
steps {
script {
sh '''
export BOT_IMAGE_TAG=${IMAGE_TAG}
export TUNNEL_IMAGE_TAG=${IMAGE_TAG}
# Pull new images explicitly
docker-compose pull bot_open_sesam tunnel_open_sesam
# Deploy with specific project name
docker-compose up -d --remove-orphans
'''
}
}
}
}
post {
success {
echo "Оба образа собраны и задеплоены успешно"
}
failure {
echo "Ошибка сборки!"
}
}
}
// Old_version
// pipeline {
// agent { label 'agent_smith' }
//
// stages {
// stage('Stop and Remove Existing Container') {
// steps {
// sh '''
// docker stop open_sesam || true
// docker rm open_sesam || true
// docker-compose down || true
// '''
// }
// }
//
// stage('Build and Run Container') {
// steps {
// sh '''
// docker-compose up --build -d
// '''
// }
// }
// }
// }

View File

@ -1,23 +1,22 @@
# USAGE # Usage
Для развертывания бота необходимо внести свой токен в файл Создать файл .env в корневой дирректории проекта, объявить и присвоить значения переменным:
.env, запись должна иметь вид: BOT_TOKEN=your_token.
Для того чтобы пользователь мог пользоваться ботом, ему TOKEN=
необходимо поделиться номером телефона нажав на соответствующую LOCK_IP=
кнопку (номер не будет виден другим пользователям, используется CARD_ID=
отправляется единожды для сверки с данными в БД). AUTH_API=
# TODO , где
TOKEN - токен телеграм для взаимодействия с ботом
LOCK_IP - ip адресс замка
CARD_ID - уникальный номер ключ-карты
AUTH_API - уникальный набор символов, для взаимодействия с API замка
Дополнительные подробности можно найти в instruction_http_api_v5.pdf и исходном коде программы (см. main.py)
## TODO
- [x] Написать базовый функционал - [x] Написать базовый функционал
~~- [ ] Создать соотношение типа "id_пользователя: досутп_к_замку:"~~ - [ ] Создать соотношение типа "id_пользователя: досутп_к_замку:"
- [x] Сделать проверку пользователей по их номеру телефона ...
- [x] Удалить модуль getenv
- [x] Создать и парсить json с информацией о пользователях и их номерах
~~- [ ] Проверять, является ли пользователь администратором, если является, выводить дополнительную кнопку, предлагающую добавить номер в БД~~
- [x] Изменить json хранящийся локально на запросы к API
- [x] Изменить названия комнта в локальном json
- [x] Ограничить логи внутри докера
- [x] Избавиться от json файла, переместить токен в .env файл, информацию от замков получать через запрос по API
- [x] Избавиться от модуля requests

48
auth.py
View File

@ -1,48 +0,0 @@
import re
import aiohttp
AUTHORIZED_USERS = {}
def normalize_phone(phone: str) -> str:
digits = re.sub(r"\D", "", phone)
if len(digits) > 10:
digits = digits[-10:]
return digits
async def authorize_user(user_id: int, phone: str) -> bool:
normalized = normalize_phone(phone)
api_url = "https://papi.dataekb.ru/check_access"
print("DEBUG: phone (var normalized) is ", normalized)
async with aiohttp.ClientSession() as session:
try:
async with session.get(api_url, params={"tel": normalized}) as response:
if response.status != 200:
print(f"Ошибка запроса к API: статус {response.status}")
return False
data = await response.json()
except Exception as e:
print(f"Исключение при запросе к API: {str(e)}")
return False
if data.get("response") != "1":
print(f"Доступ запрещён для номера: {normalized}")
return False
zone_str = data["data"].get("zone", "")
zones = [zone.strip() for zone in zone_str.split(";") if zone.strip()]
card_code = data["data"].get("card-code", "")
if not zones or not card_code:
print("Некорректный ответ API: отсутствуют зоны или код карты.")
return False
AUTHORIZED_USERS[user_id] = {"tel": normalized, "card": card_code, "zones": zones}
print(
f"{user_id} авторизован с номером: {normalized}, карта: {card_code}, зоны: {zones}"
)
return True
def is_user_auth(user_id: int) -> bool:
return user_id in AUTHORIZED_USERS

7
buttons.py 100755
View File

@ -0,0 +1,7 @@
from aiogram.utils.keyboard import ReplyKeyboardBuilder
def FBI_open_up():
kb = ReplyKeyboardBuilder()
kb.button(text = "Сизам вскройся")
return kb.as_markup(resize_keyboard = True)

View File

@ -1,20 +0,0 @@
def load_env(path=".env") -> dict:
env = {}
try:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Пропускаем пустые строки и строки-комментарии
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
env[key.strip()] = value.strip()
except FileNotFoundError:
raise Exception(
f"Файл {path} не найден. Проверьте наличие файла .env в корне проекта."
)
return env
config = load_env()

View File

@ -1,52 +0,0 @@
version: '3'
services:
bot_open_sesam:
container_name: bot_open_sesam
image: proxy.docker.dataekb.ru/local_cache/bot_open_sesam:${BOT_IMAGE_TAG:-latest}
stdin_open: true
tty: true
env_file:
- ./.env
restart: always
networks:
- bot_open_sesam_network
depends_on:
tunnel_open_sesam:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
tunnel_open_sesam:
image: proxy.docker.dataekb.ru/local_cache/tunnel_open_sesam:${BOT_IMAGE_TAG:-latest}
container_name: tunnel_open_sesam
env_file:
- ./.env
# environment:
# - SSH_HOST=91.194.84.91
# - SSH_PORT=22
# - SSH_USER=root
volumes:
# SSH ключ
- .ssh/id_rsa:/root/.ssh/id_rsa:ro
networks:
- bot_open_sesam_network
restart: always
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "1080"]
interval: 10s
timeout: 5s
retries: 5
networks:
bot_open_sesam_network:
driver: bridge
ipam:
config:
- subnet: 172.30.10.0/29
gateway: 172.30.10.1

View File

@ -1,10 +0,0 @@
FROM python:3.11-slim-bookworm
WORKDIR /bot/open_sesam
COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]

View File

@ -1,9 +0,0 @@
from .start_handler import register_start_handler
from .contact_handler import register_contact_handler
from .doors_handler import register_open_door_handler
def register_all_handlers(dp):
register_start_handler(dp)
register_contact_handler(dp)
register_open_door_handler(dp)

View File

@ -1,31 +0,0 @@
from aiogram import Dispatcher, F
from aiogram.types import Message
from keyboard import get_locks_keyboard, get_contact_keyboard
from auth import authorize_user, AUTHORIZED_USERS
def register_contact_handler(dp: Dispatcher):
@dp.message(F.contact)
async def contact_handler(msg: Message):
user_id = msg.from_user.id
if msg.contact is None:
await msg.answer("Ошибка: номер телефона не получен")
return
phone = msg.contact.phone_number
if not await authorize_user(user_id, phone):
print("DEBUG: phone in contact_handler is ", phone)
await msg.answer("Доступ запрещён, номер не идентифицирован")
return
user_data = AUTHORIZED_USERS.get(user_id)
if not user_data:
await msg.answer("Ошибка авторизации.")
return
allowed_zones = user_data["zones"]
reply_markup = await get_locks_keyboard(allowed_zones)
await msg.answer(
"Номер подтверждён. Выберите дверь для открытия", reply_markup=reply_markup
)

View File

@ -1,44 +0,0 @@
import aiohttp
from aiogram import Dispatcher
from aiogram.types import Message
from auth import AUTHORIZED_USERS, is_user_auth
from locks_api import get_lock_by_label
def register_open_door_handler(dp: Dispatcher):
@dp.message()
async def open_door_handler(msg: Message):
user_id = msg.from_user.id
if not is_user_auth(user_id):
await msg.answer("Доступ запрещён. Предоставьте номер телефона.")
return
user_data = AUTHORIZED_USERS.get(user_id)
if not user_data:
await msg.answer("Ошибка авторизации.")
return
lock_info = await get_lock_by_label(msg.text)
if not lock_info:
await msg.answer("Информация по замку не найдена.")
return
url = f"http://{lock_info['ip']}/cgi-bin/ext"
auth_info = aiohttp.BasicAuth(login="ext", password=lock_info["code"])
payload = f"CARD={user_data['card']}&DIR=0"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
url, auth=auth_info, data=payload, headers=headers, timeout=5
) as response:
if response.status == 200:
await msg.answer("Открыто")
else:
await msg.answer(
f"Ошибка при открытии замка. Код ошибки: {response.status}"
)
except Exception as e:
await msg.answer(f"Исключение: {str(e)}")

View File

@ -1,26 +0,0 @@
from aiogram import Dispatcher, F
from aiogram.types import Message
from aiogram.filters import CommandStart
from keyboard import get_contact_keyboard, get_locks_keyboard
from auth import is_user_auth, AUTHORIZED_USERS
def register_start_handler(dp: Dispatcher):
@dp.message(CommandStart())
async def command_start_handler(msg: Message):
user_id = msg.from_user.id
if is_user_auth(user_id):
user_data = AUTHORIZED_USERS.get(user_id)
if not user_data:
await msg.answer("Ошибка авторизации.")
return
allowed_zones = user_data["zones"]
reply_markup = await get_locks_keyboard(allowed_zones)
await msg.answer("Авторизация прошла успешно", reply_markup=reply_markup)
else:
reply_markup = get_contact_keyboard()
await msg.answer(
"Для пользования ботом предоставьте номер телефона",
reply_markup=reply_markup,
)

View File

@ -1,19 +0,0 @@
from aiogram.utils.keyboard import ReplyKeyboardBuilder
from aiogram.types import KeyboardButton
from locks_api import fetch_locks
async def get_locks_keyboard(allowed_locks: list):
kb = ReplyKeyboardBuilder()
locks = await fetch_locks()
for zone in allowed_locks:
lock_info = locks.get(zone)
btn_text = lock_info["name"] if lock_info and "name" in lock_info else zone
kb.button(text=btn_text)
return kb.as_markup(resize_keyboard=True)
def get_contact_keyboard():
kb = ReplyKeyboardBuilder()
kb.add(KeyboardButton(text="Поделиться контактом", request_contact=True))
return kb.as_markup(resize_keyboard=True)

View File

@ -1,35 +0,0 @@
import aiohttp
import json
LOCKS_API_URL = "https://papi.dataekb.ru/get_pacs"
async def fetch_locks() -> dict:
async with aiohttp.ClientSession() as session:
try:
async with session.get(LOCKS_API_URL) as response:
if response.status != 200:
print(f"Ошибка при получении данных замков: {response.status}")
return {}
text = await response.text()
except Exception as e:
print(f"Исключение при запросе данных замков: {str(e)}")
return {}
try:
data = json.loads(text)
if not data or not isinstance(data, list):
print("Неверный формат данных о замках, ожидался список.")
return {}
locks_dict = data[0]
return locks_dict
except Exception as e:
print(f"Ошибка при разборе данных замков: {str(e)}")
return {}
async def get_lock_by_label(label: str) -> dict:
locks = await fetch_locks()
for lock_id, details in locks.items():
if label == lock_id or details.get("name") == label:
return details
return {}

62
main.py
View File

@ -1,32 +1,62 @@
import os
import asyncio import asyncio
import logging import logging
import sys import sys
import requests
from aiogram.types import Message
from aiogram.filters import CommandStart
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.enums import ParseMode
from aiogram import F
from handlers import register_all_handlers from os import getenv
from config import config from dotenv import load_dotenv
# BOT_TOKEN = config["BOT_TOKEN"] from buttons import FBI_open_up
BOT_TOKEN=os.environ.get('BOT_TOKEN')
PROXY_URL = os.environ.get('PROXY_URL')
load_dotenv()
TOKEN = getenv("TOKEN")
dp = Dispatcher() dp = Dispatcher()
register_all_handlers(dp)
session = AiohttpSession(proxy=PROXY_URL)
@dp.message(CommandStart())
async def command_start_handler(msg: Message):
await msg.answer("msg happens", reply_markup=FBI_open_up())
@dp.message(F.text == "Сизам вскройся")
async def handle_open_door(msg: Message):
user_id = msg.from_user.id
# print(msg.__dict__)
print(user_id)
url = f"http://{getenv('LOCK_IP')}/cgi-bin/ext"
auth = ("ext", f"{getenv('AUTH_API')}")
payload = f"CARD={getenv('CARD_ID')}&DIR=0"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
try:
response = await asyncio.to_thread(
requests.post, url, auth=auth, data=payload, headers=headers, timeout=5
)
if response.status_code == 200:
await msg.answer("Открыто")
else:
await msg.answer(
f"Ошибка при открытии замка. Код ошибки: {response.status_code}"
)
except Exception as e:
await msg.answer(f"Исключение: {str(e)}")
async def main() -> None: async def main() -> None:
bot = Bot( # Initialize Bot instance with default bot properties which will be passed to all API calls
token=BOT_TOKEN bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
, session=session
, default=DefaultBotProperties(parse_mode=ParseMode.HTML) # And the run events dispatching
)
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@ -14,8 +14,9 @@ multidict==6.4.3
propcache==0.3.1 propcache==0.3.1
pydantic==2.11.3 pydantic==2.11.3
pydantic_core==2.33.1 pydantic_core==2.33.1
python-dotenv==1.1.0
requests==2.32.3
typing-inspection==0.4.0 typing-inspection==0.4.0
typing_extensions==4.13.2 typing_extensions==4.13.2
urllib3==2.4.0 urllib3==2.4.0
yarl==1.20.0 yarl==1.20.0
aiohttp-socks

View File

@ -1,15 +0,0 @@
# tunnel/Dockerfile
FROM alpine:3.19
RUN apk add --no-cache \
autossh \
openssh-client
# Директория для SSH ключей
RUN mkdir -p /root/.ssh && \
chmod 700 /root/.ssh
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -1,16 +0,0 @@
#!/bin/sh
ssh-keyscan -p ${SSH_PORT:-22} ${SSH_HOST} >> /root/.ssh/known_hosts 2>/dev/null
exec autossh \
-M 0 \
-N \
-D 0.0.0.0:1080 \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
-o "StrictHostKeyChecking=no" \
-o "ConnectTimeout=10" \
-p ${SSH_PORT:-22} \
-i /root/.ssh/id_rsa \
${SSH_USER}@${SSH_HOST}