Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions data-machine.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ function datamachine_allow_json_upload( $mimes ) {
function () {
\DataMachine\Core\Database\Chat\Chat::ensure_context_column();
\DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column();
\DataMachine\Core\Database\Chat\Chat::ensure_last_read_at_column();
},
6
);
Expand Down Expand Up @@ -555,6 +556,7 @@ function datamachine_activate_for_site() {
\DataMachine\Core\Database\Chat\Chat::create_table();
\DataMachine\Core\Database\Chat\Chat::ensure_context_column();
\DataMachine\Core\Database\Chat\Chat::ensure_agent_id_column();
\DataMachine\Core\Database\Chat\Chat::ensure_last_read_at_column();

// Ensure default agent memory files exist.
// During activation the Abilities API is unavailable (init already fired before
Expand Down
1 change: 1 addition & 0 deletions inc/Abilities/Chat/GetChatSessionAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public function execute( array $input ): array {
'session_id' => $session['session_id'],
'conversation' => $session['messages'],
'metadata' => $session['metadata'],
'last_read_at' => $session['last_read_at'] ?? null,
);
}
}
134 changes: 134 additions & 0 deletions inc/Abilities/Chat/MarkSessionReadAbility.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php
/**
* Mark Session Read Ability
*
* Sets last_read_at on a chat session to track unread messages.
*
* @package DataMachine\Abilities\Chat
* @since 0.62.0
*/

namespace DataMachine\Abilities\Chat;

use DataMachine\Core\Admin\DateFormatter;

defined( 'ABSPATH' ) || exit;

class MarkSessionReadAbility {

use ChatSessionHelpers;

public function __construct() {
$this->initDatabase();

if ( ! class_exists( 'WP_Ability' ) ) {
return;
}

$this->registerAbility();
}

/**
* Register the datamachine/mark-session-read ability.
*/
private function registerAbility(): void {
$register_callback = function () {
wp_register_ability(
'datamachine/mark-session-read',
array(
'label' => __( 'Mark Session Read', 'data-machine' ),
'description' => __( 'Mark a chat session as read up to the current timestamp.', 'data-machine' ),
'category' => 'datamachine',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'session_id' => array(
'type' => 'string',
'description' => __( 'Session ID to mark as read.', 'data-machine' ),
),
'user_id' => array(
'type' => 'integer',
'description' => __( 'User ID for ownership verification.', 'data-machine' ),
),
),
'required' => array( 'session_id' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'last_read_at' => array( 'type' => 'string' ),
'error' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( $this, 'execute' ),
'permission_callback' => array( $this, 'checkPermission' ),
'meta' => array(
'show_in_rest' => true,
),
)
);
};

if ( doing_action( 'wp_abilities_api_init' ) ) {
$register_callback();
} elseif ( ! did_action( 'wp_abilities_api_init' ) ) {
add_action( 'wp_abilities_api_init', $register_callback );
}
}

/**
* Execute mark-session-read ability.
*
* @param array $input Input parameters with session_id and optional user_id.
* @return array Result with last_read_at timestamp.
*/
public function execute( array $input ): array {
if ( empty( $input['session_id'] ) ) {
return array(
'success' => false,
'error' => 'session_id is required.',
);
}

$session_id = sanitize_text_field( $input['session_id'] );
$user_id = ! empty( $input['user_id'] ) ? (int) $input['user_id'] : get_current_user_id();

if ( $user_id <= 0 ) {
return array(
'success' => false,
'error' => 'user_id is required and must be a positive integer.',
);
}

if ( ! $this->can_access_user_sessions( $user_id ) ) {
return array(
'success' => false,
'error' => 'session_access_denied',
);
}

$session = $this->verifySessionOwnership( $session_id, $user_id );

if ( isset( $session['error'] ) ) {
return array(
'success' => false,
'error' => $session['error'],
);
}

$last_read_at = $this->chat_db->mark_session_read( $session_id, $user_id );

if ( false === $last_read_at ) {
return array(
'success' => false,
'error' => 'Failed to mark session as read.',
);
}

return array(
'success' => true,
'last_read_at' => DateFormatter::format_for_api( $last_read_at ),
);
}
}
11 changes: 7 additions & 4 deletions inc/Abilities/ChatAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use DataMachine\Abilities\Chat\GetChatSessionAbility;
use DataMachine\Abilities\Chat\DeleteChatSessionAbility;
use DataMachine\Abilities\Chat\CreateChatSessionAbility;
use DataMachine\Abilities\Chat\MarkSessionReadAbility;

defined( 'ABSPATH' ) || exit;

Expand All @@ -25,16 +26,18 @@ class ChatAbilities {
private GetChatSessionAbility $get_session;
private DeleteChatSessionAbility $delete_session;
private CreateChatSessionAbility $create_session;
private MarkSessionReadAbility $mark_session_read;

public function __construct() {
if ( ! class_exists( 'WP_Ability' ) || self::$registered ) {
return;
}

$this->list_sessions = new ListChatSessionsAbility();
$this->get_session = new GetChatSessionAbility();
$this->delete_session = new DeleteChatSessionAbility();
$this->create_session = new CreateChatSessionAbility();
$this->list_sessions = new ListChatSessionsAbility();
$this->get_session = new GetChatSessionAbility();
$this->delete_session = new DeleteChatSessionAbility();
$this->create_session = new CreateChatSessionAbility();
$this->mark_session_read = new MarkSessionReadAbility();

self::$registered = true;
}
Expand Down
36 changes: 36 additions & 0 deletions inc/Api/Chat/Chat.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,24 @@ public static function register_routes() {
)
);

