feat: Add terminal image endpoint and save images

Introduces a new API endpoint `/terminal_images` to serve generated terminal output images.

- Creates a new file `api_service/terminal_images_endpoint.py` to handle the static file serving.
- Modifies `api_service/api_server.py` to mount the new endpoint.
- Updates `cogs/terminal_cog.py` to save generated terminal images to a local directory (`terminal_images`) with unique filenames.
- Adds a base URL constant (`API_BASE_URL`) to `cogs/terminal_cog.py` for potential future use in generating image URLs.
This commit is contained in:
Slipstream 2025-05-17 19:29:05 -06:00
parent e7719406bf
commit c9d6cc40a6
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
3 changed files with 122 additions and 33 deletions

View File

@ -408,7 +408,7 @@ async def teapot_override(request: Request, exc: StarletteHTTPException):
# Ensure it's a string before calling rstrip # Ensure it's a string before calling rstrip
if not isinstance(request_path_from_scope, str): if not isinstance(request_path_from_scope, str):
request_path_from_scope = str(request_path_from_scope) request_path_from_scope = str(request_path_from_scope)
path_processed = request_path_from_scope.rstrip("/").lower() path_processed = request_path_from_scope.rstrip("/").lower()
except Exception as e: except Exception as e:
@ -567,6 +567,16 @@ app.mount("/dashboard/api", dashboard_api_app) # Mount the new dashboard API
try: try:
from api_service.webhook_endpoints import router as webhook_router # Relative import from api_service.webhook_endpoints import router as webhook_router # Relative import
app.mount("/webhook", webhook_router) # Mount directly on the main app for simplicity app.mount("/webhook", webhook_router) # Mount directly on the main app for simplicity
# Import and mount terminal images endpoint
try:
from api_service.terminal_images_endpoint import mount_terminal_images
# Mount terminal images directory as static files
mount_terminal_images(app)
log.info("Terminal images endpoint mounted successfully")
except ImportError as e:
log.error(f"Could not import terminal images endpoint: {e}")
log.error("Terminal images endpoint will not be available")
# After mounting the webhook router # After mounting the webhook router
log.info("Available routes in webhook_router:") log.info("Available routes in webhook_router:")
from fastapi.routing import APIRoute, Mount from fastapi.routing import APIRoute, Mount

View File

