From 7c53a16dfa35484afc8284c32a73e256a3d33401 Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:11:02 +0300 Subject: [PATCH 1/2] fix: Bluesky posts rejected with grapheme too big error Clamp the final post text to Bluesky's 300-grapheme limit in create_post(), make token_truncate() count characters instead of bytes, and fix the function_exists( 'mb_strlen ' ) typo that forced byte-based hashtag budgeting. Fixes Codeinwp/tweet-old-post-pro#677 Co-Authored-By: Claude Fable 5 --- .../admin/helpers/class-rop-bluesky-api.php | 7 + .../helpers/class-rop-content-helper.php | 6 +- .../helpers/class-rop-post-format-helper.php | 2 +- phpunit.xml | 3 + tests/test-bluesky-grapheme.php | 144 ++++++++++++++++++ 5 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 tests/test-bluesky-grapheme.php diff --git a/includes/admin/helpers/class-rop-bluesky-api.php b/includes/admin/helpers/class-rop-bluesky-api.php index 1806e698d..82692a2d6 100644 --- a/includes/admin/helpers/class-rop-bluesky-api.php +++ b/includes/admin/helpers/class-rop-bluesky-api.php @@ -292,6 +292,13 @@ public function create_post( $did, $post, $post_type, $hashtags, $access_token = $text = $post['content'] . $hashtags; + // Bluesky rejects records whose text exceeds 300 grapheme clusters. + if ( function_exists( 'grapheme_substr' ) ) { + $text = grapheme_substr( $text, 0, 300 ); + } else { + $text = mb_substr( $text, 0, 300, 'UTF-8' ); + } + $record = array( '$type' => 'app.bsky.feed.post', 'text' => $text, diff --git a/includes/admin/helpers/class-rop-content-helper.php b/includes/admin/helpers/class-rop-content-helper.php index d8cb3d076..af3245793 100644 --- a/includes/admin/helpers/class-rop-content-helper.php +++ b/includes/admin/helpers/class-rop-content-helper.php @@ -116,7 +116,7 @@ public function token_truncate( $string, $new_length = false ) { $length = 0; for ( $last_part = 0; $last_part < $parts_count; ++ $last_part ) { - $length += strlen( $parts[ $last_part ] ); + $length += mb_strlen( $parts[ $last_part ], 'UTF-8' ); if ( $length > $this->length ) { break; } @@ -128,10 +128,10 @@ public function token_truncate( $string, $new_length = false ) { * we should get the substring of it. */ if ( empty( $output ) && $parts_count === 1 ) { - $output = substr( $string, 0, $new_length ); + $output = mb_substr( $string, 0, $new_length, 'UTF-8' ); } // add ellipse only if set and originating text is longer than set length - if ( $this->end_ellipse && strlen( $string ) > $this->length ) { + if ( $this->end_ellipse && mb_strlen( $string, 'UTF-8' ) > $this->length ) { $output .= ' ' . $this->ellipse; } diff --git a/includes/admin/helpers/class-rop-post-format-helper.php b/includes/admin/helpers/class-rop-post-format-helper.php index e253cfdc0..39657096e 100644 --- a/includes/admin/helpers/class-rop-post-format-helper.php +++ b/includes/admin/helpers/class-rop-post-format-helper.php @@ -428,7 +428,7 @@ private function get_custom_length() { * @return int String length. */ public function string_length( $string ) { - if ( function_exists( 'mb_strlen ' ) ) { + if ( function_exists( 'mb_strlen' ) ) { return mb_strlen( $string ); } diff --git a/phpunit.xml b/phpunit.xml index 007b412c8..443815721 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -46,6 +46,9 @@ ./tests/test-bluesky-image.php + + ./tests/test-bluesky-grapheme.php + ./tests/test-x-premium-limit.php diff --git a/tests/test-bluesky-grapheme.php b/tests/test-bluesky-grapheme.php new file mode 100644 index 000000000..489ebe82c --- /dev/null +++ b/tests/test-bluesky-grapheme.php @@ -0,0 +1,144 @@ +captured_body = null; + + $interceptor = function ( $preempt, $args, $url ) { + if ( false === strpos( $url, 'com.atproto.repo.createRecord' ) ) { + return $preempt; + } + $this->captured_body = json_decode( $args['body'], true ); + + return array( + 'headers' => array(), + 'body' => wp_json_encode( + array( + 'uri' => 'at://did:plc:test/app.bsky.feed.post/test', + 'cid' => 'test-cid', + ) + ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => null, + ); + }; + + add_filter( 'pre_http_request', $interceptor, 10, 3 ); + + $api = new Rop_Bluesky_Api( 'test.bsky.social', 'test-app-password' ); + $api->create_post( + 'did:plc:test', + array( 'content' => $content ), + 'text', + $hashtags, + 'test-access-token' + ); + + remove_filter( 'pre_http_request', $interceptor, 10 ); + + $this->assertNotNull( $this->captured_body, 'create_post() should have attempted a createRecord request.' ); + + return $this->captured_body; + } + + /** + * Multibyte content that passed the user-configured maximum length (which + * can be set above 300, e.g. 1000) must still be clamped to 300 graphemes + * before it is submitted to Bluesky. + * + * @covers Rop_Bluesky_Api::create_post + */ + public function test_create_post_clamps_multibyte_content_to_grapheme_limit() { + // 310 graphemes / 620 bytes — accepted by a byte-unaware 1000 limit upstream. + $content = str_repeat( "\u{00E9}", 310 ); + + $body = $this->capture_created_record( $content, '' ); + + $this->assertLessThanOrEqual( + self::BLUESKY_MAX_GRAPHEMES, + grapheme_strlen( $body['record']['text'] ), + 'Record text exceeds the 300 grapheme Bluesky limit; the API would reject it with "grapheme too big".' + ); + } + + /** + * Hashtags are appended after upstream truncation, so content that fits + * the limit exactly must not overflow it once hashtags are concatenated. + * + * @covers Rop_Bluesky_Api::create_post + */ + public function test_create_post_clamps_appended_hashtags_to_grapheme_limit() { + // Plain ASCII content already at the 300 grapheme limit. + $content = rtrim( str_repeat( 'word ', 60 ) ); + $hashtags = ' #wordpress #news'; + + $body = $this->capture_created_record( $content, $hashtags ); + + $this->assertLessThanOrEqual( + self::BLUESKY_MAX_GRAPHEMES, + grapheme_strlen( $body['record']['text'] ), + 'Appending hashtags pushed the record text over the 300 grapheme Bluesky limit.' + ); + } + + /** + * token_truncate() must measure characters, not bytes: a 300-character + * multibyte string is within a 300-character limit and must survive + * truncation unchanged instead of being cut roughly in half. + * + * @covers Rop_Content_Helper::token_truncate + */ + public function test_token_truncate_measures_characters_not_bytes() { + $ch = new Rop_Content_Helper(); + + // 75 tokens of "ééé " = 300 characters (600 é bytes + 75 spaces). + $string = rtrim( str_repeat( "\u{00E9}\u{00E9}\u{00E9} ", 75 ) ); + $this->assertSame( 299, mb_strlen( $string, 'UTF-8' ) ); + + $this->assertSame( + $string, + $ch->token_truncate( $string, 300 ), + 'A 299-character string was truncated under a 300-character limit because lengths were counted in bytes.' + ); + } +} From 47ddc01bc7f2c92b2d18957373b475068039f939 Mon Sep 17 00:00:00 2001 From: Alexia Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:42:18 +0300 Subject: [PATCH 2/2] fix: Bluesky link card repeats the post caption as the embed description Leave the external embed description empty instead of copying the post content already sent as the record text. Applies to both link posts and image posts that carry a URL. Fixes Codeinwp/tweet-old-post-pro#676 Co-Authored-By: Claude Fable 5 --- .../admin/helpers/class-rop-bluesky-api.php | 2 +- phpunit.xml | 3 + tests/test-bluesky-card.php | 78 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/test-bluesky-card.php diff --git a/includes/admin/helpers/class-rop-bluesky-api.php b/includes/admin/helpers/class-rop-bluesky-api.php index 1806e698d..ee9ba1bcd 100644 --- a/includes/admin/helpers/class-rop-bluesky-api.php +++ b/includes/admin/helpers/class-rop-bluesky-api.php @@ -504,7 +504,7 @@ private function get_external_post_url( $post ) { 'external' => array( 'uri' => $post['post_url'], 'title' => isset( $post['title'] ) ? $post['title'] : '', - 'description' => isset( $post['content'] ) ? $post['content'] : '', + 'description' => '', ), ); } diff --git a/phpunit.xml b/phpunit.xml index 007b412c8..64e147c3f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -46,6 +46,9 @@ ./tests/test-bluesky-image.php + + ./tests/test-bluesky-card.php + ./tests/test-x-premium-limit.php diff --git a/tests/test-bluesky-card.php b/tests/test-bluesky-card.php new file mode 100644 index 000000000..cfa9f25ba --- /dev/null +++ b/tests/test-bluesky-card.php @@ -0,0 +1,78 @@ + array(), + 'body' => wp_json_encode( + array( + 'uri' => 'at://did:plc:test/app.bsky.feed.post/test', + 'cid' => 'test-cid', + ) + ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => null, + ); + }; + add_filter( 'pre_http_request', $interceptor, 10, 3 ); + + $api = new Rop_Bluesky_Api( 'test.bsky.social', 'test-app-password' ); + $api->create_post( + 'did:plc:test', + array( + 'content' => 'My caption text for this share.', + 'title' => 'My Post Title', + 'post_url' => 'https://example.org/my-post/', + ), + 'link', + '', + 'test-access-token' + ); + + remove_filter( 'pre_http_request', $interceptor, 10 ); + + $this->assertNotNull( $captured, 'create_post() should have attempted a createRecord request.' ); + + $embed = $captured['record']['embed']; + $this->assertSame( 'app.bsky.embed.external', $embed['$type'] ); + $this->assertSame( 'https://example.org/my-post/', $embed['external']['uri'] ); + $this->assertSame( 'My Post Title', $embed['external']['title'] ); + $this->assertSame( + '', + $embed['external']['description'], + 'Link card description must be empty, not a duplicate of the post caption (issue #676).' + ); + } +}