Tag: chatgpt

  • A Trio of Pibooth Plugins

    A Trio of Pibooth Plugins

    This past weekend I did a thing:

    One of the things that I had done, as previously blargged about here, is having a Pibooth Photo Booth.

    I ended up writing three custom plugins that I’m going to share here. I had plenty of help from ChatGPT because I hate Python and it’s the worst language invented and it should be destroyed.

    I also ended up hacking a bit of Pibooth core because its hook/plugin system was a bit lacking for my needs, but that was only around moving around some of the touchscreen UI items for a better user experience. But enough about that, here’s my three plugins:

    Fullscreen Toggle

    One of the things I wanted to be able to easily do, running a keyboard/mouseless touchscreen Raspbery Pi was to be able to jump in and out of Fullscreen mode to do things like debug printers, change Wifi, etc. So I pulled this plugin out of the ether. It allows you to touch the top right corner (or click, if that’s your thing) to toggle between fullscreen and windowed mode.

    import pygame
    from pibooth import hookimpl
    from pibooth.utils import LOGGER
    
    __version__ = "0.0.1"
    
    """
    Plugin to toggle fullscreen/windowed mode by tapping top right corner in take-a-picture screen.
    """
    
    # Fractional region size
    REGION_WIDTH_FRAC = 0.10  # 10% width
    REGION_HEIGHT_FRAC = 0.10  # 10% height
    
    @hookimpl
    def state_wait_do(cfg, app, win, events):
        """
        In the wait state (take-a-picture), detect a tap/click in the top-right corner
        and toggle fullscreen mode.
        """
        # Only on the initial "take a picture" screen (wait state)
        # events: list of pygame events
        for ev in events:
            if ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
                # Determine click position
                if hasattr(ev, 'pos'):
                    x, y = ev.pos
                else:
                    sw, sh = win.surface.get_size()
                    x, y = ev.x * sw, ev.y * sh
                sw, sh = win.surface.get_size()
                # Define top-right region
                region_x0 = sw * (1 - REGION_WIDTH_FRAC)
                region_y1 = sh * REGION_HEIGHT_FRAC
                if x >= region_x0 and y <= region_y1:
                    # Toggle fullscreen
                    try:
                        win.toggle_fullscreen()
                        LOGGER.info("[fullscreen_toggle] Toggled fullscreen mode")
                    except Exception as e:
                        LOGGER.error(f"[fullscreen_toggle] Failed to toggle fullscreen: {e}")
                    # Consume event
                    return ev
        # Return None to fall back to default behaviorCode language: Python (python)

    Upload to WordPress

    Well, yeah. Obvi! This plugin uploads the photos to your WordPress Media library, with an optional tag and optional post object to attach them to. It will also update the URL so that it works with the “QR Code” plugin (not mine, from Pihole or something, IDK). You just have to add some of the options to your Pihole config file, like the hostname, username, application password, etc.

    import os
    import http.client
    import base64
    import json
    import pibooth
    from pibooth.utils import LOGGER
    
    __version__ = "0.0.4"
    
    SECTION = 'WORDPRESS'
    PICTURE_SECTION = 'PICTURE'
    
    @pibooth.hookimpl
    def pibooth_configure(cfg):
        """Declare the new configuration options"""
        cfg.add_option(SECTION, 'wordpress_host', '',
            "WordPress site host (e.g., 'yourwordpresssite.com')")
        cfg.add_option(SECTION, 'wordpress_username', '',
          "WordPress username")
        cfg.add_option(SECTION, 'wordpress_password', '',
            "WordPress application password")
        cfg.add_option(SECTION, 'wordpress_parent_id', '',
            "(Optional) WordPress post or page ID to attach the uploads",
            "Post or Page ID (optional)", '')
        cfg.add_option(SECTION, 'image_title', '',
            "(Optional) Title for the uploaded image",
            "Image title (optional)", '')
        cfg.add_option(SECTION, 'image_caption', '',
            "(Optional) Caption for the uploaded image",
            "Image caption (optional)", '')
        cfg.add_option(SECTION, 'wordpress_post_tag', '',
            "(Optional) Post tag taxonomy term slug to assign to the uploaded media",
            "Post tag (optional)", '')
    
    @pibooth.hookimpl
    def pibooth_startup(app, cfg):
        """Initialize the WordPress uploader plugin"""
        app.previous_picture_url = None
        wordpress_host = cfg.get(SECTION, 'wordpress_host')
        wordpress_username = cfg.get(SECTION, 'wordpress_username')
        wordpress_password = cfg.get(SECTION, 'wordpress_password')
        wordpress_parent_id = cfg.get(SECTION, 'wordpress_parent_id')
        wordpress_post_tag = cfg.get(SECTION, 'wordpress_post_tag')
        
        image_title = ''
        image_caption = ''
    
        if cfg.has_option(SECTION, 'image_title') and cfg.get(SECTION, 'image_title'):
            image_title = cfg.get(SECTION, 'image_title')
            LOGGER.info(f"Using image title from WORDPRESS config: {image_title}")
        elif cfg.has_option(PICTURE_SECTION, 'footer_text1') and cfg.get(PICTURE_SECTION, 'footer_text1'):
            image_title = cfg.get(PICTURE_SECTION, 'footer_text1')
            LOGGER.info(f"Using image title from GENERAL config: {image_title}")
        else:
            LOGGER.info("Image title missing!")
        
        if cfg.has_option(SECTION, 'image_caption') and cfg.get(SECTION, 'image_caption'):
            image_caption = cfg.get(SECTION, 'image_caption')
            LOGGER.info(f"Using image caption from WORDPRESS config: {image_caption}")
        elif cfg.has_option(PICTURE_SECTION, 'footer_text2') and cfg.get(PICTURE_SECTION, 'footer_text2'):
            image_caption = cfg.get(PICTURE_SECTION, 'footer_text2')
            LOGGER.info(f"Using image caption from GENERAL config: {image_caption}")
        else:
            LOGGER.info("Image caption missing!")
    
        if not wordpress_host:
            LOGGER.error(f"WordPress host not defined in [{SECTION}][wordpress_host], uploading deactivated")
        elif not wordpress_username:
            LOGGER.error(f"WordPress username not defined in [{SECTION}][wordpress_username], uploading deactivated")
        elif not wordpress_password:
            LOGGER.error(f"WordPress password not defined in [{SECTION}][wordpress_password], uploading deactivated")
        else:
            LOGGER.info("WordPress uploader plugin installed")
            app.wordpress_host = wordpress_host
            app.auth_token = base64.b64encode(f"{wordpress_username}:{wordpress_password}".encode()).decode()
            app.wordpress_parent_id = wordpress_parent_id if wordpress_parent_id else None
            app.wordpress_post_tag = wordpress_post_tag if wordpress_post_tag else None
            LOGGER.info(f"Post tag set to: {app.wordpress_post_tag}")
            app.image_title = image_title
            app.image_caption = image_caption
    
            LOGGER.info(f"Image title set to: {app.image_title}")
            LOGGER.info(f"Image caption set to: {app.image_caption}")
    
    @pibooth.hookimpl
    def state_processing_exit(app, cfg):
        """Upload picture to WordPress media library"""
        # Skip plugin if disabled in config
        # if not (cfg.has_section('CUSTOM_PROCESSING') and cfg.getboolean('CUSTOM_PROCESSING', 'enabled')):
        #    return
            
        if hasattr(app, 'wordpress_host') and hasattr(app, 'auth_token'):
            name = os.path.basename(app.previous_picture_file)
    
            with open(app.previous_picture_file, 'rb') as fp:
                image_data = fp.read()
    
            headers = {
                'Content-Disposition': f'attachment; filename={name}',
                'Content-Type': 'image/jpeg',
                'Authorization': f'Basic {app.auth_token}'
            }
    
            # Prepare the request path with an optional parent ID
            path = "/wp-json/wp/v2/media"
            if app.wordpress_parent_id:
                path += f"?parent={app.wordpress_parent_id}"
    
            conn = http.client.HTTPSConnection(app.wordpress_host)
            conn.request("POST", path, body=image_data, headers=headers)
            response = conn.getresponse()
            
            if response.status == 201:
                response_data = json.loads(response.read().decode())
                app.previous_picture_url = response_data['source_url']
                
                LOGGER.info(f"Uploaded image URL: {app.previous_picture_url}")
    
                # Set metadata if provided
                metadata = {}
                if app.image_title:
                    metadata['title'] = app.image_title
                if app.image_caption:
                    metadata['caption'] = app.image_caption
                if app.wordpress_post_tag:
                    term_slug = app.wordpress_post_tag
                    tag_headers = {
                        'Content-Type': 'application/json',
                        'Authorization': f'Basic {app.auth_token}'
                    }
                    # Try to fetch existing tag
                    conn.request("GET", f"/wp-json/wp/v2/tags?slug={term_slug}", headers=tag_headers)
                    tag_resp = conn.getresponse()
                    if tag_resp.status == 200:
                        tags_list = json.loads(tag_resp.read().decode())
                        if tags_list:
                            tag_id = tags_list[0]['id']
                        else:
                            # Create the tag if not found
                            payload = json.dumps({'name': term_slug, 'slug': term_slug})
                            conn.request("POST", "/wp-json/wp/v2/tags", body=payload, headers=tag_headers)
                            create_resp = conn.getresponse()
                            if create_resp.status == 201:
                                tag_id = json.loads(create_resp.read().decode())['id']
                            else:
                                LOGGER.error(f"Failed to create tag {term_slug}. Status code: {create_resp.status}")
                                tag_id = None
                    else:
                        LOGGER.error(f"Failed to fetch tag {term_slug}. Status code: {tag_resp.status}")
                        tag_id = None
                    if tag_id:
                        metadata['tags'] = [tag_id]
                        LOGGER.info(f"Assigning tag ID {tag_id} to attachment")
    
                LOGGER.info(f"Metadata to be set: {metadata}")
    
                if metadata:
                    meta_headers = {
                        'Content-Type': 'application/json',
                        'Authorization': f'Basic {app.auth_token}'
                    }
                    conn.request("POST", f"/wp-json/wp/v2/media/{response_data['id']}", body=json.dumps(metadata), headers=meta_headers)
                    meta_response = conn.getresponse()
                    if meta_response.status != 200:
                        LOGGER.error(f"Failed to set metadata. Status code: {meta_response.status}, Response: {meta_response.read().decode()}")
    
                LOGGER.info(f"Successfully uploaded {name} to WordPress. URL: {app.previous_picture_url}")
            else:
                LOGGER.error(f"Failed to upload {name}. Status code: {response.status}, Response: {response.read().decode()}")
    
            conn.close()
    
    @pibooth.hookimpl
    def pibooth_cleanup(app):
        """Cleanup actions if necessary"""
        passCode language: Python (python)

    Custom Processing (More Fun!)

    Finally, I didn’t like the generic “PROCESSING” screen that happened. I wanted it to be more personal, so I rebuilt it so that it shows the last three images taken, along with a custom waiting message taken from an array:

    #!/usr/bin/env python3
    """
    custom_processing.py
    
    A PiBooth plugin that, during the 'processing' state, grabs the last
    three saved pictures from your output directory and shows them as thumbnails.
    """
    import os
    import glob
    import pygame
    import pibooth
    from pibooth.utils import LOGGER
    from pibooth.view import background
    from pibooth import pictures
    import random
    from pibooth import fonts
    
    __version__ = "0.0.1"
    
    # A list of fun processing messages
    PROCESSING_SAYINGS = [
        "Processing…",
        "Shaking it like a Polaroid picture",
        "Now developing…",
        "Cooking your memories",
        "Almost ready!",
        "Frame by frame…",
        "Developing brilliance…",
        "Hang tight, magic in progress…",
        "Give us a sec, art is brewing…",
        "Hold on, pixels aligning…",
        "Magic happening behind the scenes…",
        "One moment please…",
        "Crafting your memories…",
        "Almost there, thanks for your patience…",
        "Don’t blink, or you might miss it…",
        "Your masterpiece is on its way…",
    ]
    
    @pibooth.hookimpl(trylast=True)
    def state_processing_enter(cfg, app, win):
        """
        When entering the 'processing' screen, list the output folder,
        pick the 3 newest image files, and render them as thumbnails.
        """
    
        # 1) Determine your output directory (default: ~/Pictures/pibooth)
        outdir = os.path.expanduser(cfg.get('PATHS', 'output_dir') if
                                    cfg.has_option('PATHS', 'output_dir') else
                                    '~/Pictures/pibooth')
        LOGGER.info(f"[custom_processing] Scanning directory: {outdir}")
    
        # 2) Glob all image files (you can tweak extensions as needed)
        exts = ('*.jpg', '*.jpeg', '*.png', '*.avif')
        files = []
        for ext in exts:
            files.extend(glob.glob(os.path.join(outdir, ext)))
        if not files:
            LOGGER.info("[custom_processing] No files found in output dir")
            return
    
        # 3) Sort by modification time and pick the last three
        files.sort(key=lambda f: os.path.getmtime(f))
        thumbs = files[-3:]
        LOGGER.info(f"[custom_processing] Last 3 files: {thumbs}")
    
        # 4) Load, scale and display as before
        surface = win.surface
        # Redraw PiBooth’s default processing background
        win._update_background(background.Background(''))
    
        sw, sh = surface.get_size()
        thumb_h = int(sh * 0.3)
        scaled = []
        for path in thumbs:
            try:
                # Load with alpha so we can mask out black
                img = pygame.image.load(path).convert_alpha()
                ow, oh = img.get_size()
                tw = int(ow * thumb_h / oh)
                img = pygame.transform.smoothscale(img, (tw, thumb_h))
    
                # Make pure black transparent (so border and photo remain)
                img.set_colorkey((0, 0, 0))
    
                scaled.append(img)
                LOGGER.info(f"[custom_processing] Loaded & scaled {os.path.basename(path)} → {(tw, thumb_h)}")
            except Exception as e:
                LOGGER.error(f"[custom_processing] Failed to load {path}: {e}")
    
        if not scaled:
            LOGGER.info("[custom_processing] No thumbnails rendered")
            return
    
        # 5) Blit and rotate thumbnails into the hanging frames with random tilt
        # Percent‐based anchor points so they adjust on resize:
        frame_positions = [
            (int(sw * 0.20), int(sh * 0.34)),  # left (moved up to 40% height)
            (int(sw * 0.50), int(sh * 0.36)),  # center (35% height)
            (int(sw * 0.80), int(sh * 0.34)),  # right (40% height)
        ]
        for img, (cx, cy) in zip(scaled, frame_positions):
            # Random rotation between -10° and +10°
            angle = random.uniform(-10, 10)
            rot_img = pygame.transform.rotozoom(img, angle, 1.0)
            rw, rh = rot_img.get_size()
            # Center the rotated thumbnail on its anchor point
            surface.blit(rot_img, (cx - rw // 2, cy - rh // 2))
    
        # Draw the transparent custom processing template over the thumbnails
        custom_bg = os.path.expanduser("~/.config/pibooth/assets/processing_custom.png")
        try:
            # Load with alpha to preserve transparency
            overlay_img = pygame.image.load(custom_bg).convert_alpha()
            # Scale to cover the full window
            overlay_img = pygame.transform.smoothscale(overlay_img, (sw, sh))
            # Blit on top of thumbnails
            surface.blit(overlay_img, (0, 0))
        except Exception as e:
            LOGGER.error(f"[custom_processing] Failed to load processing_custom overlay '{custom_bg}': {e}")
    
        # Pick and uppercase a random processing message
        say = random.choice(PROCESSING_SAYINGS).upper()
        # Define the bottom third with 5% side padding
        bottom_rect = pygame.Rect(
            int(sw * 0.05), 
            int(sh * 2/3), 
            int(sw * 0.9), 
            int(sh / 3)
        )
        # Choose a font that fits in that rectangle
        font = fonts.get_pygame_font(
            say,
            fonts.CURRENT,
            bottom_rect.width,
            bottom_rect.height
        )
        # Render the text in the window's text color
        text_color = cfg.gettuple('WINDOW', 'text_color', int)
        text_surf = font.render(say, True, text_color)
        # Center the message within the bottom third
        text_rect = text_surf.get_rect(center=bottom_rect.center)
        surface.blit(text_surf, text_rect)
    
        pygame.display.update()
        LOGGER.info("[custom_processing] Thumbnails hung with rotation")Code language: PHP (php)

    This is what it ends up looking like, anonymized:

    So yeah, use at your own risk. I don’t offer any support. Assume it will break things and burn down your house. I am not a professional. Don’t trust Python.

    Farewell!

  • Stop WordPress From Sending Emails (And Log Them Too!)

    Stop WordPress From Sending Emails (And Log Them Too!)

    While doing some development work recently, I wanted to make sure that I disabled all email sending in my test site so that no users imported would get any weird emails.

    To do this I had ChatGPT whip me up a quick plugin, and after cleaning it up here it is to share with the world:

    <?php
    /**
     * Plugin Name: Restrict and Log Emails
     * Description: Blocks emails for users and logs all email attempts.
     * Version: 1.3
     * Author: Emrikol
     */
    
    if ( ! defined( 'ABSPATH' ) ) {
    	exit; // Prevent direct access.
    }
    define( 'REL_EMAIL_LOG_DB_VERSION', '1.2' );
    
    /**
     * Database installation and upgrade function.
     * Creates or updates the email_log table if needed based on a stored version option.
     *
     * @return void
     */
    function rel_install() {
    	global $wpdb;
    	$table_name      = $wpdb->prefix . 'email_log';
    	$charset_collate = $wpdb->get_charset_collate();
    
    	$sql = "CREATE TABLE $table_name (
    	   id mediumint(9) NOT NULL AUTO_INCREMENT,
    	   recipient_email varchar(100) NOT NULL,
    	   subject text NOT NULL,
    	   message text NOT NULL,
    	   status varchar(20) NOT NULL,
    	   sent_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
    	   PRIMARY KEY (id)
    	) $charset_collate;";
    
    	require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    	dbDelta( $sql );
    
    	// Update the database version option.
    	update_option( 'rel_email_log_db_version', REL_EMAIL_LOG_DB_VERSION, false );
    }
    register_activation_hook( __FILE__, 'rel_install' );
    
    /**
     * Adds the Email Log submenu to Tools in the WordPress admin area.
     *
     * @return void
     */
    function rel_add_admin_menu(): void {
    	if ( function_exists( 'add_submenu_page' ) ) {
    		add_submenu_page(
    			'tools.php',
    			'Email Log',
    			'Email Log',
    			'manage_options',
    			'email-log',
    			'rel_email_log_page'
    		);
    	}
    }
    add_action( 'admin_menu', 'rel_add_admin_menu' );
    
    /**
     * Displays the Email Log page in the WordPress admin area.
     *
     * @return void
     */
    function rel_email_log_page(): void {
    	global $wpdb;
    	$table_name = $wpdb->prefix . 'email_log';
    	$logs       = $wpdb->get_results( "SELECT * FROM $table_name ORDER BY sent_at DESC LIMIT 50" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    
    	echo '<div class="wrap">';
    	echo '<h1>Email Log</h1>';
    
    	if ( current_user_can( 'manage_options' ) ) {
    		// Process toggle form submission.
    		if ( isset( $_POST['rel_toggle_blocking'] ) && check_admin_referer( 'rel_toggle_blocking_action', 'rel_toggle_blocking_nonce' ) ) {
    			// Sanitize and update the blocking option.
    			if ( isset( $_POST['rel_email_blocking_enabled'] ) && in_array( $_POST['rel_email_blocking_enabled'], array( 'enabled', 'disabled' ), true ) ) {
    				$blocking = $_POST['rel_email_blocking_enabled']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
    			} else {
    				$blocking = 'enabled';
    			}
    
    			update_option( 'rel_email_blocking_enabled', $blocking, false );
    			echo '<div class="updated notice"><p>Email blocking has been ' . ( 'enabled' === $blocking ? 'enabled' : 'disabled' ) . '.</p></div>';
    		}
    		$current_blocking = get_option( 'rel_email_blocking_enabled', true );
    
    		echo '<form method="post" style="margin-bottom:20px;">';
    		wp_nonce_field( 'rel_toggle_blocking_action', 'rel_toggle_blocking_nonce' );
    		echo '<p>Email blocking is currently <strong>' . ( 'enabled' === $current_blocking ? 'enabled' : 'disabled' ) . '</strong>.';
    		echo '<br/><br/><label for="rel_email_blocking_enabled">Toggle: </label>';
    		echo '<select id="rel_email_blocking_enabled" name="rel_email_blocking_enabled">';
    		echo '<option value="enabled"' . selected( $current_blocking, 'enabled', false ) . '>Enabled</option>';
    		echo '<option value="disabled"' . selected( $current_blocking, 'disabled', false ) . '>Disabled</option>';
    		echo '</select> ';
    		echo '<input type="submit" name="rel_toggle_blocking" value="Update" class="button-primary" />';
    		echo '</p>';
    		echo '</form>';
    	}
    
    	echo '<table class="widefat fixed" cellspacing="0">';
    	echo '<thead><tr><th>ID</th><th>Email</th><th>Subject</th><th>Message</th><th>Status</th><th>Sent At</th></tr></thead>';
    	echo '<tbody>';
    
    	if ( $logs ) {
    		foreach ( $logs as $log ) {
    			$truncated_message = ( strlen( $log->message ) > 30 ) ? substr( $log->message, 0, 30 ) . '…' : $log->message;
    			echo wp_kses_post(
    				'<tr>
    					<td>' . $log->id . '</td>
    					<td>' . $log->recipient_email . '</td>
    					<td>' . $log->subject . '</td>
    					<td>' . $truncated_message . '</td>
    					<td>' . $log->status . '</td>
    					<td>' . $log->sent_at . '</td>
    				  </tr>'
    			);
    		}
    	} else {
    		echo '<tr><td colspan="6">No emails logged yet.</td></tr>';
    	}
    
    	echo '</tbody></table>';
    	echo '</div>';
    }
    
    /**
     * Intercepts email sending attempts, restricts emails for users without the 'manage_options' capability, and logs the attempt.
     *
     * @param null|bool $short_circuit Default value if email is allowed.
     * @param array     $atts An array of email attributes (to, subject, etc.).
     * @return bool|null Returns a non-null value to short-circuit email sending if restricted.
     */
    function rel_prevent_email( $short_circuit, $atts ) {
    	global $wpdb;
    	$table_name = $wpdb->prefix . 'email_log';
    
    	$recipient_email = isset( $atts['to'] ) ? $atts['to'] : '';
    	$subject         = isset( $atts['subject'] ) ? $atts['subject'] : '';
    	$message         = isset( $atts['message'] ) ? $atts['message'] : '';
    	$sent_at         = current_time( 'mysql' );
    
    	// Check if email blocking is enabled (default true).
    	$blocking = get_option( 'rel_email_blocking_enabled', 'enabled' );
    	switch ( $blocking ) {
    		case 'enabled':
    			$blocking_enabled = true;
    			break;
    		case 'disabled':
    			$blocking_enabled = false;
    			break;
    		default:
    			$blocking_enabled = true;
    			break;
    	}
    
    	// Determine status based on blocking setting.
    	if ( true === $blocking_enabled ) {
    		$status = 'Blocked';
    	} else {
    		$status = 'Sent';
    	}
    
    	// Log the email attempt.
    	$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
    		$table_name,
    		array(
    			'recipient_email' => $recipient_email,
    			'subject'         => $subject,
    			'message'         => $message,
    			'status'          => $status,
    			'sent_at'         => $sent_at,
    		)
    	);
    
    	// If blocking is enabled and the current user cannot manage options, block the email, otherwise allow sending.
    	if ( $blocking_enabled ) {
    		return true;
    	}
    	return null;
    }
    add_filter( 'pre_wp_mail', 'rel_prevent_email', 10, 2 );
    
    /**
     * Checks the current database version and updates the email_log table if necessary.
     *
     * @return void
     */
    function rel_check_db_version() {
    	if ( get_option( 'rel_email_log_db_version' ) !== REL_EMAIL_LOG_DB_VERSION ) {
    		rel_install();
    	}
    }
    add_action( 'init', 'rel_check_db_version' );
    
    Code language: PHP (php)

    As usual, don’t use this slop, nor anything you read here because this is my blarg and I can’t be trusted to do anything correct.

  • Super Simple OpenAI PHP Class

    Super Simple OpenAI PHP Class

    I’ve been playing around with hooking up ChatGPT/Dall-E to WordPress and WP-CLI. To do this, I whipped up a super simple class to make this easier:

    <?php
    class OpenAI_API {
    	public const API_KEY = 'hunter2';  // Get your own darn key!
    
    	/**
    	 * Generates an image based on the provided prompt using the OpenAI API.
    	 *
    	 * @param string $prompt The text prompt to generate the image from. Default is an empty string.
    	 * @return string The response body from the OpenAI API, or a JSON-encoded error message if the request fails.
    	 */
    	public static function generate_image( string $prompt = '' ): string {
    		$data = array(
    			'model'   => 'dall-e-3',
    			'prompt'  => trim( $prompt ),
    			'quality' => 'hd',
    			'n'       => 1,
    			'size'    => '1024x1024',
    		);
    
    		$args = array(
    			'body'        => wp_json_encode( $data ),
    			'headers'     => array(
    				'Content-Type'  => 'application/json',
    				'Authorization' => 'Bearer ' . OpenAI_API::API_KEY,
    			),
    			'method'      => 'POST',
    			'data_format' => 'body',
    		);
    
    		$response = wp_remote_post( 'https://api.openai.com/v1/images/generations', $args );
    
    		if ( is_wp_error( $response ) ) {
    			return wp_json_encode( $response );
    		} else {
    			$body = wp_remote_retrieve_body( $response );
    			return $body;
    		}
    	}
    
    	/**
    	 * Creates a chat completion using the OpenAI GPT-3.5-turbo model.
    	 *
    	 * @param string $prompt The user prompt to be sent to the OpenAI API.
    	 * @param string $system_prompt Optional. The system prompt to be sent to the OpenAI API. Defaults to a predefined prompt.
    	 * 
    	 * @return string The response body from the OpenAI API, or a JSON-encoded error message if the request fails.
    	 */
    	public static function create_chat_completion( string $prompt = '', string $system_prompt = '' ): string {
    		if ( empty( $system_prompt ) ) {
    			$system_prompt = 'You are a virtual assistant designed to provide general support across a wide range of topics. Answer concisely and directly, focusing on essential information only. Maintain a friendly and approachable tone, adjusting response length based on the complexity of the question.';
    		}
    
    		// The data to send in the request body
    		$data = array(
    			'model'    => 'gpt-3.5-turbo',
    			'messages' => array(
    				array(
    					'role'    => 'system',
    					'content' => trim( $system_prompt ),
    				),
    				array(
    					'role'    => 'user',
    					'content' => trim( $prompt ),
    				),
    			),
    		);
    
    		$args = array(
    			'body'        => wp_json_encode( $data ),
    			'headers'     => array(
    				'Content-Type'  => 'application/json',
    				'Authorization' => 'Bearer ' . OpenAI_API::API_KEY,
    			),
    			'method'      => 'POST',
    			'data_format' => 'body',
    			'timeout'     => 15,
    		);
    
    		// Perform the POST request
    		$response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', $args );
    
    		// Error handling
    		if ( is_wp_error( $response ) ) {
    			return wp_json_encode( $response );
    		} else {
    			if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
    				return wp_json_encode( array( 'error' => 'API returned non-200 status code', 'response' => wp_remote_retrieve_body( $response ) ) );
    			}
    
    			// Assuming the request was successful, you can access the response body as follows:
    			$body = wp_remote_retrieve_body( $response );
    			return $body;
    		}
    	}
    }Code language: PHP (php)

    I can generate images and get back text from the LLM. Here’s some examples ChatGPT made to show how you can use these:


    Example 1: Generating an Image

    This example generates an image of a “cozy cabin in the snowy woods at sunset” using the generate_image method and displays it in an <img> tag.

    <?php
    $image_url = OpenAI_API::generate_image("A cozy cabin in the snowy woods at sunset");
    
    if ( ! empty( $image_url ) ) {
        echo '<img src="' . esc_url( $image_url ) . '" alt="Cozy cabin in winter">';
    } else {
        echo 'Image generation failed.';
    }
    ?>Code language: PHP (php)

    Example 2: Simple Chat Completion

    This example sends a question to the create_chat_completion method and prints the response directly.

    <?php
    $response = OpenAI_API::create_chat_completion("How does photosynthesis work?");
    echo $response;
    ?>Code language: PHP (php)

    Example 3: Chat Completion with Custom System Prompt

    This example sets a custom system prompt for a specific tone, here focusing on culinary advice, and asks a relevant question.

    <?php
    $system_prompt = "You are a culinary expert. Please provide advice on healthy meal planning.";
    $response = OpenAI_API::create_chat_completion("What are some good meals for weight loss?", $system_prompt);
    echo $response;
    ?>Code language: PHP (php)

    Here are some key limitations of this simple API implementation and why these are crucial considerations for production:

    • Lack of Robust Error Handling:
      • This API implementation has basic error handling that only checks if an error occurred during the request. It doesn’t provide specific error messages for different types of failures (like rate limits, invalid API keys, or network issues).
      • Importance: In production, detailed error handling allows for clearer diagnostics and faster troubleshooting when issues arise.
    • No Caching:
      • The current API makes a fresh request for each call, even if the response might be identical to a recent query.
      • Importance: Caching can reduce API usage costs, improve response times, and reduce server load, particularly for commonly repeated queries.
    • No API Rate Limiting:
      • This implementation doesn’t limit the number of requests sent within a certain time frame.
      • Importance: Rate limiting prevents hitting API request quotas and helps avoid unexpected costs or blocked access if API limits are exceeded.
    • No Logging for Debugging:
      • There’s no logging in place for tracking request errors or failed attempts.
      • Importance: Logs provide an audit trail that helps diagnose issues over time, which is crucial for maintaining a stable application in production.
    • Lack of Security for API Key Management:
      • The API key is currently hard coded into the class.
      • Importance: In production, it’s best to use environment variables or a secure key management system to protect sensitive information and prevent accidental exposure of the API key.
    • No Response Parsing or Validation:
      • The code assumes that the API response format is always correct, without validation.
      • Importance: Inconsistent or unexpected responses can cause failures. Validation ensures the app handles different cases gracefully.

    Why Not Use in Production?

    Due to these limitations, this API should be considered a prototype or learning tool rather than a production-ready solution. Adding robust error handling, caching, rate limiting, and logging would make it more resilient, secure, and efficient for a production environment.


    Alright, so listen to the LLM and don’t do anything stupid with this, like I am doing.

  • Capturing MacOS Settings Changes

    Capturing MacOS Settings Changes

    Let me get this right out. I upgraded to MacOS 15 beta and it totally borked my machine. I had to do a full, fresh reinstall.

    Totally my fault, and I should have prepared better.

    So now, I’m trying to remember to capture my personal settings so I can use a shell script to restore them in case of emergency with defaults

    With the help of ChatGPT and a lot of stupidity, I have created this:

    #!/bin/bash
    
    # Set the DEBUG flag (default is false)
    DEBUG=false
    
    # Define a function for logging debug information
    log() {
        if [ "$DEBUG" = true ]; then
            echo "$@"
        fi
    }
    
    # Define a function for running commands with error suppression unless DEBUG is true
    run_command() {
        if [ "$DEBUG" = true ]; then
            "$@"
        else
            "$@" 2>/dev/null
        fi
    }
    
    # Step 1: Define directories for temporary files
    before_dir="/tmp/before_defaults"
    after_dir="/tmp/after_defaults"
    mkdir -p "$before_dir" "$after_dir"
    
    # Step 2: Capture system-wide and global preferences
    
    echo "Reading user-specific global preferences..."
    run_command defaults read -g > "$before_dir/user_global_defaults.plist"
    
    echo "Reading system-wide global preferences..."
    run_command sudo defaults read -g > "$before_dir/system_global_defaults.plist"
    
    echo "Reading user-specific system preferences..."
    run_command defaults read > "$before_dir/user_system_defaults.plist"
    
    echo "Reading system-wide system preferences..."
    run_command sudo defaults read > "$before_dir/system_system_defaults.plist"
    
    # Step 3: Get all domains and capture their preferences with and without sudo
    echo "Reading defaults for all domains..."
    domains=$(run_command defaults domains)
    for domain in $domains; do
        log "Reading user-specific defaults for domain: $domain"
        run_command defaults read "$domain" > "$before_dir/${domain}_user_defaults.plist"
        
        log "Reading system-wide defaults for domain: $domain"
        run_command sudo defaults read "$domain" > "$before_dir/${domain}_system_defaults.plist"
    done
    
    # Step 4: Capture network settings (excluding Launch Services to reduce noise)
    echo "Capturing network settings..."
    run_command sudo cp /Library/Preferences/SystemConfiguration/preferences.plist "$before_dir/network_settings.plist"
    
    echo "Current preferences saved. Please make your changes now."
    echo "Press any key to continue once you've made the changes..."
    read -n 1 -s
    
    # Step 5: Capture updated defaults
    echo "Reading updated user-specific global preferences..."
    run_command defaults read -g > "$after_dir/user_global_defaults.plist"
    
    echo "Reading updated system-wide global preferences..."
    run_command sudo defaults read -g > "$after_dir/system_global_defaults.plist"
    
    echo "Reading updated user-specific system preferences..."
    run_command defaults read > "$after_dir/user_system_defaults.plist"
    
    echo "Reading updated system-wide system preferences..."
    run_command sudo defaults read > "$after_dir/system_system_defaults.plist"
    
    echo "Reading updated defaults for all domains..."
    for domain in $domains; do
        log "Reading updated user-specific defaults for domain: $domain"
        run_command defaults read "$domain" > "$after_dir/${domain}_user_defaults.plist"
        
        log "Reading updated system-wide defaults for domain: $domain"
        run_command sudo defaults read "$domain" > "$after_dir/${domain}_system_defaults.plist"
    done
    
    echo "Capturing updated network settings..."
    run_command sudo cp /Library/Preferences/SystemConfiguration/preferences.plist "$after_dir/network_settings.plist"
    
    # Step 6: Compare before and after to identify changes with unified diffs and output results directly
    
    echo "Comparing preferences and generating diffs..."
    
    # Global and system diffs
    echo "Comparing global and system preferences..."
    diff -u "$before_dir/user_global_defaults.plist" "$after_dir/user_global_defaults.plist" | sed '/^\s*$/d'
    diff -u "$before_dir/system_global_defaults.plist" "$after_dir/system_global_defaults.plist" | sed '/^\s*$/d'
    
    diff -u "$before_dir/user_system_defaults.plist" "$after_dir/user_system_defaults.plist" | sed '/^\s*$/d'
    diff -u "$before_dir/system_system_defaults.plist" "$after_dir/system_system_defaults.plist" | sed '/^\s*$/d'
    
    echo "Comparing Domain Specific"
    # Domain-specific diffs
    for domain in $domains; do
        # Only run diff if both user-specific files exist
        if [ -f "$before_dir/${domain}_user_defaults.plist" ] && [ -f "$after_dir/${domain}_user_defaults.plist" ]; then
            
            #echo "Comparing user-specific defaults for domain: $domain"
            diff -u "$before_dir/${domain}_user_defaults.plist" "$after_dir/${domain}_user_defaults.plist" | sed '/^\s*$/d'
        fi
    
        # Only run diff if both system-wide files exist
        if [ -f "$before_dir/${domain}_system_defaults.plist" ] && [ -f "$after_dir/${domain}_system_defaults.plist" ]; then
            #echo "Comparing system-wide defaults for domain: $domain"
            diff -u "$before_dir/${domain}_system_defaults.plist" "$after_dir/${domain}_system_defaults.plist" | sed '/^\s*$/d'
        fi
    done
    
    diff -u "$before_dir/network_settings.plist" "$after_dir/network_settings.plist" | sed '/^\s*$/d'
    
    # Step 7: Clean up temporary files (optional, currently commented out)
    #log "Cleaning up temporary files..."
    #run_command sudo rm -r "$before_dir" "$after_dir"
    
    echo "Comparison complete."
    Code language: Bash (bash)

    This will basically do a scan of all defaults settings it can find, wait for you to make changes, and then show you a diff of what has changed.

    For a bad example, I wanted to auto hide the dock:

    $ bash default-changes.sh
    Reading user-specific global preferences...
    Reading system-wide global preferences...
    Reading user-specific system preferences...
    Reading system-wide system preferences...
    Reading defaults for all domains...
    Capturing network settings...
    Current preferences saved. Please make your changes now.
    Press any key to continue once you've made the changes...
    Reading updated user-specific global preferences...
    Reading updated system-wide global preferences...
    Reading updated user-specific system preferences...
    Reading updated system-wide system preferences...
    Reading updated defaults for all domains...
    Capturing updated network settings...
    Comparing preferences and generating diffs...
    Comparing global and system preferences...
    --- /tmp/before_defaults/user_system_defaults.plist	2024-09-30 21:23:17.183140919 -0400
    +++ /tmp/after_defaults/user_system_defaults.plist	2024-09-30 21:23:45.721177080 -0400
    @@ -95,9 +95,9 @@
         };
         ContextStoreAgent =     {
             "_DKThrottledActivityLast_DKKnowledgeStorageLogging_DKKnowledgeStorageDidInsertEventsNotification:/app/mediaUsageActivityDate" = "2024-08-15 01:49:34 +0000";
    -        "_DKThrottledActivityLast_DKKnowledgeStorageLogging_DKKnowledgeStorageDidInsertEventsNotification:/app/usageActivityDate" = "2024-10-01 01:22:53 +0000";
    +        "_DKThrottledActivityLast_DKKnowledgeStorageLogging_DKKnowledgeStorageDidInsertEventsNotification:/app/usageActivityDate" = "2024-10-01 01:23:42 +0000";
             "_DKThrottledActivityLast_DKKnowledgeStorageLogging_DKKnowledgeStorageDidInsertLocalEventsNotification:/app/mediaUsageActivityDate" = "2024-08-15 01:49:34 +0000";
    -        "_DKThrottledActivityLast_DKKnowledgeStorageLogging_DKKnowledgeStorageDidInsertLocalEventsNotification:/app/usageActivityDate" = "2024-10-01 01:22:53 +0000";
    +        "_DKThrottledActivityLast_DKKnowledgeStorageLogging_DKKnowledgeStorageDidInsertLocalEventsNotification:/app/usageActivityDate" = "2024-10-01 01:23:42 +0000";
         };
         LighthouseBitacoraFramework =     {
             "lastAggregationDate_MLHost" = "2024-08-31 00:00:00 +0000";
    @@ -6998,6 +6998,7 @@
             );
         };
         "com.apple.dock" =     {
    +        autohide = 1;
             "last-analytics-stamp" =         (
                 "748840077.891269"
             );
    @@ -12104,7 +12105,7 @@
                 "com.apple.photolibraryd.curatedlibraryprocessing" = "2024-09-30 11:58:18 +0000";
                 "com.apple.photolibraryd.periodicmaintenance" = "2024-09-29 18:28:19 +0000";
                 "com.apple.proactive.PersonalIntelligence.PersonalIntelligenceMetrics" = "2024-09-29 14:29:35 +0000";
    -            "com.apple.proactive.PersonalizationPortrait.ClientLinkStatus" = "2024-09-22 01:54:07 +0000";
    +            "com.apple.proactive.PersonalizationPortrait.ClientLinkStatus" = "2024-10-04 09:16:11 +0000";
                 "com.apple.proactive.PersonalizationPortrait.ContactHandlesCache" = "2024-09-29 23:56:51 +0000";
                 "com.apple.proactive.PersonalizationPortrait.ContactsImport" = "2024-09-29 23:57:21 +0000";
                 "com.apple.proactive.PersonalizationPortrait.CoreRoutineImport" = "2024-09-29 23:56:50 +0000";
    @@ -12115,11 +12116,11 @@
                 "com.apple.proactive.PersonalizationPortrait.ExpiredLinkReview" = "2024-09-29 23:57:21 +0000";
                 "com.apple.proactive.PersonalizationPortrait.FeedbackProcessing" = "2024-09-29 23:56:50 +0000";
                 "com.apple.proactive.PersonalizationPortrait.FeedbackStreamReviewed" = "2024-09-29 23:56:51 +0000";
    -            "com.apple.proactive.PersonalizationPortrait.LinkStatusGeneration" = "2024-09-22 01:54:07 +0000";
    +            "com.apple.proactive.PersonalizationPortrait.LinkStatusGeneration" = "2024-10-04 09:16:11 +0000";
                 "com.apple.proactive.PersonalizationPortrait.LogLocationPerplexity" = "2024-09-29 23:54:27 +0000";
    -            "com.apple.proactive.PersonalizationPortrait.LogNamedEntityFirstSource" = "2024-09-22 01:54:07 +0000";
    +            "com.apple.proactive.PersonalizationPortrait.LogNamedEntityFirstSource" = "2024-10-04 09:16:11 +0000";
                 "com.apple.proactive.PersonalizationPortrait.LogNamedEntityPerplexity" = "2024-09-29 23:56:52 +0000";
    -            "com.apple.proactive.PersonalizationPortrait.LogTopicFirstSource" = "2024-09-22 01:54:07 +0000";
    +            "com.apple.proactive.PersonalizationPortrait.LogTopicFirstSource" = "2024-10-04 09:16:11 +0000";
                 "com.apple.proactive.PersonalizationPortrait.LogTopicPerplexity" = "2024-09-29 23:57:22 +0000";
                 "com.apple.proactive.PersonalizationPortrait.MapsImport" = "2024-09-29 23:57:22 +0000";
                 "com.apple.proactive.PersonalizationPortrait.NamedEntityFiltering" = "2024-09-29 23:57:22 +0000";
    @@ -12136,7 +12137,7 @@
                 "com.apple.proactive.PersonalizationPortrait.TopicImport" = "2024-09-26 17:42:30 +0000";
                 "com.apple.proactive.PersonalizationPortrait.TopicRepairAndExport" = "2024-09-26 17:45:06 +0000";
                 "com.apple.proactive.PersonalizationPortrait.UnsupportedClient" = "2024-09-29 23:57:21 +0000";
    -            "com.apple.proactive.PersonalizationPortrait.VacuumDatabase" = "2024-09-22 01:54:07 +0000";
    +            "com.apple.proactive.PersonalizationPortrait.VacuumDatabase" = "2024-10-04 09:16:11 +0000";
                 "com.apple.proactive.ProactiveHarvesting.Cleanup" = "2024-09-29 14:29:35 +0000";
                 "com.apple.proactive.ProactiveHarvesting.Harvest.PeriodicBackground" = "2024-09-29 21:54:28 +0000";
                 "com.apple.proactived.contextualactions.training" = "2024-09-28 03:57:02 +0000";
    --- /tmp/before_defaults/system_system_defaults.plist	2024-09-30 21:23:17.281165387 -0400
    +++ /tmp/after_defaults/system_system_defaults.plist	2024-09-30 21:23:45.810771555 -0400
    @@ -374,7 +374,7 @@
                 "501:com.apple.mlhost.telemetry.daily" = "2024-09-26 21:59:29 +0000";
                 "501:com.apple.mlhostd.daily" = "2024-09-28 08:35:02 +0000";
                 "501:com.apple.parsec.SafariBrowsingAssistant" = "2024-09-27 02:53:00 +0000";
    -            "501:com.apple.photoanalysisd.backgroundanalysis" = "2024-09-30 20:14:26 +0000";
    +            "501:com.apple.photoanalysisd.backgroundanalysis" = "2024-10-01 00:02:14 +0000";
                 "501:com.apple.photoanalysisd.graphNonIntensiveTasks" = "2024-09-30 20:20:03 +0000";
                 "501:com.apple.photoanalysisd.internal" = "2024-09-15 01:53:51 +0000";
                 "501:com.apple.photoanalysisd.music" = "2024-09-30 17:05:59 +0000";
    @@ -2312,7 +2312,7 @@
             LastOSLaunchVersion = 24A335;
             "baseDate.LocalBeaconingManager" = "2023-06-01 15:07:33 +0000";
             lastAttemptDate = "2024-08-15 01:48:08 +0000";
    -        lastFinderAttemptDate = "2024-10-01 01:23:05 +0000";
    +        lastFinderAttemptDate = "2024-10-01 01:23:36 +0000";
             lastFinderPublishDates =         {
                 batteryWiFi =             (
                     "2024-09-21 01:07:39 +0000",
    @@ -3859,7 +3859,7 @@
                 "com.apple.awdd.publication" = "2024-09-29 18:17:55 +0000";
                 "com.apple.awdd.trigger:0x7f004:86400:2" = "2024-09-28 13:31:28 +0000";
                 "com.apple.awdd.trigger:0x7f006:14400:2" = "2024-09-30 05:24:18 +0000";
    -            "com.apple.backupd-auto" = "2024-10-01 01:08:37 +0000";
    +            "com.apple.backupd-auto" = "2024-10-01 01:38:37 +0000";
                 "com.apple.backupd-auto.dryspell" = "2024-09-28 01:22:46 +0000";
                 "com.apple.backupd.analytics" = "2024-09-28 05:36:06 +0000";
                 "com.apple.biome.prune-expired-events" = "2024-09-28 03:56:23 +0000";
    Comparing Domain Specific
    Comparison complete.Code language: PHP (php)

    You can see that there’s a lot of background noise, but in there is what I was looking for: com.apple.dock and autohide = 1.

    Now I can head over to macos-defaults.com and I can search for com.apple.doc and find the command to set the dock to auto hide.

    defaults write com.apple.dock "autohide" -bool "true" && killall Dock

    Thinking about it, this was probably a dumb example as it was easy to just go to macos-defaults.com and search for “autohide” to find it.

    Well, any way do what you want with this stupid thing.

  • Garbage Sysadmin: Easily Make CIFS Mounts

    Garbage Sysadmin: Easily Make CIFS Mounts

    I’ve been rebuilding my Raspberry Pi collection from scratch, and moving from Ubuntu Server to Debian/Raspbian Bookworm. One of the tasks that I quickly tried to automate was reconnecting my CIFS mounts. I wanted to do it better, and came across this method, with the help of ChatGPT, to mount them at boot:

    #!/bin/bash
    
    # Check if the script is run as root
    if [[ $EUID -ne 0 ]]; then
       echo "This script must be run as root"
       exit 1
    fi
    
    # Check for correct number of arguments
    if [ "$#" -ne 4 ]; then
        echo "Usage: $0 <RemoteDirectory> <MountDirectory> <Username> <Password>"
        exit 1
    fi
    
    REMOTE_DIR="$1"
    MOUNT_DIR="$2"
    USERNAME="$3"
    PASSWORD="$4"
    CREDENTIALS_PATH="/etc/samba/credentials-$(basename "$MOUNT_DIR")"
    
    # Escape the mount directory for systemd
    UNIT_NAME=$(systemd-escape -p --suffix=mount "$MOUNT_DIR")
    
    # Create mount directory
    mkdir -p "$MOUNT_DIR"
    
    # Create credentials file
    touch "$CREDENTIALS_PATH"
    echo "username=$USERNAME" > "$CREDENTIALS_PATH"
    echo "password=$PASSWORD" >> "$CREDENTIALS_PATH"
    chmod 600 "$CREDENTIALS_PATH"
    
    # Create systemd unit file
    UNIT_FILE_PATH="/etc/systemd/system/$UNIT_NAME"
    echo "[Unit]
    Description=Mount Share at $MOUNT_DIR
    After=network-online.target
    Wants=network-online.target
    
    [Mount]
    What=$REMOTE_DIR
    Where=$MOUNT_DIR
    Type=cifs
    Options=_netdev,iocharset=utf8,file_mode=0777,dir_mode=0777,credentials=$CREDENTIALS_PATH
    TimeoutSec=30
    
    [Install]
    WantedBy=multi-user.target" > "$UNIT_FILE_PATH"
    
    # Reload systemd, enable and start the unit
    systemctl daemon-reload
    systemctl enable "$UNIT_NAME"
    systemctl start "$UNIT_NAME"
    
    echo "Mount setup complete. Mounted $REMOTE_DIR at $MOUNT_DIR"Code language: Bash (bash)

    I’m sure this is totally insecure and a terrible idea, but it works for me so back off, buddy!

    Please don’t follow me as an example of what to do, but take this code for anything you need.

  • Matrix Reimagined: Crafting Digital Rain with Bash and ChatGPT

    Matrix Reimagined: Crafting Digital Rain with Bash and ChatGPT

    Just for fun, and I have no idea why I thought about it, I decided to work with ChatGPT (4) to build a simple bash-based version of the Matrix Digital Rain. I know there’s already better versions, like cmatrix, but we do not do things because they are easy. We do them because we are bored.

    I’ve asked ChatGPT to heavily comment the code for us so that we can see exactly what’s going on:

    #!/bin/bash
    
    # This script creates a Matrix-style falling text effect in the terminal.
    
    # Define strings for extra characters (Japanese Katakana) and extended ASCII characters
    extra_chars="カキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン"
    extended_ascii="│┤┐└┴┬├─┼┘┌≡"
    
    # Define arrays of color codes for a fading green color effect, and a static color
    fade_colors=('\033[38;2;0;255;0m' '\033[38;2;0;192;0m' '\033[38;2;0;128;0m' '\033[38;2;0;64;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;32;0m' '\033[38;2;0;16;0m' '\033[38;2;0;8;0m') # Fading green colors
    static_color='\033[38;2;0;0;0m' # Static dark green color
    white_bold='\033[1;37m' # White and bold for the primary character
    
    # Get terminal dimensions
    COLUMNS=$(tput cols) # Number of columns in the terminal
    ROWS=$(tput lines) # Number of rows in the terminal
    
    
    # Hide the cursor for a cleaner effect and clear the screen
    echo -ne '\033[?25l'
    clear
    
    # Function to generate a random character from the set of extra characters and extended ASCII
    random_char() {
    	local chars="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789${extra_chars}${extended_ascii}"
    	echo -n "${chars:RANDOM%${#chars}:1}"
    }
    
    # Generate a list of 1000 random characters
    random_chars=""
    for (( i=0; i<1000; i++ )); do
    	random_chars+=$(random_char) # Add a random character to the end of the string
    done
    
    # Initialize a counter for cycling through the random characters
    char_counter=0 # Counter for cycling through the random characters
    
    # Initialize arrays to keep track of the position and trail characters of each column
    positions=() # Array to store the current position in each column
    trail_chars=() # Array to store the trail characters in each column
    for (( c=1; c<=COLUMNS; c++ )); do
    	positions[$c]=$((RANDOM % ROWS)) # Random starting position for each column
    	trail_chars[$c]="" # Start with an empty trail for each column
    done
    
    # Function to update the display with the falling text effect
    update_line() {
    	local last_pos=0  # Track the last position to optimize cursor movement
    
    	for (( c=1; c<=COLUMNS; c++ )); do
    		# Randomly skip updating some columns to create a dynamic effect
    		if [ $((RANDOM % 4)) -ne 0 ]; then
    			continue
    		fi
    
    		local new_char=${random_chars:$char_counter:1} # Select the next character from the random string
    		char_counter=$(( (char_counter + 1) % 1000 )) # Update the counter, cycling back after 1000
    
    		local pos=${positions[$c]} # Current position in this column
    		local trail=${trail_chars[$c]} # Current trail of characters in this column
    
    		trail_chars[$c]="${new_char}${trail:0:$((ROWS - 1))}" # Update the trail by adding new character at the top
    
    		# Render the trail of characters
    		for (( i=0; i<${#trail}; i++ )); do
    			local trail_pos=$((pos - i)) # Calculate the position for each character in the trail
    			if [ $trail_pos -ge 0 ] && [ $trail_pos -lt $ROWS ]; then
    				local color=${fade_colors[i]:-$static_color} # Choose color from the fade array or static color if beyond the array
    				if [ $i -eq 0 ]; then
    					color=$white_bold # First character in the trail is white and bold
    				fi
    				if [ $last_pos -ne $trail_pos ]; then
    					printf "%b" "\033[${trail_pos};${c}H" # Move cursor to the right position
    					last_pos=$trail_pos
    				fi
    				printf "%b" "${color}${trail:$i:1}\033[0m" # Print the character with color
    			fi
    		done
    
    		positions[$c]=$((pos + 1)) # Update the position for the next cycle
    		if [ $pos -ge $((ROWS + ${#fade_colors[@]})) ]; then
    			positions[$c]=0 # Reset position if it moves off screen
    			trail_chars[$c]=""
    		fi
    	done
    }
    
    # Main loop for continuous execution of the update_line function
    while true; do
    	update_line
    done
    
    # Reset terminal settings on exit (show cursor, clear screen, reset text format)
    echo -ne '\033[?25h' # Show cursor
    clear
    tput sgr0 # Reset text format
    Code language: PHP (php)

    Challenges Faced

    Developing the Matrix Digital Rain script presented specific challenges, especially in terms of performance. The initial use of tput for cursor manipulation proved inefficient for the dynamic text display. This issue was resolved by switching to printf and ANSI escape sequences, which significantly enhanced the rendering performance.

    Another problem arose with the use of fullwidth Katakana characters, which were incompatible with monospaced fonts, disrupting the visual flow. The solution involved adopting halfwidth Katakana, ensuring better compatibility and preserving the uniformity essential for the Matrix-style effect.

    Another notable challenge emerged in development: the inefficiency of generating random characters on the fly. Due to Bash’s slower handling of string functions, this method significantly hindered performance. To tackle this, a strategic shift was made from real-time character generation to utilizing a predefined lookup table.

    This approach involved generating a large set of pseudorandom characters before entering the main loop of the program. By doing so, I could rapidly access this table during runtime, boosting performance. This change played a crucial role in maintaining fluidity and responsiveness. It also preserved the illusion of randomness, essential for the authentic Matrix effect, thus striking a balance between performance efficiency and visual fidelity.

    If you’d like to see what it looks like, here’s an example:

    So yeah. That’s it I guess? Enjoy!

  • Sending Prowl Alerts via Bash

    Sending Prowl Alerts via Bash

    I’m working on some server scripting and I wanted to find a way to make sure I get proper alerts. I tried hard getting Twilio working cheaply, but that failed. So I remembered that I use Prowl for other things on my phone–so why not just send more alerts that way?

    So, again with the help of ChatGPT I have made a terrible monstrosity:

    #!/bin/bash
    
    # Exit if any command fails, if an unset variable is used, or if a command in a pipeline fails
    set -euo pipefail
    
    # Default values
    application="Shell Notification"
    priority=0
    event="Event"
    message=""
    logfile="/var/log/send-prowl-alert.log"
    url=""
    
    # Usage information
    usage() {
        echo "Usage: $0 --message=<message> [--application=<application>] [--event=<event>] [--description=<description>] [--priority=<priority>] [--url=<url>]"
        echo "Required:"
        echo "  --message=<message>       A description of the event, generally terse"
        echo "                            Maximum of 10000 bytes"
        echo "Optional:"
        echo "  --application=<application>  The name of the application (default: 'Shell Notification')"
        echo "                               Maximum of 256 bytes"
        echo "  --event=<event>              The name of the event or subject of the notification (default: 'Event')"
        echo "                               Maximum of 1024 bytes"
        echo "  --priority=<priority>        The priority of the alert (default: 0)"
        echo "                               An integer value ranging [-2, 2] representing:"
        echo "                                   -2 Very Low"
        echo "                                   -1 Moderate"
        echo "                                    0 Normal"
        echo "                                    1 High"
        echo "                                    2 Emergency"
        echo "                               Emergency priority messages may bypass quiet hours according to the user's settings."
        echo "  --url=<url>                  The URL which should be attached to the notification"
        echo "                               Maximum of 512 bytes"
        echo "  --help                       Displays this help message"
    }
    
    # URL Encodes a string
    urlencode() {
      local string="$1"
      local length="${#string}"
      local i
    
      for ((i = 0; i < length; i++)); do
        local c="${string:i:1}"
        case $c in
          [a-zA-Z0-9.~_-])
            printf '%s' "$c"
            ;;
          *)
            printf '%%%02X' "'$c"
            ;;
        esac
      done
    }
    
    # Check if the script is being run manually or via cron
    if [[ -t 1 ]]; then
        # Output to console if being run manually
        log_message() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
    else
        # Output to log file if being run via cron
        log_message() {
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "${logfile}"
            # Truncate the log file to 1 megabyte
            if [[ $(wc -c <"${logfile}") -gt 1000000 ]]; then
                tail -c 1000000 "${logfile}" > "${logfile}.tmp" && mv "${logfile}.tmp" "${logfile}"
            fi
        }
    fi
    
    # Parse arguments
    for arg in "$@"; do
        case "${arg}" in
            --message=*)
                message=${arg#*=}
                ;;
            --application=*)
                application=${arg#*=}
                ;;
            --event=*)
                event=${arg#*=}
                ;;
            --priority=*)
                priority=${arg#*=}
                ;;
            --url=*)
                url=${arg#*=}
                ;;
            --help)
                usage
                exit 0
                ;;
            *)
                echo "Error: Unsupported argument ${arg}"
                usage
                exit 1
                ;;
        esac
    done
    
    
    # Create a variable to store error messages
    errors=""
    
    # Check if curl is installed
    if ! command -v curl &> /dev/null; then
        errors+="Error: curl is not installed. Please install curl and try again.\n"
    fi
    
    # Check that a message was provided
    if [[ -z "${message}" ]]; then
        errors+="Error: No message provided."
    fi
    
    # Check if the URL is valid
    if [[ -n $url && ! $url =~ ^(https?://)[^\s/$.?#].[^\s]*$ ]]; then
        errors+="Error: Invalid URL.\n"
    fi
    
    # Check byte size of the parameters and append error if they exceed the max limit
    if [[ "${#url}" -gt 512 ]]; then
        errors+="Error: URL exceeds maximum byte limit (512 bytes).\n"
    fi
    
    if [[ "${#application}" -gt 256 ]]; then
        errors+="Error: Application name exceeds maximum byte limit (256 bytes).\n"
    fi
    
    if [[ "${#event}" -gt 1024 ]]; then
        errors+="Error: Event name exceeds maximum byte limit (1024 bytes).\n"
    fi
    
    if [[ "${#message}" -gt 10000 ]]; then
        errors+="Error: Message exceeds maximum length (10000 bytes).\n"
    fi
    
    if [[ ! "${priority}" =~ ^-?[0-2]$ ]]; then
        errors+="Error: Invalid priority. Must be an integer between -2 and 2.\n"
    fi
    
    # Check if the API key file exists and is not empty
    if [[ ! -s ~/.prowl_api_key ]]; then
        errors+="Error: The Prowl API key file does not exist or is empty. Please create a file at ~/.prowl_api_key and add your API key to it.\nExample:\n  echo 'your_api_key' > ~/.prowl_api_key\n  chmod 600 ~/.prowl_api_key\n"
    fi
    
    # Check the permissions on the API key file, if it exists
    # We have to do it in a convoluted cross platform way because macos does not support `stat -c`
    if [[ -f ~/.prowl_api_key ]]; then
        # Retrieve the file permissions and remove any trailing '@' symbol (indicating extended attributes)
        permissions=$(ls -l -d ~/.prowl_api_key | awk '{print $1}' | tr -d '@')
    
        # Check if the permissions are not set to '-rw-------'
        if [[ "$permissions" != "-rw-------" ]]; then
            # Add an error message to the 'errors' variable
            errors+="Error: The permissions on the API key file are not set correctly. Please run 'chmod 600 ~/.prowl_api_key' and try again.\n"
        fi
    fi
    
    # If any errors were detected, print the error messages and exit
    if [[ -n $errors ]]; then
        echo -e "${errors}"  # -e enables interpretation of backslash escapes like \n
        exit 1
    fi
    
    # Get the Prowl API key
    prowl_api_key=$(cat ~/.prowl_api_key)
    
    # Save the alert data for logging before we encode it
    alert_log_string="$application : $event : $priority : $url : $message"
    
    # URL-encode the variables
    prowl_api_key=$(urlencode "$prowl_api_key")
    application=$(urlencode "$application")
    event=$(urlencode "$event")
    message=$(urlencode "$message")
    priority=$(urlencode "$priority")
    url=$(urlencode "$url")
    
    # Send the alert
    response=$(curl -s "https://api.prowlapp.com/publicapi/add?apikey=${prowl_api_key}&application=${application}&event=${event}&description=${message}&priority=${priority}&url=${url}")
    
    # Check if the alert was sent successfully
    if [[ "${response}" != *"success code=\"200\""* ]]; then
        log_message "Error sending alert: ${alert_log_string}"
        log_message "Response: ${response}"
        exit 1
    fi
    
    log_message "Alert sent successfully: $alert_log_string"
    exit 0
    Code language: PHP (php)

    Good luck!

  • Supercharge Your Clipboard with a Shell Function for iTerm2

    Supercharge Your Clipboard with a Shell Function for iTerm2

    Well, I’ve been playing with ChatGPT more, and I had ChatGPT (GPT-4) write this blog post for me. I told it that I was going to give it some of my blog posts to determine my writing style, and to have it write me a blog post about this bash function. It’s …. meh … not really “my style” but whatever.

    The idea of this shell function is to integrate with iTerm2’s proprietary escape sequences to allow you to pipe content right to your clipboard. I’m using the name pbcopy from MacOS so that I don’t need to think about it, and can use pbcopy anywhere. Now I can work on a remote server and cat stupid-stuff.log | pbcopy and get a copy of it locally to mess with, without having to click and drag or download the file. Super lazy ❇

    Enjoy the AI blog post:


    Hey folks, today we’re going to explore a neat shell function that can make your life a lot easier when you’re working in iTerm2 on macOS. The purpose of this function is to make the built-in pbcopy command work seamlessly in iTerm2, even when the command isn’t available in your shell environment.

    Without further ado, let’s dive into the code:

    # pbcopy via iTerm2
    function pbcopy() {
    	if which pbcopy >/dev/null 2>&1; then
    		pbcopy "$@"
    	else
    		# Replace ^[ with the ASCII escape character
    		local start="\e]1337;CopyToClipboard\a"
    		local end="\e]1337;EndCopy\a"
    		printf "${start}$(cat)${end}"
    	fi
    }
    Code language: PHP (php)

    You might be wondering how this magic works. Let’s break it down step by step:

    1. The function first checks if the pbcopy command is available in your shell environment using the which command. If it is, it simply calls the built-in pbcopy command with the provided arguments.
    2. If the pbcopy command isn’t available, the function creates two local variables, start and end, containing special escape sequences that iTerm2 understands.
    3. The printf command is then used to concatenate the start escape sequence, the contents of the input (read using cat), and the end escape sequence. This formatted string is what gets sent to iTerm2.

    When iTerm2 receives this specially formatted string, it recognizes the escape sequences and takes care of copying the text between them to the system clipboard.

    And that’s it! With this function in your shell environment, you can copy text to your clipboard using pbcopy without any hassle, whether the command is natively available or not. To use this function, simply add it to your shell startup file (like .bashrc or .zshrc), and you’re good to go.

    In a practical scenario, let’s say you’re working on a project and you want to share the contents of a log file with a colleague. Instead of manually opening the log file, selecting its content, and copying it to your clipboard, you can use this shell script to streamline the process. With the pbcopy function added to your shell startup file, simply open your terminal and run the command cat logfile.txt | pbcopy. This command will read the content of the logfile.txt file and pipe it directly to the pbcopy function, which in turn places the content into your clipboard. Now you can easily paste the log file content into an email, chat application, or any other medium you choose, all without leaving the comfort of your terminal. This not only saves time but also enhances your overall productivity when working with text-based data in a command-line environment.

    In conclusion, this shell function is a fantastic little tool to enhance your iTerm2 experience on macOS. I hope you find it as useful as I do. As always, if you have any questions or comments, feel free to drop them below. Happy coding!


    Happy coding, indeed 🤖

  • More Garbage Sysadmin: Reboot Linux Server on Kernel Panic

    More Garbage Sysadmin: Reboot Linux Server on Kernel Panic

    Just like restarting a server when the memory is low, I’ve had a recent problem with kernel panics on my Raspberry Pi, and I’ve found a terrible solution to fix it: Just reboot.

    Setting the /proc/sys/kernel/panic file contents to a non-zero integer will reboot the server on kernel panic after that many seconds.

    Because I’m lazy, I asked ChatGPT to write me up a startup script to do this for me, and here’s what I have now:

    To set the panic timeout value on Ubuntu Server 20.04 and later versions, you can create a systemd service unit file.

    Here are the steps to create a systemd service unit file:

    1. Open a terminal window on your Ubuntu Server.
    2. Create a new service unit file with the following command:

      sudo nano /etc/systemd/system/panic-timeout.service

      This will open a new file named panic-timeout.service in the nano text editor with superuser privileges.
    3. Add the following lines to the file:
    [Unit]
    Description=Panic Timeout
    
    [Service]
    Type=oneshot
    ExecStart=/bin/bash -c "echo 60 > /proc/sys/kernel/panic"
    
    [Install]
    WantedBy=multi-user.target
    Code language: JavaScript (javascript)

    This service unit file sets the panic timeout to 60 seconds.

    1. Save the file by pressing Ctrl+O, then exit nano by pressing Ctrl+X.
    2. Reload the systemd daemon to recognize the new service unit file with the following command:

      sudo systemctl daemon-reload
    3. Enable the service unit file to run at boot time with the following command:

      sudo systemctl enable panic-timeout.service
    4. Reboot the server to test the service unit file. After the server reboots, the panic-timeout.service will automatically run the echo command and set the panic timeout to 60 seconds.

    That’s it! With these steps, you can set the panic timeout value on the latest versions of Ubuntu Server.

    Well there you have it! Don’t forget to follow along for more terrible ideas!