discord-bot/bot.py
rainofdestiny e036908edb
Some checks failed
CI/CD / build (push) Failing after 28s
CI/CD / deploy (push) Has been skipped
Initial commit: Discord infrastructure bot
Commands: /status /logs /restart /deploy /metrics /backup
CI/CD: builds Docker image, pushes to git.csrx.ru registry, deploys to server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 05:27:51 +07:00

234 lines
9.4 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Discord bot for csrx.ru infrastructure management."""
import os
import asyncio
import logging
from datetime import datetime, timezone
import discord
from discord import app_commands
import docker
import httpx
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger(__name__)
DISCORD_TOKEN = os.environ["DISCORD_TOKEN"]
FORGEJO_TOKEN = os.environ["FORGEJO_TOKEN"]
FORGEJO_URL = os.environ.get("FORGEJO_URL", "https://git.csrx.ru")
FORGEJO_REPO = os.environ.get("FORGEJO_REPO", "jack/infra")
PROMETHEUS_URL = os.environ.get("PROMETHEUS_URL", "http://prometheus:9090")
# ---------------------------------------------------------------------------
intents = discord.Intents.default()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)
docker_client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
# ---------------------------------------------------------------------------
# Helpers
def _icon(status: str) -> str:
if status == "running": return "🟢"
if status == "paused": return "🟡"
return "🔴"
def _trunc(text: str, limit: int = 1900) -> str:
text = text.strip()
if len(text) <= limit:
return text
return "\n" + text[-limit:]
def _gb(b) -> str:
return f"{b / 1e9:.1f} GB" if b else "?"
# ---------------------------------------------------------------------------
# /status — list all containers
@tree.command(name="status", description="Статус всех Docker-контейнеров")
async def cmd_status(interaction: discord.Interaction):
await interaction.response.defer()
try:
containers = sorted(docker_client.containers.list(all=True), key=lambda c: c.name)
lines = [f"{_icon(c.status)} `{c.name}` — {c.status}" for c in containers]
embed = discord.Embed(
title="🖥️ Контейнеры",
description="\n".join(lines) or "Нет контейнеров",
color=0x5865F2,
timestamp=datetime.now(timezone.utc),
)
await interaction.followup.send(embed=embed)
except Exception as e:
await interaction.followup.send(f"❌ Ошибка: `{e}`")
# ---------------------------------------------------------------------------
# /logs <service> — last 40 lines of container logs
@tree.command(name="logs", description="Последние логи контейнера")
@app_commands.describe(service="Имя контейнера (forgejo, plane-web, traefik, …)")
async def cmd_logs(interaction: discord.Interaction, service: str):
await interaction.response.defer()
try:
c = docker_client.containers.get(service)
raw = c.logs(tail=40, timestamps=False).decode("utf-8", errors="replace")
await interaction.followup.send(
f"**Логи `{service}` (последние 40 строк):**\n```\n{_trunc(raw)}\n```"
)
except docker.errors.NotFound:
await interaction.followup.send(f"❌ Контейнер `{service}` не найден")
except Exception as e:
await interaction.followup.send(f"❌ Ошибка: `{e}`")
# ---------------------------------------------------------------------------
# /restart <service> — restart a container
@tree.command(name="restart", description="Перезапустить контейнер")
@app_commands.describe(service="Имя контейнера")
async def cmd_restart(interaction: discord.Interaction, service: str):
await interaction.response.defer()
try:
c = docker_client.containers.get(service)
await interaction.followup.send(f"🔄 Перезапускаю `{service}`…")
c.restart(timeout=30)
await interaction.channel.send(f"✅ `{service}` перезапущен")
except docker.errors.NotFound:
await interaction.followup.send(f"❌ Контейнер `{service}` не найден")
except Exception as e:
await interaction.followup.send(f"❌ Ошибка: `{e}`")
# ---------------------------------------------------------------------------
# /deploy — trigger Forgejo CI pipeline via workflow_dispatch
@tree.command(name="deploy", description="Запустить деплой через Forgejo CI")
async def cmd_deploy(interaction: discord.Interaction):
await interaction.response.defer()
try:
async with httpx.AsyncClient(timeout=15) as http:
r = await http.post(
f"{FORGEJO_URL}/api/v1/repos/{FORGEJO_REPO}/actions/workflows/deploy.yml/dispatches",
headers={"Authorization": f"token {FORGEJO_TOKEN}"},
json={"ref": "master"},
)
if r.status_code in (200, 204):
repo_url = f"{FORGEJO_URL}/{FORGEJO_REPO}/actions"
await interaction.followup.send(
f"🚀 **Деплой запущен!**\nПрогресс: {repo_url}"
)
else:
await interaction.followup.send(
f"⚠️ Forgejo ответил `{r.status_code}`:\n```{r.text[:300]}```"
)
except Exception as e:
await interaction.followup.send(f"❌ Ошибка: `{e}`")
# ---------------------------------------------------------------------------
# /metrics — CPU / RAM / disk from Prometheus
@tree.command(name="metrics", description="Метрики сервера (CPU / RAM / диск)")
async def cmd_metrics(interaction: discord.Interaction):
await interaction.response.defer()
try:
info = docker_client.info()
running = info.get("ContainersRunning", "?")
stopped = info.get("ContainersStopped", "?")
async with httpx.AsyncClient(timeout=6) as http:
async def prom(q: str):
try:
r = await http.get(
f"{PROMETHEUS_URL}/api/v1/query", params={"query": q}
)
d = r.json()
if d["status"] == "success" and d["data"]["result"]:
return float(d["data"]["result"][0]["value"][1])
except Exception:
pass
return None
cpu = await prom('100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)')
mem_total = await prom("node_memory_MemTotal_bytes")
mem_avail = await prom("node_memory_MemAvailable_bytes")
disk_total = await prom('node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}')
disk_free = await prom('node_filesystem_free_bytes{mountpoint="/",fstype!="tmpfs"}')
mem_pct = (mem_total - mem_avail) / mem_total * 100 if mem_total and mem_avail else None
disk_pct = (disk_total - disk_free) / disk_total * 100 if disk_total and disk_free else None
embed = discord.Embed(
title="📊 Метрики сервера",
color=0x2ecc71,
timestamp=datetime.now(timezone.utc),
)
embed.add_field(name="CPU", value=f"`{cpu:.1f}%`" if cpu is not None else "", inline=True)
embed.add_field(
name="RAM",
value=f"`{mem_pct:.1f}%` ({_gb(mem_total - mem_avail if mem_total and mem_avail else None)} / {_gb(mem_total)})"
if mem_pct is not None else "",
inline=True,
)
embed.add_field(
name="Диск /",
value=f"`{disk_pct:.1f}%` ({_gb(disk_total - disk_free if disk_total and disk_free else None)} / {_gb(disk_total)})"
if disk_pct is not None else "",
inline=True,
)
embed.add_field(
name="Контейнеры",
value=f"🟢 {running} running / 🔴 {stopped} stopped",
inline=False,
)
await interaction.followup.send(embed=embed)
except Exception as e:
await interaction.followup.send(f"❌ Ошибка: `{e}`")
# ---------------------------------------------------------------------------
# /backup — last backup container logs
@tree.command(name="backup", description="Логи последнего бэкапа")
async def cmd_backup(interaction: discord.Interaction):
await interaction.response.defer()
try:
containers = docker_client.containers.list(
all=True, filters={"name": "backup"}
)
if not containers:
await interaction.followup.send(
" Контейнер `backup` не найден (запускается по расписанию каждые 6 часов)"
)
return
c = containers[0]
raw = c.logs(tail=30).decode("utf-8", errors="replace")
lower = raw.lower()
icon = "🟢" if any(w in lower for w in ("uploaded", "complete", "done")) else \
"🔴" if any(w in lower for w in ("error", "fail", "traceback")) else "🟡"
await interaction.followup.send(
f"{icon} **Логи бэкапа** (`{c.name}`, статус: `{c.status}`):\n"
f"```\n{_trunc(raw, 1500)}\n```"
)
except Exception as e:
await interaction.followup.send(f"❌ Ошибка: `{e}`")
# ---------------------------------------------------------------------------
@client.event
async def on_ready():
await tree.sync()
log.info("Logged in as %s (ID: %s). Slash commands synced.", client.user, client.user.id)
client.run(DISCORD_TOKEN, log_handler=None)