Merge pull request 'dev' (#5) from dev into main

Reviewed-on: #5
This commit is contained in:
sergey 2024-10-11 07:57:44 +00:00
commit 8464ae53a5
30 changed files with 2061 additions and 2 deletions

View File

@ -0,0 +1,22 @@
name: Docker Build
on:
merge:
branches:
- main
jobs:
build:
runs-on: runner-app01-osnova-api-alert
steps:
- uses: actions/checkout@v2
- name: build
run: |
docker login git.sm8255082.ru --username sergey --password ${{ secrets.GSP }}
docker build -t osnova-api-alert:latest -f .docker/Dockerfile .
- name: push latest
run: |
docker tag home-tg-bot:latest git.sm8255082.ru/sergey/osnova-api-alert:latest
docker push git.sm8255082.ru/sergey/osnova-api-alert:latest
- name: push curent version
run: |
docker tag home-tg-bot:latest git.sm8255082.ru/Osnova/osnova-api-alert:0.0.1
docker push git.sm8255082.ru/sergey/osnova-api-alert:0.0.1

4
.gitignore vendored
View File

@ -3,6 +3,8 @@ __pycache__
.env
.vscode/
.venv/
.log
.log/
*.log
.idea/
*.idea
docker/local_redis_file/data

View File

@ -1,2 +1,83 @@
# osnova-api-alert
swagger - https://osnova-api-alert.sm8255082.ru/docs
endpoints:
- https://osnova-api-alert.sm8255082.ru/ping (get, post)
без авторизации, без параметров
- https://osnova-api-alert.sm8255082.ru/api/v1/zbx/send-to-dashboard (post)
в заголовке должен быть токен 'x-api-key: токен'
должны предаваться в data:
```json
{
"text": "string",
"subject": "string",
"problem_id": 0
}
```
- https://osnova-api-alert.sm8255082.ru/api/v1/zbx/send-to-net-chat (post)
в заголовке должен быть токен 'x-api-key: токен'
должны предаваться в data:
```json
{
"text": "string",
"subject": "string"
}
```
js для заббикса:
```javascript
function sendMessage(value) {
var params = JSON.parse(value),
data,
response,
request = new HttpRequest(),
url = 'https://osnova-api-alert.sm8255082.ru/api/v1/tg/send';
request.addHeader('Content-Type: application/json');
request.addHeader('x-api-key: токен');
data = JSON.stringify(params);
response = request.post(url, data);
if (request.getStatus() === 200) {
return 'OK';
}
else {
if (typeof response.description === 'string') {
throw response.description;
}
else {
throw 'Unknown error. Check debug log for more information.';
}
}
}
try {
Zabbix.log(4, value)
sendMessage(value);
return 'OK';
}
catch (error) {
throw 'Unknown error ' + error;
}
```
You can try to get the message_thread_id from the message link
- send a message to the topic you need from the application
- right click on the sent message and choose "Copy message link"
- paste link somewhere
- you will see something like this: https://t.me/c/1112223334/25/33
- the value 25(value after long number) from the link will be message_thread_id
I assume that -100 + 1112223334 - will be equal chat_id
The number after will be message_thread_id
And the last one should be message_id

6
auth/__init__.py Normal file
View File

@ -0,0 +1,6 @@
from .static_env import verify_user_pwd, verify_token_zabbix
__all__ = [
"verify_user_pwd",
"verify_token_zabbix",
]

31
auth/static_env.py Normal file
View File

@ -0,0 +1,31 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from config import conf
import logging as log
security = HTTPBasic()
api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False)
def verify_token_zabbix(token: str = Depends(api_key_header)):
if token != conf.zbx.token:
log.warning("Invalid token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_user_pwd(credentials: HTTPBasicCredentials = Depends(security)):
if (
credentials.username != conf.swagger.login
or credentials.password != conf.swagger.pwd
):
log.warning("Invalid credentials")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)

21
config/.env-template Normal file
View File

