@hraness/ghostget 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +1359 -0
- package/bunfig.toml +2 -0
- package/dist/apple-photos-client.js +704 -0
- package/dist/beeper-client.js +4602 -0
- package/dist/client.js +1605 -0
- package/dist/index-26yq8q16.js +31 -0
- package/dist/index-9t13b4k7.js +508 -0
- package/dist/index-axwm9b3v.js +5 -0
- package/dist/index-gwk7rbyj.js +30 -0
- package/dist/index.js +701 -0
- package/dist/messaging.js +1130 -0
- package/dist/omni-client.js +1406 -0
- package/dist/whatsapp-client.js +355 -0
- package/docs/imessage-direct-provider.md +106 -0
- package/docs/rental-listings.md +94 -0
- package/package.json +600 -0
- package/skills/ghostget/SKILL.md +251 -0
- package/skills/ghostget/agents/openai.yaml +4 -0
- package/skills/ghostget/references/adapter-contract.md +181 -0
- package/skills/ghostget/references/apple-photos.md +115 -0
- package/skills/ghostget/references/article-drafts.md +235 -0
- package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
- package/skills/ghostget/references/cross-posting-video.md +139 -0
- package/skills/ghostget/references/cross-posting.md +82 -0
- package/skills/ghostget/references/derivation.md +390 -0
- package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
- package/skills/ghostget/references/install.md +66 -0
- package/skills/ghostget/references/linkedin-adapter.md +442 -0
- package/skills/ghostget/references/messaging.md +168 -0
- package/skills/ghostget/references/meta-comet-contract.md +153 -0
- package/skills/ghostget/references/platform-patterns.md +122 -0
- package/skills/ghostget/references/provider-plugins.md +496 -0
- package/skills/ghostget/references/reddit-flair.md +78 -0
- package/skills/ghostget/references/safety-and-state.md +214 -0
- package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
- package/skills/ghostget/references/social-platform-routing.md +66 -0
- package/skills/ghostget/references/social-profile-stats.md +195 -0
- package/skills/ghostget/references/social-video-platform-routing.md +89 -0
- package/skills/ghostget/references/whatsapp-adapter.md +101 -0
- package/skills/ghostget/references/x-adapter.md +332 -0
- package/skills/ghostget/references/x-ai-disclosure.md +71 -0
- package/src/apple-photos-cli.ts +639 -0
- package/src/apple-photos-client-types.ts +168 -0
- package/src/apple-photos-client.ts +201 -0
- package/src/apple-photos-contact-evidence.ts +838 -0
- package/src/apple-photos-local-source.ts +1131 -0
- package/src/args.ts +2053 -0
- package/src/article-draft-document.ts +417 -0
- package/src/article-draft-embeds.ts +123 -0
- package/src/article-draft-images.ts +214 -0
- package/src/assets/adapter-template/wrench-adapter.json +40 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
- package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
- package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
- package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
- package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
- package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
- package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
- package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
- package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
- package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
- package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
- package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
- package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
- package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
- package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
- package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
- package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
- package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
- package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
- package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
- package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
- package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
- package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
- package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
- package/src/assets/adapters/x/wrench-adapter.json +749 -0
- package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
- package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
- package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
- package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
- package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
- package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
- package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
- package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
- package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
- package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
- package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/assets.d.ts +4 -0
- package/src/auth.ts +1157 -0
- package/src/beeper-client-types.ts +169 -0
- package/src/beeper-client.ts +250 -0
- package/src/beeper-contact-interactions-cli.ts +180 -0
- package/src/beeper-contact-interactions.ts +1612 -0
- package/src/beeper-message-bundle-v1.ts +257 -0
- package/src/beeper-message-like-me-cli.ts +84 -0
- package/src/beeper-message-like-me-export.ts +2274 -0
- package/src/beeper-message-like-me-recovery.ts +931 -0
- package/src/beeper-message-like-me-source.ts +4034 -0
- package/src/browser-admission.ts +928 -0
- package/src/browser-snapshots.ts +260 -0
- package/src/browser.ts +3873 -0
- package/src/canonical-json.ts +60 -0
- package/src/catalog-cli.ts +638 -0
- package/src/cli.ts +208 -0
- package/src/client-types.ts +301 -0
- package/src/client.ts +2726 -0
- package/src/confirmed-write-failure.ts +44 -0
- package/src/confirmed-write-model.ts +72 -0
- package/src/confirmed-write-platform.ts +1055 -0
- package/src/confirmed-write-program.ts +224 -0
- package/src/confirmed-write-runtime.ts +16 -0
- package/src/cursor-token.ts +228 -0
- package/src/derivation-dnr-readiness.ts +367 -0
- package/src/derivation-file-chooser.ts +384 -0
- package/src/derivation-network-boundary.ts +835 -0
- package/src/derivation-network-guard.ts +782 -0
- package/src/derivation-network-proxy-helper.ts +440 -0
- package/src/derivation-network-proxy.ts +385 -0
- package/src/derive-command-helper.ts +693 -0
- package/src/derive-fixtures.ts +343 -0
- package/src/derive-review.ts +980 -0
- package/src/derive.ts +4754 -0
- package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
- package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
- package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
- package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
- package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
- package/src/ghostget.ts +3672 -0
- package/src/gmail-capture.ts +1244 -0
- package/src/har-internal.ts +1242 -0
- package/src/har.ts +743 -0
- package/src/index.ts +218 -0
- package/src/invocation-read-platform.ts +55 -0
- package/src/invocation-read-program.ts +93 -0
- package/src/invocation-read-runtime.ts +11 -0
- package/src/linked-device-lifecycle-admission.ts +450 -0
- package/src/linked-device-lifecycle-journal.ts +1560 -0
- package/src/linked-device-lifecycle-runtime.ts +1602 -0
- package/src/local-cli-admission.ts +135 -0
- package/src/local-cli-contracts.ts +271 -0
- package/src/local-cli-execution.ts +98 -0
- package/src/local-cli-surface-contract.ts +1510 -0
- package/src/local-cli-tool-identity.ts +336 -0
- package/src/media/archive.ts +2325 -0
- package/src/media/args.ts +303 -0
- package/src/media/cli.ts +287 -0
- package/src/media/doctor.ts +475 -0
- package/src/media/ffmpeg.ts +1330 -0
- package/src/media/http-capture.ts +711 -0
- package/src/media/http-probe.ts +593 -0
- package/src/media/http.ts +387 -0
- package/src/media/index.ts +10 -0
- package/src/media/local-transcription.ts +613 -0
- package/src/media/lock.ts +321 -0
- package/src/media/manifest.ts +1712 -0
- package/src/media/metadata.ts +906 -0
- package/src/media/process.ts +723 -0
- package/src/media/revision.ts +166 -0
- package/src/media/runtime-closure.ts +1063 -0
- package/src/media/source-router.ts +187 -0
- package/src/media/transcriber-config.ts +1122 -0
- package/src/media/transcript-persistence-model.ts +15 -0
- package/src/media/transcript-persistence-platform.ts +89 -0
- package/src/media/transcript-persistence-program.ts +39 -0
- package/src/media/transcript-persistence-runtime.ts +24 -0
- package/src/media/transcript.ts +616 -0
- package/src/media/utf8-order.ts +30 -0
- package/src/media/whisper-cpp.ts +464 -0
- package/src/media/whisper-language.ts +58 -0
- package/src/media/yt-dlp.ts +439 -0
- package/src/message-like-me-agentic-messaging.ts +882 -0
- package/src/messaging-action-store.ts +793 -0
- package/src/messaging-runtime.ts +2136 -0
- package/src/messaging-store.ts +539 -0
- package/src/messaging-types.ts +1663 -0
- package/src/messaging.ts +334 -0
- package/src/model.ts +2671 -0
- package/src/oauth-google.ts +888 -0
- package/src/omni-client-types.ts +306 -0
- package/src/omni-client.ts +2225 -0
- package/src/omni-limits.ts +34 -0
- package/src/omni-model.ts +1614 -0
- package/src/omni-request.ts +379 -0
- package/src/omni-runtime.ts +1632 -0
- package/src/operation-deadline.ts +189 -0
- package/src/path-helper.ts +1656 -0
- package/src/pinned-https.ts +194 -0
- package/src/plan-assets.ts +832 -0
- package/src/platform-catalog.ts +1832 -0
- package/src/plugins/beeper-linked-device/plugin.ts +310 -0
- package/src/plugins/bluesky-web/plugin.ts +178 -0
- package/src/plugins/clasificados-web/plugin.ts +66 -0
- package/src/plugins/github-web/plugin.ts +80 -0
- package/src/plugins/gmail-official/plugin.ts +110 -0
- package/src/plugins/hacker-news-web/plugin.ts +52 -0
- package/src/plugins/imessage-direct/plugin.ts +195 -0
- package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
- package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
- package/src/plugins/linkedin-official/plugin.ts +84 -0
- package/src/plugins/linkedin-web/plugin.ts +412 -0
- package/src/plugins/meta-web/plugin.ts +317 -0
- package/src/plugins/reddit-web/flair.ts +492 -0
- package/src/plugins/reddit-web/plugin.ts +477 -0
- package/src/plugins/substack-web/plugin.ts +124 -0
- package/src/plugins/tiktok-web/plugin.ts +67 -0
- package/src/plugins/twitch-web/plugin.ts +54 -0
- package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
- package/src/plugins/x-official/plugin.ts +64 -0
- package/src/plugins/x-web/plugin.ts +488 -0
- package/src/plugins/youtube-web/plugin.ts +101 -0
- package/src/portable-run-recovery.ts +466 -0
- package/src/process-identity.ts +501 -0
- package/src/profile-clone-helper.ts +104 -0
- package/src/provider-catalog-views.ts +51 -0
- package/src/provider-context.ts +69 -0
- package/src/provider-contract-definitions-x.ts +45 -0
- package/src/provider-contract-definitions.ts +948 -0
- package/src/provider-contract-input-gmail.ts +176 -0
- package/src/provider-contract-input-linkedin.ts +77 -0
- package/src/provider-contract-input-x.ts +304 -0
- package/src/provider-contract-planning.ts +23 -0
- package/src/provider-contract-semantic-identity.ts +54 -0
- package/src/provider-contracts.ts +251 -0
- package/src/provider-http.ts +723 -0
- package/src/provider-plugin-auth.ts +36 -0
- package/src/provider-plugin-builtins.ts +229 -0
- package/src/provider-plugin-cleanup-barrier.ts +229 -0
- package/src/provider-plugin-cleanup-execution.ts +79 -0
- package/src/provider-plugin-cleanup-resource.ts +470 -0
- package/src/provider-plugin-contract-identity.ts +707 -0
- package/src/provider-plugin-host.ts +1972 -0
- package/src/provider-plugin-identifiers.ts +60 -0
- package/src/provider-plugin-invocation-lease.ts +889 -0
- package/src/provider-plugin-lifecycle-kernel.ts +566 -0
- package/src/provider-plugin-lifecycle.ts +1455 -0
- package/src/provider-plugin-module-analysis.ts +336 -0
- package/src/provider-plugin-package.ts +2530 -0
- package/src/provider-plugin-portable-authority.ts +106 -0
- package/src/provider-plugin-portable-catalog.ts +94 -0
- package/src/provider-plugin-portable-identity.ts +315 -0
- package/src/provider-plugin-portable-runtime.ts +2057 -0
- package/src/provider-plugin-protocol.ts +1625 -0
- package/src/provider-plugin-registry.ts +3342 -0
- package/src/provider-plugin-store.ts +2155 -0
- package/src/provider-plugin-test-fixture.ts +5 -0
- package/src/provider-plugin.ts +4907 -0
- package/src/provider-plugins.generated.ts +43 -0
- package/src/provider-plugins.ts +8 -0
- package/src/provider-subject.ts +41 -0
- package/src/provider.ts +315 -0
- package/src/providers/beeper-local-runtime.ts +5426 -0
- package/src/providers/beeper-local.ts +2739 -0
- package/src/providers/beeper-messaging.ts +462 -0
- package/src/providers/beeper-omni.ts +1159 -0
- package/src/providers/bluesky-web-runtime.ts +2895 -0
- package/src/providers/bluesky-web.ts +1614 -0
- package/src/providers/clasificados-web-runtime.ts +255 -0
- package/src/providers/clasificados-web.ts +503 -0
- package/src/providers/contact-projection.ts +301 -0
- package/src/providers/github-read-model.ts +94 -0
- package/src/providers/github-read-platform.ts +165 -0
- package/src/providers/github-read-program.ts +97 -0
- package/src/providers/github-web-runtime.ts +320 -0
- package/src/providers/github-web.ts +403 -0
- package/src/providers/gmail-api.ts +2834 -0
- package/src/providers/gmail-omni.ts +830 -0
- package/src/providers/gmail.ts +1130 -0
- package/src/providers/hacker-news-web-runtime.ts +220 -0
- package/src/providers/hacker-news-web.ts +809 -0
- package/src/providers/imessage-direct-install.ts +354 -0
- package/src/providers/imessage-direct-messaging.ts +532 -0
- package/src/providers/imessage-direct-omni.ts +400 -0
- package/src/providers/imessage-direct-runtime.ts +1498 -0
- package/src/providers/imessage-direct.ts +351 -0
- package/src/providers/instagram-video-foundations.ts +913 -0
- package/src/providers/instagram-web-profile-browser.ts +600 -0
- package/src/providers/iso-bmff.ts +338 -0
- package/src/providers/linkedin-company-platform.ts +42 -0
- package/src/providers/linkedin-company-program.ts +88 -0
- package/src/providers/linkedin-contact-failure.ts +84 -0
- package/src/providers/linkedin-contact-platform.ts +45 -0
- package/src/providers/linkedin-contact-program.ts +91 -0
- package/src/providers/linkedin-profile-activity-failure.ts +76 -0
- package/src/providers/linkedin-profile-activity-platform.ts +41 -0
- package/src/providers/linkedin-profile-activity-program.ts +67 -0
- package/src/providers/linkedin-read-failure.ts +52 -0
- package/src/providers/linkedin-self-platform.ts +53 -0
- package/src/providers/linkedin-self-program.ts +122 -0
- package/src/providers/linkedin-web-article-browser.ts +794 -0
- package/src/providers/linkedin-web-bootstrap.ts +146 -0
- package/src/providers/linkedin-web-contact.ts +830 -0
- package/src/providers/linkedin-web-feed-browser.ts +832 -0
- package/src/providers/linkedin-web-feed.ts +864 -0
- package/src/providers/linkedin-web-post-browser.ts +653 -0
- package/src/providers/linkedin-web-profile-browser.ts +683 -0
- package/src/providers/linkedin-web-runtime.ts +2388 -0
- package/src/providers/linkedin-web.ts +3305 -0
- package/src/providers/linkedin.ts +1885 -0
- package/src/providers/meta-bootstrap.ts +1043 -0
- package/src/providers/meta-facebook-group.ts +690 -0
- package/src/providers/meta-marketplace-relay.ts +747 -0
- package/src/providers/meta-omni.ts +373 -0
- package/src/providers/meta-relay-bundle.ts +1049 -0
- package/src/providers/meta-web-descriptors.ts +1740 -0
- package/src/providers/meta-web-runtime.ts +3388 -0
- package/src/providers/meta-web.ts +2931 -0
- package/src/providers/read-failure.ts +139 -0
- package/src/providers/reddit-omni.ts +462 -0
- package/src/providers/reddit-web-runtime.ts +1929 -0
- package/src/providers/reddit-web.ts +1611 -0
- package/src/providers/rental-listings.ts +460 -0
- package/src/providers/substack-omni.ts +406 -0
- package/src/providers/substack-video-mp4.ts +33 -0
- package/src/providers/substack-web-runtime.ts +2812 -0
- package/src/providers/substack-web.ts +1502 -0
- package/src/providers/tiktok-video-mp4.ts +33 -0
- package/src/providers/tiktok-web-runtime.ts +770 -0
- package/src/providers/tiktok-web.ts +2786 -0
- package/src/providers/twitch-web-runtime.ts +226 -0
- package/src/providers/twitch-web.ts +274 -0
- package/src/providers/whatsapp-account-identity.ts +55 -0
- package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
- package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
- package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
- package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
- package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
- package/src/providers/whatsapp-messaging.ts +132 -0
- package/src/providers/whatsapp-omni.ts +586 -0
- package/src/providers/whatsapp-web-runtime.ts +3108 -0
- package/src/providers/whatsapp-web.ts +1037 -0
- package/src/providers/x-image-provenance.ts +523 -0
- package/src/providers/x-made-with-ai.ts +106 -0
- package/src/providers/x-transaction-id.ts +413 -0
- package/src/providers/x-web-runtime.ts +3485 -0
- package/src/providers/x-web.ts +2332 -0
- package/src/providers/x.ts +1889 -0
- package/src/providers/youtube-web-runtime.ts +1400 -0
- package/src/providers/youtube-web.ts +1221 -0
- package/src/read-admission-runtime.ts +91 -0
- package/src/read-client.ts +521 -0
- package/src/read-effect-platform.ts +7 -0
- package/src/read-effect-runtime.ts +14 -0
- package/src/read-effect.ts +52 -0
- package/src/read-projection-admission.ts +866 -0
- package/src/read-projections.ts +3151 -0
- package/src/recovery.ts +1492 -0
- package/src/reviewed-template.ts +146 -0
- package/src/run-journal.ts +1272 -0
- package/src/runtime.ts +5116 -0
- package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
- package/src/scripts/install-whatsapp-protocol.sh +232 -0
- package/src/scripts/recover-install-stages.ts +1491 -0
- package/src/scripts/resolve-state-home.ts +30 -0
- package/src/scripts/scaffold-web-provider.ts +973 -0
- package/src/scripts/sync-bundled-adapters.ts +695 -0
- package/src/session-secrets.ts +1183 -0
- package/src/state-helper.bunfig.toml +2 -0
- package/src/state-helper.ts +2914 -0
- package/src/storage.ts +3842 -0
- package/src/transport-policy.ts +2 -0
- package/src/usage.ts +182 -0
- package/src/version.ts +2 -0
- package/src/web-session-authentication-policy.ts +136 -0
- package/src/web-session-cleanup-admission.ts +2344 -0
- package/src/web-session-cleanup-join.ts +99 -0
- package/src/web-session-client.ts +996 -0
- package/src/web-session-contract-definitions.ts +820 -0
- package/src/web-session-contract-planning.ts +42 -0
- package/src/web-session-contracts.ts +228 -0
- package/src/web-session-cookies.ts +500 -0
- package/src/web-session-execution.ts +385 -0
- package/src/web-session-http.ts +373 -0
- package/src/web-session-read-errors.ts +19 -0
- package/src/web-session-read-runtime.ts +124 -0
- package/src/web-session-recovery.ts +818 -0
- package/src/web-session-sites.ts +58 -0
- package/src/web-session-template.ts +994 -0
- package/src/web-session.ts +93 -0
- package/src/whatsapp-client-binding.ts +21 -0
- package/src/whatsapp-client-types.ts +75 -0
- package/src/whatsapp-client.ts +395 -0
- package/src/whatsapp-export-coordinate.ts +17 -0
- package/src/whatsapp-message-bundle-v2.ts +195 -0
- package/src/whatsapp-message-like-me-cli.ts +195 -0
- package/src/whatsapp-message-like-me-export.ts +1072 -0
- package/src/whatsapp-message-like-me-source.ts +1012 -0
- package/tsconfig.json +67 -0
|
@@ -0,0 +1,1929 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
import type { GhostgetAuth } from "../auth";
|
|
6
|
+
import type { BrowserFileResolver } from "../browser";
|
|
7
|
+
import { canonicalJson } from "../canonical-json";
|
|
8
|
+
import type { FileInputValue, OperationInput, WebSessionRecipe } from "../model";
|
|
9
|
+
import {
|
|
10
|
+
createWebSessionClient,
|
|
11
|
+
uploadPublicWebAsset,
|
|
12
|
+
webSessionAuthSubject,
|
|
13
|
+
type WebSessionClient,
|
|
14
|
+
type WebSessionNetworkDependencies,
|
|
15
|
+
} from "../web-session-client";
|
|
16
|
+
import type {
|
|
17
|
+
WebSessionDispatchEvent,
|
|
18
|
+
WebSessionExecution,
|
|
19
|
+
WebSessionOperationDeadline,
|
|
20
|
+
WebSessionProviderAcceptedMutationTargetEvent,
|
|
21
|
+
} from "../web-session-execution";
|
|
22
|
+
import {
|
|
23
|
+
isRedditFlairOperation,
|
|
24
|
+
parseRedditFlairChoicesResponse,
|
|
25
|
+
parseRedditFlairInput,
|
|
26
|
+
} from "../plugins/reddit-web/flair";
|
|
27
|
+
import { failedProviderRead, type ProviderReadFailureStage } from "./read-failure";
|
|
28
|
+
import {
|
|
29
|
+
REDDIT_WEB_OPERATION_NAMES,
|
|
30
|
+
REDDIT_WEB_OPERATIONS,
|
|
31
|
+
assertRedditMutationSuccess,
|
|
32
|
+
authorizeRedditWebRequest,
|
|
33
|
+
normalizeRedditCommentsResponse,
|
|
34
|
+
normalizeRedditFeedResponse,
|
|
35
|
+
normalizeRedditMessageListing,
|
|
36
|
+
normalizeRedditPostResponse,
|
|
37
|
+
parseRedditAuthoredPostPresence,
|
|
38
|
+
parseRedditMediaLeaseResponse,
|
|
39
|
+
parseRedditProfileContributionPage,
|
|
40
|
+
parseRedditThingState,
|
|
41
|
+
parseRedditVideoPostPresence,
|
|
42
|
+
parseRedditVideoSubmitResponse,
|
|
43
|
+
parseRedditVideoWebSocketMessage,
|
|
44
|
+
parseRedditWebProfileResponse,
|
|
45
|
+
parseRedditWebViewerResponse,
|
|
46
|
+
projectRedditHostedVideoMetadata,
|
|
47
|
+
redditCommunity,
|
|
48
|
+
redditFullname,
|
|
49
|
+
redditMediaAssetUrl,
|
|
50
|
+
redditPostId,
|
|
51
|
+
type RedditMediaLease,
|
|
52
|
+
type RedditMediaType,
|
|
53
|
+
type RedditWebOperationName,
|
|
54
|
+
type RedditWebViewer,
|
|
55
|
+
} from "./reddit-web";
|
|
56
|
+
|
|
57
|
+
const REDDIT_ORIGIN = "https://www.reddit.com";
|
|
58
|
+
const REDDIT_LEASE_ORIGIN = "https://old.reddit.com";
|
|
59
|
+
const REDDIT_USER_AGENT = "wrench/1.0 (local authenticated web client)";
|
|
60
|
+
const MAX_VIEWER_BYTES = 512 * 1024;
|
|
61
|
+
const MAX_READ_BYTES = 4 * 1024 * 1024;
|
|
62
|
+
const DEFAULT_LIMIT = 25;
|
|
63
|
+
const MAX_PROFILE_OVERVIEW_PAGES = 10;
|
|
64
|
+
|
|
65
|
+
const REDDIT_VIDEO_MAX_BYTES = 512 * 1024 * 1024;
|
|
66
|
+
const REDDIT_POSTER_MAX_BYTES = 20 * 1024 * 1024;
|
|
67
|
+
const REDDIT_UPLOAD_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
68
|
+
const REDDIT_VIDEO_FILENAME = "wrench-video.mp4";
|
|
69
|
+
|
|
70
|
+
export type RedditWebRuntimeDependencies = Partial<WebSessionNetworkDependencies> & {
|
|
71
|
+
readonly now?: () => number;
|
|
72
|
+
readonly sleep?: (milliseconds: number) => Promise<void>;
|
|
73
|
+
readonly waitForWebSocketMessage?: (
|
|
74
|
+
url: string,
|
|
75
|
+
options: Readonly<{ timeoutMs: number; signal?: AbortSignal }>,
|
|
76
|
+
) => Promise<unknown>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type RedditWebDesiredStatePreparation =
|
|
80
|
+
| {
|
|
81
|
+
readonly operation: "content.save";
|
|
82
|
+
readonly thingId: string;
|
|
83
|
+
readonly desiredState: boolean;
|
|
84
|
+
readonly actualState: boolean;
|
|
85
|
+
readonly alreadyDesired: boolean;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
readonly operation: "reactions.set";
|
|
89
|
+
readonly thingId: string;
|
|
90
|
+
readonly desiredState: boolean | null;
|
|
91
|
+
readonly actualState: boolean | null;
|
|
92
|
+
readonly alreadyDesired: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type RedditWebDesiredStateReadback = {
|
|
96
|
+
readonly kind: "saved";
|
|
97
|
+
readonly enabled: boolean;
|
|
98
|
+
readonly thingId: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function isRedditOperation(value: string): value is RedditWebOperationName {
|
|
102
|
+
return (REDDIT_WEB_OPERATION_NAMES as readonly string[]).includes(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function integerInput(
|
|
106
|
+
input: OperationInput,
|
|
107
|
+
name: string,
|
|
108
|
+
fallback: number,
|
|
109
|
+
minimum: number,
|
|
110
|
+
maximum: number,
|
|
111
|
+
): number {
|
|
112
|
+
const value = input[name] ?? fallback;
|
|
113
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
|
114
|
+
throw new Error(`input.${name} must be an integer between ${minimum} and ${maximum}`);
|
|
115
|
+
}
|
|
116
|
+
return value as number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stringInput(
|
|
120
|
+
input: OperationInput,
|
|
121
|
+
name: string,
|
|
122
|
+
maximum: number,
|
|
123
|
+
): string {
|
|
124
|
+
const value = input[name];
|
|
125
|
+
if (
|
|
126
|
+
typeof value !== "string"
|
|
127
|
+
|| value.length < 1
|
|
128
|
+
|| value.length > maximum
|
|
129
|
+
|| /[\0\r\n]/u.test(value)
|
|
130
|
+
) throw new Error(`input.${name} must be a bounded string`);
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function booleanInput(input: OperationInput, name: string): boolean {
|
|
135
|
+
const value = input[name];
|
|
136
|
+
if (typeof value !== "boolean") throw new Error(`input.${name} must be boolean`);
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function optionalStringInput(
|
|
141
|
+
input: OperationInput,
|
|
142
|
+
name: string,
|
|
143
|
+
maximum: number,
|
|
144
|
+
): string | undefined {
|
|
145
|
+
const value = input[name];
|
|
146
|
+
if (value === undefined) return undefined;
|
|
147
|
+
return stringInput(input, name, maximum);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function optionalBodyInput(
|
|
151
|
+
input: OperationInput,
|
|
152
|
+
name: string,
|
|
153
|
+
maximum: number,
|
|
154
|
+
): string | undefined {
|
|
155
|
+
const value = input[name];
|
|
156
|
+
if (value === undefined) return undefined;
|
|
157
|
+
if (
|
|
158
|
+
typeof value !== "string"
|
|
159
|
+
|| value.length < 1
|
|
160
|
+
|| value.length > maximum
|
|
161
|
+
|| /[\0\r]/u.test(value)
|
|
162
|
+
) throw new Error(`input.${name} must be bounded text`);
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fileInput(value: unknown, label: string): FileInputValue {
|
|
167
|
+
if (
|
|
168
|
+
typeof value !== "object"
|
|
169
|
+
|| value === null
|
|
170
|
+
|| Array.isArray(value)
|
|
171
|
+
|| Object.keys(value).sort().join(",") !== "kind,reference"
|
|
172
|
+
|| (value as { readonly kind?: unknown }).kind !== "file"
|
|
173
|
+
|| typeof (value as { readonly reference?: unknown }).reference !== "string"
|
|
174
|
+
|| (value as { readonly reference: string }).reference.length < 1
|
|
175
|
+
|| (value as { readonly reference: string }).reference.length > 1_024
|
|
176
|
+
) throw new Error(`${label} must be one plan-bound file`);
|
|
177
|
+
return value as FileInputValue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function stableFileBytes(
|
|
181
|
+
path: string,
|
|
182
|
+
maximumBytes: number,
|
|
183
|
+
label: string,
|
|
184
|
+
operationDeadline?: WebSessionOperationDeadline,
|
|
185
|
+
): Promise<Uint8Array> {
|
|
186
|
+
const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
|
|
187
|
+
const handle = await open(path, constants.O_RDONLY | noFollow);
|
|
188
|
+
try {
|
|
189
|
+
const before = operationDeadline === undefined
|
|
190
|
+
? await handle.stat()
|
|
191
|
+
: await operationDeadline.run(
|
|
192
|
+
() => handle.stat(),
|
|
193
|
+
"authenticated web operation deadline",
|
|
194
|
+
);
|
|
195
|
+
if (!before.isFile() || before.size < 1 || before.size > maximumBytes) {
|
|
196
|
+
throw new Error(`${label} must be a regular file within its reviewed byte bound`);
|
|
197
|
+
}
|
|
198
|
+
const bytes = operationDeadline === undefined
|
|
199
|
+
? await handle.readFile()
|
|
200
|
+
: await operationDeadline.run(
|
|
201
|
+
() => handle.readFile(),
|
|
202
|
+
"authenticated web operation deadline",
|
|
203
|
+
);
|
|
204
|
+
const after = operationDeadline === undefined
|
|
205
|
+
? await handle.stat()
|
|
206
|
+
: await operationDeadline.run(
|
|
207
|
+
() => handle.stat(),
|
|
208
|
+
"authenticated web operation deadline",
|
|
209
|
+
);
|
|
210
|
+
if (
|
|
211
|
+
after.dev !== before.dev
|
|
212
|
+
|| after.ino !== before.ino
|
|
213
|
+
|| after.size !== before.size
|
|
214
|
+
|| bytes.byteLength !== before.size
|
|
215
|
+
) throw new Error(`${label} changed while it was materialized`);
|
|
216
|
+
return bytes;
|
|
217
|
+
} finally {
|
|
218
|
+
await handle.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type RedditBoundMedia = Readonly<{
|
|
223
|
+
video: Uint8Array;
|
|
224
|
+
poster: Uint8Array;
|
|
225
|
+
posterType: "image/jpeg" | "image/png";
|
|
226
|
+
posterFilename: "wrench-poster.jpg" | "wrench-poster.png";
|
|
227
|
+
}>;
|
|
228
|
+
|
|
229
|
+
function assertMp4(bytes: Uint8Array): void {
|
|
230
|
+
if (
|
|
231
|
+
bytes.byteLength < 16
|
|
232
|
+
|| bytes[4] !== 0x66
|
|
233
|
+
|| bytes[5] !== 0x74
|
|
234
|
+
|| bytes[6] !== 0x79
|
|
235
|
+
|| bytes[7] !== 0x70
|
|
236
|
+
) throw new Error("Reddit video must be one bounded ISO BMFF MP4");
|
|
237
|
+
const firstBoxSize = (
|
|
238
|
+
((bytes[0] ?? 0) << 24)
|
|
239
|
+
| ((bytes[1] ?? 0) << 16)
|
|
240
|
+
| ((bytes[2] ?? 0) << 8)
|
|
241
|
+
| (bytes[3] ?? 0)
|
|
242
|
+
) >>> 0;
|
|
243
|
+
if (firstBoxSize < 16 || firstBoxSize > bytes.byteLength) {
|
|
244
|
+
throw new Error("Reddit video MP4 file-type box changed shape");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function posterType(bytes: Uint8Array): RedditBoundMedia["posterType"] {
|
|
249
|
+
if (
|
|
250
|
+
bytes.byteLength >= 8
|
|
251
|
+
&& bytes[0] === 0x89
|
|
252
|
+
&& bytes[1] === 0x50
|
|
253
|
+
&& bytes[2] === 0x4e
|
|
254
|
+
&& bytes[3] === 0x47
|
|
255
|
+
&& bytes[4] === 0x0d
|
|
256
|
+
&& bytes[5] === 0x0a
|
|
257
|
+
&& bytes[6] === 0x1a
|
|
258
|
+
&& bytes[7] === 0x0a
|
|
259
|
+
) return "image/png";
|
|
260
|
+
if (
|
|
261
|
+
bytes.byteLength >= 4
|
|
262
|
+
&& bytes[0] === 0xff
|
|
263
|
+
&& bytes[1] === 0xd8
|
|
264
|
+
&& bytes[2] === 0xff
|
|
265
|
+
) return "image/jpeg";
|
|
266
|
+
throw new Error("Reddit video poster must be one bounded PNG or JPEG");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readBoundRedditMedia(
|
|
270
|
+
input: OperationInput,
|
|
271
|
+
fileResolver: BrowserFileResolver | undefined,
|
|
272
|
+
operationDeadline?: WebSessionOperationDeadline,
|
|
273
|
+
): Promise<RedditBoundMedia> {
|
|
274
|
+
const videoInput = fileInput(input.media, "input.media");
|
|
275
|
+
const posterInput = fileInput(input.thumbnail, "input.thumbnail");
|
|
276
|
+
if (fileResolver === undefined) {
|
|
277
|
+
throw new Error("Reddit video upload requires the plan-bound file resolver");
|
|
278
|
+
}
|
|
279
|
+
const resolve = () => fileResolver([videoInput, posterInput]);
|
|
280
|
+
const paths = operationDeadline === undefined
|
|
281
|
+
? await resolve()
|
|
282
|
+
: await operationDeadline.run(resolve, "authenticated web operation deadline");
|
|
283
|
+
if (
|
|
284
|
+
paths.length !== 2
|
|
285
|
+
|| typeof paths[0] !== "string"
|
|
286
|
+
|| typeof paths[1] !== "string"
|
|
287
|
+
) throw new Error("Reddit media resolver did not return the exact video and poster files");
|
|
288
|
+
const video = await stableFileBytes(
|
|
289
|
+
paths[0],
|
|
290
|
+
REDDIT_VIDEO_MAX_BYTES,
|
|
291
|
+
"Reddit video",
|
|
292
|
+
operationDeadline,
|
|
293
|
+
);
|
|
294
|
+
const poster = await stableFileBytes(
|
|
295
|
+
paths[1],
|
|
296
|
+
REDDIT_POSTER_MAX_BYTES,
|
|
297
|
+
"Reddit video poster",
|
|
298
|
+
operationDeadline,
|
|
299
|
+
);
|
|
300
|
+
assertMp4(video);
|
|
301
|
+
const type = posterType(poster);
|
|
302
|
+
return Object.freeze({
|
|
303
|
+
video,
|
|
304
|
+
poster,
|
|
305
|
+
posterType: type,
|
|
306
|
+
posterFilename: type === "image/png" ? "wrench-poster.png" : "wrench-poster.jpg",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function exactReadHeaders(): Readonly<Record<string, string>> {
|
|
311
|
+
return Object.freeze({
|
|
312
|
+
accept: "application/json",
|
|
313
|
+
referer: `${REDDIT_ORIGIN}/`,
|
|
314
|
+
"user-agent": REDDIT_USER_AGENT,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function exactMutationHeaders(): Readonly<Record<string, string>> {
|
|
319
|
+
return Object.freeze({
|
|
320
|
+
accept: "application/json",
|
|
321
|
+
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
322
|
+
origin: REDDIT_ORIGIN,
|
|
323
|
+
referer: `${REDDIT_ORIGIN}/`,
|
|
324
|
+
"user-agent": REDDIT_USER_AGENT,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function exactLeaseHeaders(
|
|
329
|
+
community: string,
|
|
330
|
+
modhash: string,
|
|
331
|
+
): Readonly<Record<string, string>> {
|
|
332
|
+
return Object.freeze({
|
|
333
|
+
accept: "application/json",
|
|
334
|
+
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
335
|
+
origin: REDDIT_LEASE_ORIGIN,
|
|
336
|
+
referer: `${REDDIT_LEASE_ORIGIN}/r/${redditCommunity(community)}/submit`,
|
|
337
|
+
"user-agent": REDDIT_USER_AGENT,
|
|
338
|
+
"x-modhash": modhash,
|
|
339
|
+
"x-requested-with": "XMLHttpRequest",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function exactFlairHeaders(
|
|
344
|
+
community: string,
|
|
345
|
+
kind: "user" | "post",
|
|
346
|
+
): Readonly<Record<string, string>> {
|
|
347
|
+
const subreddit = redditCommunity(community);
|
|
348
|
+
return Object.freeze({
|
|
349
|
+
accept: "text/html",
|
|
350
|
+
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
351
|
+
origin: REDDIT_LEASE_ORIGIN,
|
|
352
|
+
referer: kind === "post"
|
|
353
|
+
? `${REDDIT_LEASE_ORIGIN}/r/${subreddit}/submit`
|
|
354
|
+
: `${REDDIT_LEASE_ORIGIN}/r/${subreddit}/`,
|
|
355
|
+
"user-agent": REDDIT_USER_AGENT,
|
|
356
|
+
"x-requested-with": "XMLHttpRequest",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function redditMultipartUpload(
|
|
361
|
+
lease: RedditMediaLease,
|
|
362
|
+
bytes: Uint8Array,
|
|
363
|
+
filename: string,
|
|
364
|
+
mediaType: RedditMediaType,
|
|
365
|
+
): Readonly<{ body: Uint8Array; contentType: string }> {
|
|
366
|
+
const digest = createHash("sha256")
|
|
367
|
+
.update(bytes)
|
|
368
|
+
.update(filename)
|
|
369
|
+
.digest("hex")
|
|
370
|
+
.slice(0, 32);
|
|
371
|
+
const fieldText = lease.fields.map(({ name, value }) => `${name}\0${value}`).join("\0");
|
|
372
|
+
for (let suffix = 0; suffix < 16; suffix += 1) {
|
|
373
|
+
const boundary = `wrench-reddit-upload-${digest}-${suffix}`;
|
|
374
|
+
if (
|
|
375
|
+
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
376
|
+
.includes(Buffer.from(boundary, "ascii"))
|
|
377
|
+
|| fieldText.includes(boundary)
|
|
378
|
+
) continue;
|
|
379
|
+
const chunks: Buffer[] = [];
|
|
380
|
+
for (const field of lease.fields) {
|
|
381
|
+
chunks.push(Buffer.from(
|
|
382
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`,
|
|
383
|
+
"utf8",
|
|
384
|
+
));
|
|
385
|
+
}
|
|
386
|
+
chunks.push(Buffer.from(
|
|
387
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mediaType}\r\n\r\n`,
|
|
388
|
+
"utf8",
|
|
389
|
+
));
|
|
390
|
+
chunks.push(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
391
|
+
chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`, "ascii"));
|
|
392
|
+
return Object.freeze({
|
|
393
|
+
body: new Uint8Array(Buffer.concat(chunks)),
|
|
394
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
throw new Error("Reddit media could not bind an unambiguous multipart boundary");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function uploadRedditMedia(
|
|
401
|
+
recipe: WebSessionRecipe,
|
|
402
|
+
lease: RedditMediaLease,
|
|
403
|
+
bytes: Uint8Array,
|
|
404
|
+
filename: string,
|
|
405
|
+
mediaType: RedditMediaType,
|
|
406
|
+
dependencies: RedditWebRuntimeDependencies | undefined,
|
|
407
|
+
operationDeadline: WebSessionOperationDeadline | undefined,
|
|
408
|
+
): Promise<string> {
|
|
409
|
+
const multipart = redditMultipartUpload(lease, bytes, filename, mediaType);
|
|
410
|
+
await uploadPublicWebAsset(new URL("/", lease.uploadOrigin), {
|
|
411
|
+
allowedOrigin: lease.uploadOrigin,
|
|
412
|
+
body: multipart.body,
|
|
413
|
+
contentType: multipart.contentType,
|
|
414
|
+
expectedStatus: 201,
|
|
415
|
+
maxBytes: mediaType === "video/mp4"
|
|
416
|
+
? REDDIT_VIDEO_MAX_BYTES + 1024 * 1024
|
|
417
|
+
: REDDIT_POSTER_MAX_BYTES + 1024 * 1024,
|
|
418
|
+
timeoutMs: recipe.timeoutMs,
|
|
419
|
+
userAgent: REDDIT_USER_AGENT,
|
|
420
|
+
...(operationDeadline === undefined ? {} : { operationDeadline }),
|
|
421
|
+
...(dependencies === undefined ? {} : { dependencies }),
|
|
422
|
+
});
|
|
423
|
+
return redditMediaAssetUrl(lease);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function safeRedditPreparationFailure(error: unknown): string | null {
|
|
427
|
+
if (!(error instanceof Error)) return null;
|
|
428
|
+
const message = error.message;
|
|
429
|
+
if (
|
|
430
|
+
/^public web asset upload returned unreviewed status [1-5][0-9]{2}$/u.test(message)
|
|
431
|
+
|| message === "public web asset upload failed before a reviewed response was received"
|
|
432
|
+
) return message;
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function defaultWaitForWebSocketMessage(
|
|
437
|
+
url: string,
|
|
438
|
+
options: Readonly<{ timeoutMs: number; signal?: AbortSignal }>,
|
|
439
|
+
): Promise<unknown> {
|
|
440
|
+
if (
|
|
441
|
+
!Number.isSafeInteger(options.timeoutMs)
|
|
442
|
+
|| options.timeoutMs < 1_000
|
|
443
|
+
|| options.timeoutMs > 180_000
|
|
444
|
+
) throw new Error("Reddit video websocket timeout is outside its reviewed bound");
|
|
445
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
446
|
+
let settled = false;
|
|
447
|
+
let socket: WebSocket;
|
|
448
|
+
const finish = (result: { readonly ok: true; readonly value: unknown } | { readonly ok: false }): void => {
|
|
449
|
+
if (settled) return;
|
|
450
|
+
settled = true;
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
options.signal?.removeEventListener("abort", abort);
|
|
453
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
454
|
+
socket.close(1000);
|
|
455
|
+
}
|
|
456
|
+
if (result.ok) resolve(result.value);
|
|
457
|
+
else reject(new Error("Reddit video processing websocket ended without a reviewed result"));
|
|
458
|
+
};
|
|
459
|
+
const abort = (): void => finish({ ok: false });
|
|
460
|
+
const timer = setTimeout(() => finish({ ok: false }), options.timeoutMs);
|
|
461
|
+
try {
|
|
462
|
+
socket = new WebSocket(url);
|
|
463
|
+
} catch {
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
reject(new Error("Reddit video processing websocket could not be opened"));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
469
|
+
socket.onmessage = (event): void => {
|
|
470
|
+
if (typeof event.data !== "string" || Buffer.byteLength(event.data, "utf8") > 64 * 1024) {
|
|
471
|
+
finish({ ok: false });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
finish({ ok: true, value: event.data });
|
|
475
|
+
};
|
|
476
|
+
socket.onerror = (): void => finish({ ok: false });
|
|
477
|
+
socket.onclose = (): void => finish({ ok: false });
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function currentViewer(
|
|
482
|
+
client: WebSessionClient,
|
|
483
|
+
maximumBytes = MAX_VIEWER_BYTES,
|
|
484
|
+
): Promise<RedditWebViewer> {
|
|
485
|
+
const url = new URL("/api/me.json", REDDIT_ORIGIN);
|
|
486
|
+
authorizeRedditWebRequest({
|
|
487
|
+
operation: "viewer.current",
|
|
488
|
+
url,
|
|
489
|
+
method: "GET",
|
|
490
|
+
});
|
|
491
|
+
const response = await client.requestJson({
|
|
492
|
+
url,
|
|
493
|
+
method: "GET",
|
|
494
|
+
headers: exactReadHeaders(),
|
|
495
|
+
expectedStatuses: [200],
|
|
496
|
+
expectedContentTypes: ["application/json"],
|
|
497
|
+
maxBytes: Math.min(maximumBytes, MAX_VIEWER_BYTES),
|
|
498
|
+
});
|
|
499
|
+
return parseRedditWebViewerResponse(response);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function expectedSubject(auth: GhostgetAuth): string {
|
|
503
|
+
const subject = webSessionAuthSubject(auth);
|
|
504
|
+
if (subject === null || !/^reddit:t2_[a-z0-9]{1,32}$/u.test(subject)) {
|
|
505
|
+
throw new Error("Reddit authenticated operations require an auth locator bound to an exact reddit:t2_<id> subject");
|
|
506
|
+
}
|
|
507
|
+
return subject;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function assertBoundViewer(auth: GhostgetAuth, viewer: RedditWebViewer): string {
|
|
511
|
+
const expected = expectedSubject(auth);
|
|
512
|
+
if (`reddit:${viewer.id}` !== expected) {
|
|
513
|
+
throw new Error("Reddit browser session viewer no longer matches the confirmed auth subject");
|
|
514
|
+
}
|
|
515
|
+
return expected;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function requireBoundViewer(
|
|
519
|
+
client: WebSessionClient,
|
|
520
|
+
auth: GhostgetAuth,
|
|
521
|
+
): Promise<RedditWebViewer> {
|
|
522
|
+
const viewer = await currentViewer(client);
|
|
523
|
+
assertBoundViewer(auth, viewer);
|
|
524
|
+
return viewer;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function executeFlairChoices(
|
|
528
|
+
client: WebSessionClient,
|
|
529
|
+
recipe: WebSessionRecipe,
|
|
530
|
+
input: OperationInput,
|
|
531
|
+
auth: GhostgetAuth,
|
|
532
|
+
options: {
|
|
533
|
+
readonly signal?: AbortSignal;
|
|
534
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
535
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
536
|
+
readonly setStage?: (stage: ProviderReadFailureStage) => void;
|
|
537
|
+
},
|
|
538
|
+
): Promise<WebSessionExecution> {
|
|
539
|
+
if (!isRedditFlairOperation(recipe.action)) {
|
|
540
|
+
throw new Error("Reddit flair operation is not installed");
|
|
541
|
+
}
|
|
542
|
+
const parsed = parseRedditFlairInput(recipe.action, input);
|
|
543
|
+
if (parsed.action !== "choices") {
|
|
544
|
+
throw new Error("Reddit flair selection is capture-required");
|
|
545
|
+
}
|
|
546
|
+
const viewer = await requireBoundViewer(client, auth);
|
|
547
|
+
options.setStage?.("target");
|
|
548
|
+
const flairClient = await createWebSessionClient(REDDIT_LEASE_ORIGIN, auth, {
|
|
549
|
+
timeoutMs: recipe.timeoutMs,
|
|
550
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
551
|
+
...(options.operationDeadline === undefined
|
|
552
|
+
? {}
|
|
553
|
+
: { operationDeadline: options.operationDeadline }),
|
|
554
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
555
|
+
});
|
|
556
|
+
const url = new URL("/api/flairselector", REDDIT_LEASE_ORIGIN);
|
|
557
|
+
const form = new URLSearchParams();
|
|
558
|
+
if (parsed.target.kind === "user") {
|
|
559
|
+
form.set("name", viewer.username);
|
|
560
|
+
} else {
|
|
561
|
+
form.set("is_newlink", "true");
|
|
562
|
+
}
|
|
563
|
+
form.set("r", parsed.target.community);
|
|
564
|
+
form.set("uh", viewer.modhash);
|
|
565
|
+
const body = form.toString();
|
|
566
|
+
const operation = parsed.target.kind === "user"
|
|
567
|
+
? "flair.user.choices"
|
|
568
|
+
: "flair.post.choices";
|
|
569
|
+
authorizeRedditWebRequest({
|
|
570
|
+
operation,
|
|
571
|
+
url,
|
|
572
|
+
method: "POST",
|
|
573
|
+
body,
|
|
574
|
+
community: parsed.target.community,
|
|
575
|
+
...(parsed.target.kind === "user" ? { username: viewer.username } : {}),
|
|
576
|
+
});
|
|
577
|
+
const response = await flairClient.requestText({
|
|
578
|
+
url,
|
|
579
|
+
method: "POST",
|
|
580
|
+
headers: exactFlairHeaders(parsed.target.community, parsed.target.kind),
|
|
581
|
+
body,
|
|
582
|
+
expectedContentTypes: ["text/html"],
|
|
583
|
+
maxBytes: Math.min(recipe.maxOutputBytes, MAX_VIEWER_BYTES),
|
|
584
|
+
});
|
|
585
|
+
return {
|
|
586
|
+
status: "succeeded",
|
|
587
|
+
output: parseRedditFlairChoicesResponse(response, parsed.target),
|
|
588
|
+
finalUrl: parsed.target.kind === "post"
|
|
589
|
+
? `${REDDIT_LEASE_ORIGIN}/r/${parsed.target.community}/submit`
|
|
590
|
+
: `${REDDIT_LEASE_ORIGIN}/r/${parsed.target.community}/`,
|
|
591
|
+
dispatchStarted: false,
|
|
592
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export async function probeRedditWebSubject(
|
|
597
|
+
auth: GhostgetAuth,
|
|
598
|
+
options: {
|
|
599
|
+
readonly timeoutMs?: number;
|
|
600
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
601
|
+
readonly signal?: AbortSignal;
|
|
602
|
+
} = {},
|
|
603
|
+
): Promise<string> {
|
|
604
|
+
const client = await createWebSessionClient(REDDIT_ORIGIN, auth, {
|
|
605
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
606
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
607
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
608
|
+
});
|
|
609
|
+
const viewer = await currentViewer(client);
|
|
610
|
+
return `reddit:${viewer.id}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function boundedMaximum(recipe: WebSessionRecipe): number {
|
|
614
|
+
return Math.min(recipe.maxOutputBytes, MAX_READ_BYTES);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function profileInput(input: OperationInput): string {
|
|
618
|
+
const value = stringInput(input, "profile", 64);
|
|
619
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(value)) {
|
|
620
|
+
throw new Error("input.profile must be an exact Reddit profile handle");
|
|
621
|
+
}
|
|
622
|
+
return value;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function observedAt(dependencies: RedditWebRuntimeDependencies | undefined): string {
|
|
626
|
+
const now = dependencies?.now?.() ?? Date.now();
|
|
627
|
+
if (!Number.isSafeInteger(now) || now < 0 || now > 8_640_000_000_000_000) {
|
|
628
|
+
throw new Error("Reddit profile observation time is invalid");
|
|
629
|
+
}
|
|
630
|
+
return new Date(now).toISOString();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function exactCount(value: number, window?: string): Readonly<Record<string, unknown>> {
|
|
634
|
+
return Object.freeze({
|
|
635
|
+
status: "available",
|
|
636
|
+
value,
|
|
637
|
+
precision: "exact",
|
|
638
|
+
unit: "count",
|
|
639
|
+
...(window === undefined ? {} : { window }),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const contributionUnavailable = Object.freeze({
|
|
644
|
+
status: "unavailable",
|
|
645
|
+
reason: "not-exposed",
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
async function readProfileAbout(
|
|
649
|
+
client: WebSessionClient,
|
|
650
|
+
profile: string,
|
|
651
|
+
maximumBytes: number,
|
|
652
|
+
): Promise<ReturnType<typeof parseRedditWebProfileResponse>> {
|
|
653
|
+
const url = new URL(`/user/${encodeURIComponent(profile)}/about.json`, REDDIT_ORIGIN);
|
|
654
|
+
url.searchParams.set("raw_json", "1");
|
|
655
|
+
authorizeRedditWebRequest({
|
|
656
|
+
operation: "profiles.about",
|
|
657
|
+
url,
|
|
658
|
+
method: "GET",
|
|
659
|
+
profile,
|
|
660
|
+
});
|
|
661
|
+
const response = await client.requestJson({
|
|
662
|
+
url,
|
|
663
|
+
method: "GET",
|
|
664
|
+
headers: exactReadHeaders(),
|
|
665
|
+
expectedStatuses: [200],
|
|
666
|
+
expectedContentTypes: ["application/json"],
|
|
667
|
+
maxBytes: Math.min(maximumBytes, MAX_READ_BYTES),
|
|
668
|
+
});
|
|
669
|
+
return parseRedditWebProfileResponse(response, profile);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function readVisibleContributionCount(
|
|
673
|
+
client: WebSessionClient,
|
|
674
|
+
profile: string,
|
|
675
|
+
maximumBytes: number,
|
|
676
|
+
): Promise<number | null> {
|
|
677
|
+
const ids = new Set<string>();
|
|
678
|
+
const cursors = new Set<string>();
|
|
679
|
+
let after: string | null = null;
|
|
680
|
+
for (let pageNumber = 0; pageNumber < MAX_PROFILE_OVERVIEW_PAGES; pageNumber += 1) {
|
|
681
|
+
const url = new URL(`/user/${encodeURIComponent(profile)}/overview.json`, REDDIT_ORIGIN);
|
|
682
|
+
url.searchParams.set("limit", "100");
|
|
683
|
+
url.searchParams.set("raw_json", "1");
|
|
684
|
+
if (after !== null) url.searchParams.set("after", after);
|
|
685
|
+
authorizeRedditWebRequest({
|
|
686
|
+
operation: "profiles.overview",
|
|
687
|
+
url,
|
|
688
|
+
method: "GET",
|
|
689
|
+
profile,
|
|
690
|
+
});
|
|
691
|
+
const response = await client.requestJson({
|
|
692
|
+
url,
|
|
693
|
+
method: "GET",
|
|
694
|
+
headers: exactReadHeaders(),
|
|
695
|
+
expectedStatuses: [200],
|
|
696
|
+
expectedContentTypes: ["application/json"],
|
|
697
|
+
maxBytes: Math.min(maximumBytes, MAX_READ_BYTES),
|
|
698
|
+
});
|
|
699
|
+
const page = parseRedditProfileContributionPage(response, profile);
|
|
700
|
+
for (const id of page.ids) {
|
|
701
|
+
if (ids.has(id)) throw new Error("Reddit profile overview pagination repeated a contribution");
|
|
702
|
+
ids.add(id);
|
|
703
|
+
}
|
|
704
|
+
if (page.after === null) return ids.size;
|
|
705
|
+
if (cursors.has(page.after)) throw new Error("Reddit profile overview pagination repeated a cursor");
|
|
706
|
+
cursors.add(page.after);
|
|
707
|
+
after = page.after;
|
|
708
|
+
}
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function readProfile(
|
|
713
|
+
client: WebSessionClient,
|
|
714
|
+
recipe: WebSessionRecipe,
|
|
715
|
+
input: OperationInput,
|
|
716
|
+
viewer: RedditWebViewer,
|
|
717
|
+
dependencies: RedditWebRuntimeDependencies | undefined,
|
|
718
|
+
setStage: (stage: ProviderReadFailureStage) => void = () => undefined,
|
|
719
|
+
): Promise<Readonly<Record<string, unknown>>> {
|
|
720
|
+
const requestedProfile = profileInput(input);
|
|
721
|
+
if (requestedProfile.toLocaleLowerCase("en-US") !== viewer.username.toLocaleLowerCase("en-US")) {
|
|
722
|
+
throw new Error("Reddit requested profile did not match the bound current account");
|
|
723
|
+
}
|
|
724
|
+
setStage("target");
|
|
725
|
+
const profile = await readProfileAbout(client, requestedProfile, recipe.maxOutputBytes);
|
|
726
|
+
setStage("supplemental");
|
|
727
|
+
const contributions = await readVisibleContributionCount(
|
|
728
|
+
client,
|
|
729
|
+
requestedProfile,
|
|
730
|
+
recipe.maxOutputBytes,
|
|
731
|
+
);
|
|
732
|
+
return Object.freeze({
|
|
733
|
+
schemaVersion: 1,
|
|
734
|
+
provider: "reddit",
|
|
735
|
+
target: Object.freeze({
|
|
736
|
+
kind: "profile",
|
|
737
|
+
id: profile.username,
|
|
738
|
+
url: `${REDDIT_ORIGIN}/user/${encodeURIComponent(profile.username)}/`,
|
|
739
|
+
}),
|
|
740
|
+
observedAt: observedAt(dependencies),
|
|
741
|
+
completeness: contributions === null ? "partial" : "complete",
|
|
742
|
+
metrics: Object.freeze({
|
|
743
|
+
followers: exactCount(profile.followers),
|
|
744
|
+
karma: exactCount(profile.karma),
|
|
745
|
+
contributions: contributions === null
|
|
746
|
+
? contributionUnavailable
|
|
747
|
+
: exactCount(contributions, "visible-overview"),
|
|
748
|
+
}),
|
|
749
|
+
metadata: Object.freeze({
|
|
750
|
+
handle: profile.username,
|
|
751
|
+
...(profile.displayName === null ? {} : { displayName: profile.displayName }),
|
|
752
|
+
...(profile.bio === null ? {} : { bio: profile.bio }),
|
|
753
|
+
contributionDefinition:
|
|
754
|
+
"Distinct post and comment IDs in the complete authenticated profile overview listing.",
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function afterQuery(input: OperationInput): string | undefined {
|
|
760
|
+
const after = optionalStringInput(input, "after", 40);
|
|
761
|
+
if (after === undefined) return undefined;
|
|
762
|
+
return redditFullname(after, "input.after", ["t1", "t3", "t4"]);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function readFeed(
|
|
766
|
+
client: WebSessionClient,
|
|
767
|
+
recipe: WebSessionRecipe,
|
|
768
|
+
input: OperationInput,
|
|
769
|
+
): Promise<unknown> {
|
|
770
|
+
if (input.feed !== "home") throw new Error("input.feed must be the observed Reddit home feed");
|
|
771
|
+
const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
|
|
772
|
+
const url = new URL("/.json", REDDIT_ORIGIN);
|
|
773
|
+
url.searchParams.set("limit", String(limit));
|
|
774
|
+
url.searchParams.set("raw_json", "1");
|
|
775
|
+
const after = afterQuery(input);
|
|
776
|
+
if (after !== undefined) url.searchParams.set("after", after);
|
|
777
|
+
authorizeRedditWebRequest({
|
|
778
|
+
operation: "feeds.home",
|
|
779
|
+
url,
|
|
780
|
+
method: "GET",
|
|
781
|
+
});
|
|
782
|
+
const response = await client.requestJson({
|
|
783
|
+
url,
|
|
784
|
+
method: "GET",
|
|
785
|
+
headers: exactReadHeaders(),
|
|
786
|
+
maxBytes: boundedMaximum(recipe),
|
|
787
|
+
});
|
|
788
|
+
return normalizeRedditFeedResponse(response, limit);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function postInput(input: OperationInput): string {
|
|
792
|
+
return redditPostId(stringInput(input, "post_id", 40), "input.post_id");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function readPostOrComments(
|
|
796
|
+
client: WebSessionClient,
|
|
797
|
+
recipe: WebSessionRecipe,
|
|
798
|
+
input: OperationInput,
|
|
799
|
+
comments: boolean,
|
|
800
|
+
): Promise<unknown> {
|
|
801
|
+
const postId = postInput(input);
|
|
802
|
+
const bare = postId.slice(3);
|
|
803
|
+
const url = new URL(`/comments/${encodeURIComponent(bare)}.json`, REDDIT_ORIGIN);
|
|
804
|
+
let limit = 1;
|
|
805
|
+
if (comments) {
|
|
806
|
+
limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
|
|
807
|
+
url.searchParams.set("depth", "10");
|
|
808
|
+
url.searchParams.set("limit", String(limit));
|
|
809
|
+
url.searchParams.set("raw_json", "1");
|
|
810
|
+
url.searchParams.set("sort", "confidence");
|
|
811
|
+
} else {
|
|
812
|
+
url.searchParams.set("limit", "1");
|
|
813
|
+
url.searchParams.set("raw_json", "1");
|
|
814
|
+
}
|
|
815
|
+
authorizeRedditWebRequest({
|
|
816
|
+
operation: comments ? "comments.read" : "posts.read",
|
|
817
|
+
url,
|
|
818
|
+
method: "GET",
|
|
819
|
+
targetId: postId,
|
|
820
|
+
});
|
|
821
|
+
const response = await client.requestJson({
|
|
822
|
+
url,
|
|
823
|
+
method: "GET",
|
|
824
|
+
headers: exactReadHeaders(),
|
|
825
|
+
maxBytes: boundedMaximum(recipe),
|
|
826
|
+
});
|
|
827
|
+
return comments
|
|
828
|
+
? normalizeRedditCommentsResponse(response, postId, limit)
|
|
829
|
+
: normalizeRedditPostResponse(response, postId);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
type RedditMessageFolder = "inbox" | "unread" | "sent";
|
|
833
|
+
|
|
834
|
+
function messageFolder(input: OperationInput): RedditMessageFolder {
|
|
835
|
+
const value = stringInput(input, "folder", 16);
|
|
836
|
+
if (value !== "inbox" && value !== "unread" && value !== "sent") {
|
|
837
|
+
throw new Error("input.folder must name inbox, unread, or sent");
|
|
838
|
+
}
|
|
839
|
+
return value;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function redditReadFinalUrl(
|
|
843
|
+
operation: RedditWebOperationName,
|
|
844
|
+
input: OperationInput,
|
|
845
|
+
): string | null {
|
|
846
|
+
if (operation === "profiles.read") {
|
|
847
|
+
return `${REDDIT_ORIGIN}/user/${encodeURIComponent(profileInput(input))}/`;
|
|
848
|
+
}
|
|
849
|
+
if (operation === "feeds.read") {
|
|
850
|
+
if (input.feed !== "home") {
|
|
851
|
+
throw new Error("input.feed must be the observed Reddit home feed");
|
|
852
|
+
}
|
|
853
|
+
return REDDIT_ORIGIN;
|
|
854
|
+
}
|
|
855
|
+
if (
|
|
856
|
+
operation === "posts.read"
|
|
857
|
+
|| operation === "comments.read"
|
|
858
|
+
|| operation === "media.read"
|
|
859
|
+
) {
|
|
860
|
+
return `${REDDIT_ORIGIN}/comments/${postInput(input).slice(3)}/`;
|
|
861
|
+
}
|
|
862
|
+
if (operation === "messaging.list" || operation === "messaging.read") {
|
|
863
|
+
return `${REDDIT_ORIGIN}/message/${messageFolder(input)}/`;
|
|
864
|
+
}
|
|
865
|
+
if (operation === "flair.user.choices" || operation === "flair.post.choices") {
|
|
866
|
+
const parsed = parseRedditFlairInput(operation, input);
|
|
867
|
+
if (parsed.action !== "choices") {
|
|
868
|
+
throw new Error("Reddit flair selection is capture-required");
|
|
869
|
+
}
|
|
870
|
+
return parsed.target.kind === "post"
|
|
871
|
+
? `${REDDIT_LEASE_ORIGIN}/r/${parsed.target.community}/submit`
|
|
872
|
+
: `${REDDIT_LEASE_ORIGIN}/r/${parsed.target.community}/`;
|
|
873
|
+
}
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function redditReadAccountMismatch(error: Error): boolean {
|
|
878
|
+
return error.message.includes("viewer no longer matches")
|
|
879
|
+
|| error.message.includes("no longer matches the confirmed auth subject")
|
|
880
|
+
|| error.message.includes("did not match the bound current account");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function redditReadAuthRepairRequired(error: Error): boolean {
|
|
884
|
+
return error.message.includes("auth locator bound");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function failedRedditRead(
|
|
888
|
+
operation: RedditWebOperationName,
|
|
889
|
+
error: unknown,
|
|
890
|
+
finalUrl: string | null,
|
|
891
|
+
stage: ProviderReadFailureStage,
|
|
892
|
+
): WebSessionExecution {
|
|
893
|
+
return failedProviderRead(`Reddit ${operation}`, error, finalUrl, {
|
|
894
|
+
stage,
|
|
895
|
+
authenticated: true,
|
|
896
|
+
accountMismatch: redditReadAccountMismatch,
|
|
897
|
+
authRepairRequired: redditReadAuthRepairRequired,
|
|
898
|
+
targetStatusUnavailable: operation === "posts.read"
|
|
899
|
+
|| operation === "comments.read"
|
|
900
|
+
|| operation === "media.read"
|
|
901
|
+
|| operation === "messaging.read",
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function readMessages(
|
|
906
|
+
client: WebSessionClient,
|
|
907
|
+
recipe: WebSessionRecipe,
|
|
908
|
+
input: OperationInput,
|
|
909
|
+
single: boolean,
|
|
910
|
+
): Promise<unknown> {
|
|
911
|
+
const folder = messageFolder(input);
|
|
912
|
+
const url = new URL(`/message/${folder}.json`, REDDIT_ORIGIN);
|
|
913
|
+
let limit = 1;
|
|
914
|
+
let messageId: string | null = null;
|
|
915
|
+
if (single) {
|
|
916
|
+
messageId = redditFullname(
|
|
917
|
+
stringInput(input, "message_id", 40),
|
|
918
|
+
"input.message_id",
|
|
919
|
+
["t4"],
|
|
920
|
+
);
|
|
921
|
+
url.searchParams.set("limit", "1");
|
|
922
|
+
url.searchParams.set("mark", "false");
|
|
923
|
+
url.searchParams.set("max_replies", "100");
|
|
924
|
+
url.searchParams.set("mid", messageId);
|
|
925
|
+
url.searchParams.set("raw_json", "1");
|
|
926
|
+
} else {
|
|
927
|
+
limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
|
|
928
|
+
url.searchParams.set("limit", String(limit));
|
|
929
|
+
url.searchParams.set("mark", "false");
|
|
930
|
+
url.searchParams.set("max_replies", "0");
|
|
931
|
+
url.searchParams.set("raw_json", "1");
|
|
932
|
+
const after = afterQuery(input);
|
|
933
|
+
if (after !== undefined) url.searchParams.set("after", after);
|
|
934
|
+
}
|
|
935
|
+
authorizeRedditWebRequest({
|
|
936
|
+
operation: single ? "messages.read" : "messages.list",
|
|
937
|
+
url,
|
|
938
|
+
method: "GET",
|
|
939
|
+
folder,
|
|
940
|
+
...(messageId === null ? {} : { targetId: messageId }),
|
|
941
|
+
});
|
|
942
|
+
const response = await client.requestJson({
|
|
943
|
+
url,
|
|
944
|
+
method: "GET",
|
|
945
|
+
headers: exactReadHeaders(),
|
|
946
|
+
maxBytes: boundedMaximum(recipe),
|
|
947
|
+
});
|
|
948
|
+
return normalizeRedditMessageListing(response, limit, messageId);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function readThingState(
|
|
952
|
+
client: WebSessionClient,
|
|
953
|
+
targetId: string,
|
|
954
|
+
maximumBytes: number,
|
|
955
|
+
) {
|
|
956
|
+
const url = new URL("/api/info.json", REDDIT_ORIGIN);
|
|
957
|
+
url.searchParams.set("id", targetId);
|
|
958
|
+
url.searchParams.set("raw_json", "1");
|
|
959
|
+
authorizeRedditWebRequest({
|
|
960
|
+
operation: "state.readback",
|
|
961
|
+
url,
|
|
962
|
+
method: "GET",
|
|
963
|
+
targetId,
|
|
964
|
+
});
|
|
965
|
+
const response = await client.requestJson({
|
|
966
|
+
url,
|
|
967
|
+
method: "GET",
|
|
968
|
+
headers: exactReadHeaders(),
|
|
969
|
+
maxBytes: Math.min(maximumBytes, MAX_READ_BYTES),
|
|
970
|
+
});
|
|
971
|
+
return parseRedditThingState(response, targetId);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
async function readPostPresenceValue(
|
|
975
|
+
client: WebSessionClient,
|
|
976
|
+
targetId: string,
|
|
977
|
+
maximumBytes: number,
|
|
978
|
+
operation: "state.readback" | "media.read" = "state.readback",
|
|
979
|
+
): Promise<unknown> {
|
|
980
|
+
const url = new URL("/api/info.json", REDDIT_ORIGIN);
|
|
981
|
+
url.searchParams.set("id", redditPostId(targetId));
|
|
982
|
+
url.searchParams.set("raw_json", "1");
|
|
983
|
+
authorizeRedditWebRequest({
|
|
984
|
+
operation,
|
|
985
|
+
url,
|
|
986
|
+
method: "GET",
|
|
987
|
+
targetId,
|
|
988
|
+
});
|
|
989
|
+
return client.requestJson({
|
|
990
|
+
url,
|
|
991
|
+
method: "GET",
|
|
992
|
+
headers: exactReadHeaders(),
|
|
993
|
+
expectedStatuses: [200],
|
|
994
|
+
expectedContentTypes: ["application/json"],
|
|
995
|
+
maxBytes: Math.min(maximumBytes, MAX_READ_BYTES),
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function requestRedditMediaLease(
|
|
1000
|
+
client: WebSessionClient,
|
|
1001
|
+
community: string,
|
|
1002
|
+
modhash: string,
|
|
1003
|
+
mediaType: RedditMediaType,
|
|
1004
|
+
filename: string,
|
|
1005
|
+
): Promise<unknown> {
|
|
1006
|
+
const url = new URL(
|
|
1007
|
+
mediaType === "video/mp4"
|
|
1008
|
+
? "/api/video_upload_s3.json"
|
|
1009
|
+
: "/api/image_upload_s3.json",
|
|
1010
|
+
REDDIT_LEASE_ORIGIN,
|
|
1011
|
+
);
|
|
1012
|
+
const form = new URLSearchParams();
|
|
1013
|
+
form.set("filepath", filename);
|
|
1014
|
+
form.set("mimetype", mediaType);
|
|
1015
|
+
form.set("raw_json", "1");
|
|
1016
|
+
const body = form.toString();
|
|
1017
|
+
authorizeRedditWebRequest({
|
|
1018
|
+
operation: "media.lease",
|
|
1019
|
+
url,
|
|
1020
|
+
method: "POST",
|
|
1021
|
+
body,
|
|
1022
|
+
mediaType,
|
|
1023
|
+
filename,
|
|
1024
|
+
});
|
|
1025
|
+
return client.requestJson({
|
|
1026
|
+
url,
|
|
1027
|
+
method: "POST",
|
|
1028
|
+
headers: exactLeaseHeaders(community, modhash),
|
|
1029
|
+
body,
|
|
1030
|
+
expectedStatuses: [200],
|
|
1031
|
+
expectedContentTypes: ["application/json"],
|
|
1032
|
+
maxBytes: REDDIT_UPLOAD_RESPONSE_BYTES,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function waitForRedditVideoReadback(
|
|
1037
|
+
client: WebSessionClient,
|
|
1038
|
+
recipe: WebSessionRecipe,
|
|
1039
|
+
postId: string,
|
|
1040
|
+
sleep: (milliseconds: number) => Promise<void>,
|
|
1041
|
+
) {
|
|
1042
|
+
const delays = [0, 1_000, 2_000, 3_000, 5_000] as const;
|
|
1043
|
+
for (const delay of delays) {
|
|
1044
|
+
if (delay > 0) await sleep(delay);
|
|
1045
|
+
const readback = parseRedditVideoPostPresence(
|
|
1046
|
+
await readPostPresenceValue(client, postId, recipe.maxOutputBytes),
|
|
1047
|
+
postId,
|
|
1048
|
+
);
|
|
1049
|
+
if (readback !== null) return readback;
|
|
1050
|
+
}
|
|
1051
|
+
throw new Error("Reddit video post did not appear within the bounded readback window");
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function assertRedditVideoBinding(
|
|
1055
|
+
readback: NonNullable<ReturnType<typeof parseRedditVideoPostPresence>>,
|
|
1056
|
+
expected: Readonly<{
|
|
1057
|
+
viewer: RedditWebViewer;
|
|
1058
|
+
community: string;
|
|
1059
|
+
title: string;
|
|
1060
|
+
body: string;
|
|
1061
|
+
nsfw: boolean;
|
|
1062
|
+
spoiler: boolean;
|
|
1063
|
+
notBeforeSeconds: number;
|
|
1064
|
+
nowSeconds: number;
|
|
1065
|
+
}>,
|
|
1066
|
+
): void {
|
|
1067
|
+
if (
|
|
1068
|
+
readback.post.author !== expected.viewer.username
|
|
1069
|
+
|| (readback.authorFullname !== null && readback.authorFullname !== expected.viewer.id)
|
|
1070
|
+
) throw new Error("Reddit video post readback did not bind the confirmed actor");
|
|
1071
|
+
if (
|
|
1072
|
+
readback.post.subreddit.toLowerCase() !== expected.community.toLowerCase()
|
|
1073
|
+
|| readback.post.title !== expected.title
|
|
1074
|
+
|| readback.post.body !== expected.body
|
|
1075
|
+
|| readback.nsfw !== expected.nsfw
|
|
1076
|
+
|| readback.spoiler !== expected.spoiler
|
|
1077
|
+
) throw new Error("Reddit video post readback did not bind the confirmed content and declarations");
|
|
1078
|
+
if (
|
|
1079
|
+
readback.post.createdUtc === null
|
|
1080
|
+
|| readback.post.createdUtc < expected.notBeforeSeconds - 300
|
|
1081
|
+
|| readback.post.createdUtc > expected.nowSeconds + 300
|
|
1082
|
+
) throw new Error("Reddit video post readback escaped the confirmed dispatch window");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function acceptedRedditPostId(value: string): string {
|
|
1086
|
+
let parsed: unknown;
|
|
1087
|
+
try {
|
|
1088
|
+
parsed = JSON.parse(value) as unknown;
|
|
1089
|
+
} catch {
|
|
1090
|
+
throw new Error("Reddit provider-accepted post target is not canonical JSON");
|
|
1091
|
+
}
|
|
1092
|
+
if (
|
|
1093
|
+
typeof parsed !== "object"
|
|
1094
|
+
|| parsed === null
|
|
1095
|
+
|| Array.isArray(parsed)
|
|
1096
|
+
|| Object.keys(parsed).join(",") !== "postId"
|
|
1097
|
+
) throw new Error("Reddit provider-accepted post target changed shape");
|
|
1098
|
+
const postId = redditPostId(
|
|
1099
|
+
(parsed as { readonly postId?: unknown }).postId,
|
|
1100
|
+
"Reddit provider-accepted post target ID",
|
|
1101
|
+
);
|
|
1102
|
+
if (canonicalJson({ postId }) !== value) {
|
|
1103
|
+
throw new Error("Reddit provider-accepted post target is not canonical");
|
|
1104
|
+
}
|
|
1105
|
+
return postId;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function executeMediaPublish(
|
|
1109
|
+
client: WebSessionClient,
|
|
1110
|
+
recipe: WebSessionRecipe,
|
|
1111
|
+
input: OperationInput,
|
|
1112
|
+
auth: GhostgetAuth,
|
|
1113
|
+
options: {
|
|
1114
|
+
readonly fileResolver?: BrowserFileResolver;
|
|
1115
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1116
|
+
readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1117
|
+
readonly afterProviderAcceptedMutationTarget?: (
|
|
1118
|
+
event: WebSessionProviderAcceptedMutationTargetEvent,
|
|
1119
|
+
) => Promise<void>;
|
|
1120
|
+
readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1121
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
1122
|
+
},
|
|
1123
|
+
): Promise<WebSessionExecution> {
|
|
1124
|
+
const community = redditCommunity(stringInput(input, "community", 21), "input.community");
|
|
1125
|
+
const title = stringInput(input, "title", 280);
|
|
1126
|
+
const body = optionalBodyInput(input, "body", 10_000) ?? "";
|
|
1127
|
+
const nsfw = booleanInput(input, "nsfw");
|
|
1128
|
+
const spoiler = booleanInput(input, "spoiler");
|
|
1129
|
+
const sendReplies = booleanInput(input, "send_replies");
|
|
1130
|
+
const viewer = await requireBoundViewer(client, auth);
|
|
1131
|
+
const media = await readBoundRedditMedia(
|
|
1132
|
+
input,
|
|
1133
|
+
options.fileResolver,
|
|
1134
|
+
options.operationDeadline,
|
|
1135
|
+
);
|
|
1136
|
+
const leaseClient = await createWebSessionClient(REDDIT_LEASE_ORIGIN, auth, {
|
|
1137
|
+
timeoutMs: recipe.timeoutMs,
|
|
1138
|
+
...(options.operationDeadline === undefined
|
|
1139
|
+
? {}
|
|
1140
|
+
: { operationDeadline: options.operationDeadline }),
|
|
1141
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
1142
|
+
});
|
|
1143
|
+
let started = 0;
|
|
1144
|
+
let verified = 0;
|
|
1145
|
+
let stage = "video-lease-request";
|
|
1146
|
+
let finalUrl: string | null = null;
|
|
1147
|
+
try {
|
|
1148
|
+
const videoLeaseResponse = await requestRedditMediaLease(
|
|
1149
|
+
leaseClient,
|
|
1150
|
+
community,
|
|
1151
|
+
viewer.modhash,
|
|
1152
|
+
"video/mp4",
|
|
1153
|
+
REDDIT_VIDEO_FILENAME,
|
|
1154
|
+
);
|
|
1155
|
+
stage = "video-lease-parse";
|
|
1156
|
+
const videoLease = parseRedditMediaLeaseResponse(videoLeaseResponse, {
|
|
1157
|
+
mediaType: "video/mp4",
|
|
1158
|
+
filename: REDDIT_VIDEO_FILENAME,
|
|
1159
|
+
});
|
|
1160
|
+
stage = "poster-lease-request";
|
|
1161
|
+
const posterLeaseResponse = await requestRedditMediaLease(
|
|
1162
|
+
leaseClient,
|
|
1163
|
+
community,
|
|
1164
|
+
viewer.modhash,
|
|
1165
|
+
media.posterType,
|
|
1166
|
+
media.posterFilename,
|
|
1167
|
+
);
|
|
1168
|
+
stage = "poster-lease-parse";
|
|
1169
|
+
const posterLease = parseRedditMediaLeaseResponse(posterLeaseResponse, {
|
|
1170
|
+
mediaType: media.posterType,
|
|
1171
|
+
filename: media.posterFilename,
|
|
1172
|
+
});
|
|
1173
|
+
stage = "video-upload";
|
|
1174
|
+
const videoUrl = await uploadRedditMedia(
|
|
1175
|
+
recipe,
|
|
1176
|
+
videoLease,
|
|
1177
|
+
media.video,
|
|
1178
|
+
REDDIT_VIDEO_FILENAME,
|
|
1179
|
+
"video/mp4",
|
|
1180
|
+
options.dependencies,
|
|
1181
|
+
options.operationDeadline,
|
|
1182
|
+
);
|
|
1183
|
+
stage = "poster-upload";
|
|
1184
|
+
const posterUrl = await uploadRedditMedia(
|
|
1185
|
+
recipe,
|
|
1186
|
+
posterLease,
|
|
1187
|
+
media.poster,
|
|
1188
|
+
media.posterFilename,
|
|
1189
|
+
media.posterType,
|
|
1190
|
+
options.dependencies,
|
|
1191
|
+
options.operationDeadline,
|
|
1192
|
+
);
|
|
1193
|
+
stage = "viewer-rebinding";
|
|
1194
|
+
const rebound = await currentViewer(client);
|
|
1195
|
+
assertBoundViewer(auth, rebound);
|
|
1196
|
+
if (rebound.id !== viewer.id) throw new Error("Reddit viewer changed during video upload");
|
|
1197
|
+
const url = new URL("/api/submit", REDDIT_ORIGIN);
|
|
1198
|
+
url.searchParams.set("raw_json", "1");
|
|
1199
|
+
const form = new URLSearchParams();
|
|
1200
|
+
form.set("api_type", "json");
|
|
1201
|
+
form.set("kind", "video");
|
|
1202
|
+
form.set("nsfw", String(nsfw));
|
|
1203
|
+
form.set("resubmit", "false");
|
|
1204
|
+
form.set("sendreplies", String(sendReplies));
|
|
1205
|
+
form.set("spoiler", String(spoiler));
|
|
1206
|
+
form.set("sr", community);
|
|
1207
|
+
form.set("title", title);
|
|
1208
|
+
form.set("uh", rebound.modhash);
|
|
1209
|
+
form.set("url", videoUrl);
|
|
1210
|
+
form.set("validate_on_submit", "true");
|
|
1211
|
+
form.set("video_poster_url", posterUrl);
|
|
1212
|
+
if (body !== "") form.set("text", body);
|
|
1213
|
+
const submitBody = form.toString();
|
|
1214
|
+
authorizeRedditWebRequest({
|
|
1215
|
+
operation: "media.publish",
|
|
1216
|
+
url,
|
|
1217
|
+
method: "POST",
|
|
1218
|
+
body: submitBody,
|
|
1219
|
+
community,
|
|
1220
|
+
title,
|
|
1221
|
+
...(body === "" ? {} : { text: body }),
|
|
1222
|
+
nsfw,
|
|
1223
|
+
spoiler,
|
|
1224
|
+
sendReplies,
|
|
1225
|
+
mediaUrl: videoUrl,
|
|
1226
|
+
posterUrl,
|
|
1227
|
+
});
|
|
1228
|
+
stage = "dispatch-admission";
|
|
1229
|
+
await options.beforeDispatch?.(dispatchEvent(recipe.action, 0, 0));
|
|
1230
|
+
started = 1;
|
|
1231
|
+
const notBeforeSeconds = Math.floor((options.dependencies?.now ?? Date.now)() / 1_000);
|
|
1232
|
+
stage = "submit-response";
|
|
1233
|
+
const websocketUrl = parseRedditVideoSubmitResponse(
|
|
1234
|
+
await client.requestJson({
|
|
1235
|
+
url,
|
|
1236
|
+
method: "POST",
|
|
1237
|
+
headers: exactMutationHeaders(),
|
|
1238
|
+
body: submitBody,
|
|
1239
|
+
expectedStatuses: [200],
|
|
1240
|
+
expectedContentTypes: ["application/json"],
|
|
1241
|
+
maxBytes: REDDIT_UPLOAD_RESPONSE_BYTES,
|
|
1242
|
+
}),
|
|
1243
|
+
);
|
|
1244
|
+
stage = "processing-websocket";
|
|
1245
|
+
const waitForWebSocket = options.dependencies?.waitForWebSocketMessage
|
|
1246
|
+
?? defaultWaitForWebSocketMessage;
|
|
1247
|
+
const remaining = options.operationDeadline?.remainingTimeMs() ?? 180_000;
|
|
1248
|
+
const message = await waitForWebSocket(websocketUrl, {
|
|
1249
|
+
timeoutMs: Math.max(1_000, Math.min(180_000, remaining)),
|
|
1250
|
+
...(options.operationDeadline === undefined
|
|
1251
|
+
? {}
|
|
1252
|
+
: { signal: options.operationDeadline.signal }),
|
|
1253
|
+
});
|
|
1254
|
+
const accepted = parseRedditVideoWebSocketMessage(message, community);
|
|
1255
|
+
finalUrl = accepted.url;
|
|
1256
|
+
stage = "accepted-target-recording";
|
|
1257
|
+
await options.afterProviderAcceptedMutationTarget?.({
|
|
1258
|
+
id: recipe.action,
|
|
1259
|
+
index: 1,
|
|
1260
|
+
target: {
|
|
1261
|
+
schemaVersion: 1,
|
|
1262
|
+
identifier: canonicalJson({ postId: accepted.postId }),
|
|
1263
|
+
},
|
|
1264
|
+
});
|
|
1265
|
+
stage = "independent-readback";
|
|
1266
|
+
const sleep = options.dependencies?.sleep
|
|
1267
|
+
?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
1268
|
+
const readback = await waitForRedditVideoReadback(
|
|
1269
|
+
client,
|
|
1270
|
+
recipe,
|
|
1271
|
+
accepted.postId,
|
|
1272
|
+
sleep,
|
|
1273
|
+
);
|
|
1274
|
+
const nowSeconds = Math.floor((options.dependencies?.now ?? Date.now)() / 1_000);
|
|
1275
|
+
assertRedditVideoBinding(readback, {
|
|
1276
|
+
viewer,
|
|
1277
|
+
community,
|
|
1278
|
+
title,
|
|
1279
|
+
body,
|
|
1280
|
+
nsfw,
|
|
1281
|
+
spoiler,
|
|
1282
|
+
notBeforeSeconds,
|
|
1283
|
+
nowSeconds,
|
|
1284
|
+
});
|
|
1285
|
+
verified = 1;
|
|
1286
|
+
stage = "verification-recording";
|
|
1287
|
+
await options.afterDispatchVerified?.(dispatchEvent(recipe.action, 1, 1));
|
|
1288
|
+
return {
|
|
1289
|
+
status: "succeeded",
|
|
1290
|
+
output: Object.freeze({
|
|
1291
|
+
postId: accepted.postId,
|
|
1292
|
+
url: accepted.url,
|
|
1293
|
+
community,
|
|
1294
|
+
title,
|
|
1295
|
+
video: Object.freeze({
|
|
1296
|
+
durationSeconds: readback.durationSeconds,
|
|
1297
|
+
width: readback.width,
|
|
1298
|
+
height: readback.height,
|
|
1299
|
+
}),
|
|
1300
|
+
}),
|
|
1301
|
+
finalUrl: accepted.url,
|
|
1302
|
+
dispatchStarted: true,
|
|
1303
|
+
dispatch: { planned: 1, started, verified },
|
|
1304
|
+
};
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
const preparationFailure = safeRedditPreparationFailure(error);
|
|
1307
|
+
return {
|
|
1308
|
+
status: started > 0 ? "indeterminate" : "failed",
|
|
1309
|
+
output: null,
|
|
1310
|
+
finalUrl,
|
|
1311
|
+
dispatchStarted: started > 0,
|
|
1312
|
+
dispatch: { planned: 1, started, verified },
|
|
1313
|
+
error: started > 0
|
|
1314
|
+
? `Reddit may have published the confirmed video, but ${stage} was not verified; reconcile before retrying`
|
|
1315
|
+
: `Reddit video preparation failed at ${stage} before public submission${
|
|
1316
|
+
preparationFailure === null ? "" : `; reason: ${preparationFailure}`
|
|
1317
|
+
}`,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
async function readDeletionPresence(
|
|
1323
|
+
client: WebSessionClient,
|
|
1324
|
+
recipe: WebSessionRecipe,
|
|
1325
|
+
postId: string,
|
|
1326
|
+
) {
|
|
1327
|
+
return parseRedditAuthoredPostPresence(
|
|
1328
|
+
await readPostPresenceValue(client, postId, recipe.maxOutputBytes),
|
|
1329
|
+
postId,
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function assertDeletionTarget(
|
|
1334
|
+
presence: Awaited<ReturnType<typeof readDeletionPresence>>,
|
|
1335
|
+
viewer: RedditWebViewer,
|
|
1336
|
+
expectedTitle: string,
|
|
1337
|
+
): void {
|
|
1338
|
+
if (!presence.present || presence.post === null) return;
|
|
1339
|
+
if (
|
|
1340
|
+
presence.post.author !== viewer.username
|
|
1341
|
+
|| (presence.authorFullname !== null && presence.authorFullname !== viewer.id)
|
|
1342
|
+
) throw new Error("Reddit delete target was not authored by the bound viewer");
|
|
1343
|
+
if (presence.post.title !== expectedTitle) {
|
|
1344
|
+
throw new Error("Reddit delete target title changed after confirmation");
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
async function executeContentDelete(
|
|
1349
|
+
client: WebSessionClient,
|
|
1350
|
+
recipe: WebSessionRecipe,
|
|
1351
|
+
input: OperationInput,
|
|
1352
|
+
auth: GhostgetAuth,
|
|
1353
|
+
options: {
|
|
1354
|
+
readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1355
|
+
readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1356
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
1357
|
+
},
|
|
1358
|
+
): Promise<WebSessionExecution> {
|
|
1359
|
+
const postId = redditPostId(stringInput(input, "post_id", 40), "input.post_id");
|
|
1360
|
+
const expectedTitle = stringInput(input, "expected_title", 280);
|
|
1361
|
+
const viewer = await requireBoundViewer(client, auth);
|
|
1362
|
+
const before = await readDeletionPresence(client, recipe, postId);
|
|
1363
|
+
if (!before.present) {
|
|
1364
|
+
return {
|
|
1365
|
+
status: "succeeded",
|
|
1366
|
+
output: Object.freeze({ postId, deleted: true, noOp: true }),
|
|
1367
|
+
finalUrl: `${REDDIT_ORIGIN}/comments/${postId.slice(3)}/`,
|
|
1368
|
+
noOp: true,
|
|
1369
|
+
dispatchStarted: false,
|
|
1370
|
+
dispatch: { planned: 1, started: 0, verified: 0 },
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
assertDeletionTarget(before, viewer, expectedTitle);
|
|
1374
|
+
let started = 0;
|
|
1375
|
+
let verified = 0;
|
|
1376
|
+
try {
|
|
1377
|
+
const rebound = await currentViewer(client);
|
|
1378
|
+
assertBoundViewer(auth, rebound);
|
|
1379
|
+
if (rebound.id !== viewer.id) throw new Error("Reddit viewer changed during delete preparation");
|
|
1380
|
+
const freshTarget = await readDeletionPresence(client, recipe, postId);
|
|
1381
|
+
if (!freshTarget.present) {
|
|
1382
|
+
return {
|
|
1383
|
+
status: "succeeded",
|
|
1384
|
+
output: Object.freeze({ postId, deleted: true, noOp: true }),
|
|
1385
|
+
finalUrl: `${REDDIT_ORIGIN}/comments/${postId.slice(3)}/`,
|
|
1386
|
+
noOp: true,
|
|
1387
|
+
dispatchStarted: false,
|
|
1388
|
+
dispatch: { planned: 1, started: 0, verified: 0 },
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
assertDeletionTarget(freshTarget, rebound, expectedTitle);
|
|
1392
|
+
const url = new URL("/api/del", REDDIT_ORIGIN);
|
|
1393
|
+
const form = new URLSearchParams();
|
|
1394
|
+
form.set("id", postId);
|
|
1395
|
+
form.set("uh", rebound.modhash);
|
|
1396
|
+
const body = form.toString();
|
|
1397
|
+
authorizeRedditWebRequest({
|
|
1398
|
+
operation: "content.delete",
|
|
1399
|
+
url,
|
|
1400
|
+
method: "POST",
|
|
1401
|
+
body,
|
|
1402
|
+
targetId: postId,
|
|
1403
|
+
});
|
|
1404
|
+
await options.beforeDispatch?.(dispatchEvent(recipe.action, 0, 0));
|
|
1405
|
+
started = 1;
|
|
1406
|
+
assertRedditMutationSuccess(await client.requestJson({
|
|
1407
|
+
url,
|
|
1408
|
+
method: "POST",
|
|
1409
|
+
headers: exactMutationHeaders(),
|
|
1410
|
+
body,
|
|
1411
|
+
expectedStatuses: [200],
|
|
1412
|
+
expectedContentTypes: ["application/json"],
|
|
1413
|
+
maxBytes: 512 * 1024,
|
|
1414
|
+
}));
|
|
1415
|
+
const sleep = options.dependencies?.sleep
|
|
1416
|
+
?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
1417
|
+
let after = await readDeletionPresence(client, recipe, postId);
|
|
1418
|
+
for (const delay of [500, 1_000, 2_000] as const) {
|
|
1419
|
+
if (!after.present) break;
|
|
1420
|
+
await sleep(delay);
|
|
1421
|
+
after = await readDeletionPresence(client, recipe, postId);
|
|
1422
|
+
}
|
|
1423
|
+
if (after.present) throw new Error("Reddit exact delete readback still returned the authored post");
|
|
1424
|
+
verified = 1;
|
|
1425
|
+
await options.afterDispatchVerified?.(dispatchEvent(recipe.action, 1, 1));
|
|
1426
|
+
return {
|
|
1427
|
+
status: "succeeded",
|
|
1428
|
+
output: Object.freeze({ postId, deleted: true, noOp: false }),
|
|
1429
|
+
finalUrl: `${REDDIT_ORIGIN}/comments/${postId.slice(3)}/`,
|
|
1430
|
+
dispatchStarted: true,
|
|
1431
|
+
dispatch: { planned: 1, started, verified },
|
|
1432
|
+
};
|
|
1433
|
+
} catch {
|
|
1434
|
+
return {
|
|
1435
|
+
status: started > 0 ? "indeterminate" : "failed",
|
|
1436
|
+
output: null,
|
|
1437
|
+
finalUrl: `${REDDIT_ORIGIN}/comments/${postId.slice(3)}/`,
|
|
1438
|
+
dispatchStarted: started > 0,
|
|
1439
|
+
dispatch: { planned: 1, started, verified },
|
|
1440
|
+
error: started > 0
|
|
1441
|
+
? "Reddit may have deleted the exact post, but absence was not verified; reconcile before retrying"
|
|
1442
|
+
: "Reddit delete dispatch failed before submission",
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function dispatchEvent(
|
|
1448
|
+
id: string,
|
|
1449
|
+
started: number,
|
|
1450
|
+
verified: number,
|
|
1451
|
+
): WebSessionDispatchEvent {
|
|
1452
|
+
return {
|
|
1453
|
+
id,
|
|
1454
|
+
index: 1,
|
|
1455
|
+
progress: { planned: 1, started, verified },
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function desiredReaction(input: OperationInput): -1 | 0 | 1 {
|
|
1460
|
+
const value = input.direction;
|
|
1461
|
+
if (!Number.isSafeInteger(value)) {
|
|
1462
|
+
throw new Error("input.direction must be -1, 0, or 1");
|
|
1463
|
+
}
|
|
1464
|
+
if (value !== -1 && value !== 0 && value !== 1) {
|
|
1465
|
+
throw new Error("input.direction must be -1, 0, or 1");
|
|
1466
|
+
}
|
|
1467
|
+
return value;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function desiredLikedState(direction: -1 | 0 | 1): boolean | null {
|
|
1471
|
+
return direction === 1 ? true : direction === -1 ? false : null;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function prepareDesiredStateWithClient(
|
|
1475
|
+
client: WebSessionClient,
|
|
1476
|
+
recipe: WebSessionRecipe,
|
|
1477
|
+
input: OperationInput,
|
|
1478
|
+
auth: GhostgetAuth,
|
|
1479
|
+
): Promise<{
|
|
1480
|
+
readonly viewer: RedditWebViewer;
|
|
1481
|
+
readonly preparation: RedditWebDesiredStatePreparation;
|
|
1482
|
+
}> {
|
|
1483
|
+
if (
|
|
1484
|
+
recipe.site !== "reddit"
|
|
1485
|
+
|| recipe.contractVersion !== 1
|
|
1486
|
+
|| (recipe.action !== "content.save" && recipe.action !== "reactions.set")
|
|
1487
|
+
) {
|
|
1488
|
+
throw new Error(
|
|
1489
|
+
"Reddit desired-state preparation supports only content.save and reactions.set",
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
const viewer = await requireBoundViewer(client, auth);
|
|
1493
|
+
const thingId = redditFullname(
|
|
1494
|
+
stringInput(input, "thing_id", 40),
|
|
1495
|
+
"input.thing_id",
|
|
1496
|
+
["t1", "t3"],
|
|
1497
|
+
);
|
|
1498
|
+
const before = await readThingState(client, thingId, recipe.maxOutputBytes);
|
|
1499
|
+
if (recipe.action === "content.save") {
|
|
1500
|
+
const desiredState = booleanInput(input, "saved");
|
|
1501
|
+
return Object.freeze({
|
|
1502
|
+
viewer,
|
|
1503
|
+
preparation: Object.freeze({
|
|
1504
|
+
operation: "content.save",
|
|
1505
|
+
thingId,
|
|
1506
|
+
desiredState,
|
|
1507
|
+
actualState: before.saved,
|
|
1508
|
+
alreadyDesired: before.saved === desiredState,
|
|
1509
|
+
}),
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
const direction = desiredReaction(input);
|
|
1513
|
+
const desiredState = desiredLikedState(direction);
|
|
1514
|
+
return Object.freeze({
|
|
1515
|
+
viewer,
|
|
1516
|
+
preparation: Object.freeze({
|
|
1517
|
+
operation: "reactions.set",
|
|
1518
|
+
thingId,
|
|
1519
|
+
desiredState,
|
|
1520
|
+
actualState: before.liked,
|
|
1521
|
+
alreadyDesired: before.liked === desiredState,
|
|
1522
|
+
}),
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Perform only the account and exact-target reads that precede a Reddit
|
|
1528
|
+
* desired-state write. The helper never constructs a mutation request or
|
|
1529
|
+
* enters the dispatch boundary, so capture-required execution stays inert.
|
|
1530
|
+
*/
|
|
1531
|
+
export async function prepareRedditWebDesiredState(
|
|
1532
|
+
recipe: WebSessionRecipe,
|
|
1533
|
+
input: OperationInput,
|
|
1534
|
+
auth: GhostgetAuth,
|
|
1535
|
+
options: {
|
|
1536
|
+
readonly signal?: AbortSignal;
|
|
1537
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1538
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
1539
|
+
} = {},
|
|
1540
|
+
): Promise<RedditWebDesiredStatePreparation> {
|
|
1541
|
+
if (
|
|
1542
|
+
recipe.site !== "reddit"
|
|
1543
|
+
|| recipe.contractVersion !== 1
|
|
1544
|
+
|| (recipe.action !== "content.save" && recipe.action !== "reactions.set")
|
|
1545
|
+
) {
|
|
1546
|
+
throw new Error(
|
|
1547
|
+
"Reddit desired-state preparation supports only content.save and reactions.set",
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
const client = await createWebSessionClient(REDDIT_ORIGIN, auth, {
|
|
1551
|
+
timeoutMs: recipe.timeoutMs,
|
|
1552
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1553
|
+
...(options.operationDeadline === undefined
|
|
1554
|
+
? {}
|
|
1555
|
+
: { operationDeadline: options.operationDeadline }),
|
|
1556
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
1557
|
+
});
|
|
1558
|
+
return (await prepareDesiredStateWithClient(
|
|
1559
|
+
client,
|
|
1560
|
+
recipe,
|
|
1561
|
+
input,
|
|
1562
|
+
auth,
|
|
1563
|
+
)).preparation;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/** Independently read one exact Reddit saved state for reconciliation. */
|
|
1567
|
+
export async function readRedditWebDesiredState(
|
|
1568
|
+
recipe: WebSessionRecipe,
|
|
1569
|
+
input: OperationInput,
|
|
1570
|
+
auth: GhostgetAuth,
|
|
1571
|
+
options: {
|
|
1572
|
+
readonly signal?: AbortSignal;
|
|
1573
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1574
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
1575
|
+
} = {},
|
|
1576
|
+
): Promise<RedditWebDesiredStateReadback> {
|
|
1577
|
+
if (
|
|
1578
|
+
recipe.site !== "reddit"
|
|
1579
|
+
|| recipe.contractVersion !== 1
|
|
1580
|
+
|| recipe.action !== "content.save"
|
|
1581
|
+
) {
|
|
1582
|
+
throw new Error("Reddit recovery readback supports only content.save");
|
|
1583
|
+
}
|
|
1584
|
+
const preparation = await prepareRedditWebDesiredState(
|
|
1585
|
+
recipe,
|
|
1586
|
+
input,
|
|
1587
|
+
auth,
|
|
1588
|
+
options,
|
|
1589
|
+
);
|
|
1590
|
+
if (preparation.operation !== "content.save") {
|
|
1591
|
+
throw new Error("Reddit saved-state readback changed operation kind");
|
|
1592
|
+
}
|
|
1593
|
+
return Object.freeze({
|
|
1594
|
+
kind: "saved",
|
|
1595
|
+
enabled: preparation.actualState,
|
|
1596
|
+
thingId: preparation.thingId,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
export async function readRedditWebContentDeleteDesiredState(
|
|
1601
|
+
recipe: WebSessionRecipe,
|
|
1602
|
+
input: OperationInput,
|
|
1603
|
+
auth: GhostgetAuth,
|
|
1604
|
+
options: {
|
|
1605
|
+
readonly signal?: AbortSignal;
|
|
1606
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
1607
|
+
} = {},
|
|
1608
|
+
): Promise<Readonly<{ present: boolean; postId: string }>> {
|
|
1609
|
+
if (
|
|
1610
|
+
recipe.site !== "reddit"
|
|
1611
|
+
|| recipe.action !== "content.delete"
|
|
1612
|
+
|| recipe.contractVersion !== 1
|
|
1613
|
+
) throw new Error("Reddit deletion recovery supports only content.delete@1");
|
|
1614
|
+
const postId = redditPostId(stringInput(input, "post_id", 40), "input.post_id");
|
|
1615
|
+
const expectedTitle = stringInput(input, "expected_title", 280);
|
|
1616
|
+
const client = await createWebSessionClient(REDDIT_ORIGIN, auth, {
|
|
1617
|
+
timeoutMs: recipe.timeoutMs,
|
|
1618
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1619
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
1620
|
+
});
|
|
1621
|
+
const viewer = await requireBoundViewer(client, auth);
|
|
1622
|
+
const presence = await readDeletionPresence(client, recipe, postId);
|
|
1623
|
+
assertDeletionTarget(presence, viewer, expectedTitle);
|
|
1624
|
+
return Object.freeze({ present: presence.present, postId });
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
export async function readRedditWebPublishedMutationTarget(
|
|
1628
|
+
recipe: WebSessionRecipe,
|
|
1629
|
+
input: OperationInput,
|
|
1630
|
+
auth: GhostgetAuth,
|
|
1631
|
+
identifier: string,
|
|
1632
|
+
options: {
|
|
1633
|
+
readonly signal?: AbortSignal;
|
|
1634
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
1635
|
+
} = {},
|
|
1636
|
+
): Promise<Readonly<{ present: boolean; postId: string }>> {
|
|
1637
|
+
if (
|
|
1638
|
+
recipe.site !== "reddit"
|
|
1639
|
+
|| recipe.action !== "media.publish"
|
|
1640
|
+
|| recipe.contractVersion !== 9
|
|
1641
|
+
) throw new Error("Reddit video-publish recovery supports only media.publish@9");
|
|
1642
|
+
const postId = acceptedRedditPostId(identifier);
|
|
1643
|
+
const community = redditCommunity(stringInput(input, "community", 21), "input.community");
|
|
1644
|
+
const title = stringInput(input, "title", 280);
|
|
1645
|
+
const body = optionalBodyInput(input, "body", 10_000) ?? "";
|
|
1646
|
+
const nsfw = booleanInput(input, "nsfw");
|
|
1647
|
+
const spoiler = booleanInput(input, "spoiler");
|
|
1648
|
+
const client = await createWebSessionClient(REDDIT_ORIGIN, auth, {
|
|
1649
|
+
timeoutMs: recipe.timeoutMs,
|
|
1650
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1651
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
1652
|
+
});
|
|
1653
|
+
const viewer = await requireBoundViewer(client, auth);
|
|
1654
|
+
const readback = parseRedditVideoPostPresence(
|
|
1655
|
+
await readPostPresenceValue(client, postId, recipe.maxOutputBytes),
|
|
1656
|
+
postId,
|
|
1657
|
+
);
|
|
1658
|
+
if (readback === null) return Object.freeze({ present: false, postId });
|
|
1659
|
+
const nowSeconds = Math.floor((options.dependencies?.now ?? Date.now)() / 1_000);
|
|
1660
|
+
assertRedditVideoBinding(readback, {
|
|
1661
|
+
viewer,
|
|
1662
|
+
community,
|
|
1663
|
+
title,
|
|
1664
|
+
body,
|
|
1665
|
+
nsfw,
|
|
1666
|
+
spoiler,
|
|
1667
|
+
notBeforeSeconds: 0,
|
|
1668
|
+
nowSeconds,
|
|
1669
|
+
});
|
|
1670
|
+
return Object.freeze({ present: true, postId });
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function desiredStateNoOp(
|
|
1674
|
+
preparation: RedditWebDesiredStatePreparation,
|
|
1675
|
+
): WebSessionExecution {
|
|
1676
|
+
const desired = preparation.operation === "content.save"
|
|
1677
|
+
? { saved: preparation.desiredState }
|
|
1678
|
+
: {
|
|
1679
|
+
direction: preparation.desiredState === true
|
|
1680
|
+
? 1
|
|
1681
|
+
: preparation.desiredState === false ? -1 : 0,
|
|
1682
|
+
};
|
|
1683
|
+
return {
|
|
1684
|
+
status: "succeeded",
|
|
1685
|
+
output: Object.freeze({
|
|
1686
|
+
thingId: preparation.thingId,
|
|
1687
|
+
desired: Object.freeze(desired),
|
|
1688
|
+
noOp: true,
|
|
1689
|
+
effect: "already-satisfied",
|
|
1690
|
+
}),
|
|
1691
|
+
finalUrl: REDDIT_ORIGIN,
|
|
1692
|
+
dispatchStarted: false,
|
|
1693
|
+
dispatch: { planned: 1, started: 0, verified: 0 },
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
async function executeDesiredState(
|
|
1698
|
+
client: WebSessionClient,
|
|
1699
|
+
recipe: WebSessionRecipe,
|
|
1700
|
+
input: OperationInput,
|
|
1701
|
+
auth: GhostgetAuth,
|
|
1702
|
+
options: {
|
|
1703
|
+
readonly signal?: AbortSignal;
|
|
1704
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1705
|
+
readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1706
|
+
readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1707
|
+
},
|
|
1708
|
+
): Promise<WebSessionExecution> {
|
|
1709
|
+
const prepared = await prepareDesiredStateWithClient(client, recipe, input, auth);
|
|
1710
|
+
const initialViewer = prepared.viewer;
|
|
1711
|
+
const targetId = prepared.preparation.thingId;
|
|
1712
|
+
const save = recipe.action === "content.save";
|
|
1713
|
+
const direction = save ? null : desiredReaction(input);
|
|
1714
|
+
const saved = save ? booleanInput(input, "saved") : null;
|
|
1715
|
+
if (prepared.preparation.alreadyDesired) {
|
|
1716
|
+
return desiredStateNoOp(prepared.preparation);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
let started = 0;
|
|
1720
|
+
let verified = 0;
|
|
1721
|
+
try {
|
|
1722
|
+
// Fetch a second, immediately pre-dispatch account record. This both
|
|
1723
|
+
// re-binds the actor and prevents reuse of a stale listing modhash.
|
|
1724
|
+
const freshViewer = await currentViewer(client);
|
|
1725
|
+
assertBoundViewer(auth, freshViewer);
|
|
1726
|
+
if (freshViewer.id !== initialViewer.id) {
|
|
1727
|
+
throw new Error("Reddit viewer changed during desired-state preparation");
|
|
1728
|
+
}
|
|
1729
|
+
const url = new URL(
|
|
1730
|
+
save ? (saved ? "/api/save" : "/api/unsave") : "/api/vote",
|
|
1731
|
+
REDDIT_ORIGIN,
|
|
1732
|
+
);
|
|
1733
|
+
const form = new URLSearchParams();
|
|
1734
|
+
if (!save) form.set("dir", String(direction));
|
|
1735
|
+
form.set("id", targetId);
|
|
1736
|
+
form.set("uh", freshViewer.modhash);
|
|
1737
|
+
const body = form.toString();
|
|
1738
|
+
authorizeRedditWebRequest({
|
|
1739
|
+
operation: save ? "content.save" : "reactions.set",
|
|
1740
|
+
url,
|
|
1741
|
+
method: "POST",
|
|
1742
|
+
body,
|
|
1743
|
+
targetId,
|
|
1744
|
+
...(save ? { saved: saved! } : { direction: direction! }),
|
|
1745
|
+
});
|
|
1746
|
+
await options.beforeDispatch?.(dispatchEvent(recipe.action, 0, 0));
|
|
1747
|
+
started = 1;
|
|
1748
|
+
const mutation = await client.requestJson({
|
|
1749
|
+
url,
|
|
1750
|
+
method: "POST",
|
|
1751
|
+
headers: exactMutationHeaders(),
|
|
1752
|
+
body,
|
|
1753
|
+
expectedStatuses: [200],
|
|
1754
|
+
expectedContentTypes: ["application/json"],
|
|
1755
|
+
maxBytes: Math.min(recipe.maxOutputBytes, 512 * 1024),
|
|
1756
|
+
});
|
|
1757
|
+
assertRedditMutationSuccess(mutation);
|
|
1758
|
+
const after = await readThingState(client, targetId, recipe.maxOutputBytes);
|
|
1759
|
+
if (save ? after.saved !== saved : after.liked !== desiredLikedState(direction!)) {
|
|
1760
|
+
throw new Error("Reddit desired-state readback did not match the confirmed state");
|
|
1761
|
+
}
|
|
1762
|
+
verified = 1;
|
|
1763
|
+
await options.afterDispatchVerified?.(dispatchEvent(recipe.action, 1, 1));
|
|
1764
|
+
return {
|
|
1765
|
+
status: "succeeded",
|
|
1766
|
+
output: Object.freeze({
|
|
1767
|
+
thingId: targetId,
|
|
1768
|
+
desired: save ? { saved } : { direction },
|
|
1769
|
+
noOp: false,
|
|
1770
|
+
previouslyDesired: false,
|
|
1771
|
+
}),
|
|
1772
|
+
finalUrl: REDDIT_ORIGIN,
|
|
1773
|
+
dispatchStarted: true,
|
|
1774
|
+
dispatch: { planned: 1, started, verified },
|
|
1775
|
+
};
|
|
1776
|
+
} catch {
|
|
1777
|
+
return {
|
|
1778
|
+
status: started > 0 ? "indeterminate" : "failed",
|
|
1779
|
+
output: null,
|
|
1780
|
+
finalUrl: REDDIT_ORIGIN,
|
|
1781
|
+
dispatchStarted: started > 0,
|
|
1782
|
+
dispatch: { planned: 1, started, verified },
|
|
1783
|
+
error: started > 0
|
|
1784
|
+
? "Reddit may have changed the requested state but exact readback was not verified; reconcile before retrying"
|
|
1785
|
+
: "Reddit desired-state dispatch failed before submission",
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
export async function executeRedditWebOperation(
|
|
1791
|
+
recipe: WebSessionRecipe,
|
|
1792
|
+
input: OperationInput,
|
|
1793
|
+
auth: GhostgetAuth,
|
|
1794
|
+
options: {
|
|
1795
|
+
readonly fileResolver?: BrowserFileResolver;
|
|
1796
|
+
readonly signal?: AbortSignal;
|
|
1797
|
+
readonly operationDeadline?: WebSessionOperationDeadline;
|
|
1798
|
+
readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1799
|
+
readonly afterProviderAcceptedMutationTarget?: (
|
|
1800
|
+
event: WebSessionProviderAcceptedMutationTargetEvent,
|
|
1801
|
+
) => Promise<void>;
|
|
1802
|
+
readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
|
|
1803
|
+
readonly dependencies?: RedditWebRuntimeDependencies;
|
|
1804
|
+
} = {},
|
|
1805
|
+
): Promise<WebSessionExecution> {
|
|
1806
|
+
const expectedContractVersion = recipe.action === "media.publish"
|
|
1807
|
+
? 9
|
|
1808
|
+
: recipe.action === "media.read" ? 2 : 1;
|
|
1809
|
+
if (
|
|
1810
|
+
recipe.site !== "reddit"
|
|
1811
|
+
|| !isRedditOperation(recipe.action)
|
|
1812
|
+
|| recipe.contractVersion !== expectedContractVersion
|
|
1813
|
+
) {
|
|
1814
|
+
throw new Error("Reddit authenticated web recipe is not installed");
|
|
1815
|
+
}
|
|
1816
|
+
const contract = REDDIT_WEB_OPERATIONS[recipe.action];
|
|
1817
|
+
if (contract.state !== "observed") {
|
|
1818
|
+
throw new Error(`Reddit authenticated web operation ${recipe.action} is capture-required: ${contract.reason}`);
|
|
1819
|
+
}
|
|
1820
|
+
const readFinalUrl = contract.effect === "read"
|
|
1821
|
+
? redditReadFinalUrl(recipe.action, input)
|
|
1822
|
+
: null;
|
|
1823
|
+
let client: WebSessionClient;
|
|
1824
|
+
try {
|
|
1825
|
+
client = await createWebSessionClient(REDDIT_ORIGIN, auth, {
|
|
1826
|
+
timeoutMs: recipe.timeoutMs,
|
|
1827
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
1828
|
+
...(options.operationDeadline === undefined
|
|
1829
|
+
? {}
|
|
1830
|
+
: { operationDeadline: options.operationDeadline }),
|
|
1831
|
+
...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
|
|
1832
|
+
});
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
if (contract.effect !== "read") throw error;
|
|
1835
|
+
return failedRedditRead(recipe.action, error, readFinalUrl, "bootstrap");
|
|
1836
|
+
}
|
|
1837
|
+
if (recipe.action === "media.publish") {
|
|
1838
|
+
return executeMediaPublish(client, recipe, input, auth, options);
|
|
1839
|
+
}
|
|
1840
|
+
if (recipe.action === "content.delete") {
|
|
1841
|
+
return executeContentDelete(client, recipe, input, auth, options);
|
|
1842
|
+
}
|
|
1843
|
+
if (recipe.action === "reactions.set" || recipe.action === "content.save") {
|
|
1844
|
+
return executeDesiredState(client, recipe, input, auth, options);
|
|
1845
|
+
}
|
|
1846
|
+
if (recipe.action === "flair.user.choices" || recipe.action === "flair.post.choices") {
|
|
1847
|
+
void options.beforeDispatch;
|
|
1848
|
+
void options.afterProviderAcceptedMutationTarget;
|
|
1849
|
+
void options.afterDispatchVerified;
|
|
1850
|
+
let stage: ProviderReadFailureStage = "identity";
|
|
1851
|
+
try {
|
|
1852
|
+
return await executeFlairChoices(client, recipe, input, auth, {
|
|
1853
|
+
...options,
|
|
1854
|
+
setStage: (next) => { stage = next; },
|
|
1855
|
+
});
|
|
1856
|
+
} catch (error) {
|
|
1857
|
+
return failedRedditRead(recipe.action, error, readFinalUrl, stage);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
if (recipe.action === "profiles.read") {
|
|
1862
|
+
let stage: ProviderReadFailureStage = "identity";
|
|
1863
|
+
try {
|
|
1864
|
+
const viewer = await requireBoundViewer(client, auth);
|
|
1865
|
+
const output = await readProfile(
|
|
1866
|
+
client,
|
|
1867
|
+
recipe,
|
|
1868
|
+
input,
|
|
1869
|
+
viewer,
|
|
1870
|
+
options.dependencies,
|
|
1871
|
+
(next) => { stage = next; },
|
|
1872
|
+
);
|
|
1873
|
+
return {
|
|
1874
|
+
status: "succeeded",
|
|
1875
|
+
output,
|
|
1876
|
+
finalUrl: readFinalUrl,
|
|
1877
|
+
dispatchStarted: false,
|
|
1878
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
1879
|
+
};
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
return failedRedditRead(recipe.action, error, readFinalUrl, stage);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
// R1 operations never enter the mutation dispatch ledger.
|
|
1885
|
+
void options.beforeDispatch;
|
|
1886
|
+
void options.afterProviderAcceptedMutationTarget;
|
|
1887
|
+
void options.afterDispatchVerified;
|
|
1888
|
+
let stage: ProviderReadFailureStage = "identity";
|
|
1889
|
+
try {
|
|
1890
|
+
await requireBoundViewer(client, auth);
|
|
1891
|
+
stage = "target";
|
|
1892
|
+
let output: unknown;
|
|
1893
|
+
if (recipe.action === "media.read") {
|
|
1894
|
+
const targetId = redditPostId(input.post_id, "input.post_id");
|
|
1895
|
+
output = projectRedditHostedVideoMetadata(
|
|
1896
|
+
await readPostPresenceValue(
|
|
1897
|
+
client,
|
|
1898
|
+
targetId,
|
|
1899
|
+
recipe.maxOutputBytes,
|
|
1900
|
+
"media.read",
|
|
1901
|
+
),
|
|
1902
|
+
targetId,
|
|
1903
|
+
);
|
|
1904
|
+
} else {
|
|
1905
|
+
output = recipe.action === "feeds.read"
|
|
1906
|
+
? await readFeed(client, recipe, input)
|
|
1907
|
+
: recipe.action === "posts.read"
|
|
1908
|
+
? await readPostOrComments(client, recipe, input, false)
|
|
1909
|
+
: recipe.action === "comments.read"
|
|
1910
|
+
? await readPostOrComments(client, recipe, input, true)
|
|
1911
|
+
: recipe.action === "messaging.list"
|
|
1912
|
+
? await readMessages(client, recipe, input, false)
|
|
1913
|
+
: recipe.action === "messaging.read"
|
|
1914
|
+
? await readMessages(client, recipe, input, true)
|
|
1915
|
+
: (() => {
|
|
1916
|
+
throw new Error(`Reddit authenticated web operation ${recipe.action} has no executable reviewed contract`);
|
|
1917
|
+
})();
|
|
1918
|
+
}
|
|
1919
|
+
return {
|
|
1920
|
+
status: "succeeded",
|
|
1921
|
+
output,
|
|
1922
|
+
finalUrl: readFinalUrl,
|
|
1923
|
+
dispatchStarted: false,
|
|
1924
|
+
dispatch: { planned: 0, started: 0, verified: 0 },
|
|
1925
|
+
};
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
return failedRedditRead(recipe.action, error, readFinalUrl, stage);
|
|
1928
|
+
}
|
|
1929
|
+
}
|