diff --git a/data-machine-socials.php b/data-machine-socials.php index 726e2993e..7e22981fc 100644 --- a/data-machine-socials.php +++ b/data-machine-socials.php @@ -199,6 +199,8 @@ function datamachine_socials_enqueue_assets( $hook ) { require_once DATAMACHINE_SOCIALS_PATH . 'inc/Cli/Commands/BlueskyCommand.php'; require_once DATAMACHINE_SOCIALS_PATH . 'inc/Cli/Commands/LinkedInCommand.php'; require_once DATAMACHINE_SOCIALS_PATH . 'inc/Cli/Commands/SharesCommand.php'; + require_once DATAMACHINE_SOCIALS_PATH . 'inc/Cli/Commands/CommentsCommand.php'; + WP_CLI::add_command( 'datamachine-socials comments', \DataMachineSocials\Cli\Commands\CommentsCommand::class ); WP_CLI::add_command( 'datamachine-socials linkedin', \DataMachineSocials\Cli\Commands\LinkedInCommand::class ); WP_CLI::add_command( 'datamachine-socials pinterest', \DataMachineSocials\Cli\Commands\PinterestCommand::class ); WP_CLI::add_command( 'datamachine-socials reddit', \DataMachineSocials\Cli\Commands\RedditCommand::class ); diff --git a/inc/Abilities/Instagram/InstagramReadAbility.php b/inc/Abilities/Instagram/InstagramReadAbility.php index ddebc8874..0480b60ab 100644 --- a/inc/Abilities/Instagram/InstagramReadAbility.php +++ b/inc/Abilities/Instagram/InstagramReadAbility.php @@ -24,6 +24,11 @@ class InstagramReadAbility { const GRAPH_API_URL = 'https://graph.instagram.com'; + /** + * Regex for extracting @mentions from comment text. + */ + const MENTION_REGEX = '/@([a-zA-Z0-9._]{1,30})/'; + /** * Fields to request when listing media. */ @@ -59,12 +64,12 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'action' => array( - 'type' => 'string', - 'enum' => array( 'list', 'get', 'comments' ), - 'default' => 'list', - 'description' => __( 'Action: list (recent posts), get (single post), comments (post comments)', 'data-machine-socials' ), - ), + 'action' => array( + 'type' => 'string', + 'enum' => array( 'list', 'get', 'comments', 'comments_all' ), + 'default' => 'list', + 'description' => __( 'Action: list (recent posts), get (single post), comments (one page), comments_all (all pages, normalized)', 'data-machine-socials' ), + ), 'media_id' => array( 'type' => 'string', 'description' => __( 'Instagram media ID (required for get and comments actions)', 'data-machine-socials' ), @@ -159,6 +164,15 @@ public function execute( array $input ): array { } return $this->getComments( $access_token, $input['media_id'], $input ); + case 'comments_all': + if ( empty( $input['media_id'] ) ) { + return array( + 'success' => false, + 'error' => 'media_id is required for the comments_all action', + ); + } + return $this->getAllComments( $access_token, $input['media_id'] ); + default: return array( 'success' => false, @@ -325,6 +339,139 @@ private function getComments( string $access_token, string $media_id, array $inp ); } + /** + * Fetch ALL comments for a media item, auto-paginating through every page. + * + * Returns comments in the normalized SocialComment shape used across all + * platforms. This enables generic consumers (giveaway picker, CLI, pipelines) + * to work without platform-specific knowledge. + * + * @param string $access_token Valid access token. + * @param string $media_id Instagram media ID. + * @return array Result with normalized comments. + */ + private function getAllComments( string $access_token, string $media_id ): array { + $all_comments = array(); + $after = ''; + $page = 0; + $max_pages = 200; // Safety limit: 200 pages × 50 comments = 10,000 comments max. + + do { + $page++; + $params = array( + 'fields' => 'id,text,timestamp,username,like_count', + 'limit' => 50, // Max per page for Instagram API. + 'access_token' => $access_token, + ); + + if ( ! empty( $after ) ) { + $params['after'] = $after; + } + + $url = self::GRAPH_API_URL . "/{$media_id}/comments?" . http_build_query( $params ); + $result = HttpClient::get( $url, array( 'context' => 'Instagram Comments All' ) ); + + if ( ! $result['success'] ) { + // If we already have some comments, return them with a warning. + if ( ! empty( $all_comments ) ) { + return array( + 'success' => true, + 'data' => array( + 'comments' => $all_comments, + 'count' => count( $all_comments ), + 'platform' => 'instagram', + 'partial' => true, + 'error' => 'Pagination interrupted: ' . ( $result['error'] ?? 'unknown' ), + ), + ); + } + + return array( + 'success' => false, + 'error' => 'Instagram API request failed: ' . ( $result['error'] ?? 'unknown' ), + ); + } + + $data = json_decode( $result['data'], true ); + $http_code = $result['status_code']; + + if ( 200 !== $http_code || isset( $data['error'] ) ) { + if ( ! empty( $all_comments ) ) { + return array( + 'success' => true, + 'data' => array( + 'comments' => $all_comments, + 'count' => count( $all_comments ), + 'platform' => 'instagram', + 'partial' => true, + 'error' => $data['error']['message'] ?? 'Pagination error', + ), + ); + } + + return array( + 'success' => false, + 'error' => $data['error']['message'] ?? 'Failed to fetch comments', + ); + } + + $page_comments = $data['data'] ?? array(); + + foreach ( $page_comments as $comment ) { + $all_comments[] = self::normalizeComment( $comment ); + } + + // Check for next page. + $paging = $data['paging'] ?? array(); + $after = $paging['cursors']['after'] ?? ''; + + $has_next = ! empty( $paging['next'] ) && ! empty( $after ); + + } while ( $has_next && $page < $max_pages ); + + return array( + 'success' => true, + 'data' => array( + 'comments' => $all_comments, + 'count' => count( $all_comments ), + 'platform' => 'instagram', + 'partial' => false, + 'pages' => $page, + ), + ); + } + + /** + * Normalize an Instagram comment into the generic SocialComment shape. + * + * Shape: { id, platform, author_username, text, timestamp, like_count, + * reply_count, mentions, parent_id, raw } + * + * @param array $comment Raw Instagram API comment data. + * @return array Normalized comment. + */ + public static function normalizeComment( array $comment ): array { + $text = $comment['text'] ?? ''; + $mentions = array(); + + if ( preg_match_all( self::MENTION_REGEX, $text, $matches ) ) { + $mentions = array_values( array_unique( $matches[1] ) ); + } + + return array( + 'id' => $comment['id'] ?? '', + 'platform' => 'instagram', + 'author_username' => $comment['username'] ?? '', + 'text' => $text, + 'timestamp' => $comment['timestamp'] ?? '', + 'like_count' => (int) ( $comment['like_count'] ?? 0 ), + 'reply_count' => 0, // Instagram top-level comments endpoint doesn't include reply count. + 'mentions' => $mentions, + 'parent_id' => null, // Top-level comments; replies would be fetched separately. + 'raw' => $comment, + ); + } + /** * Get the Instagram auth provider. * diff --git a/inc/Cli/Commands/CommentsCommand.php b/inc/Cli/Commands/CommentsCommand.php new file mode 100644 index 000000000..09428915b --- /dev/null +++ b/inc/Cli/Commands/CommentsCommand.php @@ -0,0 +1,289 @@ + 'datamachine/instagram-read', + 'facebook' => 'datamachine/facebook-read', + ); + + /** + * Platform-to-reply-ability slug mapping. + */ + private const REPLY_SLUG_MAP = array( + 'instagram' => 'datamachine/instagram-comment-reply', + ); + + /** + * List comments on a social media post. + * + * Fetches all comments (auto-paginates) and returns them in the + * normalized SocialComment shape with parsed @mentions. + * + * ## OPTIONS + * + * + * : Platform slug (instagram, facebook). + * + * + * : Platform-specific post/media ID. + * + * [--has-mentions] + * : Only show comments containing @mentions. + * + * [--min-mentions=] + * : Only show comments with at least this many @mentions. + * --- + * default: 0 + * --- + * + * [--exclude=] + * : Comma-separated list of usernames to exclude from results. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - count + * --- + * + * ## EXAMPLES + * + * wp datamachine-socials comments list instagram 17891234567890 + * wp datamachine-socials comments list instagram 17891234567890 --has-mentions --format=json + * wp datamachine-socials comments list instagram 17891234567890 --exclude=extrachill,botaccount + * wp datamachine-socials comments list instagram 17891234567890 --format=count + */ + public function list_( $args, $assoc_args ) { + $platform = $args[0] ?? ''; + $media_id = $args[1] ?? ''; + + if ( ! isset( self::READ_SLUG_MAP[ $platform ] ) ) { + WP_CLI::error( "Unsupported platform: {$platform}. Supported: " . implode( ', ', array_keys( self::READ_SLUG_MAP ) ) ); + } + + $ability = $this->get_ability( self::READ_SLUG_MAP[ $platform ] ); + + WP_CLI::log( "Fetching all comments from {$platform}..." ); + + $result = $ability->execute( array( + 'action' => 'comments_all', + 'media_id' => $media_id, + ) ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ); + } + + $comments = $result['data']['comments'] ?? array(); + $total = count( $comments ); + + if ( ! empty( $result['data']['partial'] ) ) { + WP_CLI::warning( 'Partial results: ' . ( $result['data']['error'] ?? 'pagination interrupted' ) ); + } + + // Apply filters. + $has_mentions = isset( $assoc_args['has-mentions'] ); + $min_mentions = absint( $assoc_args['min-mentions'] ?? 0 ); + $exclude = array(); + + if ( ! empty( $assoc_args['exclude'] ) ) { + $exclude = array_map( 'trim', explode( ',', $assoc_args['exclude'] ) ); + $exclude = array_map( 'strtolower', $exclude ); + } + + if ( $has_mentions ) { + $min_mentions = max( $min_mentions, 1 ); + } + + if ( $min_mentions > 0 || ! empty( $exclude ) ) { + $comments = array_filter( $comments, function ( $c ) use ( $min_mentions, $exclude ) { + if ( $min_mentions > 0 && count( $c['mentions'] ?? array() ) < $min_mentions ) { + return false; + } + + if ( ! empty( $exclude ) && in_array( strtolower( $c['author_username'] ?? '' ), $exclude, true ) ) { + return false; + } + + return true; + } ); + $comments = array_values( $comments ); + } + + $format = $assoc_args['format'] ?? 'table'; + $filtered = count( $comments ); + + if ( 'count' === $format ) { + WP_CLI::log( (string) $filtered ); + return; + } + + if ( empty( $comments ) ) { + WP_CLI::warning( "No comments found ({$total} total, {$filtered} after filtering)." ); + return; + } + + if ( 'json' === $format ) { + WP_CLI::log( wp_json_encode( array( + 'platform' => $platform, + 'media_id' => $media_id, + 'total' => $total, + 'filtered' => $filtered, + 'comments' => $comments, + ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + return; + } + + if ( 'csv' === $format ) { + WP_CLI::log( 'id,author_username,text,timestamp,like_count,mentions' ); + foreach ( $comments as $c ) { + $mentions_str = implode( ' ', array_map( function ( $m ) { return '@' . $m; }, $c['mentions'] ?? array() ) ); + $text = str_replace( '"', '""', $c['text'] ?? '' ); + WP_CLI::log( sprintf( + '%s,%s,"%s",%s,%d,"%s"', + $c['id'], + $c['author_username'], + $text, + $c['timestamp'], + $c['like_count'], + $mentions_str + ) ); + } + return; + } + + // Table format. + $filter_note = $filtered < $total ? " ({$filtered} after filters)" : ''; + WP_CLI::success( "Found {$total} comments{$filter_note}" ); + WP_CLI::log( '' ); + + foreach ( $comments as $c ) { + $mentions = ''; + if ( ! empty( $c['mentions'] ) ) { + $mentions = ' → @' . implode( ', @', $c['mentions'] ); + } + + $date = ! empty( $c['timestamp'] ) ? wp_date( 'Y-m-d H:i', strtotime( $c['timestamp'] ) ) : ''; + + WP_CLI::log( sprintf( + ' @%-20s %s (%d likes) %s%s', + $c['author_username'], + $date, + $c['like_count'], + mb_substr( $c['text'] ?? '', 0, 80 ), + $mentions + ) ); + } + } + + /** + * Reply to a comment on a social media post. + * + * ## OPTIONS + * + * + * : Platform slug (instagram). + * + * + * : The comment ID to reply to. + * + * + * : The reply text. + * + * ## EXAMPLES + * + * wp datamachine-socials comments reply instagram 1789000000000 "Thanks for entering!" + */ + public function reply( $args ) { + $platform = $args[0] ?? ''; + $comment_id = $args[1] ?? ''; + $message = $args[2] ?? ''; + + if ( ! isset( self::REPLY_SLUG_MAP[ $platform ] ) ) { + WP_CLI::error( "Comment reply not supported for platform: {$platform}. Supported: " . implode( ', ', array_keys( self::REPLY_SLUG_MAP ) ) ); + } + + if ( empty( $comment_id ) || empty( $message ) ) { + WP_CLI::error( 'comment_id and message are required.' ); + } + + $ability = $this->get_ability( self::REPLY_SLUG_MAP[ $platform ] ); + + $result = $ability->execute( array( + 'comment_id' => $comment_id, + 'message' => $message, + ) ); + + if ( ! $result['success'] ) { + WP_CLI::error( $result['error'] ); + } + + WP_CLI::success( "Reply posted on {$platform}!" ); + WP_CLI::log( 'Comment ID: ' . ( $result['data']['comment_id'] ?? $comment_id ) ); + WP_CLI::log( 'Reply ID: ' . ( $result['data']['reply_id'] ?? '' ) ); + } + + /** + * Get an ability by slug, or exit with error. + * + * @param string $slug Ability slug. + * @return object Ability instance. + */ + private function get_ability( string $slug ) { + if ( ! function_exists( 'wp_get_ability' ) ) { + WP_CLI::error( 'WordPress Abilities API not available (requires WP 6.9+).' ); + } + + $ability = wp_get_ability( $slug ); + if ( ! $ability ) { + WP_CLI::error( "{$slug} ability not registered." ); + } + + return $ability; + } +} diff --git a/inc/RestApi.php b/inc/RestApi.php index d49cb0666..3d31dcec0 100644 --- a/inc/RestApi.php +++ b/inc/RestApi.php @@ -118,6 +118,69 @@ public static function register_routes() { ) ); + // ===================================================================== + // Generic Comments Endpoint (normalized shape across all platforms) + // ===================================================================== + + register_rest_route( self::NAMESPACE, '/comments/(?P[a-z]+)', array( + 'methods' => 'GET', + 'callback' => array( __CLASS__, 'get_comments' ), + 'permission_callback' => array( __CLASS__, 'check_edit_permission' ), + 'args' => array( + 'platform' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Platform slug (instagram, facebook, etc.)', + ), + 'media_id' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Platform-specific post/media ID.', + ), + 'all' => array( + 'type' => 'boolean', + 'default' => true, + 'description' => 'Fetch all comments (auto-paginate). Set false for single page.', + ), + 'limit' => array( + 'type' => 'integer', + 'default' => 50, + 'sanitize_callback' => 'absint', + 'description' => 'Page size when all=false.', + ), + 'after' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Pagination cursor when all=false.', + ), + ), + ) ); + + register_rest_route( self::NAMESPACE, '/comments/(?P[a-z]+)/reply', array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'post_comment_reply' ), + 'permission_callback' => array( __CLASS__, 'check_edit_permission' ), + 'args' => array( + 'platform' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'comment_id' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'message' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_textarea_field', + ), + ), + ) ); + // ===================================================================== // Platform Read Endpoints // ===================================================================== @@ -618,6 +681,111 @@ public static function instagram_comment_reply( \WP_REST_Request $request ) { return new \WP_REST_Response( $result, $result['success'] ? 200 : 500 ); } + /** + * Generic comments endpoint — returns normalized SocialComment shape. + * + * Routes to the platform's read ability with comments_all or comments action. + * Provides a single, predictable endpoint for all platforms: + * GET /datamachine-socials/v1/comments/{platform}?media_id=... + */ + public static function get_comments( \WP_REST_Request $request ) { + $platform = $request->get_param( 'platform' ); + $media_id = $request->get_param( 'media_id' ); + $all = $request->get_param( 'all' ); + + $slug_map = array( + 'instagram' => 'datamachine/instagram-read', + 'facebook' => 'datamachine/facebook-read', + // Future: 'tiktok' => 'datamachine/tiktok-read', etc. + ); + + if ( ! isset( $slug_map[ $platform ] ) ) { + return new \WP_REST_Response( array( + 'success' => false, + 'error' => "Comments not supported for platform: {$platform}", + ), 400 ); + } + + if ( empty( $media_id ) ) { + return new \WP_REST_Response( array( + 'success' => false, + 'error' => 'media_id is required', + ), 400 ); + } + + $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $slug_map[ $platform ] ) : null; + if ( ! $ability ) { + return new \WP_REST_Response( array( + 'success' => false, + 'error' => $slug_map[ $platform ] . ' ability not registered', + ), 500 ); + } + + $input = array( + 'action' => $all ? 'comments_all' : 'comments', + 'media_id' => $media_id, + ); + + if ( ! $all ) { + $input['limit'] = $request->get_param( 'limit' ) ?: 50; + $after = $request->get_param( 'after' ); + if ( $after ) { + $input['after'] = $after; + } + } + + $result = $ability->execute( $input ); + + return new \WP_REST_Response( $result, $result['success'] ? 200 : 500 ); + } + + /** + * Generic comment reply endpoint. + * + * Routes to the platform's comment reply ability: + * POST /datamachine-socials/v1/comments/{platform}/reply + */ + public static function post_comment_reply( \WP_REST_Request $request ) { + $platform = $request->get_param( 'platform' ); + $params = $request->get_json_params() ?: $request->get_body_params(); + $comment_id = $params['comment_id'] ?? ''; + $message = $params['message'] ?? ''; + + $slug_map = array( + 'instagram' => 'datamachine/instagram-comment-reply', + // Future: 'facebook' => 'datamachine/facebook-comment-reply', etc. + ); + + if ( ! isset( $slug_map[ $platform ] ) ) { + return new \WP_REST_Response( array( + 'success' => false, + 'error' => "Comment reply not supported for platform: {$platform}", + ), 400 ); + } + + if ( empty( $comment_id ) || empty( $message ) ) { + return new \WP_REST_Response( array( + 'success' => false, + 'error' => 'comment_id and message are required', + ), 400 ); + } + + $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $slug_map[ $platform ] ) : null; + if ( ! $ability ) { + return new \WP_REST_Response( array( + 'success' => false, + 'error' => $slug_map[ $platform ] . ' ability not registered', + ), 500 ); + } + + $result = $ability->execute( array( + 'comment_id' => sanitize_text_field( $comment_id ), + 'message' => sanitize_textarea_field( $message ), + ) ); + + return new \WP_REST_Response( $result, $result['success'] ? 200 : 500 ); + } + /** * Publish an Instagram Reel. */