Automattic recently released a really cool tool called Telex that is an AI assisted block builder. I’ve been playing around with it, and it’s been really cool!
I decided to use it to build a “TeaHouse Hero” block. What is that you didn’t ask? Well, some of us really old folks might remember a Google product called “iGoogle” (🙄) that was a customized start page for your browser. Yeah, it was a different time, we were young and didn’t really know what we were doing.
One thing you could do is have different themes. Obviously I chose the “Tea House” theme. It looked something like this:
I recently learned that there’s a built-in “lightbox” function for the block editor, where you can click on images to expand them. Go ahead, try it on the picture below:
Isn’t that wild?! Who knew! This should be the default:
I took the lazy way and vibe coded this with Claude Code, so now all images on my blarg (and yours too if you’d like!) will expand by default.
This plugin comes with no warranty. I will not be able to help you with it. Use it at your own expense. If it deletes your site, code, house, whatever, that’s on you. Don’t download random things from the Internet and run them. Lesson learned. I am not held liable.
Go ahead, take this plugin, and do evil terrible things with it! Crush the skulls of your enemies!
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@hookimpldefstate_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 eventsfor ev in events:
if ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
# Determine click positionif 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 fullscreentry:
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 eventreturn 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.hookimpldefpibooth_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.hookimpldefpibooth_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!")
ifnot wordpress_host:
LOGGER.error(f"WordPress host not defined in [{SECTION}][wordpress_host], uploading deactivated")
elifnot wordpress_username:
LOGGER.error(f"WordPress username not defined in [{SECTION}][wordpress_username], uploading deactivated")
elifnot 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 elseNone
app.wordpress_post_tag = wordpress_post_tag if wordpress_post_tag elseNone
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.hookimpldefstate_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')):# returnif 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 = Noneelse:
LOGGER.error(f"Failed to fetch tag {term_slug}. Status code: {tag_resp.status}")
tag_id = Noneif 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.hookimpldefpibooth_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 Exceptionas 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 Exceptionas 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.
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
*/functionrel_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
*/functionrel_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
*/functionrel_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.InterpolatedNotPreparedecho'<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.
*/functionrel_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 ) {
returntrue;
}
returnnull;
}
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
*/functionrel_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.
Sometimes when you’re working with a local site, especially with existing data, and need to log in as a user and don’t want to mess with resetting the password (or there’s some weird SSO/MFA that’s getting in the way) you just want it to work.
Well, here you go. This snippet will automatically log you in to WordPress using the admin login. I don’t recommend using this anywhere near production or on a server that’s publicly available–for obvious reasons.
But anyway, here’s the bad idea:
<?php/**
* Force login as admin user on every request.
*/functionlol_bad_idea_force_admin_login(): void{
wp_die( 'This is a really bad idea!' ); // Remove this, it's here to stop copy paste problems for people who don't read the code.// Check if user is not already logged in.if ( ! is_user_logged_in() ) {
// Grab user object by login name.
$user = get_user_by( 'login', 'admin' );
if ( $user ) {
// Set the current user to this admin account.
wp_set_current_user( $user->ID );
// Set the WordPress auth cookie.
wp_set_auth_cookie( $user->ID );
}
}
}
add_action( 'init', 'lol_bad_idea_force_admin_login' );
Code language:PHP(php)
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:
<?phpclassOpenAI_API{
publicconst 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.
*/publicstaticfunctiongenerate_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.
*/publicstaticfunctioncreate_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 handlingif ( 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.
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.
I recently had to work with a third-party integration that used the WordPress REST API to interact with a website. We use this tool internally to move data around from other integrations, and finally into WordPress.
One of the things that I was worried about was the fact that this plugin would then have full access to the website, which is a private site. We only wanted to use it to post and then update WordPress posts, but there was always the concern that if the third party were to be hacked, then someone could read all of our posts on the private site through the REST API.
My solution was to hook into user_has_cap so that the user that was set up for the plugin to integrate with, through an application password, would only have access to read and edit its own posts. A bonus is that we wanted to be able to change the author of a post so that it would show up as specific users or project owners. That meant the authorized plugin user would also need access to these posts after the author was changed–so to get past that I scoured each post revision, and if the plugin user was the author of a revision it was also allowed access.
Finally, to make sure no other published posts were readable, I hooked into posts_results to set any post that didn’t meat the above criteria were marked as private. Below is a cleaned up version of that as an example if anyone else needs this type of functionality–feel free to use it as a starting point:
<?php/**
* Restricts post capabilities for a specific user.
*
* @param bool[] $allcaps The current user capabilities.
* @param string[] $caps The requested capabilities.
* @param array $args {
* Arguments that accompany the requested capability check.
*
* @type string $0 Requested capability.
* @type int $1 Concerned user ID.
* @type mixed ...$2 Optional second and further parameters, typically object ID.
* }
* @param WP_User $user The user object.
*
* @return bool[] The modified user capabilities.
*/functionemrikol_restrict_post_capabilities( array $allcaps, array $caps, array $args, WP_User $user ): array{
$post_id = isset( $args[2] ) ? absint( $args[2] ) : false;
if ( false === $post_id || ! get_post( $post_id ) ) {
return $allcaps;
}
if ( 'restricted' === get_user_meta( $user->ID, 'emrikol_restricted_post_capabilities', true ) ) {
$allowed_caps = array( 'read', 'read_private_posts', 'read_post', 'edit_post', 'delete_post', 'edit_others_posts', 'delete_others_posts' );
$requested_cap = isset( $caps[0] ) ? $caps[0] : '';
if ( in_array( $requested_cap, $allowed_caps, true ) ) {
if ( emrikol_user_is_author_or_revision_author( $user->ID, $post_id ) ) {
$allcaps[ $requested_cap ] = true;
} else {
$allcaps[ $requested_cap ] = false;
}
}
}
return $allcaps;
}
add_filter( 'user_has_cap', 'emrikol_restrict_post_capabilities', 10, 4 );
/**
* Restricts the public posts results based on the query.
*
* @param WP_Post[] $posts The array of posts returned by the query.
* @param WP_Query $query The WP_Query instance (passed by reference).
*
* @return array The filtered array of posts.
*/functionemrikol_restrict_public_posts_results( array $posts, WP_Query $query ): array{
if ( ! is_admin() && $query->is_main_query() ) {
$current_user = wp_get_current_user();
if ( 'restricted' === get_user_meta( $user->ID, 'emrikol_restricted_post_capabilities', true ) ) {
foreach ( $posts as $key => $post ) {
if ( ! emrikol_user_is_author_or_revision_author( $current_user->ID, $post->ID ) ) {
$posts[ $key ]->post_status = 'private';
}
}
}
}
return $posts;
}
add_filter( 'posts_results', 'emrikol_restrict_public_posts_results', 10, 2 );
/**
* Checks if the user is the author of the post or the author of a revision.
*
* @param int $user_id The ID of the user.
* @param int $post_id The ID of the post.
*
* @return bool True if the user is the author or revision author, false otherwise.
*/functionemrikol_user_is_author_or_revision_author( int $user_id, int $post_id ): bool{
$post_author_id = (int) get_post_field( 'post_author', $post_id );
if ( $user_id === $post_author_id ) {
returntrue;
}
$revisions = wp_get_post_revisions( $post_id );
foreach ( $revisions as $revision ) {
if ( $user_id === $revision->post_author ) {
returntrue;
}
}
returnfalse;
}
Code language:PHP(php)
I recently worked on profiling a customer site for performance problems, and one of the issues that I had seen was that calls to get_the_excerpt() were running HORRIBLY slow due to filters.
I ended up writing a really hacky workaround to cache excerpts to help reduce the burden they had on the page generation time, but we ended up not using this solution. Instead we found another filter in a plugin that removed the painfully slow stuff happening in the excerpt.
So below is one potential way to cache your excerpt, but be warned it’s only barely tested:
/**
* Checks the excerpt cache for a post and returns the cached excerpt if available.
* If the post is password protected, the original post excerpt is returned.
*
* @param string $post_excerpt The original post excerpt.
* @param WP_Post $post The post object.
*
* @return string The post excerpt, either from the cache or the original.
*/functionblarg_check_excerpt_cache( string $post_excerpt, WP_Post $post ): string{
// We do not want to cache password protected posts.if ( post_password_required( $post ) ) {
return $post_excerpt;
}
$cache_key = $post->ID;
$cache_group = 'blarg_cached_post_excerpt';
$cache_data = wp_cache_get( $cache_key, $cache_group );
if ( false !== $cache_data ) {
remove_all_filters( 'get_the_excerpt' );
add_filter( 'get_the_excerpt', 'blarg_check_excerpt_cache', PHP_INT_MIN, 2 );
$post_excerpt = $cache_data;
} else {
add_filter( 'get_the_excerpt', 'blarg_cache_the_excerpt', PHP_INT_MAX, 2 );
}
// At this point, do not modify anything and return.return $post_excerpt;
}
add_filter( 'get_the_excerpt', 'blarg_check_excerpt_cache', PHP_INT_MIN, 2 );
/**
* Caches the post excerpt in the WordPress object cache.
*
* @param string $post_excerpt The post excerpt to cache.
* @param WP_Post $post The post object.
*
* @return string The cached post excerpt.
*/functionblarg_cache_the_excerpt( string $post_excerpt, WP_Post $post ): string{
$cache_key = $post->ID;
$cache_group = 'blarg_cached_post_excerpt';
wp_cache_set( $cache_key, $post_excerpt, $cache_group );
return $post_excerpt;
}
/**
* Deletes the cached excerpt for a given post.
*
* @param int|WP_Post $post The post ID or WP_Post object.
*
* @return void
*/functionblarg_delete_the_excerpt_cache( int|WP_Post $post ): void{
if ( $post instanceof WP_Post ) {
$post_id = $post->ID;
} else {
$post_id = $post;
}
$cache_key = $post_id;
$cache_group = 'blarg_cached_post_excerpt';
wp_cache_delete( $cache_key, $cache_group );
}
add_action( 'clean_post_cache', 'blarg_delete_the_excerpt_cache', 10, 1 );
Code language:PHP(php)
This should automatically clear out the cache any time the post is updated, but if the excerpt gets updated in any other way you may need to purge the cache in other ways.
But if you’re still using PHP to build WordPress sites, and still using wp_nav_menu() you might not know that these menus can be performance killers!
Problem!
Under the hood, nav menus are stored as terms in a nav_menu taxonomy. For large sites with lots of terms and taxonomies, and complex menus (custom walkers, yay!) you can really start to see menus struggle. Sure, this might be only 50-100ms, but that really adds up after millions of pageviews.
Caching Solution
One of the solutions you can do us to wrap the wp_nav_menu() calls inside a caching function, like what the Cache Nav Menus plugin does. Of course, this can complicate things, and even require you to fork third party plugins and themes to maintain caching compatability.
Another option you have is to cheat. By hooking into pre_wp_nav_menu you can cache the nav menus in place. Below is an example I’ve given customers before that shows how you can cache menus in place with a simple (mu) plugin:
/**
* Filters and caches the output of a WordPress navigation menu.
*
* This function is hooked to the 'pre_wp_nav_menu' action, it generates a unique cache key
* for every individual menu based on the menu arguments and the last time the menu was modified.
* This key is then used to store and retrieve cached versions of the menu.
*
* @param string|null $output Nav menu output to short-circuit with.
* @param stdClas $args An object containing wp_nav_menu() arguments.
*
* @return string|null Nav menu output.
*/functionwpvip_pre_cache_nav_menu( string|null $output, $args ): string|null{
/**
* Filters whether to enable caching for a specific menu.
*
* This filter can be used to selectively disable caching for specific WordPress navigation menus.
* By default, all menus are cached for an hour.
*
* @param bool $enable_cache Whether to enable caching for the menu. Default true.
* @param string $args An object containing wp_nav_menu() arguments.
*/
$enable_cache = apply_filters( 'wpvip_pre_cache_nav_menu', true, $args );
if ( ! $enable_cache ) {
return $output;
}
// Define a unique cache key.
$cache_key = $args->menu . ':' . md5( serialize( $args ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
$cache_group = 'wpvip_pre_cache_nav_menu';
// Try to get the cached menu.
$output = wp_cache_get( $cache_key, $cache_group );
// If the menu isn't cached, generate and cache it.if ( false === $output ) {
$args->echo = false;
remove_action( 'pre_wp_nav_menu', 'wpvip_pre_cache_nav_menu', 10, 2 );
$output = wp_nav_menu( $args );
add_action( 'pre_wp_nav_menu', 'wpvip_pre_cache_nav_menu', 10, 2 );
wp_cache_set( $cache_key, $output, $cache_group, HOUR_IN_SECONDS );
}
return $output;
}
add_action( 'pre_wp_nav_menu', 'wpvip_pre_cache_nav_menu', 10, 2 );Code language:PHP(php)
Now, due to the weird way that you can call menus via wp_nav_menu() this might require some tweaking depending on your needs (are you using an int, string, or WP_Query to query the desired menu? Why so many choices?)
This is defaulting to caching for one hour, but you can likely cache for MUCH longer since menus rarely change. You could even cache forever and use the wp_update_nav_menu hook to purge and/or prime the cache.