Compare commits
16 Commits
e7c8791f43
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e20068410 | |||
| a0833b818b | |||
| 27377b2e60 | |||
| 1167c3922e | |||
| 2f2b4d05a1 | |||
| c2d097d7ef | |||
| a8a8b932f8 | |||
| 5b01609a2f | |||
| 92689f95d4 | |||
| 8794d54a73 | |||
| 8356e9c021 | |||
| 8bc90b745a | |||
| 88ac296393 | |||
| ee32057c0b | |||
| 29617a07a5 | |||
| 3ba2ddfbd6 |
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Config
|
|
||||||
PORTAINER_URL="http://192.168.1.17:9000"
|
|
||||||
API_KEY="${PORTAINER_API_KEY}" # store this securely!
|
|
||||||
STACK_ID=27 # local_stuff stack
|
|
||||||
|
|
||||||
# Redeploy Stack
|
|
||||||
curl -s -X POST "$PORTAINER_URL/api/stacks/$STACK_ID/redeploy" \
|
|
||||||
-H "X-API-Key: $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{}'
|
|
||||||
@@ -4,12 +4,14 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
options: --privileged # Required for Docker-in-Docker
|
options: --privileged
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -24,7 +26,7 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and Push Docker Image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -34,11 +36,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Deploy to Portainer
|
- name: Deploy to Portainer
|
||||||
run: |
|
run: |
|
||||||
curl --location --request PUT -k 'https://192.168.1.17:9443/api/stacks/27?endpointId=2' \
|
curl -k -X PUT 'https://192.168.1.17:9443/api/stacks/27?endpointId=2' \
|
||||||
--header 'X-API-Key: ${{ secrets.PORTAINER_API_KEY }}' \
|
-H "X-API-Key: ${{ secrets.PORTAINER_API_KEY }}" \
|
||||||
--header 'Content-Type: application/json' \
|
-H "Content-Type: application/json" \
|
||||||
--data '{
|
-d @- <<EOF
|
||||||
"prune": false,
|
{
|
||||||
"pullImage": true,
|
"prune": false,
|
||||||
"stackFileContent": "version: '\''3.8'\''\nservices:\n jarvis:\n image: gitea.abzk.fr/mrzaiko/jarvis:latest\n container_name: jarvis"
|
"pullImage": true,
|
||||||
}'
|
"stackFileContent": "$(cat portainer-stack.yml | sed 's/"/\\"/g' | tr '\n' '\\n')"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@
|
|||||||
tmp/
|
tmp/
|
||||||
venv/
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.db
|
||||||
|
config/
|
||||||
|
.env
|
||||||
|
|||||||
123
api/ntfy.py
Normal file
123
api/ntfy.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyAPI:
|
||||||
|
def __init__(self, db_path, server, auth_token, authorized_user_id):
|
||||||
|
self.server = server
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {auth_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
self.authorized_user_id = authorized_user_id
|
||||||
|
self.db_path = db_path
|
||||||
|
self.loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
db = sqlite3.connect(self.db_path)
|
||||||
|
c = db.cursor()
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
topic TEXT PRIMARY KEY
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def subscribe(self, topic):
|
||||||
|
url = f"{self.server}/{topic}"
|
||||||
|
try:
|
||||||
|
r = requests.get(url, stream=True, headers=self.auth_header)
|
||||||
|
client = sseclient.SSEClient(r)
|
||||||
|
for event in client.events():
|
||||||
|
if event.data:
|
||||||
|
text = f"[{topic}] {event.data}"
|
||||||
|
app.bot.send_message(chat_id=self.authorized_user_id, text=text)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Topic {topic}: {e}")
|
||||||
|
time.sleep(5)
|
||||||
|
self.subscribe(topic) # retry
|
||||||
|
|
||||||
|
def get_subscriptions(self):
|
||||||
|
db = sqlite3.connect(self.db_path)
|
||||||
|
c = db.cursor()
|
||||||
|
c.execute("SELECT topic FROM subscriptions")
|
||||||
|
topics = [row[0] for row in c.fetchall()]
|
||||||
|
db.close()
|
||||||
|
return topics
|
||||||
|
|
||||||
|
def add_subscription(self, topic):
|
||||||
|
db = sqlite3.connect(self.db_path)
|
||||||
|
c = db.cursor()
|
||||||
|
c.execute("INSERT OR IGNORE INTO subscriptions (topic) VALUES (?)", (topic,))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def remove_subscription(self, topic):
|
||||||
|
db = sqlite3.connect(self.db_path)
|
||||||
|
c = db.cursor()
|
||||||
|
c.execute("DELETE FROM subscriptions WHERE topic = ?", (topic,))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def listen_topic(self, topic, app):
|
||||||
|
print(f"[INFO] Listening to topic: {topic}")
|
||||||
|
|
||||||
|
url = f"{self.server}/{topic}/json"
|
||||||
|
try:
|
||||||
|
with requests.get(url, stream=True, headers=self.headers) as response:
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
message = json.loads(line.decode("utf-8"))
|
||||||
|
text = message.get("message", "")
|
||||||
|
title = message.get("title", "")
|
||||||
|
|
||||||
|
if text:
|
||||||
|
text = f"[{topic}]\n{title}\n{text}\n"
|
||||||
|
text += "-" * 40 + "\n"
|
||||||
|
text += "/menu\n"
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
app.bot.send_message(
|
||||||
|
chat_id=self.authorized_user_id,
|
||||||
|
text=text,
|
||||||
|
),
|
||||||
|
self.loop,
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[ERROR] Failed to parse JSON: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Topic {topic}: {e}")
|
||||||
|
|
||||||
|
print(f"[INFO] Stopped listening to topic: {topic}")
|
||||||
|
|
||||||
|
# === NTFY LISTENER THREAD ===
|
||||||
|
def ntfy_listener(self, app):
|
||||||
|
print("[INFO] Starting NTFY listener thread...")
|
||||||
|
|
||||||
|
active_topics = {}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
topics = set(self.get_subscriptions())
|
||||||
|
for topic in topics:
|
||||||
|
if topic not in active_topics:
|
||||||
|
print(f"[INFO] Starting thread for topic: {topic}")
|
||||||
|
t = threading.Thread(
|
||||||
|
target=self.listen_topic,
|
||||||
|
args=(
|
||||||
|
topic,
|
||||||
|
app,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
active_topics[topic] = t
|
||||||
|
time.sleep(10) # refresh check
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from qbittorrent import Client
|
from qbittorrent import Client
|
||||||
|
from torrent import TorrentApi
|
||||||
|
|
||||||
# Replace with your qBittorrent Web UI credentials
|
# Replace with your qBittorrent Web UI credentials
|
||||||
qb = Client('http://192.168.1.17:8112')
|
torrent = TorrentApi("http://192.168.1.17:8112", "tMHNjrJr7nhjyhJrYsahi4anq2h6LJ")
|
||||||
qb.login('admin', 'tMHNjrJr7nhjyhJrYsahi4anq2h6LJ')
|
|
||||||
|
|
||||||
# Retrieve all active torrents
|
# Retrieve all active torrents
|
||||||
torrents = qb.torrents(filter='downloading')
|
torrents = torrent.get_torrents()
|
||||||
|
|
||||||
# Print details of each active torrent
|
# Print details of each active torrent
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
@@ -17,4 +17,4 @@ for torrent in torrents:
|
|||||||
print(f"Download Speed: {torrent['dlspeed'] / 1024:.2f} KB/s")
|
print(f"Download Speed: {torrent['dlspeed'] / 1024:.2f} KB/s")
|
||||||
print(f"Upload Speed: {torrent['upspeed'] / 1024:.2f} KB/s")
|
print(f"Upload Speed: {torrent['upspeed'] / 1024:.2f} KB/s")
|
||||||
print(f"Size: {torrent['size'] / (1024 * 1024):.2f} MB")
|
print(f"Size: {torrent['size'] / (1024 * 1024):.2f} MB")
|
||||||
print('-' * 40)
|
print("-" * 40)
|
||||||
@@ -2,26 +2,33 @@
|
|||||||
|
|
||||||
from qbittorrent import Client
|
from qbittorrent import Client
|
||||||
|
|
||||||
|
|
||||||
class TorrentApi:
|
class TorrentApi:
|
||||||
def __init__(self, ip, password, username='admin'):
|
def __init__(self, ip, password, username="admin"):
|
||||||
# Initialize the qBittorrent client
|
# Initialize the qBittorrent client
|
||||||
self.qb = Client(ip)
|
self.qb = Client(ip)
|
||||||
self.qb.login(username, password)
|
self.qb.login(username, password)
|
||||||
|
|
||||||
def get_torrents(self):
|
def get_torrents(self):
|
||||||
return self.get_filtered_torrents('all')
|
return self.get_filtered_torrents("all")
|
||||||
|
|
||||||
def get_filtered_torrents(self, state):
|
def get_filtered_torrents(self, state):
|
||||||
|
|
||||||
|
self.qb.login()
|
||||||
|
|
||||||
# Retrieve torrents filtered by the given state
|
# Retrieve torrents filtered by the given state
|
||||||
torrents = self.qb.torrents(filter=state)
|
torrents = self.qb.torrents(filter=state)
|
||||||
# Extract relevant information
|
# Extract relevant information
|
||||||
torrent_list = []
|
torrent_list = []
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
torrent_info = {
|
torrent_info = {
|
||||||
'name': torrent['name'],
|
"name": torrent["name"],
|
||||||
'state': torrent['state'],
|
"state": torrent["state"],
|
||||||
'progress': torrent['progress'] * 100, # Convert to percentage
|
"progress": torrent["progress"] * 100, # Convert to percentage
|
||||||
'eta': torrent['eta']
|
"eta": torrent["eta"],
|
||||||
|
"upspeed": torrent["upspeed"],
|
||||||
|
"dlspeed": torrent["dlspeed"],
|
||||||
|
"size": torrent["size"],
|
||||||
}
|
}
|
||||||
torrent_list.append(torrent_info)
|
torrent_list.append(torrent_info)
|
||||||
return torrent_list
|
return torrent_list
|
||||||
|
|||||||
21
config.py
Normal file
21
config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
load_dotenv() # Loads variables from .env into environment
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_var(name: str) -> str:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None or value == "":
|
||||||
|
raise EnvironmentError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
BOT_TOKEN = get_env_var("BOT_TOKEN")
|
||||||
|
AUTHORIZED_USER_ID = int(get_env_var("AUTHORIZED_USER_ID"))
|
||||||
|
NTFY_AUTH_HEADER = get_env_var("NTFY_AUTH_HEADER")
|
||||||
|
KUMA_API_PASSWORD = get_env_var("KUMA_API_PASSWORD")
|
||||||
|
TORRENT_API_PASSWORD = get_env_var("TORRENT_API_PASSWORD")
|
||||||
|
PORTAINER_API_KEY = get_env_var("PORTAINER_API_KEY")
|
||||||
178
main.py
178
main.py
@@ -5,129 +5,115 @@ from telegram.ext import Application, CommandHandler, CallbackQueryHandler, Cont
|
|||||||
|
|
||||||
from uptime_kuma_api import MonitorStatus
|
from uptime_kuma_api import MonitorStatus
|
||||||
import api.kuma as kuma
|
import api.kuma as kuma
|
||||||
|
|
||||||
import api.torrent as torrent
|
import api.torrent as torrent
|
||||||
|
import api.ntfy as ntfy
|
||||||
|
|
||||||
TOKEN = "7396669954:AAH8_I0Y-qg3j_LfbUdRTOLPDKh80NdijMo"
|
import threading
|
||||||
|
|
||||||
# --- Menu Definitions ---
|
from config import (
|
||||||
def main_menu_keyboard():
|
BOT_TOKEN,
|
||||||
return InlineKeyboardMarkup([
|
AUTHORIZED_USER_ID,
|
||||||
[InlineKeyboardButton("Torrents", callback_data='status_downloading')],
|
NTFY_AUTH_HEADER,
|
||||||
[InlineKeyboardButton("Status", callback_data='menu_status')]
|
KUMA_API_PASSWORD,
|
||||||
])
|
TORRENT_API_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
def torrents_menu_keyboard():
|
from menus.menus import (
|
||||||
return InlineKeyboardMarkup([
|
main_menu_keyboard,
|
||||||
# First row: Display the status counts for downloading, paused, seeding
|
torrents_menu_keyboard,
|
||||||
[InlineKeyboardButton(f"Downloading", callback_data='status_downloading')],
|
status_menu_keyboard,
|
||||||
[InlineKeyboardButton(f"Active", callback_data='status_active')],
|
handle_menu,
|
||||||
[InlineKeyboardButton(f"All", callback_data='status_all')],
|
)
|
||||||
# Second row: Back button
|
|
||||||
[InlineKeyboardButton("🔙 Back", callback_data='menu_main')]
|
NTFY_SERVER = "http://192.168.1.2:54720"
|
||||||
])
|
DB_PATH = "config/subscriptions.db"
|
||||||
|
|
||||||
def status_menu_keyboard():
|
|
||||||
return InlineKeyboardMarkup([
|
|
||||||
[InlineKeyboardButton("🔙 Back", callback_data='menu_main')]
|
|
||||||
])
|
|
||||||
|
|
||||||
# --- Command Handlers ---
|
# --- Command Handlers ---
|
||||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
await update.message.reply_text("Choose an option:", reply_markup=main_menu_keyboard())
|
try:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"Choose an option:", reply_markup=main_menu_keyboard()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"An error occurred: {e}")
|
||||||
|
# Optionally log the error
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
def format_torrents(torrents):
|
async def info(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
if len(torrents) == 0:
|
user_id = update.message.from_user.id
|
||||||
return "No torrents."
|
await update.message.reply_text(f"Your UserID is {user_id}.")
|
||||||
|
|
||||||
text = ""
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
for torrent in torrents:
|
|
||||||
if i > 10:
|
|
||||||
text += "...\n"
|
|
||||||
return text
|
|
||||||
|
|
||||||
text += f"- {torrent['name']} - {torrent['progress']} ({torrent['eta']})\n"
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
# --- Callback Query Handler ---
|
|
||||||
async def handle_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
||||||
query = update.callback_query
|
|
||||||
await query.answer()
|
|
||||||
|
|
||||||
match query.data:
|
|
||||||
case 'menu_main':
|
|
||||||
await query.edit_message_text("Choose an option:", reply_markup=main_menu_keyboard())
|
|
||||||
|
|
||||||
case 'status_downloading':
|
|
||||||
t_api = context.bot_data.get("torrent_api", {})
|
|
||||||
torrents = t_api.get_filtered_torrents("downloading")
|
|
||||||
|
|
||||||
text = format_torrents(torrents)
|
|
||||||
|
|
||||||
await query.edit_message_text(text, reply_markup=torrents_menu_keyboard())
|
|
||||||
|
|
||||||
case 'status_active':
|
|
||||||
t_api = context.bot_data.get("torrent_api", {})
|
|
||||||
torrents = t_api.get_filtered_torrents("active")
|
|
||||||
|
|
||||||
text = format_torrents(torrents)
|
|
||||||
|
|
||||||
await query.edit_message_text(text, reply_markup=torrents_menu_keyboard())
|
|
||||||
|
|
||||||
|
|
||||||
case 'status_all':
|
async def subscribe(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
t_api = context.bot_data.get("torrent_api", {})
|
n_api = context.bot_data.get("ntfy_api", {})
|
||||||
torrents = t__api.get_filtered_torrents("all")
|
|
||||||
|
|
||||||
text = format_torrents(torrents)
|
if update.effective_user.id != AUTHORIZED_USER_ID:
|
||||||
|
return
|
||||||
await query.edit_message_text(text, reply_markup=torrents_menu_keyboard())
|
if not context.args:
|
||||||
|
await update.message.reply_text("Usage: /subscribe <topic>")
|
||||||
case 'menu_status':
|
return
|
||||||
k_api = context.bot_data.get("kuma_api", {})
|
topic = context.args[0]
|
||||||
monitors = k_api.get_status()
|
n_api.add_subscription(topic)
|
||||||
|
await update.message.reply_text(f"Subscribed to topic: {topic}")
|
||||||
up_text, down_text, paused_text = "", "", ""
|
|
||||||
for _, monitor in monitors.items():
|
|
||||||
status = monitor['status']
|
|
||||||
|
|
||||||
if status == MonitorStatus.UP:
|
|
||||||
up_text += f" - {monitor['name']}\n"
|
|
||||||
elif status == MonitorStatus.DOWN:
|
|
||||||
down_text += f" - {monitor['name']}\n"
|
|
||||||
else:
|
|
||||||
paused_text += f" - {monitor['name']}\n"
|
|
||||||
|
|
||||||
|
|
||||||
status_text = f"📡 *Status:*\n\n 🟢 Up:\n{up_text}\n🔴 Down\n{down_text}\n⏸️ Paused\n{paused_text}"
|
async def unsubscribe(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
n_api = context.bot_data.get("ntfy_api", {})
|
||||||
await query.edit_message_text(status_text, reply_markup=status_menu_keyboard())
|
|
||||||
|
if update.effective_user.id != AUTHORIZED_USER_ID:
|
||||||
|
return
|
||||||
|
if not context.args:
|
||||||
|
await update.message.reply_text("Usage: /unsubscribe <topic>")
|
||||||
|
return
|
||||||
|
topic = context.args[0]
|
||||||
|
n_api.remove_subscription(topic)
|
||||||
|
await update.message.reply_text(f"Unsubscribed from topic: {topic}")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_subs(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
n_api = context.bot_data.get("ntfy_api", {})
|
||||||
|
|
||||||
|
if update.effective_user.id != AUTHORIZED_USER_ID:
|
||||||
|
return
|
||||||
|
topics = n_api.get_subscriptions()
|
||||||
|
if not topics:
|
||||||
|
await update.message.reply_text("You are not subscribed to any topics.")
|
||||||
|
else:
|
||||||
|
await update.message.reply_text("Subscribed topics:\n" + "\n".join(topics))
|
||||||
|
|
||||||
case _:
|
|
||||||
await query.edit_message_text("Unknown option selected.", reply_markup=main_menu_keyboard())
|
|
||||||
|
|
||||||
# --- Main Function ---
|
# --- Main Function ---
|
||||||
def main():
|
def main():
|
||||||
print("Starting Jarvis...")
|
print("Starting Jarvis...")
|
||||||
|
|
||||||
# Initiate api
|
|
||||||
kuma_api = kuma.KumaAPI("http://192.168.1.2:36667", "k!PTfyvoIJho9o*gX6F1")
|
|
||||||
torrent_api = torrent.TorrentApi("http://192.168.1.17:8112", "tMHNjrJr7nhjyhJrYsahi4anq2h6LJ")
|
|
||||||
|
|
||||||
app = Application.builder().token(TOKEN).build()
|
# Initiate api
|
||||||
|
kuma_api = kuma.KumaAPI("http://192.168.1.2:36667", KUMA_API_PASSWORD)
|
||||||
|
torrent_api = torrent.TorrentApi(
|
||||||
|
"http://192.168.1.17:8112", TORRENT_API_PASSWORD, username="MrZaiko"
|
||||||
|
)
|
||||||
|
|
||||||
|
ntfy_api = ntfy.NtfyAPI(DB_PATH, NTFY_SERVER, NTFY_AUTH_HEADER, AUTHORIZED_USER_ID)
|
||||||
|
|
||||||
|
app = Application.builder().token(BOT_TOKEN).build()
|
||||||
app.bot_data["kuma_api"] = kuma_api
|
app.bot_data["kuma_api"] = kuma_api
|
||||||
app.bot_data["torrent_api"] = torrent_api
|
app.bot_data["torrent_api"] = torrent_api
|
||||||
|
app.bot_data["ntfy_api"] = ntfy_api
|
||||||
|
|
||||||
app.add_handler(CommandHandler("start", start))
|
app.add_handler(CommandHandler("menu", menu))
|
||||||
app.add_handler(CallbackQueryHandler(handle_menu))
|
app.add_handler(CallbackQueryHandler(handle_menu))
|
||||||
|
|
||||||
|
app.add_handler(CommandHandler("info", info))
|
||||||
|
app.add_handler(CommandHandler("subscribe", subscribe))
|
||||||
|
app.add_handler(CommandHandler("unsubscribe", unsubscribe))
|
||||||
|
app.add_handler(CommandHandler("list", list_subs))
|
||||||
|
|
||||||
|
threading.Thread(target=ntfy_api.ntfy_listener, args=(app,), daemon=True).start()
|
||||||
|
|
||||||
print("Bot is running... Press Ctrl+C to stop.")
|
print("Bot is running... Press Ctrl+C to stop.")
|
||||||
app.run_polling()
|
app.run_polling()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
156
menus/menus.py
Normal file
156
menus/menus.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
|
||||||
|
|
||||||
|
from uptime_kuma_api import MonitorStatus
|
||||||
|
import api.kuma as kuma
|
||||||
|
import api.torrent as torrent
|
||||||
|
import api.ntfy as ntfy
|
||||||
|
|
||||||
|
from menus.portainer import button_handler
|
||||||
|
|
||||||
|
|
||||||
|
# --- Menu Definitions ---
|
||||||
|
def main_menu_keyboard():
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
[
|
||||||
|
[InlineKeyboardButton("Torrents", callback_data="torrent_downloading")],
|
||||||
|
[InlineKeyboardButton("Status", callback_data="menu_status")],
|
||||||
|
[InlineKeyboardButton("Portainer", callback_data="portainer_menu")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def torrents_menu_keyboard():
|
||||||
|
print("Creating meny keyboard")
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
[
|
||||||
|
# First row: Display the status counts for downloading, paused, seeding
|
||||||
|
[InlineKeyboardButton(f"Downloading", callback_data="torrent_downloading")],
|
||||||
|
[InlineKeyboardButton(f"Active", callback_data="torrent_active")],
|
||||||
|
[InlineKeyboardButton(f"All", callback_data="torrent_all")],
|
||||||
|
# Second row: Back button
|
||||||
|
[InlineKeyboardButton("🔙 Back", callback_data="menu_main")],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def status_menu_keyboard():
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
[[InlineKeyboardButton("🔙 Back", callback_data="menu_main")]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_torrents(torrents):
|
||||||
|
if len(torrents) == 0:
|
||||||
|
return "No torrents."
|
||||||
|
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
for torrent in torrents:
|
||||||
|
if i > 5:
|
||||||
|
text += "...\n"
|
||||||
|
return text
|
||||||
|
|
||||||
|
text += f"Name: {torrent['name']}\n"
|
||||||
|
text += f"State: {torrent['state']}\n"
|
||||||
|
text += f"Progress: {torrent['progress']:.2f}%\n"
|
||||||
|
text += f"ETA: {torrent['eta']}\n"
|
||||||
|
text += "-" * 20 + "\n"
|
||||||
|
|
||||||
|
text += f"- {torrent['name']} - {torrent['progress']} ({torrent['eta']})\n"
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# --- MAIN HANDLER FUNCTION ---
|
||||||
|
# ==================================
|
||||||
|
|
||||||
|
|
||||||
|
# --- Callback Query Handler ---
|
||||||
|
async def handle_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
if query.data.startswith("portainer_"):
|
||||||
|
await button_handler(update, context)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(query.data)
|
||||||
|
|
||||||
|
match query.data:
|
||||||
|
case "menu_main":
|
||||||
|
await query.edit_message_text(
|
||||||
|
"Choose an option:", reply_markup=main_menu_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
case "torrent_downloading":
|
||||||
|
|
||||||
|
t_api = context.bot_data.get("torrent_api", {})
|
||||||
|
torrents = t_api.get_filtered_torrents("downloading")
|
||||||
|
|
||||||
|
if len(torrents) == 0:
|
||||||
|
text = "No downloading torrents."
|
||||||
|
else:
|
||||||
|
text = format_torrents(torrents)
|
||||||
|
|
||||||
|
await query.edit_message_text(
|
||||||
|
text, reply_markup=torrents_menu_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
case "torrent_active":
|
||||||
|
t_api = context.bot_data.get("torrent_api", {})
|
||||||
|
torrents = t_api.get_filtered_torrents("active")
|
||||||
|
|
||||||
|
if len(torrents) == 0:
|
||||||
|
text = "No active torrents."
|
||||||
|
else:
|
||||||
|
text = format_torrents(torrents)
|
||||||
|
|
||||||
|
await query.edit_message_text(
|
||||||
|
text, reply_markup=torrents_menu_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
case "torrent_all":
|
||||||
|
t_api = context.bot_data.get("torrent_api", {})
|
||||||
|
torrents = t_api.get_filtered_torrents("all")
|
||||||
|
|
||||||
|
if len(torrents) == 0:
|
||||||
|
text = "No torrents."
|
||||||
|
else:
|
||||||
|
text = format_torrents(torrents)
|
||||||
|
|
||||||
|
await query.edit_message_text(
|
||||||
|
text, reply_markup=torrents_menu_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
case "menu_status":
|
||||||
|
k_api = context.bot_data.get("kuma_api", {})
|
||||||
|
monitors = k_api.get_status()
|
||||||
|
|
||||||
|
up_text, down_text, paused_text = "", "", ""
|
||||||
|
for _, monitor in monitors.items():
|
||||||
|
status = monitor["status"]
|
||||||
|
|
||||||
|
if status == MonitorStatus.UP:
|
||||||
|
up_text += f" - {monitor['name']}\n"
|
||||||
|
elif status == MonitorStatus.DOWN:
|
||||||
|
down_text += f" - {monitor['name']}\n"
|
||||||
|
else:
|
||||||
|
paused_text += f" - {monitor['name']}\n"
|
||||||
|
|
||||||
|
status_text = f"📡 *Status:*\n\n 🟢 Up:\n{up_text}\n🔴 Down\n{down_text}\n⏸️ Paused\n{paused_text}"
|
||||||
|
|
||||||
|
await query.edit_message_text(
|
||||||
|
status_text, reply_markup=status_menu_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
case _:
|
||||||
|
await query.edit_message_text(
|
||||||
|
"Unknown option selected.", reply_markup=main_menu_keyboard()
|
||||||
|
)
|
||||||
138
menus/portainer.py
Normal file
138
menus/portainer.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
|
from telegram.ext import (
|
||||||
|
ApplicationBuilder,
|
||||||
|
CommandHandler,
|
||||||
|
CallbackQueryHandler,
|
||||||
|
ContextTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
from config import BOT_TOKEN, PORTAINER_API_KEY
|
||||||
|
|
||||||
|
PORTAINER_URL = "https://192.168.1.17:9443/api"
|
||||||
|
STACKS_PER_PAGE = 5
|
||||||
|
user_page = 0 # Tracks current page per user
|
||||||
|
|
||||||
|
|
||||||
|
def get_stacks():
|
||||||
|
headers = {"X-API-Key": f"{PORTAINER_API_KEY}"}
|
||||||
|
response = requests.get(
|
||||||
|
f"{PORTAINER_URL}/stacks?filters=" + '{"EndpointID":2}',
|
||||||
|
headers=headers,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json() if response.ok else []
|
||||||
|
|
||||||
|
|
||||||
|
def get_stack_status(stack_id):
|
||||||
|
"""Fetch the stack status based on its ID."""
|
||||||
|
headers = {"X-API-Key": f"{PORTAINER_API_KEY}"}
|
||||||
|
response = requests.get(
|
||||||
|
f"{PORTAINER_URL}/stacks/{stack_id}?endpointId=2", headers=headers, verify=False
|
||||||
|
)
|
||||||
|
if response.ok:
|
||||||
|
stack_info = response.json()
|
||||||
|
return stack_info.get(
|
||||||
|
"Status", ""
|
||||||
|
) # Assuming the status is returned in lowercase
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def start_portainer(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
global user_page
|
||||||
|
|
||||||
|
user_page = 0
|
||||||
|
await send_stack_list(update, context)
|
||||||
|
|
||||||
|
|
||||||
|
def build_stack_markup(stacks, page):
|
||||||
|
start_idx = page * STACKS_PER_PAGE
|
||||||
|
end_idx = start_idx + STACKS_PER_PAGE
|
||||||
|
sliced = stacks[start_idx:end_idx]
|
||||||
|
|
||||||
|
keyboard = []
|
||||||
|
for stack in sliced:
|
||||||
|
stack_name = stack["Name"]
|
||||||
|
stack_id = stack["Id"]
|
||||||
|
status = get_stack_status(stack_id)
|
||||||
|
|
||||||
|
# Add smiley based on stack status
|
||||||
|
if status == 1:
|
||||||
|
smiley = "🟢" # Stack is up
|
||||||
|
else:
|
||||||
|
smiley = "🔴" # Stack is down
|
||||||
|
|
||||||
|
# Button labels: Stack name with status smiley
|
||||||
|
row = [
|
||||||
|
InlineKeyboardButton(
|
||||||
|
f"{smiley} {stack_name}",
|
||||||
|
callback_data=f"portainer_select_{stack_id}",
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
f"▶️ Start", callback_data=f"portainer_start_{stack_id}"
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(f"⏹ Stop", callback_data=f"portainer_stop_{stack_id}"),
|
||||||
|
]
|
||||||
|
keyboard.append(row)
|
||||||
|
|
||||||
|
nav_row = []
|
||||||
|
if page > 0:
|
||||||
|
nav_row.append(InlineKeyboardButton("⬅️ Prev", callback_data="portainer_prev"))
|
||||||
|
if end_idx < len(stacks):
|
||||||
|
nav_row.append(InlineKeyboardButton("➡️ Next", callback_data="portainer_next"))
|
||||||
|
keyboard.append(nav_row)
|
||||||
|
|
||||||
|
keyboard.append([InlineKeyboardButton("🔙 Main Menu", callback_data="menu_main")])
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_stack_list(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
global user_page
|
||||||
|
|
||||||
|
page = user_page
|
||||||
|
stacks = get_stacks()
|
||||||
|
|
||||||
|
if not stacks:
|
||||||
|
await update.message.reply_text(
|
||||||
|
"No stacks found or unable to connect to Portainer."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if update.callback_query:
|
||||||
|
await update.callback_query.answer()
|
||||||
|
await update.callback_query.edit_message_text(
|
||||||
|
text=f"Select a stack (page {page + 1}):",
|
||||||
|
reply_markup=build_stack_markup(stacks, page),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(
|
||||||
|
text=f"Select a stack (page {page + 1}):",
|
||||||
|
reply_markup=build_stack_markup(stacks, page),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
global user_page
|
||||||
|
|
||||||
|
query = update.callback_query
|
||||||
|
data = query.data
|
||||||
|
|
||||||
|
if data == "portainer_next":
|
||||||
|
user_page += 1
|
||||||
|
elif data == "portainer_prev":
|
||||||
|
user_page = max(0, user_page - 1)
|
||||||
|
elif data.startswith("portainer_start_") or data.startswith("portainer_stop_"):
|
||||||
|
_, action, stack_id = data.split("_")
|
||||||
|
await handle_stack_action(query, action, stack_id)
|
||||||
|
await send_stack_list(update, context)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_stack_action(query, action, stack_id):
|
||||||
|
endpoint = f"{PORTAINER_URL}/stacks/{stack_id}/{action}?endpointId=2"
|
||||||
|
headers = {"X-API-Key": f"{PORTAINER_API_KEY}"}
|
||||||
|
response = requests.post(endpoint, headers=headers, verify=False)
|
||||||
|
msg = "✅ Success!" if response.ok else f"❌ Failed: {response.text}"
|
||||||
|
await query.answer(text=msg, show_alert=True)
|
||||||
10
portainer-stack.yaml
Normal file
10
portainer-stack.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# portainer-stack.yml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
jarvis:
|
||||||
|
image: gitea.abzk.fr/mrzaiko/jarvis:latest
|
||||||
|
container_name: jarvis
|
||||||
|
volumes:
|
||||||
|
- /home/portainer/docker-config/local_stuff/jarvis/:/app/config
|
||||||
|
env_file:
|
||||||
|
- stack.env
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
python-qbittorrent
|
python-qbittorrent
|
||||||
uptime-kuma-api
|
uptime-kuma-api
|
||||||
python-telegram-bot
|
python-telegram-bot
|
||||||
|
requests
|
||||||
|
dotenv
|
||||||
|
|||||||
Reference in New Issue
Block a user