restic 봇 생성
This commit is contained in:
parent
ee761d3568
commit
97a3158c10
6 changed files with 389 additions and 0 deletions
13
restic/.env
Normal file
13
restic/.env
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# restic 백업 알림 봇 설정
|
||||
|
||||
# 디스코드 봇 토큰 (Bot 페이지에서 발급)
|
||||
DISCORD_BOT_TOKEN=MTQ1MDQyMDEwMDc3NDYyNTQyMg.GLoqvA.QO9E6E2s5uLCQfl4a_OZR6A_PVKjwcQ7KwsMAM
|
||||
|
||||
# 성공 알림 채널 ID
|
||||
DISCORD_SUCCESS_CHANNEL_ID=1450289742175408279
|
||||
|
||||
# 실패 알림 채널 ID
|
||||
DISCORD_FAILURE_CHANNEL_ID=1450289802090909716
|
||||
|
||||
# 웹훅 서버 포트
|
||||
WEBHOOK_PORT=5001
|
||||
12
restic/Dockerfile
Normal file
12
restic/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
FROM python:3.14.2-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 의존성 설치
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 소스 복사
|
||||
COPY main.py .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
86
restic/README.md
Normal file
86
restic/README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# 📦 Restic Backup Discord Bot
|
||||
|
||||
restic 백업 결과를 디스코드로 알려주는 봇입니다.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- ✅ **성공 알림** - 백업 성공 시 지정 채널로 알림
|
||||
- ❌ **실패 알림** - 백업 실패 시 별도 채널로 알림
|
||||
- 📊 **상세 정보** - 파일 수, 크기, 소요 시간, 스냅샷 ID 표시
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
| 기술 | 설명 |
|
||||
| -------------- | ---------------- |
|
||||
| **Python** | 런타임 |
|
||||
| **discord.py** | 디스코드 API |
|
||||
| **aiohttp** | 비동기 HTTP 서버 |
|
||||
|
||||
---
|
||||
|
||||
## 📡 웹훅 엔드포인트
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
| --------------- | -------------- |
|
||||
| `POST /success` | 백업 성공 알림 |
|
||||
| `POST /failure` | 백업 실패 알림 |
|
||||
|
||||
### 요청 형식
|
||||
|
||||
**성공:**
|
||||
|
||||
```json
|
||||
{
|
||||
"files": "1382",
|
||||
"size": "316.110 MiB",
|
||||
"duration": "0:01",
|
||||
"snapshot_id": "8042b460"
|
||||
}
|
||||
```
|
||||
|
||||
**실패:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "에러 메시지"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 변수
|
||||
|
||||
| 변수 | 설명 |
|
||||
| ---------------------------- | ----------------- |
|
||||
| `DISCORD_BOT_TOKEN` | 디스코드 봇 토큰 |
|
||||
| `DISCORD_SUCCESS_CHANNEL_ID` | 성공 알림 채널 ID |
|
||||
| `DISCORD_FAILURE_CHANNEL_ID` | 실패 알림 채널 ID |
|
||||
| `WEBHOOK_PORT` | 웹훅 포트 (5001) |
|
||||
|
||||
---
|
||||
|
||||
## 📁 구조
|
||||
|
||||
```
|
||||
restic/
|
||||
├── main.py # 봇 메인 로직
|
||||
├── docker-compose.yml # Docker Compose 설정
|
||||
├── Dockerfile # 컨테이너 빌드
|
||||
├── requirements.txt # Python 의존성
|
||||
└── .env # 환경 변수
|
||||
```
|
||||
20
restic/docker-compose.yml
Normal file
20
restic/docker-compose.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
services:
|
||||
discord-bot-restic:
|
||||
build: .
|
||||
container_name: discord-bot-restic
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
ports:
|
||||
# 호스트 restic 스크립트 접근용 (localhost만)
|
||||
- "127.0.0.1:5001:5001"
|
||||
networks:
|
||||
- bot
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
bot:
|
||||
external: true
|
||||
256
restic/main.py
Normal file
256
restic/main.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""
|
||||
restic 백업 디스코드 알림 봇
|
||||
- 백업 성공/실패 시 각각 다른 채널로 알림
|
||||
- HTTP 웹훅으로 알림 수신
|
||||
"""
|
||||
|
||||
import os
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from aiohttp import web
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
# 환경변수
|
||||
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||
SUCCESS_CHANNEL_ID = int(os.getenv("DISCORD_SUCCESS_CHANNEL_ID", "0"))
|
||||
FAILURE_CHANNEL_ID = int(os.getenv("DISCORD_FAILURE_CHANNEL_ID", "0"))
|
||||
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "5001"))
|
||||
|
||||
# 봇 설정
|
||||
intents = discord.Intents.default()
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""바이트를 읽기 쉬운 형식으로 변환"""
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes:.2f} {unit}"
|
||||
size_bytes /= 1024
|
||||
return f"{size_bytes:.2f} PB"
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
"""봇 시작 시 실행"""
|
||||
print(f"✅ 봇 로그인: {bot.user}")
|
||||
try:
|
||||
synced = await bot.tree.sync()
|
||||
print(f"✅ {len(synced)}개 명령어 동기화 완료")
|
||||
except Exception as e:
|
||||
print(f"❌ 명령어 동기화 실패: {e}")
|
||||
|
||||
|
||||
async def send_success_notification(data: dict):
|
||||
"""백업 성공 알림 전송"""
|
||||
if not SUCCESS_CHANNEL_ID:
|
||||
print("❌ SUCCESS_CHANNEL_ID가 설정되지 않음")
|
||||
return
|
||||
|
||||
channel = bot.get_channel(SUCCESS_CHANNEL_ID)
|
||||
if not channel:
|
||||
print(f"❌ 채널을 찾을 수 없음: {SUCCESS_CHANNEL_ID}")
|
||||
return
|
||||
|
||||
try:
|
||||
# 스냅샷 ID 축약 (앞 8자리만)
|
||||
snapshot_id = data.get('snapshot_id', 'N/A')
|
||||
if len(snapshot_id) > 8:
|
||||
snapshot_id = snapshot_id[:8]
|
||||
|
||||
embed = discord.Embed(
|
||||
title="✅ 백업 완료",
|
||||
color=0x2ecc71, # 초록색
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# 통계 정보
|
||||
stats = f"📁 **{data.get('files', '0')}** 파일 • 💾 **{data.get('size', 'N/A')}** • ⏱️ **{data.get('duration', 'N/A')}**"
|
||||
embed.description = stats
|
||||
|
||||
# 백업 폴더 목록 (1024자 제한)
|
||||
folders = data.get('folders', [])
|
||||
if folders:
|
||||
folder_list = " ".join([f"`{f}`" for f in folders])
|
||||
# 디스코드 필드 길이 제한 적용
|
||||
if len(folder_list) > 1000:
|
||||
folder_list = folder_list[:1000] + "..."
|
||||
embed.add_field(name="📂 백업 대상", value=folder_list, inline=False)
|
||||
|
||||
# 하단 정보
|
||||
embed.set_footer(text=f"🆔 {snapshot_id} • 🖥️ {os.uname().nodename}")
|
||||
|
||||
await channel.send(embed=embed)
|
||||
print(f"✅ 성공 알림 전송")
|
||||
except Exception as e:
|
||||
print(f"❌ 알림 전송 실패: {e}")
|
||||
|
||||
|
||||
async def send_failure_notification(data: dict):
|
||||
"""백업 실패 알림 전송"""
|
||||
if not FAILURE_CHANNEL_ID:
|
||||
print("❌ FAILURE_CHANNEL_ID가 설정되지 않음")
|
||||
return
|
||||
|
||||
channel = bot.get_channel(FAILURE_CHANNEL_ID)
|
||||
if not channel:
|
||||
print(f"❌ 채널을 찾을 수 없음: {FAILURE_CHANNEL_ID}")
|
||||
return
|
||||
|
||||
try:
|
||||
error_msg = data.get('error', 'Unknown error')
|
||||
# 에러 메시지 길이 제한
|
||||
if len(error_msg) > 500:
|
||||
error_msg = error_msg[:500] + "..."
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🛑 백업 실패",
|
||||
color=0xe74c3c, # 빨간색
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
# 에러 내용
|
||||
embed.description = f"```\n{error_msg}\n```"
|
||||
|
||||
# 하단 정보
|
||||
embed.set_footer(text=f"🖥️ {os.uname().nodename}")
|
||||
|
||||
await channel.send(embed=embed)
|
||||
print(f"✅ 실패 알림 전송")
|
||||
except Exception as e:
|
||||
print(f"❌ 실패 알림 전송 오류: {e}")
|
||||
|
||||
|
||||
# HTTP 웹훅 핸들러
|
||||
async def success_handler(request):
|
||||
"""백업 성공 웹훅"""
|
||||
try:
|
||||
data = await request.json()
|
||||
await send_success_notification(data)
|
||||
return web.Response(text="OK")
|
||||
except Exception as e:
|
||||
print(f"❌ 성공 웹훅 오류: {e}")
|
||||
return web.Response(text="Error", status=500)
|
||||
|
||||
|
||||
async def failure_handler(request):
|
||||
"""백업 실패 웹훅"""
|
||||
try:
|
||||
data = await request.json()
|
||||
await send_failure_notification(data)
|
||||
return web.Response(text="OK")
|
||||
except Exception as e:
|
||||
print(f"❌ 실패 웹훅 오류: {e}")
|
||||
return web.Response(text="Error", status=500)
|
||||
|
||||
|
||||
async def send_sync_success_notification(data: dict):
|
||||
"""동기화 성공 알림 전송"""
|
||||
if not SUCCESS_CHANNEL_ID:
|
||||
return
|
||||
|
||||
channel = bot.get_channel(SUCCESS_CHANNEL_ID)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
title="✅ 동기화 완료",
|
||||
color=0x3498db, # 파란색
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
stats = f"💾 **{data.get('size', 'N/A')}** • ⏱️ **{data.get('duration', 'N/A')}**"
|
||||
embed.description = stats
|
||||
embed.add_field(name="📍 대상", value=f"`{data.get('target', 'N/A')}`", inline=False)
|
||||
embed.set_footer(text=f"🖥️ {os.uname().nodename}")
|
||||
|
||||
await channel.send(embed=embed)
|
||||
print(f"✅ 동기화 성공 알림 전송")
|
||||
except Exception as e:
|
||||
print(f"❌ 동기화 성공 알림 전송 오류: {e}")
|
||||
|
||||
|
||||
async def send_sync_failure_notification(data: dict):
|
||||
"""동기화 실패 알림 전송"""
|
||||
if not FAILURE_CHANNEL_ID:
|
||||
return
|
||||
|
||||
channel = bot.get_channel(FAILURE_CHANNEL_ID)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
try:
|
||||
error_msg = data.get('error', 'Unknown error')
|
||||
if len(error_msg) > 500:
|
||||
error_msg = error_msg[:500] + "..."
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🛑 동기화 실패",
|
||||
color=0xe74c3c,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
embed.description = f"```\n{error_msg}\n```"
|
||||
embed.add_field(name="📍 대상", value=f"`{data.get('target', 'N/A')}`", inline=False)
|
||||
embed.set_footer(text=f"🖥️ {os.uname().nodename}")
|
||||
|
||||
await channel.send(embed=embed)
|
||||
print(f"✅ 동기화 실패 알림 전송")
|
||||
except Exception as e:
|
||||
print(f"❌ 동기화 실패 알림 전송 오류: {e}")
|
||||
|
||||
|
||||
async def sync_success_handler(request):
|
||||
"""동기화 성공 웹훅"""
|
||||
try:
|
||||
data = await request.json()
|
||||
await send_sync_success_notification(data)
|
||||
return web.Response(text="OK")
|
||||
except Exception as e:
|
||||
print(f"❌ 동기화 성공 웹훅 오류: {e}")
|
||||
return web.Response(text="Error", status=500)
|
||||
|
||||
|
||||
async def sync_failure_handler(request):
|
||||
"""동기화 실패 웹훅"""
|
||||
try:
|
||||
data = await request.json()
|
||||
await send_sync_failure_notification(data)
|
||||
return web.Response(text="OK")
|
||||
except Exception as e:
|
||||
print(f"❌ 동기화 실패 웹훅 오류: {e}")
|
||||
return web.Response(text="Error", status=500)
|
||||
|
||||
|
||||
async def start_webhook_server():
|
||||
"""웹훅 서버 시작"""
|
||||
app = web.Application()
|
||||
app.router.add_post("/success", success_handler)
|
||||
app.router.add_post("/failure", failure_handler)
|
||||
app.router.add_post("/sync-success", sync_success_handler)
|
||||
app.router.add_post("/sync-failure", sync_failure_handler)
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT)
|
||||
await site.start()
|
||||
print(f"✅ 웹훅 서버 시작: http://0.0.0.0:{WEBHOOK_PORT}")
|
||||
print(f" - 백업 성공: POST /success")
|
||||
print(f" - 백업 실패: POST /failure")
|
||||
print(f" - 동기화 성공: POST /sync-success")
|
||||
print(f" - 동기화 실패: POST /sync-failure")
|
||||
|
||||
|
||||
async def main():
|
||||
"""메인 함수"""
|
||||
# 웹훅 서버 시작
|
||||
await start_webhook_server()
|
||||
|
||||
# 봇 시작
|
||||
await bot.start(BOT_TOKEN)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
2
restic/requirements.txt
Normal file
2
restic/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
discord.py==2.6.4
|
||||
aiohttp==3.13.2
|
||||
Loading…
Add table
Reference in a new issue