register_rest_route(
'datamachine/v1',
'/chat/sessions/(?P<session_id>[a-f0-9-]+)/read',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( self::class, 'mark_session_read' ),
'permission_callback' => $chat_permission_callback,
'args' => array(
'session_id' => array(
'type' => 'string',
'required' => true,
'description' => __( 'Session ID to mark as read', 'data-machine' ),
'sanitize_callback' => 'sanitize_text_field',
),
),
)
);

register_rest_route(
'datamachine/v1',
'/chat/sessions',
Expand Down Expand Up @@ -355,6 +373,24 @@ public static function list_sessions( WP_REST_Request $request ) {
);
}

/**
* Mark a chat session as read.
*
* @since 0.62.0
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response data or error.
*/
public static function mark_session_read( WP_REST_Request $request ) {
return self::execute_ability(
'datamachine/mark-session-read',
array(
'session_id' => sanitize_text_field( $request->get_param( 'session_id' ) ),
'user_id' => get_current_user_id(),
)
);
}

/**
* Delete a chat session.
*
Expand Down
114 changes: 114 additions & 0 deletions inc/Core/Database/Chat/Chat.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public static function create_table(): void {
context VARCHAR(20) NOT NULL DEFAULT 'chat' COMMENT 'Execution context: chat, pipeline, system',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_read_at DATETIME NULL COMMENT 'When the user last read this session',
expires_at DATETIME NULL COMMENT 'Auto-cleanup timestamp',
PRIMARY KEY (session_id),
KEY user_id (user_id),
Expand Down Expand Up @@ -145,6 +146,31 @@ public static function ensure_context_column(): void {
}
}

/**
* Ensure last_read_at column exists for unread message tracking.
*
* dbDelta can miss edge cases on existing installs, so we perform an explicit
* column check and ALTER as a safety net.
*
* @since 0.62.0
* @return void
*/
public static function ensure_last_read_at_column(): void {
global $wpdb;

$table_name = self::get_prefixed_table_name();

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
$column = $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table_name, 'last_read_at' ) );

if ( $column ) {
return;
}

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD COLUMN last_read_at DATETIME NULL AFTER updated_at', $table_name ) );
}

/**
* Check if table exists
*
Expand Down Expand Up @@ -514,12 +540,15 @@ public function get_user_sessions(
}
}

$last_read_at = $session['last_read_at'] ?? null;

$result[] = array(
'session_id' => $session['session_id'],
'title' => $session['title'] ?? null,
'context' => $session['context'] ?? 'chat',
'first_message' => mb_substr( $first_message, 0, 100 ),
'message_count' => count( $messages ),
'unread_count' => $this->count_unread( $messages, $last_read_at ),
'created_at' => DateFormatter::format_for_api( $session['created_at'] ?? null ),
'updated_at' => DateFormatter::format_for_api( $session['updated_at'] ?? $session['created_at'] ?? null ),
);
Expand Down Expand Up @@ -689,6 +718,91 @@ public function update_title( string $session_id, string $title ): bool {
return true;
}

/**
* Count unread assistant messages in a session.
*
* Counts assistant messages whose metadata.timestamp is newer than
* the given last_read_at value. If last_read_at is NULL, all assistant
* messages are considered unread.
*
* @since 0.62.0
*
* @param array $messages Decoded messages array from the session.
* @param string|null $last_read_at ISO 8601 or MySQL datetime string, or null if never read.
* @return int Number of unread assistant messages.
*/
public function count_unread( array $messages, ?string $last_read_at ): int {
$count = 0;

foreach ( $messages as $msg ) {
if ( ( $msg['role'] ?? '' ) !== 'assistant' ) {
continue;
}

// Skip tool call/result messages — only count visible assistant responses.
$type = $msg['metadata']['type'] ?? 'text';
if ( 'tool_call' === $type || 'tool_result' === $type ) {
continue;
}

if ( null === $last_read_at ) {
++$count;
continue;
}

$timestamp = $msg['metadata']['timestamp'] ?? null;
if ( $timestamp && strtotime( $timestamp ) > strtotime( $last_read_at ) ) {
++$count;
}
}

return $count;
}

/**
* Mark a session as read by setting last_read_at to the current time.
*
* @since 0.62.0
*
* @param string $session_id Session UUID.
* @param int $user_id User ID for ownership verification.
* @return string|false The new last_read_at value on success, false on failure.
*/
public function mark_session_read( string $session_id, int $user_id ) {
global $wpdb;

$table_name = self::get_prefixed_table_name();
$last_read_at = current_time( 'mysql', true );

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->update(
$table_name,
array( 'last_read_at' => $last_read_at ),
array(
'session_id' => $session_id,
'user_id' => $user_id,
),
array( '%s' ),
array( '%s', '%d' )
);

if ( false === $result ) {
do_action(
'datamachine_log',
'error',
'Failed to mark chat session as read',
array(
'session_id' => $session_id,
'user_id' => $user_id,
'error' => $wpdb->last_error,
)
);
return false;
}

return $last_read_at;
}

/**
* Cleanup old sessions based on retention period
*
Expand Down
Loading