Compare commits

..

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

17 changed files with 207 additions and 363 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,14 +1,22 @@
# USAGE
# Usage
Для развертывания бота необходимо внести свой токен в файл
.env, запись должна иметь вид: BOT_TOKEN=your_token.
Создать файл .env в корневой дирректории проекта, объявить и присвоить значения переменным:
Для того чтобы пользователь мог пользоваться ботом, ему
необходимо поделиться номером телефона нажав на соответствующую
кнопку (номер не будет виден другим пользователям, используется
отправляется единожды для сверки с данными в БД).
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] Написать базовый функционал
~~- [ ] Создать соотношение типа "id_пользователя: досутп_к_замку:"~~
@ -16,8 +24,4 @@
- [x] Удалить модуль getenv
- [x] Создать и парсить json с информацией о пользователях и их номерах
~~- [ ] Проверять, является ли пользователь администратором, если является, выводить дополнительную кнопку, предлагающую добавить номер в БД~~
- [x] Изменить json хранящийся локально на запросы к API
- [x] Изменить названия комнта в локальном json
- [x] Ограничить логи внутри докера
- [x] Избавиться от json файла, переместить токен в .env файл, информацию от замков получать через запрос по API
- [x] Избавиться от модуля requests
- [ ] Сделать логирование о том, что кто-то открыл дверь в конкретное время

60
auth.py
View File

@ -1,48 +1,38 @@
from config import config
import re
import aiohttp
ALLOWED_PHONE_NUMBERS = list(config.get("users", {}).keys())
AUTHORIZED_USERS = {}
def check_user_auth(phone: str) -> bool:
return phone in ALLOWED_PHONE_NUMBERS
def normalize_phone(phone: str) -> str:
digits = re.sub(r"\D", "", phone)
if len(digits) > 10:
digits = digits[-10:]
return digits
phone = phone.strip()
phone = re.sub(r"[^\d+]", "", phone)
if not phone.startswith("+"):
phone = "+" + phone
return phone
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}")
def authorize_user(user_id: int, phone: str) -> bool:
normalized_phone = normalize_phone(phone)
if normalized_phone in ALLOWED_PHONE_NUMBERS:
AUTHORIZED_USERS[user_id] = normalized_phone
# if check_user_auth(phone):
# AUTHORIZED_USERS[user_id] = phone
print(f"{user_id} авторизован с номером: {normalized_phone}")
return True
else:
print(
f"Пользователь {user_id} пытался авторизоваться с номером {normalized_phone}"
)
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

23
bot_config.json 100644
View File

@ -0,0 +1,23 @@
{
"bot_token": "",
"locks": {
"Room418": {
"ip": "10.9.1.26",
"auth_api": "73B15D12"
},
"Floor4": {
"ip": "10.9.1.27",
"auth_api": "F901C40A"
}
},
"users": {
"+79000959392": {
"access_card": "000D001195DD",
"lock_id": ["Room418", "Floor4"]
},
"+79221716513": {
"access_card": "",
"lock_id": ["Room418", "Floor4"]
}
}
}

View File

@ -1,20 +1,11 @@
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
import json
CONFIG_PATH = "bot_config.json"
config = load_env()
def load_config(path: str = CONFIG_PATH) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
config = load_config()

View File

@ -1,52 +1,10 @@
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}
open_sesam:
container_name: open_sesam
build: .
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
- ./.env:/bot/open_sesam/.env
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,4 +1,4 @@
FROM python:3.11-slim-bookworm
FROM python:3.13.3-slim
WORKDIR /bot/open_sesam

View File

@ -2,30 +2,35 @@ 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
from auth import authorize_user, normalize_phone
from config import config
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("Доступ запрещён, номер не идентифицирован")
phone = normalize_phone(msg.contact.phone_number)
if not authorize_user(user_id, phone):
await msg.answer("Доступ запрещен, номер не идентифицирован")
return
user_data = AUTHORIZED_USERS.get(user_id)
if not user_data:
await msg.answer("Ошибка авторизации.")
user_conf = config.get("users", {}).get(phone)
print(f"***user_conf для {phone}: {user_conf}***")
if not user_conf:
await msg.answer("Пользователь не опознан")
return
allowed_zones = user_data["zones"]
reply_markup = await get_locks_keyboard(allowed_zones)
allowed_locks = user_conf.get("lock_id", [])
print(f"***allowed_locks = {allowed_locks}***")
reply_markup = get_locks_keyboard(allowed_locks)
await msg.answer(
"Номер подтверждён. Выберите дверь для открытия", reply_markup=reply_markup
"Номер подтвержден. Выберите дверь для открытия", reply_markup=reply_markup
)

View File

