Compare commits

..

18 Commits

Author SHA1 Message Date
6e20068410 Add portainer menu
All checks were successful
Build and Deploy / build (push) Successful in 2m55s
2025-04-20 17:14:31 +02:00
a0833b818b Better env variables
All checks were successful
Build and Deploy / build (push) Successful in 2m8s
2025-04-20 16:16:54 +02:00
27377b2e60 Fix
All checks were successful
Build and Deploy / build (push) Successful in 2m9s
2025-04-20 16:05:56 +02:00
1167c3922e Fix workflow
Some checks failed
Build and Deploy / build (push) Has been cancelled
2025-04-20 16:03:26 +02:00
2f2b4d05a1 Update workflow
Some checks failed
Build and Deploy / build (push) Has been cancelled
2025-04-20 15:59:00 +02:00
c2d097d7ef Update env management
All checks were successful
Build and Deploy / build (push) Successful in 2m57s
2025-04-20 13:11:33 +02:00
a8a8b932f8 Update workflow
All checks were successful
Build and Deploy / build (push) Successful in 2m16s
2025-04-20 13:05:10 +02:00
5b01609a2f Fix Dockerfile and declutter main.py
All checks were successful
Build and Deploy / build (push) Successful in 2m14s
2025-04-20 11:03:09 +02:00
92689f95d4 Fix requirements.txt
All checks were successful
Build and Deploy / build (push) Successful in 2m30s
2025-04-20 10:54:31 +02:00
8794d54a73 Update secret management
Some checks failed
Build and Deploy / build (push) Failing after 1m39s
2025-04-20 10:51:26 +02:00
8356e9c021 Try another user
All checks were successful
Build and Deploy / build (push) Successful in 3m13s
2025-04-20 00:22:40 +02:00
8bc90b745a Update requirements.txt and imports
All checks were successful
Build and Deploy / build (push) Successful in 3m5s
2025-04-20 00:11:45 +02:00
88ac296393 Add ntfy support
All checks were successful
Build and Deploy / build (push) Successful in 2m37s
2025-04-20 00:05:11 +02:00
ee32057c0b Fix torrent api bug
All checks were successful
Build and Deploy / build (push) Successful in 3m41s
2025-04-19 22:16:25 +02:00
29617a07a5 Remove deploy.sh
All checks were successful
Build and Deploy / build (push) Successful in 2m13s
2025-04-10 08:33:52 +02:00
3ba2ddfbd6 Fix typo
All checks were successful
Build and Deploy / build (push) Successful in 2m17s
2025-04-10 08:30:39 +02:00
e7c8791f43 Merge branch 'main' of https://gitea.abzk.fr/MrZaiko/Jarvis
All checks were successful
Build and Deploy / build (push) Successful in 2m9s
2025-04-08 12:43:21 +02:00
42b14c406a Add a tentative fix 2025-04-08 12:42:51 +02:00
13 changed files with 569 additions and 131 deletions

2
.envrc Normal file
View File

@@ -0,0 +1,2 @@
export VIRTUAL_ENV=venv
layout python3

View File

@@ -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 '{}'

View File

@@ -4,12 +4,14 @@ on:
push:
branches:
- main
jobs:
build:
runs-on: linux
container:
image: catthehacker/ubuntu:act-latest
options: --privileged # Required for Docker-in-Docker
options: --privileged
steps:
- name: Checkout code
uses: actions/checkout@v3
@@ -24,7 +26,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
- name: Build and Push Docker Image
uses: docker/build-push-action@v4
with:
context: .
@@ -34,11 +36,13 @@ jobs:
- name: Deploy to Portainer
run: |
curl --location --request PUT -k 'https://192.168.1.17:9443/api/stacks/27?endpointId=2' \
--header 'X-API-Key: ${{ secrets.PORTAINER_API_KEY }}' \
--header 'Content-Type: application/json' \
--data '{
"prune": false,
"pullImage": true,
"stackFileContent": "version: '\''3.8'\''\nservices:\n jarvis:\n image: gitea.abzk.fr/mrzaiko/jarvis:latest\n container_name: jarvis"
}'
curl -k -X PUT 'https://192.168.1.17:9443/api/stacks/27?endpointId=2' \
-H "X-API-Key: ${{ secrets.PORTAINER_API_KEY }}" \
-H "Content-Type: application/json" \
-d @- <<EOF
{
"prune": false,
"pullImage": true,
"stackFileContent": "$(cat portainer-stack.yml | sed 's/"/\\"/g' | tr '\n' '\\n')"
}
EOF

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@
tmp/
venv/
__pycache__/
*.db
config/
.env

123
api/ntfy.py Normal file
View 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

View File