@ -0,0 +1,21 @@
OAA_CFG__RUN__HOST=0.0.0.0
OAA_CFG__RUN__PORT=8000
OAA_CFG__RUN__RELOAD=True
OAA_CFG__LOG__LEVEL=30
OAA_CFG__LOG__LEVEL_TO_FILE=30
OAA_CFG__SWAGGER__LOGIN=admin
OAA_CFG__SWAGGER__PWD=P@ssw0rd!
OAA_CFG__REDIS__HOST=localhost
OAA_CFG__REDIS__PORT=6379
OAA_CFG__REDIS__PWD=P@ssw0rd!
OAA_CFG__TG__BOT_TOKEN=string
OAA_CFG__TG__CHAT_ID=0
OAA_CFG__TG__DASHBOARD_TRED_ID=0
OAA_CFG__TG__NET_TRED_ID=0
OAA_CFG__ZBX__TOKEN=string
OAA_CFG__ZBX__CLOSE_ALERT_PATTERN=^Problem has been resolved

7
config/__init__.py Normal file
View File

@ -0,0 +1,7 @@
from .config import conf, STATIC_DIR
__all__ = [
"conf",
STATIC_DIR,
]

104
config/config.py Normal file
View File

@ -0,0 +1,104 @@
from pydantic import BaseModel
from pydantic_settings import (
BaseSettings,
SettingsConfigDict,
)
from pathlib import Path
import logging
BASE_DIR = Path(__file__).parent.parent
TEMPLATES_DIR = BASE_DIR / "web" / "templates"
STATIC_DIR = BASE_DIR / "web" / "static"
class RunConfig(BaseModel):
host: str = "0.0.0.0"
port: int = 8000
reload: bool = False
class LogConfig(BaseModel):
level: int = 30
level_to_file: int = 30
dateformat: str = "%Y-%m-%d %H:%M:%S"
format: str = (
"[%(asctime)s.%(msecs)03d] %(module)-25s:%(lineno)4d | %(funcName)-20s| %(levelname)-8s | %(message)s"
)
class PrefixConfig(BaseModel):
swagger: str = "/docs"
api_v1: str = "/api/v1"
tg_v1: str = api_v1 + "/tg"
ping: str = "/ping"
zbx: str = api_v1 + "/zbx"
class RedisConfig(BaseModel):
host: str = "localhost"
port: int = 6379
pwd: str | None = None
class TelegramConfig(BaseModel):
bot_token: str
chat_id: int
dashboard_tred_id: int | None = None
net_tred_id: int | None = None
class FromZabbix(BaseModel):
token: str
close_alert_pattern: str
class SwaggerConfig(BaseModel):
openapi_url: str = "/openapi.json"
title: str = "API"
oauth2_redirect_url: str = "/docs/oauth2-redirect"
swagger_js_url: str = "/static/swagger/swagger-ui-bundle.js"
swagger_css_url: str = "/static/swagger/swagger-ui.css"
swagger_favicon_url: str = "/static/swagger/favicon.png"
login: str
pwd: str
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(
BASE_DIR / "config" / ".env-template",
BASE_DIR / "config" / ".env",
),
case_sensitive=False,
env_nested_delimiter="__",
env_prefix="OAA_CFG__",
)
run: RunConfig = RunConfig()
swagger: SwaggerConfig
log: LogConfig = LogConfig()
prefix: PrefixConfig = PrefixConfig()
redis: RedisConfig
tg: TelegramConfig
zbx: FromZabbix
conf = Settings()
logging.basicConfig(
level=conf.log.level,
datefmt=conf.log.dateformat,
format=conf.log.format,
)
file_handler = logging.FileHandler(BASE_DIR / "logfile.log")
file_handler.setLevel(conf.log.level_to_file)
file_handler.setFormatter(
logging.Formatter(
"[%(asctime)s.%(msecs)03d] %(module)s:%(lineno)4d | %(funcName)s| %(levelname)s | %(message)s"
)
)
logging.getLogger().addHandler(file_handler)

12
docker/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM python:3.12
ADD auth ./auth
ADD config ./config
ADD redis_db ./redis_db
ADD routers ./routers
ADD schemas ./schemas
ADD telegram ./telegram
ADD web ./web
ADD main.py ./main.py
ADD requirements.txt /requirements.txt
RUN pip install -r requirements.txt --root-user-action=ignore
CMD ["python", "main.py"]

View File

View File

@ -0,0 +1,12 @@
version: '3.3'
services:
redis:
image: redis:latest
restart: always
ports:
- "6379:6379"
volumes:
- ./local_redis_file/data:/data
#command: ["redis-server", --port 6379]
command: [redis-server, --protected-mode yes, --port 6379, --requirepass, P@ssw0rd!]