@ -0,0 +1,57 @@
"""
Terminal Images Endpoint
This module provides an endpoint for serving terminal images generated by the terminal_cog.py.
"""
import os
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from typing import Optional
# Create a router for the terminal images endpoint
router = APIRouter(tags=["Terminal Images"])
# Path to the terminal_images directory
TERMINAL_IMAGES_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'terminal_images'))
# Ensure the terminal_images directory exists
os.makedirs(TERMINAL_IMAGES_DIR, exist_ok=True)
@router.get("/{filename}")
async def get_terminal_image(filename: str):
"""
Get a terminal image by filename.
Args:
filename: The filename of the terminal image
Returns:
The terminal image file
Raises:
HTTPException: If the file is not found
"""
file_path = os.path.join(TERMINAL_IMAGES_DIR, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Terminal image not found")
return FileResponse(file_path)
# Function to mount the terminal images directory as static files
def mount_terminal_images(app):
"""
Mount the terminal_images directory as static files.
Args:
app: The FastAPI app to mount the static files on
"""
# Check if the directory exists
if os.path.exists(TERMINAL_IMAGES_DIR) and os.path.isdir(TERMINAL_IMAGES_DIR):
# Mount the terminal_images directory as static files
app.mount("/terminal_images", StaticFiles(directory=TERMINAL_IMAGES_DIR), name="terminal_images")
print(f"Mounted terminal images directory: {TERMINAL_IMAGES_DIR}")
else:
print(f"Warning: Terminal images directory '{TERMINAL_IMAGES_DIR}' not found. Terminal images will not be available.")

View File

@ -5,6 +5,8 @@ from PIL import Image, ImageDraw, ImageFont
import subprocess import subprocess
import os import os
import io import io
import uuid
import time
from collections import deque from collections import deque
import shlex # For safer command parsing if not using shell=True for everything import shlex # For safer command parsing if not using shell=True for everything
@ -24,6 +26,9 @@ MAX_HISTORY_LINES = 500 # Max lines to keep in history
AUTO_UPDATE_INTERVAL_SECONDS = 3 AUTO_UPDATE_INTERVAL_SECONDS = 3
MAX_OUTPUT_LINES_PER_IMAGE = (IMG_HEIGHT - 2 * PADDING) // (FONT_SIZE + LINE_SPACING) MAX_OUTPUT_LINES_PER_IMAGE = (IMG_HEIGHT - 2 * PADDING) // (FONT_SIZE + LINE_SPACING)
OWNER_ID = 452666956353503252 OWNER_ID = 452666956353503252
TERMINAL_IMAGES_DIR = "terminal_images" # Directory to store terminal images
# Use your actual domain or IP address here
API_BASE_URL = "https://slipstreamm.dev" # Base URL for the API
# --- Helper: Owner Check --- # --- Helper: Owner Check ---
async def is_owner_check(interaction: discord.Interaction) -> bool: async def is_owner_check(interaction: discord.Interaction) -> bool:
@ -69,20 +74,23 @@ class TerminalCog(commands.Cog, name="Terminal"):
# Fallback or raise error # Fallback or raise error
# self.owner_id = YOUR_FALLBACK_OWNER_ID # if you have one # self.owner_id = YOUR_FALLBACK_OWNER_ID # if you have one
def _generate_terminal_image(self) -> io.BytesIO: def _generate_terminal_image(self) -> tuple[io.BytesIO, str]:
"""Generates an image of the current terminal output.""" """
Generates an image of the current terminal output.
Returns a tuple of (BytesIO object, filename)
"""
image = Image.new('RGB', (IMG_WIDTH, IMG_HEIGHT), BACKGROUND_COLOR) image = Image.new('RGB', (IMG_WIDTH, IMG_HEIGHT), BACKGROUND_COLOR)
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
char_width, _ = self.font.getbbox("M")[2:] # Get width of a character char_width, _ = self.font.getbbox("M")[2:] # Get width of a character
if char_width == 0: char_width = FONT_SIZE // 2 # Estimate if getbbox fails for default font if char_width == 0: char_width = FONT_SIZE // 2 # Estimate if getbbox fails for default font
y_pos = PADDING y_pos = PADDING
# Determine visible lines based on scroll offset # Determine visible lines based on scroll offset
start_index = self.scroll_offset start_index = self.scroll_offset
end_index = min(len(self.output_history), self.scroll_offset + MAX_OUTPUT_LINES_PER_IMAGE) end_index = min(len(self.output_history), self.scroll_offset + MAX_OUTPUT_LINES_PER_IMAGE)
visible_lines = list(self.output_history)[start_index:end_index] visible_lines = list(self.output_history)[start_index:end_index]
for line in visible_lines: for line in visible_lines:
@ -92,11 +100,11 @@ class TerminalCog(commands.Cog, name="Terminal"):
display_color = PROMPT_COLOR display_color = PROMPT_COLOR
elif "error" in line.lower() or "failed" in line.lower(): # Basic error detection elif "error" in line.lower() or "failed" in line.lower(): # Basic error detection
display_color = ERROR_COLOR display_color = ERROR_COLOR
# Handle lines longer than image width (simple truncation) # Handle lines longer than image width (simple truncation)
# A more advanced version could wrap text or allow horizontal scrolling. # A more advanced version could wrap text or allow horizontal scrolling.
max_chars_on_line = (IMG_WIDTH - 2 * PADDING) // char_width if char_width > 0 else 80 max_chars_on_line = (IMG_WIDTH - 2 * PADDING) // char_width if char_width > 0 else 80
# Truncate if needed # Truncate if needed
# Pillow's draw.text handles clipping, but explicit truncation can be clearer # Pillow's draw.text handles clipping, but explicit truncation can be clearer
# For simplicity, we'll let Pillow clip. # For simplicity, we'll let Pillow clip.
@ -108,10 +116,22 @@ class TerminalCog(commands.Cog, name="Terminal"):
if y_pos > IMG_HEIGHT - PADDING - FONT_SIZE: if y_pos > IMG_HEIGHT - PADDING - FONT_SIZE:
break # Stop if no more space break # Stop if no more space
# Create a unique filename with timestamp
filename = f"terminal_{uuid.uuid4().hex[:8]}_{int(time.time())}.png"
# Ensure the terminal_images directory exists
os.makedirs(TERMINAL_IMAGES_DIR, exist_ok=True)
# Save the image to the terminal_images directory
file_path = os.path.join(TERMINAL_IMAGES_DIR, filename)
image.save(file_path, format='PNG')
# Also return a BytesIO object for backward compatibility
img_byte_arr = io.BytesIO() img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format='PNG') image.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0) img_byte_arr.seek(0)
return img_byte_arr
return img_byte_arr, filename
async def _update_terminal_message(self, interaction: Interaction | None = None, new_content: str | None = None): async def _update_terminal_message(self, interaction: Interaction | None = None, new_content: str | None = None):
"""Updates the terminal message with a new image and view.""" """Updates the terminal message with a new image and view."""
@ -125,19 +145,18 @@ class TerminalCog(commands.Cog, name="Terminal"):
await self.terminal_message.edit(content="Terminal session ended.", view=None, attachments=[]) await self.terminal_message.edit(content="Terminal session ended.", view=None, attachments=[])
return return
image_bytes = self._generate_terminal_image() # Generate the image and save it to the terminal_images directory
discord_file = File(fp=image_bytes, filename="terminal_output.png") image_bytes, filename = self._generate_terminal_image()
# Create the URL for the image
image_url = f"{API_BASE_URL}/terminal_images/{filename}"
if self.terminal_view: if self.terminal_view:
self.terminal_view.update_button_states(self) # Update button enable/disable self.terminal_view.update_button_states(self) # Update button enable/disable
target_message = self.terminal_message # Prepare the message content with the image URL
edit_kwargs = {"attachments": [discord_file], "view": self.terminal_view} content = f"Terminal Output: [View Image]({image_url})" if not new_content else new_content
if new_content: edit_kwargs = {"content": content, "view": self.terminal_view, "attachments": []}
edit_kwargs["content"] = new_content
else: # Clear old content if any
edit_kwargs["content"] = None
try: try:
if interaction and not interaction.response.is_done(): if interaction and not interaction.response.is_done():
@ -174,7 +193,7 @@ class TerminalCog(commands.Cog, name="Terminal"):
except Exception as e: except Exception as e:
print(f"Error terminating process: {e}") print(f"Error terminating process: {e}")
self.active_process = None self.active_process = None
final_message = "Terminal session ended." final_message = "Terminal session ended."
if self.terminal_message: if self.terminal_message:
try: try:
@ -199,7 +218,7 @@ class TerminalCog(commands.Cog, name="Terminal"):
if self.terminal_active and self.terminal_message: if self.terminal_active and self.terminal_message:
await interaction.response.send_message(f"A terminal session is already active. View it here: {self.terminal_message.jump_url}", ephemeral=True) await interaction.response.send_message(f"A terminal session is already active. View it here: {self.terminal_message.jump_url}", ephemeral=True)
return return
await interaction.response.defer(ephemeral=False) # Ephemeral False to allow message editing await interaction.response.defer(ephemeral=False) # Ephemeral False to allow message editing
self.terminal_active = True self.terminal_active = True
@ -212,14 +231,17 @@ class TerminalCog(commands.Cog, name="Terminal"):
self.scroll_offset = max(0, len(self.output_history) - MAX_OUTPUT_LINES_PER_IMAGE) # Scroll to bottom self.scroll_offset = max(0, len(self.output_history) - MAX_OUTPUT_LINES_PER_IMAGE) # Scroll to bottom
self.terminal_view = TerminalView(cog=self, owner_id=self.owner_id) self.terminal_view = TerminalView(cog=self, owner_id=self.owner_id)
image_bytes = self._generate_terminal_image() # Generate the image and save it to the terminal_images directory
discord_file = File(fp=image_bytes, filename="terminal_output.png") _, filename = self._generate_terminal_image()
# Create the URL for the image
image_url = f"{API_BASE_URL}/terminal_images/{filename}"
# Send initial message and store it # Send initial message and store it
# Use followup since we deferred # Use followup since we deferred
self.terminal_message = await interaction.followup.send( self.terminal_message = await interaction.followup.send(
file=discord_file, content=f"Terminal Output: [View Image]({image_url})",
view=self.terminal_view view=self.terminal_view
) )
self.terminal_view.message = self.terminal_message # Give view a reference to the message self.terminal_view.message = self.terminal_message # Give view a reference to the message
@ -269,7 +291,7 @@ class TerminalCog(commands.Cog, name="Terminal"):
self.output_history.append(f"Error: Directory not found: {new_cwd}") self.output_history.append(f"Error: Directory not found: {new_cwd}")
except Exception as e: except Exception as e:
self.output_history.append(f"Error changing directory: {e}") self.output_history.append(f"Error changing directory: {e}")
self.output_history.append(f"{self.current_cwd}> ") # New prompt self.output_history.append(f"{self.current_cwd}> ") # New prompt
self.scroll_offset = max(0, len(self.output_history) - MAX_OUTPUT_LINES_PER_IMAGE) self.scroll_offset = max(0, len(self.output_history) - MAX_OUTPUT_LINES_PER_IMAGE)
await self._update_terminal_message(interaction) await self._update_terminal_message(interaction)
@ -446,7 +468,7 @@ class TerminalView(ui.View):
def _add_buttons(self): def _add_buttons(self):
self.clear_items() # Clear existing items if any (e.g., on re-creation) self.clear_items() # Clear existing items if any (e.g., on re-creation)
# Scroll Up # Scroll Up
self.scroll_up_button = ui.Button(label="Scroll Up", emoji="⬆️", style=discord.ButtonStyle.secondary, row=0) self.scroll_up_button = ui.Button(label="Scroll Up", emoji="⬆️", style=discord.ButtonStyle.secondary, row=0)
self.scroll_up_button.callback = self.scroll_up_callback self.scroll_up_button.callback = self.scroll_up_callback
@ -456,7 +478,7 @@ class TerminalView(ui.View):
self.scroll_down_button = ui.Button(label="Scroll Down", emoji="⬇️", style=discord.ButtonStyle.secondary, row=0) self.scroll_down_button = ui.Button(label="Scroll Down", emoji="⬇️", style=discord.ButtonStyle.secondary, row=0)
self.scroll_down_button.callback = self.scroll_down_callback self.scroll_down_button.callback = self.scroll_down_callback
self.add_item(self.scroll_down_button) self.add_item(self.scroll_down_button)
# Send Input # Send Input
self.send_input_button = ui.Button(label="Send Input", emoji="⌨️", style=discord.ButtonStyle.primary, row=1) self.send_input_button = ui.Button(label="Send Input", emoji="⌨️", style=discord.ButtonStyle.primary, row=1)
self.send_input_button.callback = self.send_input_callback self.send_input_button.callback = self.send_input_callback
@ -466,7 +488,7 @@ class TerminalView(ui.View):
self.refresh_button = ui.Button(label="Refresh", emoji="🔄", style=discord.ButtonStyle.success, row=1) self.refresh_button = ui.Button(label="Refresh", emoji="🔄", style=discord.ButtonStyle.success, row=1)
self.refresh_button.callback = self.refresh_callback self.refresh_button.callback = self.refresh_callback
self.add_item(self.refresh_button) self.add_item(self.refresh_button)
# Close/Exit Button # Close/Exit Button
self.close_button = ui.Button(label="Close Terminal", emoji="", style=discord.ButtonStyle.danger, row=1) self.close_button = ui.Button(label="Close Terminal", emoji="", style=discord.ButtonStyle.danger, row=1)
self.close_button.callback = self.close_callback self.close_button.callback = self.close_callback
@ -478,7 +500,7 @@ class TerminalView(ui.View):
async def interaction_check(self, interaction: Interaction) -> bool: async def interaction_check(self, interaction: Interaction) -> bool:
"""Ensure only the bot owner can interact.""" """Ensure only the bot owner can interact."""
# Use the cog's owner_id which should be set correctly # Use the cog's owner_id which should be set correctly
is_allowed = interaction.user.id == OWNER_ID is_allowed = interaction.user.id == OWNER_ID
if not is_allowed: if not is_allowed:
await interaction.response.send_message("You are not authorized to use these buttons.", ephemeral=True) await interaction.response.send_message("You are not authorized to use these buttons.", ephemeral=True)
return is_allowed return is_allowed
@ -487,11 +509,11 @@ class TerminalView(ui.View):
"""Enable/disable buttons based on terminal state.""" """Enable/disable buttons based on terminal state."""
# Scroll Up # Scroll Up
self.scroll_up_button.disabled = cog_state.scroll_offset <= 0 self.scroll_up_button.disabled = cog_state.scroll_offset <= 0
# Scroll Down # Scroll Down
max_scroll = len(cog_state.output_history) - MAX_OUTPUT_LINES_PER_IMAGE max_scroll = len(cog_state.output_history) - MAX_OUTPUT_LINES_PER_IMAGE
self.scroll_down_button.disabled = cog_state.scroll_offset >= max_scroll or len(cog_state.output_history) <= MAX_OUTPUT_LINES_PER_IMAGE self.scroll_down_button.disabled = cog_state.scroll_offset >= max_scroll or len(cog_state.output_history) <= MAX_OUTPUT_LINES_PER_IMAGE
# Send Input & Refresh should generally be enabled if terminal is active # Send Input & Refresh should generally be enabled if terminal is active
self.send_input_button.disabled = not cog_state.terminal_active or (cog_state.active_process is not None and cog_state.active_process.poll() is None) # Disable if command running self.send_input_button.disabled = not cog_state.terminal_active or (cog_state.active_process is not None and cog_state.active_process.poll() is None) # Disable if command running
self.refresh_button.disabled = not cog_state.terminal_active self.refresh_button.disabled = not cog_state.terminal_active