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,23 +1,27 @@
# USAGE # Usage
Для развертывания бота необходимо внести свой токен в файл Создать файл .env в корневой дирректории проекта, объявить и присвоить значения переменным:
.env, запись должна иметь вид: BOT_TOKEN=your_token.
Для того чтобы пользователь мог пользоваться ботом, ему
необходимо поделиться номером телефона нажав на соответствующую
кнопку (номер не будет виден другим пользователям, используется
отправляется единожды для сверки с данными в БД).
# TODO TOKEN=
LOCK_IP=
CARD_ID=
AUTH_API=
- [x] Написать базовый функционал , где
TOKEN - токен телеграм для взаимодействия с ботом
LOCK_IP - ip адресс замка
CARD_ID - уникальный номер ключ-карты
AUTH_API - уникальный набор символов, для взаимодействия с API замка
Дополнительные подробности можно найти в instruction_http_api_v5.pdf и исходном коде программы (см. main.py)
## TODO
- [x] Написать базовый функционал
~~- [ ] Создать соотношение типа "id_пользователя: досутп_к_замку:"~~ ~~- [ ] Создать соотношение типа "id_пользователя: досутп_к_замку:"~~
- [x] Сделать проверку пользователей по их номеру телефона ... - [x] Сделать проверку пользователей по их номеру телефона ...
- [x] Удалить модуль getenv - [x] Удалить модуль getenv
- [x] Создать и парсить json с информацией о пользователях и их номерах - [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 re
import aiohttp
ALLOWED_PHONE_NUMBERS = list(config.get("users", {}).keys())
AUTHORIZED_USERS = {} AUTHORIZED_USERS = {}
def check_user_auth(phone: str) -> bool:
return phone in ALLOWED_PHONE_NUMBERS
def normalize_phone(phone: str) -> str: def normalize_phone(phone: str) -> str:
digits = re.sub(r"\D", "", phone) phone = phone.strip()
if len(digits) > 10: phone = re.sub(r"[^\d+]", "", phone)
digits = digits[-10:] if not phone.startswith("+"):
return digits phone = "+" + phone
return phone
async def authorize_user(user_id: int, phone: str) -> bool: def authorize_user(user_id: int, phone: str) -> bool:
normalized = normalize_phone(phone) normalized_phone = normalize_phone(phone)
api_url = "https://papi.dataekb.ru/check_access" if normalized_phone in ALLOWED_PHONE_NUMBERS:
print("DEBUG: phone (var normalized) is ", normalized) AUTHORIZED_USERS[user_id] = normalized_phone
async with aiohttp.ClientSession() as session: # if check_user_auth(phone):
try: # AUTHORIZED_USERS[user_id] = phone
async with session.get(api_url, params={"tel": normalized}) as response: print(f"{user_id} авторизован с номером: {normalized_phone}")
if response.status != 200: return True
print(f"Ошибка запроса к API: статус {response.status}") else:
return False print(
data = await response.json() f"Пользователь {user_id} пытался авторизоваться с номером {normalized_phone}"
except Exception as e: )
print(f"Исключение при запросе к API: {str(e)}")
return False
if data.get("response") != "1":
print(f"Доступ запрещён для номера: {normalized}")
return False 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: def is_user_auth(user_id: int) -> bool:
return user_id in AUTHORIZED_USERS 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: import json
env = {}
try: CONFIG_PATH = "bot_config.json"
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() 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' version: '3'
services: services:
bot_open_sesam: open_sesam:
container_name: bot_open_sesam container_name: open_sesam
image: proxy.docker.dataekb.ru/local_cache/bot_open_sesam:${BOT_IMAGE_TAG:-latest} build: .
stdin_open: true stdin_open: true
tty: 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: volumes:
# SSH ключ - ./.env:/bot/open_sesam/.env
- .ssh/id_rsa:/root/.ssh/id_rsa:ro
networks:
- bot_open_sesam_network
restart: always 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 WORKDIR /bot/open_sesam

View File

@ -2,30 +2,35 @@ from aiogram import Dispatcher, F
from aiogram.types import Message from aiogram.types import Message
from keyboard import get_locks_keyboard, get_contact_keyboard 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): def register_contact_handler(dp: Dispatcher):
@dp.message(F.contact) @dp.message(F.contact)
async def contact_handler(msg: Message): async def contact_handler(msg: Message):
user_id = msg.from_user.id user_id = msg.from_user.id
if msg.contact is None: if msg.contact is None:
await msg.answer("Ошибка: номер телефона не получен") await msg.answer("Ошибка: номер телефона не получен")
return return
phone = msg.contact.phone_number phone = normalize_phone(msg.contact.phone_number)
if not await authorize_user(user_id, phone):
print("DEBUG: phone in contact_handler is ", phone) if not authorize_user(user_id, phone):
await msg.answer("Доступ запрещён, номер не идентифицирован") await msg.answer("Доступ запрещен, номер не идентифицирован")
return return
user_data = AUTHORIZED_USERS.get(user_id) user_conf = config.get("users", {}).get(phone)
if not user_data: print(f"***user_conf для {phone}: {user_conf}***")
await msg.answer("Ошибка авторизации.") if not user_conf:
await msg.answer("Пользователь не опознан")
return return
allowed_zones = user_data["zones"] allowed_locks = user_conf.get("lock_id", [])
reply_markup = await get_locks_keyboard(allowed_zones) print(f"***allowed_locks = {allowed_locks}***")
reply_markup = get_locks_keyboard(allowed_locks)
await msg.answer( 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 import Dispatcher
from aiogram.types import Message from aiogram.types import Message
from auth import AUTHORIZED_USERS, is_user_auth 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): def register_open_door_handler(dp: Dispatcher):
@dp.message() @dp.message()
async def open_door_handler(msg: Message): async def open_door_handler(msg: Message):
print(
f"DEBUG: Получено сообщение от пользователя {msg.from_user.id}: '{msg.text}'"
)
user_id = msg.from_user.id user_id = msg.from_user.id
if not is_user_auth(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 return
user_data = AUTHORIZED_USERS.get(user_id) url = f"http://{lock_conf['ip']}/cgi-bin/ext"
if not user_data: auth_info = ("ext", lock_conf["auth_api"])
await msg.answer("Ошибка авторизации.") payload = f"CARD={user_conf['access_card']}&DIR=0"
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"} headers = {"Content-Type": "application/x-www-form-urlencoded"}
try: try:
async with aiohttp.ClientSession() as session: print(
async with session.post( f"***DEBUG: Отправляю запрос к {url} c payload: {payload} и auth: {auth_info}"
url, auth=auth_info, data=payload, headers=headers, timeout=5 )
) as response: response = await asyncio.to_thread(
if response.status == 200: requests.post,
await msg.answer("Открыто") url,
else: auth=auth_info,
await msg.answer( data=payload,
f"Ошибка при открытии замка. Код ошибки: {response.status}" 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: except Exception as e:
await msg.answer(f"Исключение: {str(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 keyboard import get_contact_keyboard, get_locks_keyboard
from auth import is_user_auth, AUTHORIZED_USERS from auth import is_user_auth, AUTHORIZED_USERS
from config import config
def register_start_handler(dp: Dispatcher): def register_start_handler(dp: Dispatcher):
@ -11,16 +12,25 @@ def register_start_handler(dp: Dispatcher):
async def command_start_handler(msg: Message): async def command_start_handler(msg: Message):
user_id = msg.from_user.id user_id = msg.from_user.id
if is_user_auth(user_id): if is_user_auth(user_id):
user_data = AUTHORIZED_USERS.get(user_id) phone = AUTHORIZED_USERS.get(user_id)
if not user_data: if not phone:
await msg.answer("Ошибка авторизации.") await msg.answer("Номер не найден")
return return
allowed_zones = user_data["zones"]
reply_markup = await get_locks_keyboard(allowed_zones) user_conf = config.get("user", {}).get(phone)
await msg.answer("Авторизация прошла успешно", reply_markup=reply_markup) 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: else:
reply_markup = get_contact_keyboard() reply_markup = get_contact_keyboard()
await msg.answer( await msg.answer(
"Для пользования ботом предоставьте номер телефона", "Для пользования ботом, предоставьте номер телефона",
reply_markup=reply_markup, 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.utils.keyboard import ReplyKeyboardBuilder
from aiogram.types import KeyboardButton 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() kb = ReplyKeyboardBuilder()
locks = await fetch_locks() for lock in allowed_locks:
for zone in allowed_locks: kb.button(text=lock)
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) 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 asyncio
import logging import logging
import sys import sys
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode 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 handlers import register_all_handlers from handlers import register_all_handlers
from config import config from config import config
# BOT_TOKEN = config["BOT_TOKEN"]
BOT_TOKEN=os.environ.get('BOT_TOKEN') BOT_TOKEN = config["bot_token"]
PROXY_URL = os.environ.get('PROXY_URL')
dp = Dispatcher() dp = Dispatcher()
register_all_handlers(dp) register_all_handlers(dp)
session = AiohttpSession(proxy=PROXY_URL)
async def main() -> None: async def main() -> None:
bot = Bot( bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
token=BOT_TOKEN
, session=session
, default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@ -14,8 +14,8 @@ 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
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}