36
main.py
View File

@ -0,0 +1,36 @@
import logging as log
from config import conf, STATIC_DIR
import uvicorn
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from starlette.staticfiles import StaticFiles
from routers import router
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
log.info("main APP stopped")
main_app = FastAPI(
default_response_class=ORJSONResponse,
docs_url=None,
lifespan=lifespan,
)
main_app.include_router(router)
main_app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
if __name__ == "__main__":
log.info("Starting server")
uvicorn.run(
"main:main_app",
host=conf.run.host,
port=conf.run.port,
reload=conf.run.reload,
)
log.info("Server stopped")

12
osnova-api-alert.service Normal file
View File

@ -0,0 +1,12 @@
[Unit]
Description=Send alert from Osnova to telegram
After=multi-user.target
[Service]
User=linuxadm
Type=simple
Restart=always
ExecStart=/home/linuxadm/python-scripts/osnova-api-alert/.venv/bin/python3 /home/linuxadm/python-scripts/osnova-api-alert/main.py
[Install]
WantedBy=multi-user.target

1402
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
pyproject.toml Normal file
View File

@ -0,0 +1,24 @@
[tool.poetry]
name = "app-api-alert"
version = "0.1.0"
description = ""
authors = ["sergey <sergey@sm8255082.ru>"]
license = "GNU GENERAL PUBLIC LICENSE"
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
pydantic-settings = "^2.5.2"
fastapi = "^0.115.0"
uvicorn = {extras = ["standard"], version = "^0.31.0"}
orjson = "^3.10.7"
redis = "^5.1.1"
aiohttp = "^3.10.9"
[tool.poetry.group.dev.dependencies]
black = "^24.8.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

13
redis_db/__init__.py Normal file
View File

@ -0,0 +1,13 @@
from .crud import (
get_value,
set_value,
ping,
pop_value,
)
__all__ = [
"get_value",
"set_value",
"ping",
"pop_value",
]

33
redis_db/crud.py Normal file
View File

@ -0,0 +1,33 @@
import logging as log
from .r_helper import RedisManager
async def ping():
async with RedisManager() as redis_connect:
if redis_connect:
result = await redis_connect.client.ping()
log.info("Ping - %s", result)
return result
async def set_value(key, value):
async with RedisManager() as redis_connect:
if redis_connect:
await redis_connect.client.set(key, value)
log.info("Set %s = %s", key, value)
async def get_value(key):
async with RedisManager() as redis_connect:
if redis_connect:
value = await redis_connect.client.get(key)
log.info("Get %s = %s", key, value)
return value
async def pop_value(key):
async with RedisManager() as redis_connect:
if redis_connect:
value = await redis_connect.client.getdel(key)
log.info("Get and delete %s = %s", key, value)
return value

30
redis_db/r_helper.py Normal file
View File

@ -0,0 +1,30 @@
from redis.asyncio import Redis
from redis import ConnectionError
from config import conf
import logging as log
class RedisManager:
def __init__(self):
self.client = None
self.connect_params = {
"host": conf.redis.host,
"port": conf.redis.port,
}
if conf.redis.pwd:
self.connect_params["password"] = conf.redis.pwd
async def __aenter__(self):
self.client = Redis(**self.connect_params)
try:
await self.client.ping()
log.info("connected to Redis")
return self
except ConnectionError:
log.warning("failed to connect to Redis")
self.client = None
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.client:
await self.client.close()
log.info("closed connection to Redis")

Binary file not shown.

25
routers/__init__.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter
from .swagger import router as swagger_router
from .ping import router as ping_router
from .from_zbx import router as zbx_router
from config import conf
router = APIRouter()
router.include_router(
swagger_router,
prefix=conf.prefix.swagger,
)
router.include_router(
ping_router,
prefix=conf.prefix.ping,
tags=["Ping"],
)
router.include_router(
zbx_router,
prefix=conf.prefix.zbx,
tags=["From Zabbix"],
)

50
routers/from_zbx.py Normal file
View File