@ -1,44 +1,72 @@
import aiohttp
import asyncio
import requests
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
from config import config
def register_open_door_handler(dp: Dispatcher):
@dp.message()
async def open_door_handler(msg: Message):
print(
f"DEBUG: Получено сообщение от пользователя {msg.from_user.id}: '{msg.text}'"
)
user_id = msg.from_user.id
if not is_user_auth(user_id):
await msg.answer("Доступ запрещён. Предоставьте номер телефона.")
await msg.answer(
"Доступ запрщен. Необходимо предоставить свой номер телефона."
)
else:
print("OK")
phone = AUTHORIZED_USERS.get(user_id)
print(AUTHORIZED_USERS)
print(f"***user_id={user_id},phone={phone}***")
user_conf = config.get("users", {}).get(phone)
print(f"***phone={phone}, user_conf={user_conf}***")
# allowed_locks = user_conf.get("locks", [])
# print(f"***allowed_locks={allowed_locks}***")
# if msg.text not in allowed_locks:
# print("**********************")
# print(f"***{allowed_locks}***")
# print(f"***{msg.text}***")
# print("**********************")
# return
lock_conf = config.get("locks", {}).get(msg.text)
if not lock_conf:
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"
url = f"http://{lock_conf['ip']}/cgi-bin/ext"
auth_info = ("ext", lock_conf["auth_api"])
payload = f"CARD={user_conf['access_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}"
)
print(
f"***DEBUG: Отправляю запрос к {url} c payload: {payload} и auth: {auth_info}"
)
response = await asyncio.to_thread(
requests.post,
url,
auth=auth_info,
data=payload,
headers=headers,
timeout=5,
)
print(
f"DEBUG: URL: {url}, status: {response.status_code}, response: {response.text}"
)
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)}")

View File

@ -4,6 +4,7 @@ from aiogram.filters import CommandStart
from keyboard import get_contact_keyboard, get_locks_keyboard
from auth import is_user_auth, AUTHORIZED_USERS
from config import config
def register_start_handler(dp: Dispatcher):
@ -11,16 +12,25 @@ def register_start_handler(dp: Dispatcher):
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("Ошибка авторизации.")
phone = AUTHORIZED_USERS.get(user_id)
if not phone:
await msg.answer("Номер не найден")
return
allowed_zones = user_data["zones"]
reply_markup = await get_locks_keyboard(allowed_zones)
await msg.answer("Авторизация прошла успешно", reply_markup=reply_markup)
user_conf = config.get("user", {}).get(phone)
if not user_conf:
await msg.answer("Пользователь не найден в конфигурации")
return
allowed_locks = user_conf.get("locks_id", [])
reply_markup = get_locks_keyboard(allowed_locks)
await msg.answer(
"Авторизация прошла успешно",
reply_markup=reply_markup,
)
else:
reply_markup = get_contact_keyboard()
await msg.answer(
"Для пользования ботом предоставьте номер телефона",
"Для пользования ботом, предоставьте номер телефона",
reply_markup=reply_markup,
)

30
init_config.py 100644
View File

@ -0,0 +1,30 @@
import os
from dotenv import load_dotenv
ENV_FILE = ".env"
def check_env_file():
return os.path.exists(ENV_FILE)
def create_env_file():
print("Файл .env отсутствует и будет создан автоматически.")
token = input("Введите TOKEN: ")
lock_ip = input("Введите LOCK_IP: ")
card_id = input("Введите CARD_ID: ")
auth_api = input("Введите AUTH_API: ")
with open(ENV_FILE, "w") as f:
f.write(f"TOKEN={token}\n")
f.write(f"LOCK_IP={lock_ip}\n")
f.write(f"CARD_ID={card_id}\n")
f.write(f"AUTH_API={auth_api}\n")
print("Файл .env создан. Происходит запуск приложения")
def load_env():
load_dotenv()

View File

@ -1,15 +1,12 @@
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):
def get_locks_keyboard(allowed_locks: list):
print(f"DEBUG: allowed_locks = {allowed_locks}")
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)
for lock in allowed_locks:
kb.button(text=lock)
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 {}

15
main.py
View File

@ -1,32 +1,23 @@
import os
import asyncio
import logging
import sys
from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from handlers import register_all_handlers
from config import config
# BOT_TOKEN = config["BOT_TOKEN"]
BOT_TOKEN=os.environ.get('BOT_TOKEN')
PROXY_URL = os.environ.get('PROXY_URL')
BOT_TOKEN = config["bot_token"]
dp = Dispatcher()
register_all_handlers(dp)
session = AiohttpSession(proxy=PROXY_URL)
async def main() -> None:
bot = Bot(
token=BOT_TOKEN
, session=session
, default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
await dp.start_polling(bot)

View File

@ -14,8 +14,8 @@ multidict==6.4.3
propcache==0.3.1
pydantic==2.11.3
pydantic_core==2.33.1
requests==2.32.3
typing-inspection==0.4.0
typing_extensions==4.13.2
urllib3==2.4.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}