@@ -1,13 +1,13 @@
#!/usr/bin/env python3
from qbittorrent import Client
from torrent import TorrentApi
# Replace with your qBittorrent Web UI credentials
qb = Client('http://192.168.1.17:8112')
qb.login('admin', 'tMHNjrJr7nhjyhJrYsahi4anq2h6LJ')
torrent = TorrentApi("http://192.168.1.17:8112", "tMHNjrJr7nhjyhJrYsahi4anq2h6LJ")
# Retrieve all active torrents
torrents = qb.torrents(filter='downloading')
torrents = torrent.get_torrents()
# Print details of each active torrent
for torrent in torrents:
@@ -17,4 +17,4 @@ for torrent in torrents:
print(f"Download Speed: {torrent['dlspeed'] / 1024:.2f} KB/s")
print(f"Upload Speed: {torrent['upspeed'] / 1024:.2f} KB/s")
print(f"Size: {torrent['size'] / (1024 * 1024):.2f} MB")
print('-' * 40)
print("-" * 40)

View File

@@ -2,26 +2,33 @@
from qbittorrent import Client
class TorrentApi:
def __init__(self, ip, password, username='admin'):
def __init__(self, ip, password, username="admin"):
# Initialize the qBittorrent client
self.qb = Client(ip)
self.qb.login(username, password)
def get_torrents(self):
return self.get_filtered_torrents('all')
return self.get_filtered_torrents("all")
def get_filtered_torrents(self, state):
self.qb.login()
# Retrieve torrents filtered by the given state
torrents = self.qb.torrents(filter=state)
# Extract relevant information
torrent_list = []
for torrent in torrents:
torrent_info = {
'name': torrent['name'],
'state': torrent['state'],
'progress': torrent['progress'] * 100, # Convert to percentage
'eta': torrent['eta']
"name": torrent["name"],
"state": torrent["state"],
"progress": torrent["progress"] * 100, # Convert to percentage
"eta": torrent["eta"],
"upspeed": torrent["upspeed"],
"dlspeed": torrent["dlspeed"],
"size": torrent["size"],
}
torrent_list.append(torrent_info)
return torrent_list

21
config.py Normal file
View 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
View File

@@ -5,129 +5,115 @@ from telegram.ext import Application, CommandHandler, CallbackQueryHandler, Cont
from uptime_kuma_api import MonitorStatus
import api.kuma as kuma
import api.torrent as torrent
import api.ntfy as ntfy
TOKEN = "7396669954:AAH8_I0Y-qg3j_LfbUdRTOLPDKh80NdijMo"
import threading
# --- Menu Definitions ---
def main_menu_keyboard():
return InlineKeyboardMarkup([
[InlineKeyboardButton("Torrents", callback_data='status_downloading')],
[InlineKeyboardButton("Status", callback_data='menu_status')]
])
from config import (
BOT_TOKEN,
AUTHORIZED_USER_ID,
NTFY_AUTH_HEADER,
KUMA_API_PASSWORD,
TORRENT_API_PASSWORD,
)
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')]
])
from menus.menus import (
main_menu_keyboard,
torrents_menu_keyboard,
status_menu_keyboard,
handle_menu,
)
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 ---
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("Choose an option:", reply_markup=main_menu_keyboard())
async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
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):
if len(torrents) == 0:
return "No torrents."
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':
torrent_api = context.bot_data.get("torrent_api", {})
torrents = torrent_api.get_filtered_torrents("downloading")
text = format_torrents(torrents)
await query.edit_message_text(text, reply_markup=torrents_menu_keyboard())
case 'status_active':
torrent_api = context.bot_data.get("torrent_api", {})
torrents = torrent_api.get_filtered_torrents("active")
text = format_torrents(torrents)
await query.edit_message_text(text, reply_markup=torrents_menu_keyboard())
async def info(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user_id = update.message.from_user.id
await update.message.reply_text(f"Your UserID is {user_id}.")
case 'status_all':
torrent_api = context.bot_data.get("torrent_api", {})
torrents = torrent_api.get_filtered_torrents("all")
async def subscribe(update: Update, context: ContextTypes.DEFAULT_TYPE):
n_api = context.bot_data.get("ntfy_api", {})
text = format_torrents(torrents)
await query.edit_message_text(text, reply_markup=torrents_menu_keyboard())
case 'menu_status':
kuma_api = context.bot_data.get("kuma_api", {})
monitors = kuma_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"
if update.effective_user.id != AUTHORIZED_USER_ID:
return
if not context.args:
await update.message.reply_text("Usage: /subscribe <topic>")
return
topic = context.args[0]
n_api.add_subscription(topic)
await update.message.reply_text(f"Subscribed to topic: {topic}")
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())
async def unsubscribe(update: Update, context: ContextTypes.DEFAULT_TYPE):
n_api = context.bot_data.get("ntfy_api", {})
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 ---
def main():
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["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(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.")
app.run_polling()
if __name__ == "__main__":
main()

156
menus/menus.py Normal file
View 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
View 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
View 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

View File

@@ -1,3 +1,5 @@
python-qbittorrent
uptime-kuma-api
python-telegram-bot
requests
dotenv