@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
import logging as log
from schemas import (
zbxMessageToDashboard,
zbxMessageToNetworkChat,
)
from auth import verify_token_zabbix
from redis_db import set_value, pop_value
from config import conf
import re
from telegram import send_message
from telegram import del_message
router = APIRouter()
@router.post("/send-to-dashboard")
async def send_message_to_dashboard(
message: zbxMessageToDashboard,
token: str = Depends(verify_token_zabbix),
):
match = re.search(conf.zbx.close_alert_pattern, message.text)
log.info(f"match: {match}")
if match:
msg_id = await pop_value(message.problem_id)
if msg_id:
msg_id = int(msg_id.decode("utf-8"))
await del_message(message_id=msg_id, chat_id=conf.tg.chat_id)
return
result = await send_message(
text=message.subject + "\n\n" + message.text,
chat_id=conf.tg.chat_id,
message_thread_id=conf.tg.dashboard_tred_id,
)
if result and result["status"] == 200:
await set_value(message.problem_id, result["msg_id"])
@router.post("/send-to-net-chat")
async def send_message_to_net_chat(
message: zbxMessageToNetworkChat,
token: str = Depends(verify_token_zabbix),
):
await send_message(
text=message.subject + "\n\n" + message.text,
chat_id=conf.tg.chat_id,
message_thread_id=conf.tg.net_tred_id,
)

16
routers/ping.py Normal file
View File

@ -0,0 +1,16 @@
from fastapi import APIRouter
from redis_db import ping
router = APIRouter()
@router.get("")
async def ping_post():
redis_ping = await ping()
return {"ok": True, "redis": redis_ping}
@router.post("")
async def ping_get():
redis_ping = await ping()
return {"ok": True, "redis": redis_ping}

29
routers/swagger.py Normal file
View File

@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.security import HTTPBasicCredentials
from config import conf
from auth import verify_user_pwd
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from fastapi.security import HTTPBasicCredentials
router = APIRouter()
@router.get("", include_in_schema=False)
async def custom_swagger_ui_html(
credentials: HTTPBasicCredentials = Depends(verify_user_pwd),
):
return get_swagger_ui_html(
openapi_url=conf.swagger.openapi_url,
title=conf.swagger.title,
oauth2_redirect_url=conf.swagger.oauth2_redirect_url,
swagger_js_url=conf.swagger.swagger_js_url,
swagger_css_url=conf.swagger.swagger_css_url,
swagger_favicon_url=conf.swagger.swagger_favicon_url,
)

9
schemas/__init__.py Normal file
View File

@ -0,0 +1,9 @@
from .from_zabbix import (
MessageToDashboard as zbxMessageToDashboard,
MessageToNetworkChat as zbxMessageToNetworkChat,
)
__all__ = [
"zbxMessageToDashboard",
"zbxMessageToNetworkChat",
]

14
schemas/from_zabbix.py Normal file
View File

@ -0,0 +1,14 @@
from pydantic import BaseModel
class Message(BaseModel):
text: str
class MessageToDashboard(Message):
subject: str
problem_id: int
class MessageToNetworkChat(Message):
subject: str

9
telegram/__init__.py Normal file
View File

@ -0,0 +1,9 @@
from .message import (
send_message,
del_message,
)
__all__ = [
"send_message",
"del_message",
]

53
telegram/message.py Normal file
View File

@ -0,0 +1,53 @@
import logging as log
import aiohttp
from config import conf
async def send_message(
text: str,
chat_id: int,
message_thread_id: int | None = None,
) -> dict | None:
url = f"https://api.telegram.org/bot{conf.tg.bot_token}/sendMessage"
params = {
"chat_id": chat_id,
"text": text,
}
if message_thread_id:
params["message_thread_id"] = message_thread_id
async with aiohttp.ClientSession() as session:
async with session.post(
url,
json=params,
) as response:
log.info(f"Response status: {response.status}")
resp = await response.json()
if response.status == 200:
log.info(f"Message with ID: {resp['result']['message_id']} send")
return {
"status": response.status,
"msg_id": resp["result"]["message_id"],
}
log.warning(f"Message not send. Response status: {response.status}")
async def del_message(
message_id: int,
chat_id: int,
) -> dict | None:
url = f"https://api.telegram.org/bot{conf.tg.bot_token}/deleteMessage"
async with aiohttp.ClientSession() as session:
async with session.post(
url,
json={
"chat_id": chat_id,
"message_id": message_id,
},
) as response:
if response.status == 200:
log.info(f"Message ID {message_id} deleted")
return {
"status": response.status,
}
log.warning(f"Response status: {response.status}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long