| 1 |
<?php |
| 2 |
/** |
| 3 |
* OpenStation — Agents: identity layer (synthetic WordPress users). |
| 4 |
* |
| 5 |
* Each agent has a real row in `wp_users` so capability checks, edit |
| 6 |
* locks, comment attribution, and the standard WP audit trail work |
| 7 |
* without a parallel ACL. The row is "synthetic" only in that every |
| 8 |
* login and session path is blocked — the agent never authenticates; |
| 9 |
* it is invoked on the site's behalf. |
| 10 |
* |
| 11 |
* Those blocks, and `openstation_agent_is_agent()` itself, live in |
| 12 |
* guard.php, which loads unconditionally. This file owns the row |
| 13 |
* lifecycle (create / delete) and the identity surface: the bot avatar |
| 14 |
* and the wp-admin Users list "Type" column, so administrators can |
| 15 |
* tell synthetic accounts apart. |
| 16 |
* |
| 17 |
* Definition meta constants live in store.php. |
| 18 |
* |
| 19 |
* @package OpenStation |
| 20 |
*/ |
| 21 |
|
| 22 |
defined( 'ABSPATH' ) || exit; |
| 23 |
|
| 24 |
require_once OPENSTATION_DIR . 'includes/agents/guard.php'; |
| 25 |
|
| 26 |
/** |
| 27 |
* Resolve a unique `user_login` for an agent given its desired slug. |
| 28 |
* |
| 29 |
* Returns the input prefixed with `agent-`, or appends a numeric suffix |
| 30 |
* if a user with that login already exists. |
| 31 |
* |
| 32 |
* @param string $slug Sanitized slug. |
| 33 |
* @return string |
| 34 |
*/ |
| 35 |
function openstation_agent_resolve_unique_login( $slug ) { |
| 36 |
$base = 'agent-' . $slug; |
| 37 |
$login = $base; |
| 38 |
$counter = 1; |
| 39 |
while ( username_exists( $login ) ) { |
| 40 |
++$counter; |
| 41 |
$login = $base . '-' . $counter; |
| 42 |
} |
| 43 |
return $login; |
| 44 |
} |
| 45 |
|
| 46 |
/** |
| 47 |
* Build a synthetic, RFC-shaped email for an agent. |
| 48 |
* |
| 49 |
* The address is never sent to — it just satisfies `wp_insert_user`'s |
| 50 |
* schema validation and reserves the slot so `email_exists()` stays |
| 51 |
* unique across agents. |
| 52 |
* |
| 53 |
* @param string $slug Sanitized agent slug. |
| 54 |
* @return string |
| 55 |
*/ |
| 56 |
function openstation_agent_synthetic_email( $slug ) { |
| 57 |
$host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); |
| 58 |
if ( ! is_string( $host ) || '' === $host ) { |
| 59 |
$host = 'invalid.local'; |
| 60 |
} |
| 61 |
$email = $slug . '@agents.' . $host; |
| 62 |
$counter = 1; |
| 63 |
while ( email_exists( $email ) ) { |
| 64 |
++$counter; |
| 65 |
$email = $slug . '+' . $counter . '@agents.' . $host; |
| 66 |
} |
| 67 |
return $email; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Create a synthetic agent user row. Definition meta is written by the |
| 72 |
* `openstation_agent_create()` orchestrator in store.php — call that, |
| 73 |
* not this, unless you only need the bare row. |
| 74 |
* |
| 75 |
* @param array{name:string, role:string, slug?:string} $args Agent |
| 76 |
* creation args. `role` MUST be one of the site's registered |
| 77 |
* roles. `slug` defaults to `sanitize_title( $name )`. |
| 78 |
* @return WP_User|WP_Error |
| 79 |
*/ |
| 80 |
function openstation_agent_create_user( $args ) { |
| 81 |
$name = isset( $args['name'] ) ? trim( (string) $args['name'] ) : ''; |
| 82 |
$role = isset( $args['role'] ) ? sanitize_key( $args['role'] ) : ''; |
| 83 |
$slug = isset( $args['slug'] ) ? sanitize_title( $args['slug'] ) : ''; |
| 84 |
|
| 85 |
if ( '' === $name ) { |
| 86 |
return new WP_Error( |
| 87 |
'openstation_agent_invalid_name', |
| 88 |
__( 'Agent name is required.', 'desktop-mode' ) |
| 89 |
); |
| 90 |
} |
| 91 |
|
| 92 |
$roles = wp_roles()->get_names(); |
| 93 |
if ( '' === $role || ! isset( $roles[ $role ] ) ) { |
| 94 |
return new WP_Error( |
| 95 |
'openstation_agent_invalid_role', |
| 96 |
__( 'Pick a valid WordPress role for the agent.', 'desktop-mode' ) |
| 97 |
); |
| 98 |
} |
| 99 |
|
| 100 |
if ( '' === $slug ) { |
| 101 |
$slug = sanitize_title( $name ); |
| 102 |
} |
| 103 |
if ( '' === $slug ) { |
| 104 |
return new WP_Error( |
| 105 |
'openstation_agent_invalid_slug', |
| 106 |
__( 'Agent slug could not be derived from the name.', 'desktop-mode' ) |
| 107 |
); |
| 108 |
} |
| 109 |
|
| 110 |
$user_id = wp_insert_user( |
| 111 |
array( |
| 112 |
'user_login' => openstation_agent_resolve_unique_login( $slug ), |
| 113 |
'user_email' => openstation_agent_synthetic_email( $slug ), |
| 114 |
'user_pass' => wp_generate_password( 64, true, true ), |
| 115 |
'display_name' => $name, |
| 116 |
'nickname' => $name, |
| 117 |
'role' => $role, |
| 118 |
'show_admin_bar_front' => false, |
| 119 |
) |
| 120 |
); |
| 121 |
|
| 122 |
if ( is_wp_error( $user_id ) ) { |
| 123 |
return $user_id; |
| 124 |
} |
| 125 |
|
| 126 |
update_user_meta( $user_id, OPENSTATION_AGENT_USER_MARKER_META, '1' ); |
| 127 |
|
| 128 |
return new WP_User( $user_id ); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Delete an agent user. Definition meta rows die with the user |
| 133 |
* (`wp_delete_user()` removes all usermeta). Content the agent |
| 134 |
* authored is NOT reassigned — pass a reassign id when the caller |
| 135 |
* wants to keep it. |
| 136 |
* |
| 137 |
* @param int $user_id Agent user id. |
| 138 |
* @param int|null $reassign Optional user id to reassign authored content to. |
| 139 |
* @return true|WP_Error |
| 140 |
*/ |
| 141 |
function openstation_agent_delete( $user_id, $reassign = null ) { |
| 142 |
if ( ! openstation_agent_is_agent( $user_id ) ) { |
| 143 |
return new WP_Error( |
| 144 |
'openstation_agent_not_an_agent', |
| 145 |
__( 'User is not a OpenStation agent.', 'desktop-mode' ) |
| 146 |
); |
| 147 |
} |
| 148 |
|
| 149 |
if ( ! function_exists( 'wp_delete_user' ) ) { |
| 150 |
require_once ABSPATH . 'wp-admin/includes/user.php'; |
| 151 |
} |
| 152 |
|
| 153 |
// On multisite `wp_delete_user()` only removes the user from the |
| 154 |
// CURRENT site — the network account (and the agent's meta with |
| 155 |
// it) lives on. The agent's whole identity is its wp user, so |
| 156 |
// deleting the agent means the network-wide delete. |
| 157 |
if ( is_multisite() ) { |
| 158 |
if ( ! function_exists( 'wpmu_delete_user' ) ) { |
| 159 |
require_once ABSPATH . 'wp-admin/includes/ms.php'; |
| 160 |
} |
| 161 |
if ( null !== $reassign ) { |
| 162 |
// `wpmu_delete_user()` has no reassign parameter; hand the |
| 163 |
// current site's content over before the user goes. |
| 164 |
wp_delete_user( (int) $user_id, $reassign ); |
| 165 |
} |
| 166 |
$deleted = wpmu_delete_user( (int) $user_id ); |
| 167 |
} else { |
| 168 |
$deleted = wp_delete_user( (int) $user_id, $reassign ); |
| 169 |
} |
| 170 |
if ( ! $deleted ) { |
| 171 |
return new WP_Error( |
| 172 |
'openstation_agent_delete_failed', |
| 173 |
__( 'Could not delete the agent user.', 'desktop-mode' ) |
| 174 |
); |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* Fires after an agent is deleted. |
| 179 |
* |
| 180 |
* @param int $user_id Agent user id (row no longer exists when this fires). |
| 181 |
* @param int $actor_id User who deleted the agent. |
| 182 |
*/ |
| 183 |
do_action( 'openstation_agent_deleted', (int) $user_id, get_current_user_id() ); |
| 184 |
|
| 185 |
return true; |
| 186 |
} |
| 187 |
|
| 188 |
// --------------------------------------------------------------------------- |
| 189 |
// Identity surface |
| 190 |
// |
| 191 |
// `openstation_agent_avatar_url()` lives in bootstrap.php: the WP |
| 192 |
// Explorer integration needs it for the entity icon and loads while the |
| 193 |
// feature flag is off, when this file does not. |
| 194 |
// --------------------------------------------------------------------------- |
| 195 |
|
| 196 |
/** |
| 197 |
* Substitute the bot glyph for agent avatars across the WP admin. |
| 198 |
* |
| 199 |
* @param array $args Args being assembled by `get_avatar_data()`. |
| 200 |
* @param int|string|WP_User|WP_Comment $id_or_email Identifier the caller passed. |
| 201 |
* @return array |
| 202 |
*/ |
| 203 |
function openstation_agent_avatar( $args, $id_or_email ) { |
| 204 |
$user_id = 0; |
| 205 |
if ( is_numeric( $id_or_email ) ) { |
| 206 |
$user_id = (int) $id_or_email; |
| 207 |
} elseif ( $id_or_email instanceof WP_User ) { |
| 208 |
$user_id = (int) $id_or_email->ID; |
| 209 |
} elseif ( $id_or_email instanceof WP_Comment ) { |
| 210 |
$user_id = (int) $id_or_email->user_id; |
| 211 |
} elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) { |
| 212 |
$user = get_user_by( 'email', $id_or_email ); |
| 213 |
if ( $user ) { |
| 214 |
$user_id = (int) $user->ID; |
| 215 |
} |
| 216 |
} |
| 217 |
|
| 218 |
if ( $user_id > 0 && openstation_agent_is_agent( $user_id ) ) { |
| 219 |
$args['url'] = openstation_agent_avatar_url( $user_id ); |
| 220 |
$args['found_avatar'] = true; |
| 221 |
} |
| 222 |
return $args; |
| 223 |
} |
| 224 |
add_filter( 'pre_get_avatar_data', 'openstation_agent_avatar', 10, 2 ); |
| 225 |
|
| 226 |
/** |
| 227 |
* Add a "Type" column to the wp-admin Users list that labels agents. |
| 228 |
* |
| 229 |
* @param string[] $columns Existing column id => label map. |
| 230 |
* @return string[] |
| 231 |
*/ |
| 232 |
function openstation_agent_users_columns( $columns ) { |
| 233 |
$columns['openstation_agent_type'] = __( 'Type', 'desktop-mode' ); |
| 234 |
return $columns; |
| 235 |
} |
| 236 |
add_filter( 'manage_users_columns', 'openstation_agent_users_columns' ); |
| 237 |
|
| 238 |
/** |
| 239 |
* Render the cell for the "Type" column. |
| 240 |
* |
| 241 |
* @param string $output Existing rendered HTML. |
| 242 |
* @param string $column_name Column id. |
| 243 |
* @param int $user_id User id being rendered. |
| 244 |
* @return string |
| 245 |
*/ |
| 246 |
function openstation_agent_users_custom_column( $output, $column_name, $user_id ) { |
| 247 |
if ( 'openstation_agent_type' !== $column_name ) { |
| 248 |
return $output; |
| 249 |
} |
| 250 |
if ( openstation_agent_is_agent( $user_id ) ) { |
| 251 |
return '<span class="os-agent-type" aria-label="' . esc_attr__( 'OpenStation agent', 'desktop-mode' ) . '">' |
| 252 |
. '<span class="dashicons dashicons-superhero" aria-hidden="true"></span> ' |
| 253 |
. esc_html__( 'Agent', 'desktop-mode' ) |
| 254 |
. '</span>'; |
| 255 |
} |
| 256 |
return '<span class="os-agent-type-human">' . esc_html__( 'Person', 'desktop-mode' ) . '</span>'; |
| 257 |
} |
| 258 |
add_filter( 'manage_users_custom_column', 'openstation_agent_users_custom_column', 10, 3 ); |
| 259 |
|