diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a8421..20ece51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Publish exact Threads text through `posts.publish@5` without requiring a + PNG, while preserving optional PNG publication and exact permalink readback. + ## 0.13.1 - 2026-08-23 - Keep Beeper account selector aliases internal while returning plain, diff --git a/package.json b/package.json index 97d41d2..0723cb0 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json", "src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json", "src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json", + "src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json", "src/assets/adapters/tiktok/wrench-web-adapter.json", "src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json", "src/assets/adapters/whatsapp/wrench-web-adapter.json", diff --git a/skills/wrench/references/platform-patterns.md b/skills/wrench/references/platform-patterns.md index 74cd0d9..12fbe40 100644 --- a/skills/wrench/references/platform-patterns.md +++ b/skills/wrench/references/platform-patterns.md @@ -82,7 +82,7 @@ template. - Treat Instagram, Threads, Facebook Page, Facebook Group, and Facebook Marketplace as separate surfaces and auth/account bindings even when Meta shares infrastructure. - Live-proven direct R1 coverage currently includes Instagram feed/post/media/comment/inbox summaries, the Threads feed, Facebook's bounded initial personal home-feed bootstrap, one exact Group's bounded first feed page, the Marketplace browse feed with cursor continuation, and one exact Marketplace listing. Every result is exact-current-user bound; Group and Marketplace reads additionally bind their exact numeric targets. - The personal Facebook home result makes no completion or pagination claim. The Group reader assembles only the complete first streamed page and never exposes its provider cursor because the matching continuation query is not yet reviewed. Marketplace reads use a direct inert HTML bootstrap, resolve the current registered Relay revision from bounded canonical first-party bundles without executing them, and assemble each complete streamed page before returning a locally authenticated, account/descriptor/target/chain-bound cursor envelope. A locally truncated projection returns no cursor so it cannot skip unreturned listings. Exact listing reads use the inert bootstrap so the browser-only item-seen mutation is never executed. -- Threads has two separate observed Meta writes. `posts.publish@4` requires exactly one plan-bound PNG, while `media.publish@1` requires exactly one bounded ISO BMFF MP4 with one video track. Each binds the completed upload dimensions and exact created locator into durable target evidence, then verifies the exact permalink actor, text, media identity, dimensions, and media type before success. Instagram `media.publish@1` remains a capture-required reservation and admits only named image/video MIME types instead of wildcards. Facebook personal Messenger listing is capture-required: the current route and folder queries, paging and completeness, and acknowledgement/presence behavior are not proven. Every other Meta write, all message reads/sends, every Facebook Page operation, and all Group operations except the exact first-page feed read remain capture-required. Marketplace messaging and mutations remain capture-required; its observed feed/listing reads do not confer publishing, seller, conversation, or purchase authority. +- Threads has two separate observed Meta writes. `posts.publish@5` publishes exact text with an optional plan-bound PNG, while `media.publish@1` requires exactly one bounded ISO BMFF MP4 with one video track. Both bind the exact created locator into durable target evidence; the image and video paths also bind completed-upload dimensions. Success requires independent permalink verification of the exact actor, text, optional media identity, dimensions, and media type. Instagram `media.publish@1` remains a capture-required reservation and admits only named image/video MIME types instead of wildcards. Facebook personal Messenger listing is capture-required: the current route and folder queries, paging and completeness, and acknowledgement/presence behavior are not proven. Every other Meta write, all message reads/sends, every Facebook Page operation, and all Group operations except the exact first-page feed read remain capture-required. Marketplace messaging and mutations remain capture-required; its observed feed/listing reads do not confer publishing, seller, conversation, or purchase authority. - Capture each surface's exact first-party GraphQL/REST exchange and dynamic token source. Do not copy an operation name, revision, feature set, or actor binding across surfaces. - DMs, comments, replies, posts, stories, reels, shares, Page messages, Group posts, and Marketplace listing publication are R3. Reversible likes, reactions, follows, and saves may be R2. - For Facebook, bind Page/profile/group/Marketplace actor and target independently. Keep roles, ads, business settings, member moderation, purchases, payments, shipping commitments, and deletion R4. diff --git a/skills/wrench/references/social-platform-routing.md b/skills/wrench/references/social-platform-routing.md index d013bc8..bd80929 100644 --- a/skills/wrench/references/social-platform-routing.md +++ b/skills/wrench/references/social-platform-routing.md @@ -10,7 +10,7 @@ Use this as routing guidance only. Always inspect `wrench capabilities | LinkedIn | `linkedin` | Official OAuth | Inspect installed schema | Documented API post | | Bluesky | `bluesky-web` | Profile-backed Bluesky web session | `body`, optional image, media type, and alt text | AT Protocol feed post | | Substack | `substack-web` | Browser cookies/profile | `body`, optional Note media | Public Substack Note, not an article/newsletter | -| Threads | `meta-web` | Browser cookies/profile | Image `posts.publish`: `body`, audience, one PNG. Video `media.publish`: `body`, audience, one MP4. | Threads post | +| Threads | `meta-web` | Browser cookies/profile | Text/image `posts.publish`: `body`, audience, optional one PNG. Video `media.publish`: `body`, audience, one MP4. | Threads post | Cleanup is capability-driven too. At this reference revision, `bluesky-web` exposes observed `content.delete@1` only for one current-account post URI plus @@ -59,5 +59,5 @@ Do not place credentials in the input file. Delete task input files after planni - If the ordered image set exceeds one platform's bound, request a smaller shared set or an explicit per-platform set. Do not manufacture a collage. - If LinkedIn requires visibility and the user requested a public cross-post, use `public`; otherwise obtain the user's audience choice. - If Threads requires `audience` and the user requested ordinary posting, use the installed schema's ordinary/default audience only when that meaning is explicit in the capability description. -- At this reference revision, reviewed Threads image publication requires exactly one PNG and reviewed video publication requires exactly one MP4. Treat text-only Threads publication as unavailable unless the installed schema says otherwise. +- At this reference revision, reviewed Threads text/image publication accepts no attachment or exactly one PNG, and reviewed video publication requires exactly one MP4. A dummy PNG, collage, or composer click-path changes the package and is not an installed fallback. JPEG and other image types remain unavailable unless the installed schema independently lists them. - If image alt text is supported on only some targets, preserve the same factual description on every target that accepts it; unsupported alt text is not a reason to alter the visible post. diff --git a/skills/wrench/references/social-video-platform-routing.md b/skills/wrench/references/social-video-platform-routing.md index 237b532..d11b569 100644 --- a/skills/wrench/references/social-video-platform-routing.md +++ b/skills/wrench/references/social-video-platform-routing.md @@ -14,7 +14,7 @@ are authoritative and may change. | Substack | `substack-web` | `media.publish` when observed | Public Substack Note, not an article or newsletter. | | TikTok | `tiktok-web` | `media.publish` when observed | Native TikTok video | | Instagram | `meta-web` on the Instagram surface | `media.publish` when observed | Instagram video or Reel only when the schema says so. | -| Threads | `meta-web` on the Threads surface | `media.publish` | Observed single-MP4 Threads video post; image `posts.publish` remains a separate contract. | +| Threads | `meta-web` on the Threads surface | `media.publish` | Observed single-MP4 Threads video post; text/image `posts.publish` remains a separate contract. | | YouTube Shorts | `youtube-web` | `media.publish` when observed | Studio video upload. Community `posts.publish` is not a Short. | | Reddit | `reddit-web` | `media.publish` when observed | One video post in one exact confirmed subreddit. | diff --git a/src/assets/adapters/threads/wrench-web-adapter.json b/src/assets/adapters/threads/wrench-web-adapter.json index c5c0278..be5eb5d 100644 --- a/src/assets/adapters/threads/wrench-web-adapter.json +++ b/src/assets/adapters/threads/wrench-web-adapter.json @@ -1,7 +1,7 @@ { "schemaVersion": 4, "id": "threads-web", - "version": "1.6.0", + "version": "1.7.0", "displayName": "Threads (Authenticated Internal API)", "surfaceId": "threads", "origins": [ @@ -411,9 +411,9 @@ } }, "posts.publish": { - "description": "Observed contract: publish one confirmed Threads post with one reviewed PNG after exact viewer binding, synchronous upload completion, exact minimal created-locator binding, durable upload-dimension target retention, and independent exact permalink actor/text/image readback.", + "description": "Observed contract: publish one confirmed Threads post with optional reviewed PNG after exact viewer binding, optional synchronous upload completion, exact minimal created-locator binding, durable response-bound post identity plus completed-upload dimensions when an image is supplied, and independent exact permalink actor/text and optional image readback.", "risk": "R3", - "sideEffect": "Publishes one externally visible Threads post with the exact confirmed text and one reviewed PNG.", + "sideEffect": "Publishes one externally visible Threads post with the exact confirmed text and optional reviewed PNG.", "idempotency": "local-at-most-once", "dedupeWindowMs": 86400000, "input": { @@ -426,7 +426,7 @@ }, "attachment": { "type": "file", - "description": "One exact reviewed PNG attachment", + "description": "Optional exact reviewed PNG attachment", "maxBytes": 20971520, "mediaTypes": [ "image/png" @@ -441,14 +441,13 @@ } }, "required": [ - "attachment", "body" ] }, "webSession": { "site": "threads", "action": "posts.publish", - "contractVersion": 4, + "contractVersion": 5, "timeoutMs": 60000, "maxOutputBytes": 8388608 } diff --git a/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json b/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json new file mode 100644 index 0000000..c5c0278 --- /dev/null +++ b/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json @@ -0,0 +1,693 @@ +{ + "schemaVersion": 4, + "id": "threads-web", + "version": "1.6.0", + "displayName": "Threads (Authenticated Internal API)", + "surfaceId": "threads", + "origins": [ + "https://www.threads.com" + ], + "browserDomains": [ + "www.threads.com" + ], + "operations": { + "comments.read": { + "description": "Capture-required contract reservation: the exact acknowledgement-free response and target binding still require a reviewed live capture.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum comments", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "post_id" + ] + }, + "webSession": { + "site": "threads", + "action": "comments.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "content.edit": { + "description": "Capture-required contract reservation: the exact actor, audience, attachment transport, dispatch response, and independent publication readback require an authorized fixture.", + "risk": "R3", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "body": { + "type": "string", + "description": "Replacement text", + "minLength": 1, + "maxLength": 450 + } + }, + "required": [ + "post_id", + "body" + ] + }, + "webSession": { + "site": "threads", + "action": "content.edit", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "content.save": { + "description": "Capture-required contract reservation: the exact actor, target, mutation response, and independent desired-state readback require an authorized fixture.", + "risk": "R2", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "saved": { + "type": "boolean", + "description": "Exact desired saved state" + } + }, + "required": [ + "post_id", + "saved" + ] + }, + "webSession": { + "site": "threads", + "action": "content.save", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "content.share": { + "description": "Capture-required contract reservation: the exact actor, audience, attachment transport, dispatch response, and independent publication readback require an authorized fixture.", + "risk": "R3", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "destination": { + "type": "string", + "description": "Reviewed share destination", + "enum": [ + "capture-required" + ] + } + }, + "required": [ + "post_id", + "destination" + ] + }, + "webSession": { + "site": "threads", + "action": "content.share", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "feeds.read": { + "description": "Observed contract: one bounded first page from live direct signed-in Threads Relay preload JSON with exact Barcelona viewer binding and no continuation cursor accepted or exposed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "feed": { + "type": "string", + "description": "Reviewed feed", + "enum": [ + "for-you" + ] + }, + "limit": { + "type": "number", + "description": "Maximum results", + "minimum": 1, + "maximum": 30 + } + }, + "required": [ + "feed" + ] + }, + "webSession": { + "site": "threads", + "action": "feeds.read", + "contractVersion": 2, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "likes.set": { + "description": "Capture-required contract reservation: the exact actor, target, mutation response, and independent desired-state readback require an authorized fixture.", + "risk": "R2", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "liked": { + "type": "boolean", + "description": "Exact desired like state" + } + }, + "required": [ + "post_id", + "liked" + ] + }, + "webSession": { + "site": "threads", + "action": "likes.set", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "media.read": { + "description": "Capture-required contract reservation: the exact acknowledgement-free response and target binding still require a reviewed live capture.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "post_id" + ] + }, + "webSession": { + "site": "threads", + "action": "media.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "media.publish": { + "description": "Publish one confirmed Threads MP4 through the reviewed single-request video upload and exact post-create contract, then verify the exact actor, text, dimensions, media identity, and permalink independently.", + "risk": "R3", + "sideEffect": "Uploads one video and publishes one externally visible Threads post with the exact confirmed body and audience.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "body": { + "type": "string", + "description": "Exact post body", + "minLength": 1, + "maxLength": 450 + }, + "media": { + "type": "file", + "description": "Exact plan-bound Threads MP4", + "maxBytes": 1073741824, + "mediaTypes": [ + "video/mp4" + ] + }, + "audience": { + "type": "string", + "description": "Explicit audience", + "enum": [ + "default" + ] + } + }, + "required": [ + "body", + "media", + "audience" + ] + }, + "webSession": { + "site": "threads", + "action": "media.publish", + "contractVersion": 1, + "timeoutMs": 600000, + "maxOutputBytes": 8388608 + } + }, + "messaging.list": { + "description": "Capture-required contract reservation: Threads inbox uses Lightspeed/Msys state and may acknowledge or update presence; Relay setup metadata is not message-list authority.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "folder": { + "type": "string", + "description": "Reviewed inbox folder", + "enum": [ + "inbox" + ] + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum thread summaries", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "folder" + ] + }, + "webSession": { + "site": "threads", + "action": "messaging.list", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "messaging.read": { + "description": "Capture-required contract reservation: Threads conversation reads require protocol-correct Lightspeed/Msys acknowledgement analysis.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "conversation_id": { + "type": "string", + "description": "Exact conversation ID", + "minLength": 1, + "maxLength": 512 + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum messages", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "conversation_id" + ] + }, + "webSession": { + "site": "threads", + "action": "messaging.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "messaging.send": { + "description": "Capture-required contract reservation: Threads message send requires protocol-correct Lightspeed/Msys or E2EE implementation.", + "risk": "R3", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "conversation_id": { + "type": "string", + "description": "Exact conversation ID", + "minLength": 1, + "maxLength": 512 + }, + "body": { + "type": "string", + "description": "Message body", + "minLength": 1, + "maxLength": 1000 + }, + "attachment": { + "type": "file", + "description": "Optional exact message attachment", + "maxBytes": 1073741824, + "mediaTypes": [ + "image/*", + "video/*" + ] + } + }, + "required": [ + "conversation_id", + "body" + ] + }, + "webSession": { + "site": "threads", + "action": "messaging.send", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "posts.publish": { + "description": "Observed contract: publish one confirmed Threads post with one reviewed PNG after exact viewer binding, synchronous upload completion, exact minimal created-locator binding, durable upload-dimension target retention, and independent exact permalink actor/text/image readback.", + "risk": "R3", + "sideEffect": "Publishes one externally visible Threads post with the exact confirmed text and one reviewed PNG.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "body": { + "type": "string", + "description": "Post body", + "minLength": 1, + "maxLength": 450 + }, + "attachment": { + "type": "file", + "description": "One exact reviewed PNG attachment", + "maxBytes": 20971520, + "mediaTypes": [ + "image/png" + ] + }, + "audience": { + "type": "string", + "description": "Explicit audience", + "enum": [ + "default" + ] + } + }, + "required": [ + "attachment", + "body" + ] + }, + "webSession": { + "site": "threads", + "action": "posts.publish", + "contractVersion": 4, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "posts.quote": { + "description": "Capture-required contract reservation: the exact actor, audience, attachment transport, dispatch response, and independent publication readback require an authorized fixture.", + "risk": "R3", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "body": { + "type": "string", + "description": "Quote text", + "minLength": 1, + "maxLength": 450 + } + }, + "required": [ + "post_id", + "body" + ] + }, + "webSession": { + "site": "threads", + "action": "posts.quote", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "posts.read": { + "description": "Capture-required contract reservation: the exact acknowledgement-free response and target binding still require a reviewed live capture.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "post_id" + ] + }, + "webSession": { + "site": "threads", + "action": "posts.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "posts.repost": { + "description": "Capture-required contract reservation: the exact actor, audience, attachment transport, dispatch response, and independent publication readback require an authorized fixture.", + "risk": "R3", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "reposted": { + "type": "boolean", + "description": "Exact desired repost state" + } + }, + "required": [ + "post_id", + "reposted" + ] + }, + "webSession": { + "site": "threads", + "action": "posts.repost", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "profiles.read": { + "description": "Observed contract: read an exact target-bound Threads follower count and explicit recent-views availability from the signed-in profile and Insights pages.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "profile": { + "type": "string", + "description": "Canonical lowercase Threads handle bound to the signed-in profile", + "minLength": 1, + "maxLength": 30 + } + }, + "required": [ + "profile" + ] + }, + "webSession": { + "site": "threads", + "action": "profiles.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "relationships.follow.set": { + "description": "Capture-required contract reservation: the exact actor, target, mutation response, and independent desired-state readback require an authorized fixture.", + "risk": "R2", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "user_id": { + "type": "string", + "description": "Exact target account ID", + "minLength": 1, + "maxLength": 80 + }, + "following": { + "type": "boolean", + "description": "Exact desired following state" + } + }, + "required": [ + "user_id", + "following" + ] + }, + "webSession": { + "site": "threads", + "action": "relationships.follow.set", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "replies.create": { + "description": "Capture-required contract reservation: the exact actor, audience, attachment transport, dispatch response, and independent publication readback require an authorized fixture.", + "risk": "R3", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact Threads post ID", + "minLength": 1, + "maxLength": 80 + }, + "comment_id": { + "type": "string", + "description": "Exact parent comment ID", + "minLength": 1, + "maxLength": 256 + }, + "body": { + "type": "string", + "description": "Reply body", + "minLength": 1, + "maxLength": 450 + }, + "attachment": { + "type": "file", + "description": "Optional exact reply attachment", + "maxBytes": 1073741824, + "mediaTypes": [ + "image/*", + "video/*", + "image/gif" + ] + } + }, + "required": [ + "post_id", + "comment_id", + "body" + ] + }, + "webSession": { + "site": "threads", + "action": "replies.create", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + }, + "threads.publish": { + "description": "Capture-required contract reservation: the exact actor, audience, attachment transport, dispatch response, and independent publication readback require an authorized fixture.", + "risk": "R3", + "sideEffect": "Changes remote threads state only after exact preview confirmation and reviewed dispatch.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "items": { + "type": "array", + "description": "Ordered root and self-replies", + "minItems": 1, + "maxItems": 25, + "items": { + "type": "string", + "description": "One thread item", + "minLength": 1, + "maxLength": 450 + } + } + }, + "required": [ + "items" + ] + }, + "webSession": { + "site": "threads", + "action": "threads.publish", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 8388608 + } + } + } +} diff --git a/src/plugins/meta-web/plugin.test.ts b/src/plugins/meta-web/plugin.test.ts index c414eb8..8b093d2 100644 --- a/src/plugins/meta-web/plugin.test.ts +++ b/src/plugins/meta-web/plugin.test.ts @@ -35,7 +35,7 @@ describe("Meta web plugin account subjects", () => { operation.name === "posts.publish"); const threadsVideoPublish = binding("threads").operations.find((operation) => operation.name === "media.publish"); - expect(threadsPublish?.historicalContractVersions).toEqual([1, 2, 3]); + expect(threadsPublish?.historicalContractVersions).toEqual([1, 2, 3, 4]); expect(threadsPublish?.reconciliation).toEqual({ kind: "provider-accepted-target-presence", }); diff --git a/src/plugins/meta-web/plugin.ts b/src/plugins/meta-web/plugin.ts index 946cc40..cedaf50 100644 --- a/src/plugins/meta-web/plugin.ts +++ b/src/plugins/meta-web/plugin.ts @@ -92,7 +92,7 @@ const historicalVersions = Object.freeze({ }), threads: Object.freeze({ "feeds.read": Object.freeze([1]), - "posts.publish": Object.freeze([1, 2, 3]), + "posts.publish": Object.freeze([1, 2, 3, 4]), }), facebook: Object.freeze({ "feeds.read": Object.freeze([1]), @@ -112,7 +112,7 @@ const historicalVersions = Object.freeze({ const contractSemanticIdentities = Object.freeze({ instagram: "3e2f5a7d655030588754ffe64141f4108e76c57f227fe903549ba192f1735c68", - threads: "435efcfab30a01e32d0b12caba7740ef17ef166b99e608d48f9d0abad98a20c2", + threads: "04b95f200d512ef150043986fea9a106a401c5b7a2ba501cd454983edcacb00a", facebook: "f4724c9619794070da784d03fdaef5889cc4f3d237f9f402e9d5a53f7c156184", "facebook-page": "0a13cbe416286efe003ecf9c28fefcbd45c5d1f3b62a94936d9278ad8e488ada", "facebook-group": "30717a546b60658ecc2e199a14babb4fa2b46afd1e8d9113044a1b7afda3d376", @@ -184,7 +184,7 @@ export const metaWebPlugin = defineProviderPlugin({ const readback = await runtime.readThreadsWebPublishedMutationTarget({ site: "threads", action: operation, - contractVersion: operation === "posts.publish" ? 4 : 1, + contractVersion: operation === "posts.publish" ? 5 : 1, timeoutMs: 60_000, maxOutputBytes: 2 * 1024 * 1024, }, input, auth, context.target.identifier); diff --git a/src/provider-contract-inventory.test.ts b/src/provider-contract-inventory.test.ts index 5dbc944..914a115 100644 --- a/src/provider-contract-inventory.test.ts +++ b/src/provider-contract-inventory.test.ts @@ -4,19 +4,19 @@ import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; const predecessorDefaultInventorySha256 = - "9a122f221a78f13461d39613edf24fd4ff5c7e8417697a20244a4abc5d9692f4"; + "da82d8d952847e034bb46c0e9a5e2048b4592c778724db1f3c47501babbdd1d8"; const predecessorLegacyInventorySha256 = [ - "56b3c5a04a779f3296d116553cda5131de6a7b9d0a1c8be940ced272ee1c13ea", - "6e7e85e46058ba3e5accd77cd72c4f2dc096a4ce0a4739b84738f0aeba70910d", - "00043989bf01cd091e047b37f1cd93a5a5fbc3580d24901f7d4739bad2f82e9c", - "856e4e4c944aa79ae5dbacb748b774478961d0e0d2c11f983155183d53c90c28", - "a80f67a25a66525902069d58463fb741b13a1e96036b0aee8966519d2dae1f37", - "46f0ce3b81f50058f542efbe8e84a3c3842c6bda2fc7a610e2a314ad0f3eca80", - "18b599bdb7691f096e351b2105d10a3ff82ff43f430f5b68f8db22716b717850", - "16b4110baec0154d421c8a125ac7aab1a6291e59b5b9f84280c383fd05e90688", - "aba5332a8ed9023eb6c13dfeec8e10320d77a425414ebc3985926d0426e2b7be", - "c04ff1a21d0654f6b0a457b489ac283576f31d5ade2b4669477b7418c32c40ef", - "9042345e1a39033a0dcafa8afd1646801487d18c5a14af39f23b9e2bed500abc", + "c3026bbfb57dec86760e27873508c583706500d2a3fa4a025f0d134eb0a65612", + "415bc8e2bed9b36ddf7b0660ac66eb748eeae5bf190c54c61a1bbad70f4ca8cc", + "3d95388ae3382bba19102662385ac3957f32e70330f53e985a221c7a8a2c157e", + "75b8956b70aabdbd3c8bf54212622eda24a1983198792c2f0c964098e47cc9db", + "5866c4d375b8a5f059738eb08601696c99d761554999c255d5e94c134f2070a9", + "338b5807d7804d1dc4ee3c9f9b3fe399d8d281e8c569d54406816d5acfe3420b", + "d608471810222a16a94f9b804bd545eadfdd7aba82049dbd7b584614279b2295", + "0e5abb2681b76cc2059edcf9dcdb639c072d3a8d7192335bdce7c6f784af5d6a", + "7bd485de58af72638842244da421cf8d048a5d298d718094f77b372e72fd92c2", + "59662306372d7b050a8bd76d04e698025fa65eaf07417bc351985605c79f95ae", + "d267d31ec70fd633d6ba2b6f9769dded20df7456dd4b7f9a056670204cd316f1", "9fbec27b0fc78e6881092336d5c18501125c9c9cd7c123d8258ac99bb374f217", "f9f0feaf7f6bd5a11df12421e7517e44365238ba1508faa707ab24f60bc2558f", "ced281b781c1f73af8315245a68dfb5ea9fee9f737671e18fe1cd3ee5015ed20", @@ -212,22 +212,22 @@ describe("durable provider contract inventory", () => { "staging", ] as const) { expect(inventoryForNodeEnv(nodeEnv)).toEqual({ - rows: 307, + rows: 308, sha256: predecessorDefaultInventorySha256, currentOnlyRows: 7, currentOnlySha256: "ee4f714b977e75d55f85d41582267eb61b5eed6eb60ed1272739541aa3191c1a", legacyRows: [ - 307, - 307, - 307, - 307, - 307, - 307, - 307, - 174, - 160, - 160, - 160, + 308, + 308, + 308, + 308, + 308, + 308, + 308, + 175, + 161, + 161, + 161, 23, 23, 23, diff --git a/src/providers/meta-web-runtime.internal.test.ts b/src/providers/meta-web-runtime.internal.test.ts index 5db6e52..2cdd0be 100644 --- a/src/providers/meta-web-runtime.internal.test.ts +++ b/src/providers/meta-web-runtime.internal.test.ts @@ -658,7 +658,7 @@ function recipe( ) ? 2 : site === "threads" && action === "posts.publish" - ? 4 + ? 5 : 1, ): WebSessionRecipe { return { @@ -1220,6 +1220,136 @@ describe("Meta authenticated internal-data runtime", () => { ]); }); + test("publishes one text-only Threads post without rupload and verifies permalink readback", async () => { + const uploadId = "1786923725481"; + const postId = "987654321_12345"; + const postCode = "CodeABC"; + const text = "how your email finds me"; + const bootstrap = threadsHtml + script({ + require: [ + ["SprinkleConfig", [], { + param_name: "jazoest", + version: 2, + should_randomize: false, + }, 1], + ["WebBloksVersioningID", [], { + versioningID: "a".repeat(64), + }, 2], + ], + }); + const published = threadsImagePost(postId, postCode, text, { includeImage: false }); + const readback = threadsHtml + script({ post: published }); + const acceptedTargetIdentifier = canonicalJson({ + code: postCode, + id: postId, + url: `https://www.threads.com/@viewer/post/${postCode}`, + }); + const calls: Call[] = []; + const events: string[] = []; + const network = dependencies( + "threads", + calls, + (call) => { + events.push(`${call.method} ${call.url.pathname}`); + if (call.method === "GET" && call.url.pathname === "/") { + return new Response(bootstrap, { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + if (call.url.pathname.startsWith("/rupload_igphoto/")) { + throw new Error("text-only Threads publish must not rupload"); + } + if (call.url.pathname === "/api/v1/media/configure_text_post_app_feed/") { + expect(call.headers.get("x-csrftoken")).toBe("csrf-fixture"); + const form = new URLSearchParams(typeof call.body === "string" ? call.body : ""); + expect(Object.fromEntries(form)).toEqual({ + audience: "default", + caption: text, + creator_geo_gating_info: JSON.stringify({ whitelist_country_codes: [] }), + is_threads: "true", + should_include_permalink: "true", + text_post_app_info: JSON.stringify({ + excluded_inline_media_ids: "[]", + is_genai_invocation_post: false, + is_reply_approval_enabled: false, + is_spoiler_media: false, + text_with_entities: { entities: [], text }, + }), + upload_id: uploadId, + web_session_id: "::wg8yw9", + jazoest: "21250", + }); + return new Response(JSON.stringify(threadsCreateResponse(postId, postCode)), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (call.url.pathname === `/@viewer/post/${postCode}`) { + return new Response(readback, { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + throw new Error(`unexpected Threads test request ${call.method} ${call.url.pathname}`); + }, + undefined, + threadsMutationCookies(), + ); + const result = await executeMetaWebOperation( + recipe("threads", "posts.publish"), + { + audience: "default", + body: text, + }, + auth("threads"), + { + beforeDispatch: (event) => { + events.push(`before ${event.progress.started}`); + return Promise.resolve(); + }, + afterProviderAcceptedMutationTarget: (event) => { + events.push(`accepted ${event.target.identifier}`); + expect(event).toEqual({ + id: "posts.publish", + index: 1, + target: { schemaVersion: 1, identifier: acceptedTargetIdentifier }, + }); + return Promise.resolve(); + }, + afterDispatchVerified: (event) => { + events.push(`after ${event.progress.verified}`); + return Promise.resolve(); + }, + dependencies: { ...network, now: () => Number(uploadId) }, + }, + ); + expect(result).toMatchObject({ + status: "succeeded", + output: { + post: { + id: postId, + caption: text, + user: { id: "12345" }, + image: null, + }, + }, + finalUrl: `https://www.threads.com/@viewer/post/${postCode}`, + dispatchStarted: true, + dispatch: { planned: 1, started: 1, verified: 1 }, + }); + expect(result.output).not.toHaveProperty("attachment"); + expect(events).toEqual([ + "GET /", + "GET /", + "before 0", + "POST /api/v1/media/configure_text_post_app_feed/", + `accepted ${acceptedTargetIdentifier}`, + `GET /@viewer/post/${postCode}`, + "after 1", + ]); + }); + test("uploads one plan-bound Threads MP4, dispatches once, and verifies the exact permalink readback", async () => { const root = mkdtempSync(join(tmpdir(), "wrench-threads-video-")); chmodSync(root, 0o700); @@ -1798,6 +1928,60 @@ describe("Meta authenticated internal-data runtime", () => { { dependencies: network }, ))).toContain("account cookie did not match"); expect(calls).toHaveLength(2); + + expect(await readThreadsWebPublishedMutationTarget( + recipe("threads", "posts.publish", 4), + input, + auth("threads"), + identifier, + { dependencies: network }, + )).toEqual({ present: true, postId }); + expect(calls).toHaveLength(3); + }); + + test("reconciles one accepted text-only Threads target with its exact permalink GET only", async () => { + const postId = "987654321_12345"; + const postCode = "CodeABC"; + const text = "how your email finds me"; + const url = `https://www.threads.com/@viewer/post/${postCode}`; + const identifier = canonicalJson({ + code: postCode, + id: postId, + url, + }); + const input = { + audience: "default", + body: text, + } as const; + const calls: Call[] = []; + const network = dependencies("threads", calls, (call) => { + expect(call.method).toBe("GET"); + expect(call.url.href).toBe(url); + return new Response( + `${threadsHtml}${script({ + post: threadsImagePost(postId, postCode, text, { includeImage: false }), + })}`, + { status: 200, headers: { "content-type": "text/html" } }, + ); + }); + expect(await readThreadsWebPublishedMutationTarget( + recipe("threads", "posts.publish"), + input, + auth("threads"), + identifier, + { dependencies: network }, + )).toEqual({ present: true, postId }); + expect(calls.map((call) => `${call.method} ${call.url.href}`)).toEqual([ + `GET ${url}`, + ]); + expect(await rejectionMessage(readThreadsWebPublishedMutationTarget( + recipe("threads", "posts.publish"), + { ...input, attachment: { kind: "file", reference: "confirmed-fixture" } }, + auth("threads"), + identifier, + { dependencies: network }, + ))).toContain("did not bind the confirmed media input"); + expect(calls).toHaveLength(1); }); test("reconciles one accepted Threads video target without resolving or uploading the file", async () => { @@ -1848,7 +2032,7 @@ describe("Meta authenticated internal-data runtime", () => { auth("threads"), identifier, { dependencies: network }, - ))).toContain("did not match its exact media contract"); + ))).toContain("did not bind the confirmed media input"); expect(calls).toHaveLength(1); }); diff --git a/src/providers/meta-web-runtime.ts b/src/providers/meta-web-runtime.ts index 5f9b650..c4807ab 100644 --- a/src/providers/meta-web-runtime.ts +++ b/src/providers/meta-web-runtime.ts @@ -53,7 +53,6 @@ import { type FacebookMarketplaceFeed, type MetaWebOperationContract, type MetaWebSite, - type ThreadsImageProjection, type ThreadsVideoProjection, } from "./meta-web"; import { isoBmffVideoDimensions } from "./iso-bmff"; @@ -770,7 +769,7 @@ type ThreadsCreatedPost = Readonly<{ readonly mediaId: string; readonly mediaType: 1 | 2; readonly width: number; - }>; + }> | null; }>; type ThreadsCreateFailureCategory = @@ -823,7 +822,7 @@ function threadsCreatedPost( value: unknown, viewerId: string, expectedBody: string, - uploaded: ThreadsUploadedMedia, + uploaded: ThreadsUploadedMedia | null, ): ThreadsCreatedPost { // The reviewed synchronous composer response projects only the new post // locator. Keep the upload dimensions locally bound, then require the @@ -840,7 +839,7 @@ function threadsCreatedPost( ); } const minimalLocator = Object.keys(value.media).sort().join(",") === "code,permalink,pk"; - if (!minimalLocator && uploaded.mediaType !== 2) { + if (!minimalLocator && uploaded?.mediaType !== 2) { throw new ThreadsCreateResponseError( "success-shape", "Threads create response did not match the reviewed success shape", @@ -859,6 +858,7 @@ function threadsCreatedPost( || projectedUser?.id !== viewerId || projected.video === null || projected.video.mediaId !== projected.id + || uploaded === null || projected.video.mediaType !== uploaded.mediaType || projected.video.width !== uploaded.width || projected.video.height !== uploaded.height @@ -922,12 +922,14 @@ function threadsCreatedPost( } return Object.freeze({ locator: Object.freeze({ code, id: pk, url: url.href }), - media: Object.freeze({ - height: uploaded.height, - mediaId: pk, - mediaType: uploaded.mediaType, - width: uploaded.width, - }), + media: uploaded === null + ? null + : Object.freeze({ + height: uploaded.height, + mediaId: pk, + mediaType: uploaded.mediaType, + width: uploaded.width, + }), }); } @@ -948,11 +950,11 @@ async function createThreadsPost( client: WebSessionClient, viewer: BoundMetaViewer, prepared: Extract, - uploaded: ThreadsUploadedMedia, + uploaded: ThreadsUploadedMedia | null, config: ThreadsRequestConfig, + uploadId: string, ): Promise { const csrfToken = webSessionCookie(client.cookies, "csrftoken"); - const uploadId = uploaded.id; const webSessionId = threadsWebSessionId(uploadId); const form = new URLSearchParams(); form.set("audience", prepared.audience); @@ -1016,32 +1018,47 @@ async function executeThreadsPost( readonly now: () => number; }, ): Promise { - const image = await materializeThreadsImage( - prepared.attachment, - options.fileResolver, - options.operationDeadline, - ); + const image = prepared.attachment === undefined + ? null + : await materializeThreadsImage( + prepared.attachment, + options.fileResolver, + options.operationDeadline, + ); const uploadId = threadsUploadId(options.now); let started = 0; let verified = 0; let created: ThreadsCreatedPost | null = null; - let failureStage = "image upload confirmation"; + let failureStage = image === null ? "post create admission" : "image upload confirmation"; try { // Rupload can accept an orphaned provider blob, but it cannot publish a // Threads post. Keep the durable post-dispatch boundary immediately in // front of configure_text_post_app_feed so an upload transport failure is // safely retryable instead of being misclassified as an indeterminate - // public post. - const uploaded = await uploadThreadsImage(client, image, uploadId); + // public post. Text-only posts skip rupload and use the same create form. + const uploaded = image === null + ? null + : await uploadThreadsImage(client, image, uploadId); const dispatchViewer = await currentViewer("threads", client); if (dispatchViewer.subject !== viewer.subject) { - throw new Error("Threads current viewer changed after the image upload"); + throw new Error( + image === null + ? "Threads current viewer changed before the post dispatch" + : "Threads current viewer changed after the image upload", + ); } const config = threadsRequestConfig(dispatchViewer.rootHtml); await options.beforeDispatch?.(metaDispatchEvent("posts.publish", started, verified)); started = 1; failureStage = "post create response"; - created = await createThreadsPost(client, dispatchViewer, prepared, uploaded, config); + created = await createThreadsPost( + client, + dispatchViewer, + prepared, + uploaded, + config, + uploadId, + ); const createdImage = created.media; failureStage = "accepted target retention"; await options.afterProviderAcceptedMutationTarget?.({ @@ -1049,15 +1066,21 @@ async function executeThreadsPost( index: 1, target: { schemaVersion: 1, - identifier: canonicalJson({ - code: created.locator.code, - height: createdImage.height, - id: created.locator.id, - mediaType: createdImage.mediaType, - remoteMediaId: createdImage.mediaId, - url: created.locator.url, - width: createdImage.width, - }), + identifier: createdImage === null + ? canonicalJson({ + code: created.locator.code, + id: created.locator.id, + url: created.locator.url, + }) + : canonicalJson({ + code: created.locator.code, + height: createdImage.height, + id: created.locator.id, + mediaType: createdImage.mediaType, + remoteMediaId: createdImage.mediaId, + url: created.locator.url, + width: createdImage.width, + }), }, }); failureStage = "permalink readback"; @@ -1075,29 +1098,40 @@ async function executeThreadsPost( created.locator.code, created.locator.url, prepared.body, - image, + createdImage === null + ? null + : { height: createdImage.height, width: createdImage.width }, ); - const remoteImage = post.image as ThreadsImageProjection; - if ( - remoteImage.mediaId !== createdImage.mediaId - || remoteImage.mediaType !== createdImage.mediaType - || remoteImage.width !== createdImage.width - || remoteImage.height !== createdImage.height - ) throw new Error("Threads permalink readback changed the response-bound image"); + if (createdImage === null) { + if (post.image !== null) { + throw new Error("Threads permalink readback introduced an unconfirmed image"); + } + } else { + const remoteImage = post.image; + if ( + remoteImage === null + || remoteImage.mediaId !== createdImage.mediaId + || remoteImage.mediaType !== createdImage.mediaType + || remoteImage.width !== createdImage.width + || remoteImage.height !== createdImage.height + ) throw new Error("Threads permalink readback changed the response-bound image"); + } verified = 1; await options.afterDispatchVerified?.(metaDispatchEvent("posts.publish", started, verified)); return { status: "succeeded", - output: Object.freeze({ - post, - attachment: Object.freeze({ - height: remoteImage.height, - mediaType: image.mediaType, - remoteMediaId: remoteImage.mediaId, - verifiedBy: "permalink-readback", - width: remoteImage.width, - }), - }), + output: createdImage === null || post.image === null + ? Object.freeze({ post }) + : Object.freeze({ + post, + attachment: Object.freeze({ + height: post.image.height, + mediaType: "image/png" as const, + remoteMediaId: post.image.mediaId, + verifiedBy: "permalink-readback", + width: post.image.width, + }), + }), finalUrl: created.locator.url, dispatchStarted: true, dispatch: { planned: 1, started, verified }, @@ -1106,6 +1140,9 @@ async function executeThreadsPost( const publicFailureStage = failureStage === "post create response" ? `${failureStage} (${error instanceof ThreadsCreateResponseError ? error.category : "unexpected"})` : failureStage; + const verifiedSurfaces = image === null + ? "exact actor, ID, code, text, and permalink readback" + : "exact actor, ID, code, text, image, and permalink readback"; return { status: started > 0 ? "indeterminate" : "failed", output: null, @@ -1113,8 +1150,10 @@ async function executeThreadsPost( dispatchStarted: started > 0, dispatch: { planned: 1, started, verified }, error: started > 0 - ? `Threads may have accepted the image upload or post but exact actor, ID, code, text, image, and permalink readback was not verified; failure stage: ${publicFailureStage}; reconcile before retrying` - : "Threads image upload failed before post submission; retry with a fresh confirmed plan", + ? `Threads may have accepted the ${image === null ? "post" : "image upload or post"} but ${verifiedSurfaces} was not verified; failure stage: ${publicFailureStage}; reconcile before retrying` + : image === null + ? "Threads post create failed before submission; retry with a fresh confirmed plan" + : "Threads image upload failed before post submission; retry with a fresh confirmed plan", }; } } @@ -1157,9 +1196,16 @@ async function executeThreadsVideo( await options.beforeDispatch?.(metaDispatchEvent("media.publish", started, verified)); started = 1; failureStage = "post create response"; - created = await createThreadsPost(client, dispatchViewer, prepared, uploaded, config); + created = await createThreadsPost( + client, + dispatchViewer, + prepared, + uploaded, + config, + uploadId, + ); const createdVideo = created.media; - if (createdVideo.mediaType !== 2) { + if (createdVideo === null || createdVideo.mediaType !== 2) { throw new Error("Threads create response changed the confirmed video media type"); } failureStage = "accepted target retention"; @@ -1240,7 +1286,7 @@ async function executeThreadsVideo( } } -type ThreadsPublishedMutationTarget = Readonly<{ +type ThreadsPublishedMediaMutationTarget = Readonly<{ code: string; height: number; id: string; @@ -1250,6 +1296,22 @@ type ThreadsPublishedMutationTarget = Readonly<{ width: number; }>; +type ThreadsPublishedTextMutationTarget = Readonly<{ + code: string; + id: string; + url: string; +}>; + +type ThreadsPublishedMutationTarget = + | ThreadsPublishedMediaMutationTarget + | ThreadsPublishedTextMutationTarget; + +function isThreadsPublishedMediaTarget( + target: ThreadsPublishedMutationTarget, +): target is ThreadsPublishedMediaMutationTarget { + return "mediaType" in target; +} + function threadsPublishedMutationTargetDimension( value: unknown, label: string, @@ -1260,38 +1322,13 @@ function threadsPublishedMutationTargetDimension( return value as number; } -function parseThreadsPublishedMutationTarget( - identifier: string, -): ThreadsPublishedMutationTarget { - let value: unknown; - try { - value = JSON.parse(identifier); - } catch { - throw new Error("Threads provider-accepted post target is not canonical JSON"); - } - if ( - !isRecord(value) - || Object.keys(value).sort().join(",") - !== "code,height,id,mediaType,remoteMediaId,url,width" - ) throw new Error("Threads provider-accepted post target contained unsupported fields"); - if ( - typeof value.id !== "string" - || !/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(value.id) - || typeof value.remoteMediaId !== "string" - || value.remoteMediaId !== value.id - ) throw new Error("Threads provider-accepted post target returned invalid media identifiers"); - if (typeof value.code !== "string" || !/^[A-Za-z0-9_-]{1,64}$/u.test(value.code)) { - throw new Error("Threads provider-accepted post target returned an invalid post code"); - } - if (value.mediaType !== 1 && value.mediaType !== 2) { - throw new Error("Threads provider-accepted post target did not identify one reviewed media item"); - } - if (typeof value.url !== "string" || value.url.length < 1 || value.url.length > 2_048) { +function parseThreadsPublishedPermalink(value: unknown, code: string): URL { + if (typeof value !== "string" || value.length < 1 || value.length > 2_048) { throw new Error("Threads provider-accepted post target returned an invalid permalink"); } let url: URL; try { - url = new URL(value.url); + url = new URL(value); } catch { throw new Error("Threads provider-accepted post target returned an invalid permalink"); } @@ -1305,8 +1342,47 @@ function parseThreadsPublishedMutationTarget( || path.length !== 4 || !/^@[A-Za-z0-9._]{1,64}$/u.test(path[1] ?? "") || path[2] !== "post" - || path[3] !== value.code + || path[3] !== code ) throw new Error("Threads provider-accepted post target returned an invalid permalink"); + return url; +} + +function parseThreadsPublishedMutationTarget( + identifier: string, +): ThreadsPublishedMutationTarget { + let value: unknown; + try { + value = JSON.parse(identifier); + } catch { + throw new Error("Threads provider-accepted post target is not canonical JSON"); + } + if (!isRecord(value)) { + throw new Error("Threads provider-accepted post target contained unsupported fields"); + } + if (typeof value.id !== "string" || !/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(value.id)) { + throw new Error("Threads provider-accepted post target returned invalid media identifiers"); + } + if (typeof value.code !== "string" || !/^[A-Za-z0-9_-]{1,64}$/u.test(value.code)) { + throw new Error("Threads provider-accepted post target returned an invalid post code"); + } + const keys = Object.keys(value).sort().join(","); + const url = parseThreadsPublishedPermalink(value.url, value.code); + if (keys === "code,id,url") { + const parsed = Object.freeze({ code: value.code, id: value.id, url: url.href }); + if (canonicalJson(parsed) !== identifier) { + throw new Error("Threads provider-accepted post target is not canonical"); + } + return parsed; + } + if (keys !== "code,height,id,mediaType,remoteMediaId,url,width") { + throw new Error("Threads provider-accepted post target contained unsupported fields"); + } + if (typeof value.remoteMediaId !== "string" || value.remoteMediaId !== value.id) { + throw new Error("Threads provider-accepted post target returned invalid media identifiers"); + } + if (value.mediaType !== 1 && value.mediaType !== 2) { + throw new Error("Threads provider-accepted post target did not identify one reviewed media item"); + } const parsed = Object.freeze({ code: value.code, height: threadsPublishedMutationTargetDimension( @@ -1341,26 +1417,42 @@ export async function readThreadsWebPublishedMutationTarget( readonly dependencies?: MetaWebRuntimeDependencies; } = {}, ): Promise<{ readonly present: true; readonly postId: string }> { - const isImagePublish = recipe.site === "threads" + const isPostPublish = recipe.site === "threads" && recipe.action === "posts.publish" - && recipe.contractVersion === 4; + && (recipe.contractVersion === 4 || recipe.contractVersion === 5); const isVideoPublish = recipe.site === "threads" && recipe.action === "media.publish" && recipe.contractVersion === 1; - if (!isImagePublish && !isVideoPublish) { - throw new Error("Threads publish recovery supports only posts.publish@4 or media.publish@1"); + if (!isPostPublish && !isVideoPublish) { + throw new Error( + "Threads publish recovery supports only posts.publish@4, posts.publish@5, or media.publish@1", + ); } const target = parseThreadsPublishedMutationTarget(identifier); const prepared = prepareMetaRead(recipe, input, auth, Object.freeze({})); if ( - (isImagePublish && (prepared.kind !== "threads-post" || target.mediaType !== 1)) - || (isVideoPublish && (prepared.kind !== "threads-video" || target.mediaType !== 2)) + (isPostPublish && prepared.kind !== "threads-post") + || (isVideoPublish && prepared.kind !== "threads-video") ) { throw new Error("Threads publish recovery input did not match its exact media contract"); } if (prepared.kind !== "threads-post" && prepared.kind !== "threads-video") { throw new Error("Threads publish recovery input did not match its exact media contract"); } + const mediaTarget = isThreadsPublishedMediaTarget(target) ? target : null; + if (prepared.kind === "threads-post" && ( + (recipe.contractVersion === 4 && prepared.attachment === undefined) + || (prepared.attachment !== undefined) !== (mediaTarget !== null) + || (mediaTarget !== null && mediaTarget.mediaType !== 1) + )) { + throw new Error("Threads publish recovery target did not bind the confirmed media input"); + } + if ( + prepared.kind === "threads-video" + && (mediaTarget === null || mediaTarget.mediaType !== 2) + ) { + throw new Error("Threads publish recovery target did not bind the confirmed media input"); + } const expectedSubject = expectedMetaAuthSubject("threads", auth); const viewerId = expectedSubject.slice("threads:".length); const dependencies = metaWebSessionDependencies("threads", auth, options.dependencies); @@ -1378,33 +1470,52 @@ export async function readThreadsWebPublishedMutationTarget( expectedContentTypes: ["text/html"], maxBytes: Math.min(recipe.maxOutputBytes, MAX_BOOTSTRAP_BYTES), }); - const post = prepared.kind === "threads-post" - ? normalizeThreadsPostHtml( - html, - viewerId, - target.id, - target.code, - target.url, - prepared.body, - { height: target.height, width: target.width }, - ) - : normalizeThreadsVideoPostHtml( - html, - viewerId, - target.id, - target.code, - target.url, - prepared.body, - { height: target.height, width: target.width }, - ); - const media = "image" in post ? post.image : post.video; - if ( - media === null - || media.mediaId !== target.remoteMediaId - || media.mediaType !== target.mediaType - || media.width !== target.width - || media.height !== target.height - ) throw new Error("Threads publish recovery readback changed the accepted media"); + if (prepared.kind === "threads-video") { + if (mediaTarget === null || mediaTarget.mediaType !== 2) { + throw new Error("Threads publish recovery target did not bind the confirmed media input"); + } + const post = normalizeThreadsVideoPostHtml( + html, + viewerId, + target.id, + target.code, + target.url, + prepared.body, + { height: mediaTarget.height, width: mediaTarget.width }, + ); + const media = post.video; + if ( + media === null + || media.mediaId !== mediaTarget.remoteMediaId + || media.mediaType !== mediaTarget.mediaType + || media.width !== mediaTarget.width + || media.height !== mediaTarget.height + ) throw new Error("Threads publish recovery readback changed the accepted media"); + } else { + const post = normalizeThreadsPostHtml( + html, + viewerId, + target.id, + target.code, + target.url, + prepared.body, + mediaTarget === null + ? null + : { height: mediaTarget.height, width: mediaTarget.width }, + ); + const media = post.image; + if (mediaTarget !== null) { + if ( + media === null + || media.mediaId !== mediaTarget.remoteMediaId + || media.mediaType !== mediaTarget.mediaType + || media.width !== mediaTarget.width + || media.height !== mediaTarget.height + ) throw new Error("Threads publish recovery readback changed the accepted media"); + } else if (media !== null) { + throw new Error("Threads publish recovery readback introduced unconfirmed media"); + } + } return Object.freeze({ present: true, postId: target.id }); } @@ -1621,7 +1732,7 @@ type PreparedMetaRead = } | { readonly kind: "threads-post"; - readonly attachment: FileInputValue; + readonly attachment?: FileInputValue; readonly audience: "default"; readonly body: string; } @@ -1739,15 +1850,12 @@ function prepareMetaRead( || body.length > 450 || /[\0\r]/u.test(body) ) throw new Error("input.body must be 1 to 450 bounded UTF-16 code units"); - if (input.attachment === undefined) { - throw new Error("reviewed Threads posts.publish currently requires one PNG attachment"); - } const audience = input.audience === undefined ? "default" : exactEnumInput(input, "audience", ["default"]); return Object.freeze({ kind: "threads-post", - attachment: fileInput(input.attachment), + ...(input.attachment === undefined ? {} : { attachment: fileInput(input.attachment) }), audience: audience as "default", body, }); diff --git a/src/providers/meta-web.test.ts b/src/providers/meta-web.test.ts index 736700f..88064f2 100644 --- a/src/providers/meta-web.test.ts +++ b/src/providers/meta-web.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { parseRuntimeManifest, validateOperationInput } from "../model"; +import { providerPluginRegistry } from "../provider-plugins"; import facebookGroupManifest from "../assets/adapters/facebook-group/wrench-web-adapter.json"; import facebookMarketplaceManifest from "../assets/adapters/facebook-marketplace/wrench-web-adapter.json"; import facebookPageManifest from "../assets/adapters/facebook-page/wrench-web-adapter.json"; @@ -12,6 +14,7 @@ import threadsV1Manifest from "../assets/adapters/threads/wrench-web-adapter.v1. import threadsV12Manifest from "../assets/adapters/threads/wrench-web-adapter.v1.2.0.json"; import threadsV14Manifest from "../assets/adapters/threads/wrench-web-adapter.v1.4.0.json"; import threadsV15Manifest from "../assets/adapters/threads/wrench-web-adapter.v1.5.0.json"; +import threadsV16Manifest from "../assets/adapters/threads/wrench-web-adapter.v1.6.0.json"; import { metaWebPlugin } from "../plugins/meta-web/plugin"; import { META_WEB_OPERATIONS, @@ -411,7 +414,7 @@ describe("Meta consumer-web policy", () => { }); expect(META_WEB_OPERATIONS.threads["posts.publish"]).toMatchObject({ state: "observed", - contractVersion: 4, + contractVersion: 5, }); expect(META_WEB_OPERATIONS.threads["media.publish"]).toMatchObject({ state: "observed", @@ -458,7 +461,7 @@ describe("Meta consumer-web policy", () => { expect(instagramManifest.version).toBe("1.3.0"); expect(instagramV1Manifest.version).toBe("1.0.0"); - expect(threadsManifest.version).toBe("1.6.0"); + expect(threadsManifest.version).toBe("1.7.0"); expect(threadsV1Manifest.version).toBe("1.0.0"); for (const [site, operation, current, prior] of affected) { @@ -513,15 +516,48 @@ describe("Meta consumer-web policy", () => { test("versions locator-bound Threads publication while preserving reviewed predecessors", () => { expect(threadsV12Manifest.version).toBe("1.2.0"); + expect(threadsV16Manifest.version).toBe("1.6.0"); expect(threadsV12Manifest.operations["posts.publish"].webSession.contractVersion).toBe(2); - expect(threadsManifest.operations["posts.publish"].webSession.contractVersion).toBe(4); - expect(threadsManifest.operations["posts.publish"].input.required).toContain("attachment"); + expect(threadsV16Manifest.operations["posts.publish"].webSession.contractVersion).toBe(4); + expect(threadsV16Manifest.operations["media.publish"].webSession.contractVersion).toBe(1); + expect(threadsV16Manifest.operations["profiles.read"].webSession.contractVersion).toBe(1); + expect(threadsV16Manifest.operations["posts.publish"].input.required).toEqual([ + "attachment", + "body", + ]); + expect(threadsManifest.operations["posts.publish"].webSession.contractVersion).toBe(5); + expect(threadsManifest.operations["posts.publish"].input.required).toEqual(["body"]); + expect(threadsManifest.operations["posts.publish"].input.properties).toHaveProperty( + "attachment", + ); const binding = metaWebPlugin.bindings.find((candidate) => candidate.surfaceId === "threads"); const descriptor = binding?.operations.find((candidate) => candidate.name === "posts.publish"); - expect(descriptor?.contractVersions).toEqual([1, 2, 3, 4]); + expect(descriptor?.contractVersions).toEqual([1, 2, 3, 4, 5]); + }); + + test("validates Threads posts.publish for text-only and PNG inputs", () => { + const parsed = parseRuntimeManifest(threadsManifest, providerPluginRegistry); + expect(parsed.ok).toBeTrue(); + if (!parsed.ok) throw new Error("current Threads manifest must parse"); + const schema = parsed.value.operations["posts.publish"]?.input; + if (schema === undefined) throw new Error("current Threads posts.publish schema is missing"); + expect(validateOperationInput(schema, { + body: "how your email finds me", + }, parsed.value.origins)).toMatchObject({ ok: true }); + expect(validateOperationInput(schema, { + body: "how your email finds me", + attachment: "asset:png", + audience: "default", + }, parsed.value.origins)).toMatchObject({ ok: true }); + expect(validateOperationInput(schema, { + attachment: "asset:png", + }, parsed.value.origins)).toMatchObject({ + ok: false, + issues: ["input.body is required"], + }); }); test("binds each consumer surface to its exact bootstrapped viewer", () => { @@ -1108,6 +1144,29 @@ describe("Meta consumer-web policy", () => { { width: 959, height: 1022 }, )).toThrow("did not bind the confirmed actor, ID, code, permalink, text, and image"); + expect(normalizeThreadsPostHtml( + `${threadsHtml}${html({ post: withoutImage })}`, + "12345", + expected.pk, + expected.code, + expected.canonical_url, + expected.caption.text, + null, + )).toMatchObject({ + id: expected.pk, + caption: expected.caption.text, + image: null, + }); + expect(() => normalizeThreadsPostHtml( + `${threadsHtml}${html({ post: expected })}`, + "12345", + expected.pk, + expected.code, + expected.canonical_url, + expected.caption.text, + null, + )).toThrow("did not bind the confirmed actor, ID, code, permalink, and text"); + expect(() => normalizeThreadsPostHtml( `${threadsHtml}${html({ post: { diff --git a/src/providers/meta-web.ts b/src/providers/meta-web.ts index 341b8aa..39237fe 100644 --- a/src/providers/meta-web.ts +++ b/src/providers/meta-web.ts @@ -208,8 +208,8 @@ export const META_WEB_OPERATIONS = Object.freeze({ ), "posts.publish": observedMutation( "R3", - "reviewed live single-PNG upload with synchronous 200 completion, exact minimal configure_text_post_app_feed created-locator binding, durable response-bound post identity plus completed-upload dimensions, and independent exact permalink actor/text/image readback", - 4, + "reviewed live configure_text_post_app_feed create with optional single-PNG upload, exact minimal created-locator binding, durable response-bound post identity plus completed-upload dimensions when an image is supplied, and independent exact permalink actor/text and optional image readback", + 5, ), "media.publish": observedMutation( "R3", @@ -1931,7 +1931,7 @@ export function normalizeThreadsPostHtml( expectedCode: string, expectedUrl: string, expectedCaption: string, - expectedImage: Readonly<{ readonly height: number; readonly width: number }>, + expectedImage: Readonly<{ readonly height: number; readonly width: number }> | null, ): ThreadsPostProjection { if (!/^[0-9]{1,32}(?:_[0-9]{1,32})?$/u.test(expectedPostId)) { throw new Error("Threads readback expected post ID is invalid"); @@ -1970,21 +1970,28 @@ export function normalizeThreadsPostHtml( ) return; const projected = projectThreadsPublishPost(value, "Threads post readback"); const user = isRecord(projected.user) ? projected.user : null; + const imageMatches = expectedImage === null + ? projected.image === null + : projected.image !== null + && projected.image.mediaId === expectedPostId + && projected.image.width === expectedImage.width + && projected.image.height === expectedImage.height; if ( projected.caption === expectedCaption && projected.code === expectedCode && projected.canonical_url === locator.href && user?.id === viewerId - && projected.image !== null - && projected.image.mediaId === expectedPostId - && projected.image.width === expectedImage.width - && projected.image.height === expectedImage.height + && imageMatches ) { matches.push(projected); } }); if (matches.length < 1) { - throw new Error("Threads post readback did not bind the confirmed actor, ID, code, permalink, text, and image"); + throw new Error( + expectedImage === null + ? "Threads post readback did not bind the confirmed actor, ID, code, permalink, and text" + : "Threads post readback did not bind the confirmed actor, ID, code, permalink, text, and image", + ); } if (matches.length !== 1) { throw new Error("Threads post readback returned an ambiguous exact post"); @@ -2883,7 +2890,7 @@ export const metaWebEvidenceSnapshot = Object.freeze({ threads: Object.freeze({ viewer: "GET / HTML BarcelonaSessionInfo plus Relay viewer.user.id", feed: "GET / signed-in first-page Relay feedData preload without cursor continuation", - publishImage: "POST one PNG to the exact rupload entity with synchronous 200 completion, POST configure_text_post_app_feed with exact actor/text/image response binding, then GET the exact returned permalink for independent image readback", + publishImage: "POST configure_text_post_app_feed with exact created-locator response binding after optional PNG rupload; when an image is supplied, require synchronous 200 upload completion and completed-upload dimensions; then GET the exact returned permalink for independent actor/text and optional image readback", publishVideo: "POST one MP4 to the exact rupload_igvideo entity with synchronous 200 completion and exact dimensions, POST configure_text_post_app_feed with exact actor/text/video locator binding, then GET the exact returned permalink for independent video readback", }), facebook: Object.freeze({ diff --git a/src/scripts/sync-bundled-adapters.test.ts b/src/scripts/sync-bundled-adapters.test.ts index 4c846a5..111a1cb 100644 --- a/src/scripts/sync-bundled-adapters.test.ts +++ b/src/scripts/sync-bundled-adapters.test.ts @@ -200,6 +200,7 @@ describe("single-process bundled adapter generation sync", () => { "threads-web@1.3.0", "threads-web@1.4.0", "threads-web@1.5.0", + "threads-web@1.6.0", "tiktok-web@1.0.0", "whatsapp-web@1.0.0", "whatsapp-web@1.1.0", diff --git a/src/web-session-contract-definitions.ts b/src/web-session-contract-definitions.ts index 1359abc..e017603 100644 --- a/src/web-session-contract-definitions.ts +++ b/src/web-session-contract-definitions.ts @@ -449,7 +449,7 @@ const META_WEB_OPERATIONS = Object.freeze({ threads: operationPolicies("threads", ["feeds.read", "media.publish", "posts.publish", "profiles.read"], { "feeds.read": 2, "media.publish": 1, - "posts.publish": 4, + "posts.publish": 5, }), facebook: operationPolicies("facebook", ["feeds.read"], { "feeds.read": 2,