From 6e2006841010ee45a54cebd3f1e988d0b2d3cb91 Mon Sep 17 00:00:00 2001 From: MrZaiko Date: Sun, 20 Apr 2025 17:14:31 +0200 Subject: [PATCH] Add portainer menu --- main.py | 2 +- menus.py | 133 ------------------------------------ menus/menus.py | 156 +++++++++++++++++++++++++++++++++++++++++++ menus/portainer.py | 138 ++++++++++++++++++++++++++++++++++++++ portainer-stack.yaml | 2 + 5 files changed, 297 insertions(+), 134 deletions(-) delete mode 100644 menus.py create mode 100644 menus/menus.py create mode 100644 menus/portainer.py diff --git a/main.py b/main.py index e6b2587..fb9156d 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ from config import ( TORRENT_API_PASSWORD, ) -from menus import ( +from menus.menus import ( main_menu_keyboard, torrents_menu_keyboard, status_menu_keyboard, diff --git a/menus.py b/menus.py deleted file mode 100644 index 1de5851..0000000 --- a/menus.py +++ /dev/null @@ -1,133 +0,0 @@ -#!/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 - - -# --- Menu Definitions --- -def main_menu_keyboard(): - return InlineKeyboardMarkup( - [ - [InlineKeyboardButton("Torrents", callback_data="status_downloading")], - [InlineKeyboardButton("Status", callback_data="menu_status")], - ] - ) - - -def torrents_menu_keyboard(): - return InlineKeyboardMarkup( - [ - # First row: Display the status counts for downloading, paused, seeding - [InlineKeyboardButton(f"Downloading", callback_data="status_downloading")], - [InlineKeyboardButton(f"Active", callback_data="status_active")], - [InlineKeyboardButton(f"All", callback_data="status_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 - - -# --- 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") - - 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 "status_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 "status_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() - ) diff --git a/menus/menus.py b/menus/menus.py new file mode 100644 index 0000000..e773dfa --- /dev/null +++ b/menus/menus.py @@ -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() + ) diff --git a/menus/portainer.py b/menus/portainer.py new file mode 100644 index 0000000..1459695 --- /dev/null +++ b/menus/portainer.py @@ -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) diff --git a/portainer-stack.yaml b/portainer-stack.yaml index d98ef02..14226c4 100644 --- a/portainer-stack.yaml +++ b/portainer-stack.yaml @@ -6,3 +6,5 @@ services: container_name: jarvis volumes: - /home/portainer/docker-config/local_stuff/jarvis/:/app/config + env_file: + - stack.env