diff --git a/package.json b/package.json index fc21678..a29ef5c 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json", "src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json", "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/tiktok/wrench-web-adapter.json", "src/assets/adapters/whatsapp/wrench-web-adapter.json", "src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json", diff --git a/skills/wrench/references/platform-patterns.md b/skills/wrench/references/platform-patterns.md index 829fbd3..b4b2b29 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 `posts.publish@4` is the one observed Meta write: it requires exactly one plan-bound PNG, binds the exact minimal created locator plus the completed upload dimensions into durable target evidence, and verifies the exact permalink actor, text, and image before success. 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 `posts.publish@5` is the one observed Meta write: it publishes exact text with an optional plan-bound PNG, binds the exact minimal created locator plus completed-upload dimensions when an image is supplied, and verifies the exact permalink actor, text, and optional image before success. 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 03e3923..daa91a4 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 | `threads-web` | Browser cookies/profile | `body`, explicit audience when required, one required PNG attachment in the current reviewed schema | Threads post | +| Threads | `threads-web` | Browser cookies/profile | `body`, explicit audience when required, optional PNG attachment | 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 @@ -55,5 +55,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, the reviewed Threads contract requires exactly one PNG. Treat text-only Threads publication as unavailable unless the installed schema says otherwise. +- At this reference revision, the reviewed Threads contract publishes text-only posts and optional PNG image posts. When the package is text-only, publish it as text. 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/src/assets/adapters/threads/wrench-web-adapter.json b/src/assets/adapters/threads/wrench-web-adapter.json index 52b077e..c171402 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.4.0", + "version": "1.5.0", "displayName": "Threads (Authenticated Internal API)", "surfaceId": "threads", "origins": [ @@ -367,9 +367,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": { @@ -382,7 +382,7 @@ }, "attachment": { "type": "file", - "description": "One exact reviewed PNG attachment", + "description": "Optional exact reviewed PNG attachment", "maxBytes": 20971520, "mediaTypes": [ "image/png" @@ -397,14 +397,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.4.0.json b/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json new file mode 100644 index 0000000..52b077e --- /dev/null +++ b/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json @@ -0,0 +1,622 @@ +{ + "schemaVersion": 4, + "id": "threads-web", + "version": "1.4.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 + } + }, + "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 + } + }, + "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 9792947..7318777 100644 --- a/src/plugins/meta-web/plugin.test.ts +++ b/src/plugins/meta-web/plugin.test.ts @@ -33,7 +33,7 @@ describe("Meta web plugin account subjects", () => { test("declares exact accepted-target reconciliation only for Threads publishing", () => { const threadsPublish = binding("threads").operations.find((operation) => operation.name === "posts.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 672e3ea..4e2cd20 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: "7f0acb6d0e978d6579a744f724bea39dd629614a04ca9e24f2c8c6ad4fe53c9f", - threads: "8c04b52062e53812cb4d938968867e4b8a61d771241dc2b074e0d3d16d299f92", + threads: "3bca292063c874757577568b46799fd6eb5db65cb398d8b1fc18d51811082926", facebook: "f4724c9619794070da784d03fdaef5889cc4f3d237f9f402e9d5a53f7c156184", "facebook-page": "0a13cbe416286efe003ecf9c28fefcbd45c5d1f3b62a94936d9278ad8e488ada", "facebook-group": "30717a546b60658ecc2e199a14babb4fa2b46afd1e8d9113044a1b7afda3d376", @@ -182,7 +182,7 @@ export const metaWebPlugin = defineProviderPlugin({ const readback = await runtime.readThreadsWebPublishedMutationTarget({ site: "threads", action: operation, - contractVersion: 4, + contractVersion: 5, 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 2acc6d1..92e1efd 100644 --- a/src/provider-contract-inventory.test.ts +++ b/src/provider-contract-inventory.test.ts @@ -4,15 +4,15 @@ import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; const predecessorDefaultInventorySha256 = - "02708d72411b28807b6a2d08dda91bb4a21c89c589a527326bd452e94888cf3e"; + "d44d26aff6bef4f736a193b7a89dcd238f42351f4c1edfada663bac602997ed5"; const predecessorLegacyInventorySha256 = [ - "77ac36d3b7e7cc552f8e9fdf4ebc13ae63545ce2bc9c1757a0537e32b4c56661", - "8a51116a328059c9394f2776a1000be78f4f19a2ba7d4a182cfe0bf5ff90f8f0", - "4946acae29bb0f58494f66a10e47c9199bde377a62e4d27d9a2d61320b751a5b", - "a9cddc698de5a1bdf87fec3fbfc8fdfc3f4c7517edfd08bfe452788f919ebdc3", - "6f7ac531e16f2c60a44e28067cbc5de1390731a326d5166a07604abc4a54e242", - "69a1f62efead38c2ad564d966a4f89f1b8668a84cc13c63b178b4916d069a526", - "95f376424414cd6ad96558d0b20317e423cb708271143889c11882f5d4863211", + "017ceb50a73df4ab3d560f037301e9d9feb1b469335f562437fc4149e2a57bb4", + "da31b31e080192dd6f3b123df4e3ea08a5a0b16d75bac3ee18a5f9dca66804c8", + "1d4b26ca89e56ec6c61cb4384cf91452517c89fbd6ace6ebe41b9a8ec5d02b5d", + "0084531007fa0999238c609271523d4ee7075c2dade3ade246f068aa8516627e", + "cfcb25c2428604e1576f34f53564ea648b6d606b61ff790359f7e3f9cbd3e2db", + "c627f5c3a03da878485770339115c9d310718cf86d0491753afc26767d3a918d", + "14fef1dc2f994ca548c76ae886a6a8e5ba3b35fc5603fb7e7eda34ea9f36ac84", ]; const moduleUrl = (name: string) => pathToFileURL( @@ -200,11 +200,11 @@ describe("durable provider contract inventory", () => { "staging", ] as const) { expect(inventoryForNodeEnv(nodeEnv)).toEqual({ - rows: 283, + rows: 284, sha256: predecessorDefaultInventorySha256, currentOnlyRows: 4, currentOnlySha256: "b68a0e0f8f9be77f46d4ae7a5aa3a75de2ee6e420e8dc588247390bd1600553c", - legacyRows: [283, 283, 283, 283, 283, 283, 283], + legacyRows: [284, 284, 284, 284, 284, 284, 284], legacySha256: predecessorLegacyInventorySha256, acceptedLegacy: true, rejectedUnknown: true, diff --git a/src/providers/meta-web-runtime.internal.test.ts b/src/providers/meta-web-runtime.internal.test.ts index a9ed851..b940701 100644 --- a/src/providers/meta-web-runtime.internal.test.ts +++ b/src/providers/meta-web-runtime.internal.test.ts @@ -575,7 +575,7 @@ function recipe( ) ? 2 : site === "threads" && action === "posts.publish" - ? 4 + ? 5 : 1, ): WebSessionRecipe { return { @@ -904,6 +904,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("keeps a rejected Threads upload before the durable post-dispatch boundary", async () => { const root = mkdtempSync(join(tmpdir(), "wrench-threads-upload-failure-")); chmodSync(root, 0o700); @@ -1227,6 +1357,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("executes Instagram timeline through the exact JSON endpoint with no dispatch", async () => { diff --git a/src/providers/meta-web-runtime.ts b/src/providers/meta-web-runtime.ts index 7c44941..838d925 100644 --- a/src/providers/meta-web-runtime.ts +++ b/src/providers/meta-web-runtime.ts @@ -47,7 +47,6 @@ import { type FacebookMarketplaceFeed, type MetaWebOperationContract, type MetaWebSite, - type ThreadsImageProjection, } from "./meta-web"; import { bootstrapMetaComet, @@ -618,14 +617,16 @@ export type ThreadsPostLocator = Readonly<{ readonly url: string; }>; +type ThreadsCreatedImage = Readonly<{ + readonly height: number; + readonly mediaId: string; + readonly mediaType: 1; + readonly width: number; +}>; + type ThreadsCreatedPost = Readonly<{ readonly locator: ThreadsPostLocator; - readonly image: Readonly<{ - readonly height: number; - readonly mediaId: string; - readonly mediaType: 1; - readonly width: number; - }>; + readonly image: ThreadsCreatedImage | null; }>; type ThreadsCreateFailureCategory = @@ -677,7 +678,7 @@ function threadsCreateRequestFailureCategory(error: unknown): ThreadsCreateFailu function threadsCreatedPost( value: unknown, viewerId: string, - uploaded: ThreadsUploadedImage, + uploaded: ThreadsUploadedImage | null, ): ThreadsCreatedPost { // The reviewed synchronous composer response projects only the new post // locator. Keep the upload dimensions locally bound, then require the @@ -742,12 +743,14 @@ function threadsCreatedPost( } return Object.freeze({ locator: Object.freeze({ code, id: pk, url: url.href }), - image: Object.freeze({ - height: uploaded.height, - mediaId: pk, - mediaType: 1, - width: uploaded.width, - }), + image: uploaded === null + ? null + : Object.freeze({ + height: uploaded.height, + mediaId: pk, + mediaType: 1, + width: uploaded.width, + }), }); } @@ -768,11 +771,11 @@ async function createThreadsPost( client: WebSessionClient, viewer: BoundMetaViewer, prepared: Extract, - uploaded: ThreadsUploadedImage, + uploaded: ThreadsUploadedImage | 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); @@ -836,11 +839,13 @@ 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 reboundViewer = await currentViewer("threads", client); if (reboundViewer.subject !== viewer.subject) { throw new Error("Threads current viewer changed before the post dispatch"); @@ -850,18 +855,27 @@ async function executeThreadsPost( 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); await options.beforeDispatch?.(metaDispatchEvent("posts.publish", started, verified)); started = 1; failureStage = "post create response"; - created = await createThreadsPost(client, reboundViewer, prepared, uploaded, config); + created = await createThreadsPost( + client, + reboundViewer, + prepared, + uploaded, + config, + uploadId, + ); const createdImage = created.image; failureStage = "accepted target retention"; await options.afterProviderAcceptedMutationTarget?.({ @@ -869,15 +883,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"; @@ -895,29 +915,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 }, @@ -926,6 +957,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, @@ -933,13 +967,15 @@ 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", }; } } -type ThreadsPublishedMutationTarget = Readonly<{ +type ThreadsPublishedImageMutationTarget = Readonly<{ code: string; height: number; id: string; @@ -949,6 +985,22 @@ type ThreadsPublishedMutationTarget = Readonly<{ width: number; }>; +type ThreadsPublishedTextMutationTarget = Readonly<{ + code: string; + id: string; + url: string; +}>; + +type ThreadsPublishedMutationTarget = + | ThreadsPublishedImageMutationTarget + | ThreadsPublishedTextMutationTarget; + +function isThreadsPublishedImageTarget( + target: ThreadsPublishedMutationTarget, +): target is ThreadsPublishedImageMutationTarget { + return "mediaType" in target; +} + function threadsPublishedMutationTargetDimension( value: unknown, label: string, @@ -959,38 +1011,16 @@ 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) { - throw new Error("Threads provider-accepted post target did not identify one reviewed image"); - } - 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"); } @@ -1004,8 +1034,51 @@ 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"); + } + const keys = Object.keys(value).sort().join(","); + 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 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) { + throw new Error("Threads provider-accepted post target did not identify one reviewed image"); + } const parsed = Object.freeze({ code: value.code, height: threadsPublishedMutationTargetDimension( @@ -1043,13 +1116,16 @@ export async function readThreadsWebPublishedMutationTarget( if ( recipe.site !== "threads" || recipe.action !== "posts.publish" - || recipe.contractVersion !== 4 - ) throw new Error("Threads publish recovery supports only posts.publish@4"); + || (recipe.contractVersion !== 4 && recipe.contractVersion !== 5) + ) throw new Error("Threads publish recovery supports only posts.publish@4 or posts.publish@5"); const target = parseThreadsPublishedMutationTarget(identifier); const prepared = prepareMetaRead(recipe, input, auth, Object.freeze({})); if (prepared.kind !== "threads-post") { throw new Error("Threads publish recovery input did not match posts.publish"); } + if ((prepared.attachment !== undefined) !== isThreadsPublishedImageTarget(target)) { + 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); @@ -1074,16 +1150,22 @@ export async function readThreadsWebPublishedMutationTarget( target.code, target.url, prepared.body, - { height: target.height, width: target.width }, + isThreadsPublishedImageTarget(target) + ? { height: target.height, width: target.width } + : null, ); - const image = post.image; - if ( - image === null - || image.mediaId !== target.remoteMediaId - || image.mediaType !== target.mediaType - || image.width !== target.width - || image.height !== target.height - ) throw new Error("Threads publish recovery readback changed the accepted image"); + if (isThreadsPublishedImageTarget(target)) { + const image = post.image; + if ( + image === null + || image.mediaId !== target.remoteMediaId + || image.mediaType !== target.mediaType + || image.width !== target.width + || image.height !== target.height + ) throw new Error("Threads publish recovery readback changed the accepted image"); + } else if (post.image !== null) { + throw new Error("Threads publish recovery readback introduced an unconfirmed image"); + } return Object.freeze({ present: true, postId: target.id }); } @@ -1292,7 +1374,7 @@ type PreparedMetaRead = } | { readonly kind: "threads-post"; - readonly attachment: FileInputValue; + readonly attachment?: FileInputValue; readonly audience: "default"; readonly body: string; } @@ -1380,15 +1462,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 260f8c1..8fe15dd 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"; @@ -9,6 +11,7 @@ import instagramV1Manifest from "../assets/adapters/instagram/wrench-web-adapter import threadsManifest from "../assets/adapters/threads/wrench-web-adapter.json"; import threadsV1Manifest from "../assets/adapters/threads/wrench-web-adapter.v1.0.0.json"; 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 { metaWebPlugin } from "../plugins/meta-web/plugin"; import { META_WEB_OPERATIONS, @@ -404,7 +407,7 @@ describe("Meta consumer-web policy", () => { }); expect(META_WEB_OPERATIONS.threads["posts.publish"]).toMatchObject({ state: "observed", - contractVersion: 4, + contractVersion: 5, }); expect(META_WEB_OPERATIONS.instagram["likes.set"]).toMatchObject({ state: "capture-required" }); expect(META_WEB_OPERATIONS.instagram["messaging.send"]?.reason).toContain("E2EE"); @@ -447,7 +450,7 @@ describe("Meta consumer-web policy", () => { expect(instagramManifest.version).toBe("1.2.0"); expect(instagramV1Manifest.version).toBe("1.0.0"); - expect(threadsManifest.version).toBe("1.4.0"); + expect(threadsManifest.version).toBe("1.5.0"); expect(threadsV1Manifest.version).toBe("1.0.0"); for (const [site, operation, current, prior] of affected) { @@ -469,15 +472,41 @@ describe("Meta consumer-web policy", () => { test("versions locator-bound Threads publication while preserving reviewed predecessors", () => { expect(threadsV12Manifest.version).toBe("1.2.0"); + expect(threadsV14Manifest.version).toBe("1.4.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(threadsV14Manifest.operations["posts.publish"].webSession.contractVersion).toBe(4); + expect(threadsV14Manifest.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", () => { @@ -918,6 +947,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 7b4c0c4..3c09930 100644 --- a/src/providers/meta-web.ts +++ b/src/providers/meta-web.ts @@ -205,8 +205,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, ), "messaging.list": captureRequired( "messaging.list", @@ -1530,7 +1530,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"); @@ -1569,21 +1569,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"); @@ -2414,7 +2421,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", - publish: "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", + publish: "POST configure_text_post_app_feed with exact actor/text 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", }), facebook: Object.freeze({ viewer: "GET / HTML CurrentUserInitialData, corroborated by c_user", diff --git a/src/scripts/sync-bundled-adapters.test.ts b/src/scripts/sync-bundled-adapters.test.ts index f328216..34a292b 100644 --- a/src/scripts/sync-bundled-adapters.test.ts +++ b/src/scripts/sync-bundled-adapters.test.ts @@ -181,6 +181,7 @@ describe("single-process bundled adapter generation sync", () => { "threads-web@1.1.0", "threads-web@1.2.0", "threads-web@1.3.0", + "threads-web@1.4.0", "whatsapp-web@1.0.0", "whatsapp-web@1.1.0", "whatsapp-web@1.2.0", diff --git a/src/web-session-contract-definitions.ts b/src/web-session-contract-definitions.ts index 555fe3f..f506529 100644 --- a/src/web-session-contract-definitions.ts +++ b/src/web-session-contract-definitions.ts @@ -426,7 +426,7 @@ const META_WEB_OPERATIONS = Object.freeze({ }), threads: operationPolicies("threads", ["feeds.read", "posts.publish"], { "feeds.read": 2, - "posts.publish": 4, + "posts.publish": 5, }), facebook: operationPolicies("facebook", ["feeds.read"], { "feeds.read": 2,